class_name PropAnchorData extends RefCounted ## Normalized, JSON-safe prop-anchor model shared by authoring tools. const ORIGIN_ID: String = "origin" const ROLE_STRINGS: PackedStringArray = ["pivot", "grip", "emitter", "impact", "mount", "custom"] const ALLOWED_HAND_STRINGS: PackedStringArray = ["both", "left_only", "right_only"] enum Hand { LEFT, RIGHT } static func default_anchors() -> Dictionary: return {ORIGIN_ID: _origin()} static func normalize_anchors(raw: Variant, warn: bool = true) -> Dictionary: var result: Dictionary = {} if raw is Dictionary: for raw_id: Variant in (raw as Dictionary): if not (raw as Dictionary)[raw_id] is Dictionary: if warn: push_warning("PropAnchorData: skipped malformed anchor.") continue var id: String = normalize_id(str(raw_id), result) if id == ORIGIN_ID: continue var source: Dictionary = (raw as Dictionary)[raw_id] as Dictionary var role: String = str(source.get("role", "custom")).to_lower() if not ROLE_STRINGS.has(role) or role == "pivot": if role == "pivot" and warn: push_warning("PropAnchorData: non-origin pivot changed to custom.") role = "custom" var hand: String = str(source.get("allowed_hands", "both")).to_lower() if not ALLOWED_HAND_STRINGS.has(hand): hand = "both" result[id] = {"role": role, "position": Vector2(_finite(source.get("x", source.get("position", Vector2.ZERO)), true), _finite(source.get("y", source.get("position", Vector2.ZERO)), false)), "rotation_deg": _wrap_deg(_finite(source.get("rotation_deg", 0.0))), "z_index_offset": clampi(int(source.get("z_index_offset", 0)), -5, 5), "allowed_hands": hand} result[ORIGIN_ID] = _origin() return result.duplicate(true) static func serialize_anchors(anchors: Dictionary) -> Dictionary: var normalized: Dictionary = normalize_anchors(anchors, false) var result: Dictionary = {} for id: String in normalized: var anchor: Dictionary = normalized[id] var pos: Vector2 = anchor.get("position", Vector2.ZERO) result[id] = {"role": str(anchor.get("role", "custom")), "x": pos.x, "y": pos.y, "rotation_deg": float(anchor.get("rotation_deg", 0.0)), "z_index_offset": int(anchor.get("z_index_offset", 0)), "allowed_hands": str(anchor.get("allowed_hands", "both"))} return result static func editable_ids(anchors: Dictionary) -> Array[String]: var result: Array[String] = [] for id: String in normalize_anchors(anchors, false): if id != ORIGIN_ID: result.append(id) return result static func preferred_editable_id(anchors: Dictionary, preferred_id: String = "") -> String: var normalized: Dictionary = normalize_anchors(anchors, false) if preferred_id != ORIGIN_ID and normalized.has(preferred_id): return preferred_id if normalized.has("primary_grip") and str(normalized["primary_grip"].get("role", "")) == "grip": return "primary_grip" for id: String in editable_ids(normalized): if str(normalized[id].get("role", "")) == "grip": return id var ids: Array[String] = editable_ids(normalized) return ids[0] if not ids.is_empty() else "" static func translated_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary: var normalized: Dictionary = normalize_anchors(anchors, false) if not is_finite(delta.x) or not is_finite(delta.y): push_warning("PropAnchorData: ignored non-finite anchor translation.") return normalized for id: String in editable_ids(normalized): var anchor: Dictionary = normalized[id].duplicate(true) anchor["position"] = anchor.get("position", Vector2.ZERO) + delta normalized[id] = anchor normalized[ORIGIN_ID] = _origin() return normalized static func try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary: if not is_finite(delta.x) or not is_finite(delta.y): return {"ok": false, "anchors": {}} var result: Dictionary = normalize_anchors(anchors, false) for id: String in editable_ids(result): var anchor: Dictionary = result[id].duplicate(true) var pos: Vector2 = anchor.get("position", Vector2.ZERO) + delta if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}} anchor["position"] = pos; result[id] = anchor result[ORIGIN_ID] = _origin() return {"ok": true, "anchors": result} static func try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary: if not is_finite(angle_rad): return {"ok": false, "anchors": {}} var result: Dictionary = normalize_anchors(anchors, false) for id: String in editable_ids(result): var anchor: Dictionary = result[id].duplicate(true) var pos: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle_rad) if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}} anchor["position"] = pos anchor["rotation_deg"] = _wrap_deg(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle_rad)) result[id] = anchor result[ORIGIN_ID] = _origin() return {"ok": true, "anchors": result} ## Returns a fully normalized scaled copy, or {"ok": false} without changing ## the caller's data when the operation cannot be represented safely. static func try_scale_non_origin(anchors: Dictionary, factor: float) -> Dictionary: if not is_finite(factor) or factor <= 0.0: return {"ok": false, "anchors": {}} var normalized: Dictionary = normalize_anchors(anchors, false) for id: String in editable_ids(normalized): var anchor: Dictionary = normalized[id].duplicate(true) var position: Vector2 = anchor.get("position", Vector2.ZERO) * factor if not is_finite(position.x) or not is_finite(position.y): return {"ok": false, "anchors": {}} anchor["position"] = position normalized[id] = anchor normalized[ORIGIN_ID] = _origin() return {"ok": true, "anchors": normalized} static func normalize_id(candidate: String, anchors: Dictionary) -> String: var id: String = candidate.strip_edges().to_lower() var regex := RegEx.new(); regex.compile("[^a-z0-9]+") id = regex.sub(id, "_", true).strip_edges().trim_prefix("_").trim_suffix("_") if id.is_empty() or not id[0].is_valid_identifier(): id = "anchor" id = id.substr(0, 32) if id == ORIGIN_ID and not anchors.has(id): return id var base: String = id; var suffix: int = 2 while anchors.has(id) or id == ORIGIN_ID: id = base.substr(0, 32 - str(suffix).length()) + "_" + str(suffix); suffix += 1 return id static func ids_for_role(anchors: Dictionary, role: String) -> Array[String]: var ids: Array[String] = [] for id: String in normalize_anchors(anchors, false): if str(normalize_anchors(anchors, false)[id].get("role", "")) == role: ids.append(id) return ids static func compatible_grip_ids(anchors: Dictionary, hand: int) -> Array[String]: var result: Array[String] = [] for id: String in ids_for_role(anchors, "grip"): var allowed: String = str(normalize_anchors(anchors, false)[id].get("allowed_hands", "both")) if allowed == "both" or (hand == Hand.LEFT and allowed == "left_only") or (hand == Hand.RIGHT and allowed == "right_only"): result.append(id) return result static func local_transform(anchor: Dictionary, mirror_x: bool = false) -> Transform2D: var position: Vector2 = anchor.get("position", Vector2.ZERO) var angle: float = deg_to_rad(float(anchor.get("rotation_deg", 0.0))) if mirror_x: position.x = -position.x; angle = PI - angle return Transform2D(angle, position) static func solve_attachment_transform(target_transform: Transform2D, anchor: Dictionary, positive_scale: Vector2, mirror_x: bool) -> Transform2D: var effective: Transform2D = local_transform(anchor, mirror_x) var scale: Vector2 = Vector2(maxf(absf(positive_scale.x) if is_finite(positive_scale.x) else 1.0, 0.0001), maxf(absf(positive_scale.y) if is_finite(positive_scale.y) else 1.0, 0.0001)) var basis := Transform2D(target_transform.get_rotation() - effective.get_rotation(), Vector2.ZERO).scaled(scale) basis.origin = target_transform.origin - basis.basis_xform(effective.origin) return basis static func preset_layout(preset: String, bounds: Rect2) -> Dictionary: var center: Vector2 = bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO var horizontal: bool = bounds.size.x >= bounds.size.y var length: float = bounds.size.x if horizontal else bounds.size.y var angle: float = 0.0 if horizontal else 90.0 var endpoint: Vector2 = Vector2(bounds.end.x, center.y) if horizontal else Vector2(center.x, bounds.end.y) var offset: Vector2 = (Vector2(minf(length * 0.2, 32.0), 0.0) if horizontal else Vector2(0.0, minf(length * 0.2, 32.0))) var make := func(role: String, pos: Vector2) -> Dictionary: return {"role": role, "position": pos, "rotation_deg": angle, "z_index_offset": 0, "allowed_hands": "both"} var out: Dictionary = default_anchors() match preset: "one_handed": out["primary_grip"] = make.call("grip", center) "two_handed": out["primary_grip"] = make.call("grip", center - offset); out["secondary_grip"] = make.call("grip", center + offset) "tool_nozzle": out["primary_grip"] = make.call("grip", center); out["emitter"] = make.call("emitter", endpoint) "melee_edge": out["primary_grip"] = make.call("grip", center); out["impact"] = make.call("impact", endpoint) return out static func _origin() -> Dictionary: return {"role": "pivot", "position": Vector2.ZERO, "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"} static func _finite(value: Variant, x: bool = true) -> float: var n: float = value.x if value is Vector2 and x else (value.y if value is Vector2 else float(value)) return n if is_finite(n) else 0.0 static func _wrap_deg(value: float) -> float: return fposmod(value + 180.0, 360.0) - 180.0