Implement Phase 10a - Prop Scaling

- Added specifications for Phase 10a, introducing permanent size changes to `.prop` documents and reversible uniform instance scaling in Sandbox EDIT mode.
- Updated `PropCanvas`, `PropAnchorData`, `PropEditor`, `StageGizmos`, `SandboxStage`, and `StageSelection` classes to support new scaling features.
- Implemented document transactions for scaling, including normalization to a 96 px maximum dimension.
- Introduced a new **Transform Whole Prop** tool in the Prop Editor for moving, rotating, and resizing props.
- Created unit tests for the new scaling functionality, validating shape scaling, anchor transformations, and whole-prop interactions.
- Ensured compatibility with existing prop formats and maintained the integrity of the document structure during scaling operations.
This commit is contained in:
2026-09-22 22:23:31 -04:00
parent 97f3730413
commit 0a7f97cec7
18 changed files with 1782 additions and 78 deletions
+42
View File
@@ -71,6 +71,48 @@ static func translated_non_origin(anchors: Dictionary, delta: Vector2) -> Dictio
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]+")