175 lines
9.3 KiB
GDScript
175 lines
9.3 KiB
GDScript
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
|