Files
stickman/docs/specs/phase_10a_prop_scaling.md

500 lines
30 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 10a — Prop Scaling
Status: **implemented and user verified** (amended and verified 2026-09-22).
Source: `docs/archive/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/editors/prop/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/props/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/editors/prop/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/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/editors/stage/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/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://content/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.