- 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.
500 lines
29 KiB
Markdown
500 lines
29 KiB
Markdown
# Phase 10a — Prop Scaling
|
||
|
||
Status: **implemented and user verified** (amended and verified 2026-09-22).
|
||
|
||
Source: `plans/PHASE_10a_PROP_SCALING.md`. Phase 10a adds two distinct operations:
|
||
permanent size changes to a `.prop` document in the Prop Editor, and reversible
|
||
uniform instance scaling of one `PropBlock` in Sandbox EDIT mode. The final UI uses
|
||
origin-based Normalize Size and direct whole-prop Arrange gestures; it does not expose
|
||
a numeric document-scale control. Sandbox keeps single-prop corner handles that scale
|
||
around the prop's local origin.
|
||
|
||
## 1. Existing contracts and scope
|
||
|
||
- `PropCanvas` stores every shape's `PackedVector2Array` points and computes the
|
||
union bounds. `PropEditor` owns the matching anchor dictionary, dirty/autosave
|
||
flow, inspector, and the mode-specific top-bar controls. `PropAnchorData`
|
||
always synthesizes a fixed `origin` at `(0, 0)`.
|
||
- Circles carry both `points` and `radius`; both must remain consistent after a
|
||
document-wide uniform scale. Keep `shape_type`, `closed`, `vertex_flags`,
|
||
`reshaped`, fill, outline color, and material preset unchanged.
|
||
- `StageGizmos` currently has translation and rotation gestures and emits
|
||
`transform_started`, `transform_updated`, and `transform_committed`.
|
||
`SandboxStage` already uses these signals to sync frozen bodies, save authored
|
||
transforms, rebuild spatial cells, and mark the scene dirty.
|
||
- `SandboxStage._collect_scene_data()` already writes prop `scale`; scene loading
|
||
already reads it. `_restore_authored_state()` restores authored position and
|
||
rotation and zeroes rigid-body velocities without changing scale. No `.prop`
|
||
or `.scene` version change is required.
|
||
|
||
In scope: document scale/normalize controls, a passive 1:1 guide in the Prop
|
||
Editor, prop-only Sandbox scale handles, and focused verification. Keep the
|
||
existing built-in/custom prop rendering and one-hull custom collision policy.
|
||
Do not add Sandbox numeric scale fields, presets, new authoring modes, or
|
||
document-wide undo/redo. Debt item #37 continues to track undo.
|
||
|
||
The 2026-09-22 amendment replaces the visible, Arrange-only **Resize Whole
|
||
Prop** toggle with **Transform Whole Prop**. One union gizmo moves, rotates,
|
||
or uniformly resizes the complete document. It does not change Sandbox scaling.
|
||
|
||
## 2. Class and API contracts
|
||
|
||
| Class / file | Required contract |
|
||
| --- | --- |
|
||
| `class_name PropCanvas extends Control` (`scripts/prop_canvas.gd`) | Keep `can_scale_all_shapes()` / `scale_all_shapes()`, and add equivalent validated document helpers `can_translate_all_shapes(delta: Vector2) -> bool`, `translate_all_shapes(delta: Vector2) -> bool`, `can_rotate_all_shapes(angle_rad: float) -> bool`, and `rotate_all_shapes(angle_rad: float) -> bool`. Successful commit helpers mutate every shape and emit `shape_changed(shapes: Array[Dictionary])` exactly once; preflight/no-op/failure never mutates or emits. Add the transient whole-prop tool API in §3.4. `get_shapes_bounds() -> Rect2` remains authoritative for its union gizmo. |
|
||
| `class_name PropAnchorData extends RefCounted` (`scripts/prop_anchor_data.gd`) | Keep pure `try_scale_non_origin()`, and add pure `try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary` and `try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary`, each returning `{ "ok": bool, "anchors": Dictionary }`. Translation changes editable positions only. Rotation rotates editable positions about `(0,0)` and adds the angle to their wrapped `rotation_deg`. Every helper regenerates canonical `origin` unchanged and rejects invalid results without partially modifying input. Existing `translated_non_origin()` may delegate to the validated helper. |
|
||
| `class_name PropEditor extends Control` (`scripts/prop_editor.gd`) | Own three atomic document transactions: `_translate_document(delta: Vector2) -> bool`, `_rotate_document(angle_rad: float) -> bool`, and existing `_scale_document(factor: float) -> bool`. Each prevalidates shapes and anchors, applies the anchor copy, commits canvas geometry once, rolls anchors back on an unexpected canvas failure, and refreshes inspector/preview/buttons through the existing shape-change path. Connect the typed request signals in §3.4. Persist only guide visibility in editor settings. |
|
||
| `class_name StageGizmos extends Node2D` (`scripts/stage_gizmos.gd`) | Extend `enum Handle` with `SCALE`. Draw and hit-test four corner crosses only when `_targets.size() == 1` and `_targets[0] is PropBlock`; keep hit radii and strokes in screen pixels via camera zoom. Capture start `scale: Vector2`, world origin, selected corner, and cursor at gesture start. Use one positive uniform multiplier through the drag; emit the existing transform signals. |
|
||
| `class_name SandboxStage extends Node2D` (`scripts/sandbox_stage.gd`) | Keep the current EDIT pointer routing and existing transform-signal handlers. Route scale gestures through the same physics-sync and authored-state commit path as move/rotate. No save/load or PLAY→EDIT reset rewrite is needed unless verification exposes a defect. |
|
||
| `class_name StageSelection extends RefCounted` (`scripts/stage_selection.gd`) | Its existing `get_world_aabb(node: Node2D) -> Rect2` transforms visual points to world space. Verify scaled/rotated built-in and custom props; change the method only if a specific failing case appears. |
|
||
|
||
No new persistent shape fields are required. The whole-prop tool adds three
|
||
typed commit-request signals. Use static type hints for new fields, parameters,
|
||
local values where inference is ambiguous, and return values. The signal
|
||
interfaces are:
|
||
|
||
```gdscript
|
||
signal shape_changed(shapes: Array[Dictionary])
|
||
signal transform_started(nodes: Array[Node2D])
|
||
signal transform_updated(nodes: Array[Node2D])
|
||
signal transform_committed(nodes: Array[Node2D])
|
||
signal whole_prop_move_requested(delta: Vector2)
|
||
signal whole_prop_rotate_requested(angle_rad: float)
|
||
signal whole_prop_resize_requested(factor: float)
|
||
```
|
||
|
||
## 3. Prop Editor document transaction
|
||
|
||
### 3.1 Explicit scale and 96 × 96 normalization
|
||
|
||
Do not expose a numeric scale textbox or Apply Scale button. In Arrange, the
|
||
mode top bar presents **Transform Whole Prop**, **📏 Normalize Size**, **🎯 Center
|
||
Prop**, and **Show Stickman Guide**, in that order. Normalize Size is a
|
||
document operation, not saved UI state: it uniformly fits valid shape-point
|
||
bounds to a 96 px maximum dimension around `(0, 0)`, preserves proportional
|
||
anchor offsets, and leaves outline width unchanged. Disable it when there is no
|
||
valid shape geometry. Transform Whole Prop supplies direct move, rotate, and
|
||
uniform resize gestures; its detailed snapshot and commit contract is in §3.4.
|
||
Transform Whole Prop and Normalize Size are hidden in Reshape and Attach, while
|
||
Center Prop and Show Stickman Guide remain available.
|
||
|
||
Normalize uses the shape-point union already returned by `get_shapes_bounds()`:
|
||
|
||
```gdscript
|
||
var extent: float = maxf(bounds.size.x, bounds.size.y)
|
||
var factor: float = 96.0 / extent
|
||
```
|
||
|
||
For a nonempty finite bounds with `extent > 0`, apply this factor uniformly
|
||
around `(0, 0)`. The resulting maximum geometry extent is 96 world pixels;
|
||
the smaller dimension retains its aspect ratio. A line with one nonzero
|
||
dimension is valid. Empty or point-only geometry, zero extent, nonfinite
|
||
values, or an output coordinate/radius that cannot be represented safely make
|
||
the action a no-op. Do not silently clamp the factor and miss the 96 px target.
|
||
Use a reasonable coordinate safety cap documented in code rather than relying
|
||
on floating-point overflow. Normalization does **not** call **Center Prop**:
|
||
off-origin content stays off-origin at its new proportional offset.
|
||
|
||
### 3.2 Geometry and anchor invariants
|
||
|
||
The transaction computes its result from a deep copy before updating either
|
||
owner. Transform every point of every polygon, line, and circle as `point *=
|
||
factor`. For every circle, also set `radius *= factor`. Do not regenerate
|
||
circle points: that would erase an intentionally reshaped circle or move its
|
||
center. Preserve `reshaped` exactly as stored. Keep `vertex_flags` in their
|
||
original index order. Outline width is a style value and stays unchanged;
|
||
the 96 px target measures shape points, not stroke thickness. Preserve the
|
||
canonical origin while multiplying all editable anchor positions by the same
|
||
factor. Anchor rotations and metadata stay unchanged.
|
||
|
||
Call `_canvas.can_scale_all_shapes(factor)` and
|
||
`PropAnchorData.try_scale_non_origin(_anchors, factor)` before changing either
|
||
owner. A failed preflight leaves both unchanged. Apply the prevalidated anchor
|
||
copy without emitting a separate document edit, then commit the canvas shapes
|
||
with the same factor; this synchronous path must use the same canvas validation
|
||
as preflight. Let its one `shape_changed` emission use the existing
|
||
`PropEditor._on_canvas_shape_changed()` dirty/autosave/preview refresh path.
|
||
Refresh selected anchor controls and any disabled button state afterward.
|
||
The scale operation must not reset pan/zoom, selection, edit mode, clipboard,
|
||
or guide visibility. Both successful actions mark the document dirty once;
|
||
changing the guide toggle does not.
|
||
|
||
Example with factor `2`:
|
||
|
||
```text
|
||
Before: circle points centered at (20, 10), radius 12;
|
||
primary_grip.position = (8, -3), origin = (0, 0)
|
||
After: circle points centered at (40, 20), radius 24;
|
||
primary_grip.position = (16, -6), origin = (0, 0)
|
||
```
|
||
|
||
Save, autosave, recovery, and load keep the existing `.prop` v1.0 shape and
|
||
anchor representation. `PropFile._serialize_shape()` must continue to coerce
|
||
export-format point dictionaries; never assign JSON arrays directly to a
|
||
`PackedVector2Array`.
|
||
|
||
### 3.3 Stickman scale guide
|
||
|
||
Add a **Show Stickman Guide** `CheckBox` in the Prop Editor canvas controls.
|
||
Default is OFF for a fresh settings file. Persist the Boolean as
|
||
`show_stickman_guide` in `user://prop_editor_settings.json` alongside
|
||
`recent_props`; a missing key means `false`, and old settings files remain
|
||
valid. Apply the loaded value during `_ready()` after the controls exist.
|
||
Toggling calls `PropCanvas.set_show_stickman_guide()`, saves settings, and
|
||
redraws, without changing `_dirty`, autosave, `.prop` data, or scene data.
|
||
|
||
Draw the same 1:1 head circle and bone-line reference already described by
|
||
`WholeStickmanPreview.GUIDE_JOINTS`, `GUIDE_BONES`, and
|
||
`GUIDE_HEAD_RADIUS`. Prefer preloading that source's constants or extracting
|
||
a shared constant-only guide source if compilation requires it; do not copy
|
||
the numerical joint table into a second implementation. Draw raw master
|
||
coordinates with `Hips = Vector2.ZERO` after `_draw_grid()` and before user
|
||
shapes, under the canvas's existing pan/zoom transform. This keeps a 96 px
|
||
prop directly comparable with the approximately 940 px standing figure in
|
||
every view. Use muted translucent strokes with width divided by `_zoom` for
|
||
legibility; keep the guide noninteractive and out of hit-testing, shape bounds,
|
||
thumbnails, and `.prop` serialization. Existing origin marker and selection
|
||
gizmos remain above it. New/load/reset do not forcibly change its saved
|
||
visibility preference.
|
||
|
||
### 3.4 Arrange-only Transform Whole Prop tool (2026-09-22 decision)
|
||
|
||
**Interaction and scope.** Replace **↗ Resize Whole Prop** with one prominent
|
||
toggle labeled **✥ Transform Whole Prop** in the Arrange mode top bar. It is available only in Arrange
|
||
(`EditMode.TRANSFORM`) when valid shape geometry exists. It starts OFF, remains
|
||
armed after each successful gesture, is session-only, and is excluded from
|
||
settings and `.prop` data. This extends the already learned per-shape Arrange
|
||
mapping to the full document:
|
||
|
||
| Target | Gesture | Whole-document result |
|
||
| --- | --- | --- |
|
||
| Any visible shape body | Drag | Move all shapes and editable anchors. Transparent gaps inside the union box do nothing. |
|
||
| Dot below union bounds | Drag | Rotate all shapes and editable anchors about canonical `(0,0)`. Ctrl snaps to 15°. |
|
||
| Any of four union corners | Drag | Uniformly resize all shapes and editable anchors about canonical `(0,0)`. |
|
||
|
||
Hit-test in the strict order **corner → rotation dot → visible shape body**.
|
||
This keeps small handles usable when they overlap geometry. Suppress per-shape
|
||
selection, translation/rotation/scale gizmos, and right-click menus while the
|
||
tool is armed. Preserve `_selected_shape_idx` internally and restore its
|
||
selection/inspector when the toggle turns OFF. Existing wheel/pinch zoom and
|
||
middle-button/touchpad pan remain available during the tool.
|
||
|
||
Draw one two-tone union-bounds outline, four large constant-screen-size corner
|
||
crosses (at least 12 px hit radius), and the familiar constant-screen-size
|
||
rotation dot below the union AABB. Keep the canonical origin marker visible;
|
||
if **Show Other Markers** hid it, restore that preference when the tool exits.
|
||
Also draw small passive markers for every non-origin anchor, with grip markers
|
||
clearly visible. During a gesture, the union outline and markers follow the
|
||
temporary preview. These markers are noninteractive and excluded from bounds,
|
||
hit-testing, thumbnails, and serialization. The tooltip must say that body,
|
||
dot, and corners move, rotate, and resize the whole prop including grip markers.
|
||
|
||
**Typed Canvas API and signals.** Replace resize-specific tool methods with:
|
||
|
||
```gdscript
|
||
enum WholePropDrag { NONE, MOVE, ROTATE, SCALE }
|
||
|
||
func set_whole_prop_transform_enabled(enabled: bool) -> void
|
||
func is_whole_prop_transform_enabled() -> bool
|
||
func is_whole_prop_transform_dragging() -> bool
|
||
func cancel_whole_prop_transform() -> void
|
||
|
||
signal whole_prop_move_requested(delta: Vector2)
|
||
signal whole_prop_rotate_requested(angle_rad: float)
|
||
signal whole_prop_resize_requested(factor: float)
|
||
```
|
||
|
||
`set_whole_prop_transform_enabled(false)` cancels a live preview before
|
||
disarming. `cancel_whole_prop_transform()` clears the active drag, snapshots,
|
||
preview arrays, accumulated rotation, and pending payload without changing
|
||
authoritative data or the armed state. `PropEditor` connects the three signals
|
||
to `_translate_document`, `_rotate_document`, and `_scale_document`. Do not
|
||
emit `anchors_changed` for these document gestures.
|
||
|
||
The implementation also retains resize-named compatibility aliases internally
|
||
(`set_whole_prop_resize_enabled`, `is_whole_prop_resize_enabled`,
|
||
`is_whole_prop_resize_dragging`, and `cancel_whole_prop_resize`) so existing
|
||
controller paths continue to target the same Transform Whole Prop state. They
|
||
are not separate user-facing tools.
|
||
|
||
**Snapshot and preview contract.** On pointer-down, capture deep starting
|
||
copies of `shapes: Array[Dictionary]` and normalized anchors, starting union
|
||
bounds, pointer, selected handle, and any gesture-specific state. Render all
|
||
motion from those starting copies into temporary preview shape/anchor arrays.
|
||
Do not replace authoritative `shapes` or editor `_anchors` before release;
|
||
therefore `get_shape_data()`, save, autosave, and recovery always see the
|
||
pre-drag document if their timer runs during a drag. Preview motion can redraw
|
||
each input event but never emits edit signals or arms autosave.
|
||
|
||
All gestures preserve shape type, layer order, `closed`, `vertex_flags`,
|
||
`reshaped`, fill/outline, and outline width, plus anchor role, z offset,
|
||
allowed hands, and IDs. Canonical `origin` remains at `(0,0)` with rotation
|
||
`0°`. Circle behavior is explicit: move and rotate transform its stored points
|
||
but leave `radius` unchanged; resize transforms its points and multiplies
|
||
`radius`. Never regenerate circle points.
|
||
|
||
**Move math.** Capture `start_pointer`; compute from the baseline every frame:
|
||
|
||
```gdscript
|
||
var delta: Vector2 = current_pointer - start_pointer
|
||
if snap_enabled:
|
||
delta = Vector2(snappedf(delta.x, grid_size), snappedf(delta.y, grid_size))
|
||
preview_point = start_point + delta
|
||
preview_anchor.position = start_anchor.position + delta
|
||
```
|
||
|
||
Snap the **delta**, so all layers and anchors retain their relative placement
|
||
even when the starting document is off-grid. Anchor `rotation_deg` and circle
|
||
radius remain unchanged. Moving the whole prop means moving authored geometry
|
||
and editable anchor markers relative to the fixed document pivot; the canonical
|
||
origin itself does not translate. A zero delta is a no-op.
|
||
|
||
**Rotate math.** The pivot is always canonical `Vector2.ZERO`, independent of
|
||
the union-bounds center. Capture the pointer angle around origin and accumulate
|
||
wrapped frame deltas so crossing `-PI/PI` cannot jump:
|
||
|
||
```gdscript
|
||
var frame_delta: float = wrapf(current_angle - previous_angle, -PI, PI)
|
||
total_angle += frame_delta
|
||
previous_angle = current_angle
|
||
var preview_angle: float = snappedf(total_angle, deg_to_rad(15.0)) if ctrl_pressed else total_angle
|
||
preview_point = start_point.rotated(preview_angle)
|
||
preview_anchor.position = start_anchor.position.rotated(preview_angle)
|
||
preview_anchor.rotation_deg = wrapf(start_anchor.rotation_deg + rad_to_deg(preview_angle), -180.0, 180.0)
|
||
```
|
||
|
||
Reject pointer-down or motion whose angle around origin is nonfinite or too
|
||
close to origin for stable measurement. Always render the captured baseline
|
||
rotated by `preview_angle`; never rotate the prior preview. The rotation dot's
|
||
union-bounds location is only an input affordance and does not change the pivot.
|
||
Circle radius and `reshaped` remain unchanged. An approximately zero angle is
|
||
a no-op.
|
||
|
||
**Resize math.** Preserve the implemented uniform resize rule. Compute the
|
||
gesture direction from the starting union center and selected starting corner,
|
||
but measure pointer travel from the exact captured press position:
|
||
|
||
```gdscript
|
||
var direction: Vector2 = start_corner - start_bounds.get_center()
|
||
var pointer_delta: Vector2 = current_pointer - start_pointer
|
||
var factor: float = 1.0 + pointer_delta.dot(direction) / direction.length_squared()
|
||
```
|
||
|
||
The factor is exactly `1.0` on pointer-down even when the press is off the
|
||
handle center. Apply it to points, circle radii, and editable anchor positions
|
||
about `(0,0)`; anchor rotations stay unchanged. Clamp to positive finite
|
||
`0.01..10.0`, satisfy canvas coordinate/radius and anchor preflight, and never
|
||
cross zero or flip geometry. Reject a near-zero direction denominator. An
|
||
approximately `1.0` factor is a no-op.
|
||
|
||
**Commit and cancellation.** On left release, capture the final payload, clear
|
||
the temporary preview, and emit exactly one matching typed request signal.
|
||
The controller transaction prevalidates geometry and anchors, applies both
|
||
atomically, and lets the canvas's single `shape_changed` emission mark dirty,
|
||
arm autosave once, refresh inspector/preview/buttons, and retain the armed
|
||
Transform Whole Prop tool. An invalid/no-op release emits nothing. On an
|
||
unexpected canvas commit failure, restore the original anchors and leave the
|
||
document unchanged.
|
||
|
||
Esc during a drag cancels only that preview and keeps the tool armed. Esc while
|
||
armed but idle turns the tool OFF. Toggling OFF, changing to Reshape/Attach,
|
||
New, Load, or Clear cancels a live preview and disarms before changing mode or
|
||
document. Route Esc through `PropEditor._unhandled_key_input()` while respecting
|
||
dialogs and text fields, and consume it. A committed gesture is not reverted by
|
||
Esc because document-wide UndoRedo remains deferred under debt #37. Each move,
|
||
rotate, or resize gesture must become one future shapes-plus-anchors undo unit.
|
||
|
||
```text
|
||
Arrange, shape scope Transform Whole Prop ON
|
||
selected-shape bbox all-shape union bbox
|
||
body = move one shape body = move entire document
|
||
dot = rotate one shape dot = rotate entire document
|
||
corners = resize one shape corners = resize entire document
|
||
|
||
pointer down → deep snapshots → temporary preview from baseline
|
||
→ release → one typed controller transaction
|
||
→ shape_changed once → dirty/autosave once → tool remains armed
|
||
↘ Esc/mode/data change → discard preview, no dirty change
|
||
```
|
||
|
||
## 4. Sandbox Stage instance gesture
|
||
|
||
### 4.1 Visual and input contract
|
||
|
||
When exactly one `PropBlock` is selected in EDIT, draw four crosses on the
|
||
corners of `StageSelection.get_world_aabb(prop)`. Use a contrasting backing
|
||
and bright core so they remain visible on light/dark props, and a 12 screen px
|
||
hit radius. Handle hit-testing precedes the existing rotate ring, then normal
|
||
body selection; an active placement mode keeps the current gizmo precedence.
|
||
No scale handles appear for multi-selection, terrain, trigger areas,
|
||
stickmen, missing-prop placeholders, DIRECT, or PLAY. The existing rotation
|
||
ring and body translation remain available.
|
||
|
||
On pointer-down, record the selected corner's world point, the prop's
|
||
`global_position` as pivot, and its starting `scale: Vector2`. For each drag
|
||
update, project the cursor vector onto the start-corner vector around the
|
||
fixed pivot:
|
||
|
||
```gdscript
|
||
var start: Vector2 = start_corner_world - pivot_world
|
||
var current: Vector2 = cursor_world - pivot_world
|
||
var requested: float = current.dot(start) / start.length_squared()
|
||
var new_scale: Vector2 = start_scale * positive_clamped_multiplier
|
||
```
|
||
|
||
Guard near-zero denominators/nonfinite values. Clamp the multiplier so the
|
||
absolute values of both final scale components stay within `0.05..20.0` and
|
||
never cross zero; retain the signs of any previously saved scale. The gesture
|
||
multiplies both axes by **the same positive factor**, preserving the instance's
|
||
starting aspect ratio even if an older `.scene` contains nonuniform scale.
|
||
It changes only `prop.scale`, never `global_position`, `global_rotation`,
|
||
template vertices, radius, anchors, `material_preset`, or `mass`. Compute from
|
||
the captured starting scale on every motion event rather than multiplying the
|
||
previous frame, avoiding drift. Draw from the updated world AABB while moving.
|
||
One release emits one `transform_committed` and enters the existing dirty/save
|
||
pipeline. No motion or invalid gesture must not produce a scale edit; keep
|
||
the existing move/rotate behavior unless a focused regression needs repair.
|
||
|
||
### 4.2 Physics and lifecycle
|
||
|
||
The `PropBlock` root is a `RigidBody2D`; its child visuals and collision
|
||
geometry inherit its node transform. `material_preset` remains the sole mass
|
||
source (`PropBlock.mass_for()`), so scale must not recalculate mass. During
|
||
EDIT drag, use the existing `_on_gizmo_transform_updated()`/
|
||
`_sync_editable_physics_transform()` path to keep the frozen physics body in
|
||
sync. On commit, `_on_transform_committed()` saves authored state and rebuilds
|
||
grid cells. PLAY may move/rotate the prop physically; returning to EDIT must
|
||
restore authored position/rotation and zero velocities while retaining the
|
||
authored scale. Saving during PLAY must serialize authored position/rotation
|
||
and the selected scale as the current code already does.
|
||
|
||
Godot warns that scaling collision shapes can produce unexpected physics
|
||
behavior. Verify polygon and circle contacts at several positive scales in
|
||
the project engine, including a rotated prop and PLAY→EDIT→PLAY. If the
|
||
`RigidBody2D` root transform does not give matching collisions, adapt the
|
||
per-instance collision construction while keeping the scene's scale field,
|
||
visual size, preset mass, and source `.prop` unchanged. A visually scaled prop
|
||
with stale collision is not an acceptable implementation.
|
||
|
||
## 5. Data and signal flow
|
||
|
||
```mermaid
|
||
flowchart LR
|
||
UI[Whole-prop body, dot, or corner drag] --> V[Snapshot-only live preview]
|
||
V -->|one typed request on release| E[PropEditor validates transaction]
|
||
N[Normalize Size] --> E
|
||
E --> A[PropAnchorData transforms non-origin anchors]
|
||
E --> C[PropCanvas transforms every shape]
|
||
C -->|shape_changed once| D[Dirty + autosave + preview refresh]
|
||
D --> P[Existing PropFile v1.0 save]
|
||
P --> L[Sandbox PropBlock instance]
|
||
G[StageGizmos prop corner drag] -->|uniform prop.scale| S[Stage transform signals]
|
||
S --> F[Frozen-body sync + authored state + dirty]
|
||
F --> I[Existing .scene scale field]
|
||
```
|
||
|
||
```text
|
||
Sandbox EDIT selection
|
||
├─ one PropBlock → AABB + rotate ring + four scale crosses
|
||
└─ any other selection → existing AABB/rotate behavior, no scale crosses
|
||
|
||
PLAY → EDIT → PLAY
|
||
authored {position, rotation, scale} → simulate → restore position/rotation
|
||
and zero velocities → retain authored scale → simulate at retained size
|
||
```
|
||
|
||
Example persisted values remain additive to existing formats:
|
||
|
||
```json
|
||
{"version":"1.0","shapes":[{"shape_type":"circle","points":[{"x":24,"y":0},{"x":0,"y":24},{"x":-24,"y":0},{"x":0,"y":-24}],"radius":24,"reshaped":false}],"anchors":{"origin":{"role":"pivot","x":0,"y":0},"primary_grip":{"role":"grip","x":12,"y":-4}}}
|
||
```
|
||
|
||
```json
|
||
{"type":"prop","file_id":"prop_1","asset_id":"res://props/ball.prop","position":{"x":100,"y":200},"rotation":0,"scale":{"x":1.5,"y":1.5},"material_preset":2}
|
||
```
|
||
|
||
The JSON fragments show only the fields relevant to scaling; ordinary
|
||
serializer fields remain present in real files.
|
||
|
||
## 6. Implementation order and acceptance
|
||
|
||
1. Keep the existing scale transaction, guide, and whole-prop resize behavior;
|
||
add validated geometry/anchor move and rotate helpers plus atomic editor
|
||
transactions.
|
||
2. Rename and extend the Arrange-only toggle/gizmo with body move and rotation
|
||
dot gestures, retaining snapshot preview and resize behavior.
|
||
3. Implement prop-only Stage scale crosses and pointer gesture through the
|
||
existing transform signal flow. Leave persistence/lifecycle code alone
|
||
unless an assertion fails.
|
||
4. Run Godot headless syntax checks for every changed script and focused
|
||
headless tests. Per project instructions, use
|
||
`Godot_v4.7.1-stable_win64_console.exe --headless --check-only --script
|
||
res://scripts/<changed>.gd --path C:\Godot4\stickman`; if class caches need
|
||
refresh, run a headless editor scan first, then repeat checks. Fix failures
|
||
before handoff.
|
||
|
||
Acceptance checks:
|
||
|
||
- A `200%` document-scale transaction doubles all point coordinates, circle radii, and
|
||
editable anchor offsets, keeps origin `(0,0)`, and produces one dirty edit.
|
||
A reshaped circle remains reshaped; style widths, flags, and order remain.
|
||
- Normalize a 192 × 48 bounds to 96 × 24; a nonzero off-origin center scales
|
||
proportionally and is not silently centered. Invalid/empty geometry is a
|
||
no-op. Save/load and autosave/recovery retain exact point and anchor values.
|
||
- The guide starts hidden with no settings file, can be enabled in all modes,
|
||
stays aligned with world origin through pan/zoom, survives editor restart
|
||
through settings, and never enters a `.prop` file or a thumbnail.
|
||
- In Arrange, **Transform Whole Prop** shows one union outline, four large
|
||
corners, and a rotation dot. Body drag moves all points and non-origin
|
||
anchors; with Snap ON, its baseline-derived delta lands on the current grid.
|
||
Circle radius and anchor angles do not change. Canonical origin stays fixed.
|
||
- Rotation-dot drag rotates all points and anchor positions about `(0,0)` and
|
||
adds the same wrapped angle to every editable anchor's `rotation_deg`.
|
||
Circle radius/primitive state stay fixed, crossing ±180° is continuous, and
|
||
Ctrl produces 15° increments.
|
||
- Corner drag retains the verified uniform origin-based resize behavior,
|
||
including circle radius and anchor positions, without changing anchor angle.
|
||
- During every gesture, authoritative shape/anchor export stays at the baseline
|
||
while the union outline, shapes, and visible markers show preview copies.
|
||
Release performs one matching editor transaction, one `shape_changed`, and
|
||
one dirty/autosave arm. No-op release and cancellation emit nothing.
|
||
- Esc cancels an active drag but leaves the tool armed; idle Esc, toggle OFF,
|
||
mode switch, New, Load, or Clear cancel and disarm without a dirty edit. The
|
||
tool stays armed after a successful commit. Turning it OFF restores the
|
||
former selected shape and per-shape Arrange behavior.
|
||
- Focused headless tests assert numeric point/radius/anchor values, anchor-angle
|
||
wrapping, snap deltas, signal counts, baseline immutability during preview,
|
||
resize regression, no-op/cancel behavior, and selection/mode restoration.
|
||
- Exactly one selected prop exposes four usable crosses. Dragging retains its
|
||
aspect ratio and pivot, never flips it, changes collision bounds along with
|
||
visuals, and leaves material mass unchanged. Other object types and multiple
|
||
selection do not expose scale handles.
|
||
- Save/reload retains prop scale. PLAY→EDIT restores position/rotation and
|
||
zeroes velocities while retaining scale; a second PLAY collides at the
|
||
retained size. Existing rotation, translation, selection, and custom-prop
|
||
rendering still work.
|
||
|
||
## 7. Trade-offs and deferred work
|
||
|
||
- Origin-based sizing is predictable for anchor offsets and preserves the
|
||
fixed `origin`, but an off-origin prop moves relative to its bbox center
|
||
when resized. **Center Prop** remains the explicit remedy; no auto-centering.
|
||
- Geometry bounds exclude outline stroke and physics center of mass, matching
|
||
current Center Prop semantics and debt item #39.
|
||
- Stage scaling uses one rigid-body root transform and the existing `.scene`
|
||
scale field. Physics accuracy must be proved in the project engine; a
|
||
per-instance collider adjustment is a contingency, not a separate user UI.
|
||
- No document-wide undo is added; debt item #37 remains open.
|
||
- One visible **Transform Whole Prop** toggle avoids three competing subtools,
|
||
a hidden modifier key, or a fourth primary mode. Its body/dot/corner mapping
|
||
mirrors per-shape Arrange. Each completed move, rotate, or resize is one
|
||
future undo unit; Esc provides cancellation before commit, while existing
|
||
save/recovery protects committed work until document-wide undo is designed.
|
||
- Move translates authored geometry and non-origin anchors relative to the
|
||
fixed canonical pivot. Rotate uses that same pivot and changes anchor
|
||
orientation as well as position. This consistency benefits runtime mounting,
|
||
though an off-origin prop may orbit widely; **Center Prop** remains explicit.
|
||
|
||
The focused Phase 10a suite and Prop Editor startup smoke test passed, and the
|
||
user verified the final Prop Editor layout and controls in the Godot Editor.
|