Phase 14 - Organized directories.

This commit is contained in:
2026-10-03 19:13:39 -04:00
parent 6666173c4f
commit d950fd9d9a
307 changed files with 1452 additions and 1110 deletions
+174
View File
@@ -0,0 +1,174 @@
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