# 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/.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.