Add new test file for phase 19 scenery with unique identifier

This commit is contained in:
2026-10-17 12:53:38 -04:00
parent 36032ebb1e
commit a0510e6405
74 changed files with 40948 additions and 149 deletions
@@ -0,0 +1,252 @@
# Phase 19 — Scenery, backgrounds, and surface finishes: architecture and implementation specification
> **Status:** Original Phase 19 scenery architecture and the approved Stage-relative sizing amendment are integrated; automated and render-capable checks pass. This specification follows the [accepted product brief](../plans/phase_19_scenery_backgrounds_surface_finishes.md), the [approved UI/UX specification](phase_19_scenery_backgrounds_ui_ux.md), and the [Stage panel UI system](stage_panel_ui_system.md). Full Phase 19 user/editor verification remains pending. The earlier sections describe the original implementation contract; the sizing amendment starts from the current `.scene` v1.5, `.prop` v1.1, Stage controller, and shared UI.
## Decisions and scope
1. Use independent, inert `Node2D` placements for wall panels and `.prop` Scenery artwork. The source `.prop` is a **live reference**: its current vector art is used on reopen. Missing art remains a saved, visible, repairable placement. A given `.prop` may be used as inert Scenery and, when it has valid collision geometry, as a functional Prop.
2. Sky is one adaptive, viewport-filling, screen-space vector drawing behind the world. Its details stay fixed during camera movement; it does not tint other objects. Existing scenes and Blank Scene use an internal neutral Sky. The four child-facing Sky presets are Clear Day, Cloudy Day, Sunset, and Starry Night.
3. The existing Prop Editor opens **inside the live Stage session** for Make artwork. The Stage and its unsaved document remain in memory, with Stage input suspended until the editor returns. The standalone F6 route remains valid.
4. Select several creates a temporary Scenery selection and snapshot-based transform preview. It does not create a persistent group or a parent that changes the saved hierarchy. Sky, Ground, characters, and functional Props are outside this transform.
5. Phase 19 adds neither Stage Undo/Redo nor a whole-set transform. Group preview has its own exact Cancel/Keep boundary. Stage-wide Undo/Redo is a separately planned phase and is logged in [technical debt](../architecture/tech_debt_and_optimizations.md).
## Existing constraints and ownership
`scenes/stage/sandbox_stage.tscn` contains a `SandboxStage` root, `Camera2D`, and `World`. The scene adapter serializes direct `World` children; `StageSceneIO` currently writes v1.4. `StageSelection` currently considers every direct world child selectable by world AABB. `StageSpatialIndex` indexes AABBs into 16 px cells and `StageTerrainPainter` treats any indexed overlap as an occupancy conflict. `PropBlock` currently draws at `z_index` -1, 0 (legacy), or 1, while terrain is 0. These are explicit integration points, not implicit behavior to rely on.
```mermaid
flowchart TD
Chrome[StageChrome / shared gallery] --> Intent[SandboxStage input arbitration]
Intent --> Decor[StageSceneryController]
Intent --> Terrain[StageTerrainPainter]
Decor --> World[World: WallPanel + SceneryArtwork]
Terrain --> World
World --> Adapter[StageSceneAdapter]
Sky[StageSkyView] --> Adapter
Adapter --> IO[StageSceneIO v1.5]
Art[PropFile + PropLibrary visual scan] --> Decor
Art --> Editor[Stage-hosted PropEditor]
```
| Owner / location | Typed contract or change | Boundary |
| --- | --- | --- |
| `scripts/stage/stage_draw_order.gd`, `class_name StageDrawOrder extends RefCounted` | Constants `WALL := -40`, `FAR := -30`, `NEAR := -20`, `TERRAIN := -10`, `FLOOR := -5`; `static func scenery_z(depth: StringName) -> int`; `static func apply_terrain(node: TerrainBlock) -> void` | One drawing policy. Leave the existing Prop `-1/0/1` and character relative order intact; rebase terrain drawing only. Never serialize raw `z_index` in place of a semantic depth/finish. |
| `scripts/stage/scenery/stage_sky_view.gd`, `class_name StageSkyView extends Control` | `func set_sky(id: StringName) -> void`; `func get_sky() -> StringName`; `func apply_theme(theme: SandboxTheme) -> void` | Full-rect child of a dedicated `CanvasLayer` with `layer = -1`, mouse ignored. Deterministic vector `_draw()` on viewport resize/theme/choice only. No world hit test or physics. |
| `scripts/stage/scenery/stage_wall_panel.gd`, `class_name StageWallPanel extends Node2D` | `var finish_id: StringName`; `var tint: Color`; `var panel_size: Vector2`; optional `var motif_origin_offset: Vector2`; `func configure(size: Vector2, finish: StringName, color: Color) -> void`; `func get_local_bounds() -> Rect2` | Finite world rectangle centered at `position`. No `CollisionObject2D`, navigation, support, or interaction registration. Resize changes `panel_size`, never root scale, so motifs retain their world pitch. The renderer lays the pattern out from each panel's current world transform, so eraser-created fragments keep continuous brick courses; the optional offset preserves source-wall lineage in saved data. |
| `scripts/stage/scenery/stage_scenery_artwork.gd`, `class_name StageSceneryArtwork extends Node2D` | `var asset_path: String`; `var display_name: String`; `var depth: StringName`; `var last_known_local_bounds: Rect2`; `var missing: bool`; `func load_visual(path: String) -> bool`; `func set_depth(value: StringName) -> void`; `func get_visual_world_bounds() -> Rect2`; `func get_saved_footprint() -> Rect2` | Build a `Visuals` child via `PropVisuals.build()` from the complete shape stack. No `PropBlock`, collision hull, physics, Use spots, anchors, or event identity. If loading fails, render a neutral marker/known footprint from saved data and preserve path/name/transform. |
| `scripts/stage/scenery/stage_surface_renderer.gd`, `class_name StageSurfaceRenderer extends RefCounted` | `static func is_wall_finish(id: StringName) -> bool`; `static func is_ground_finish(id: StringName) -> bool`; `static func draw_wall(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void`; `static func draw_ground(canvas: CanvasItem, rect: Rect2, finish: StringName, tint: Color, world_origin: Vector2) -> void` | Shared vector definitions for Paint/Brick/Wood, with Grass/Dirt/Sidewalk/Asphalt for Ground. One motif vocabulary and world-coordinate phase; no raster scale blur. Drawing helpers clip motifs to finite panel/block bounds and bound work to visible tiles. |
| `scripts/terrain/terrain_block.gd`, `class_name TerrainBlock extends StaticBody2D` | Add `var finish_id: StringName` and `func set_finish(id: StringName) -> void` for `spawn_id == "ground"`; preserve `polygon_points`, fill, outline, and `CollisionPolygon2D` | Ground appearance changes without altering collision geometry, navigation, or terrain occupancy. Ramp/Step retain the old look. |
| `scripts/editors/stage/stage_scenery_controller.gd`, `class_name StageSceneryController extends Node` | `signal placement_committed(node: Node2D)`; `signal document_change_requested`; `signal preview_state_changed(active: bool)`; `func begin_artwork(path: String, depth: StringName) -> bool`; `func begin_wall(finish: StringName, initial_world_position: Vector2, tint: Color) -> void`; `func begin_wall_eraser(initial_world_position: Vector2) -> bool`; `func cancel_active_gesture() -> void`; `func exit_tool() -> void`; `func begin_group_transform(kind: StringName, members: Array[Node2D]) -> bool`; `func preview_group(delta: Vector2, factor: float) -> bool`; `func commit_group() -> void`; `func cancel_group() -> void` | Owns Scenery-only tool/substate and snapshots. A wall-eraser release subtracts its snapped rectangle from every intersected wall panel and emits one document change when anything changed. `SandboxStage` retains top-level mode, Save, dirty, and input priority. Typed intent signals do not pass UI controls into persistence. |
| `scripts/editors/stage/sandbox_stage.gd`, `class_name SandboxStage extends Node2D` | Route Scenery tools before ordinary selection/placement; gate save/PLAY while a group preview needs resolution; apply controller commits through existing `_mark_scene_dirty()` and `_save_object_state()` as appropriate | Do not append all Phase 19 interaction state to the root controller. Cancel pending drafts on tool/mode changes before existing direct/play transitions. |
| `scripts/stage/stage_selection.gd`, `class_name StageSelection extends RefCounted` | Add a typed selection context, for example `enum SelectionScope { ACTION, SCENERY }` plus `func set_scope(scope: SelectionScope) -> void`; make hit/hover/box use the same predicate | Ordinary EDIT excludes wall/artwork, including missing broad bounds. Edit Scenery includes only those; smaller cutouts outrank panels, with the overlap chooser for remaining ambiguity. Sky is never selectable. |
| `scripts/stage/stage_scene_io.gd`, `class_name StageSceneIO extends RefCounted`; `scripts/editors/stage/stage_scene_adapter.gd`, `class_name StageSceneAdapter extends RefCounted` | v1.5 migration/validation, sky and new object encode/decode, known footprint, Ground finish | Validate document before `_reset_scene_content()`. Invalid new fields cannot silently erase the current scene. Preserve missing Scenery entries across save cycles. |
`StageSpawner` remains the factory for physical terrain, Props, stickmen, and Areas. A small Scenery factory may live in `StageSceneryController` or `scripts/stage/scenery/` to create inert nodes; it must not route visual artwork through `spawn_prop_asset()`. `StageRunSession` and the event/rule engine must treat Scenery nodes as nonparticipants even in PLAY and paused EDIT. Keep Scenery outside the physical spatial index and terrain-paint candidate set, but include it in `World` serialization and Scenery-only selection.
## Scene v1.5 contract
All scene objects remain independent entries in the existing `objects` array. `file_id` is unique within a file, allocated by the adapter; it is not a durable group ID. Scenery object transforms use world coordinates. The Sky ID is a root property. The examples omit unrelated current fields.
```json
{
"version": "1.5",
"scene_name": "Living Room",
"sky_id": "sky_clear_day",
"camera": {"position": {"x": 0, "y": -400}, "zoom": 0.5},
"objects": [
{"file_id": "wall_panel_1", "type": "wall_panel", "position": {"x": -280, "y": -420}, "size": {"x": 320, "y": 400}, "finish_id": "finish_wall_paint", "color": "#5A9D70"},
{"file_id": "scenery_artwork_1", "type": "scenery_artwork", "asset_id": "res://content/props/window_frame.prop", "display_name": "Window frame", "depth": "near", "position": {"x": 0, "y": -450}, "rotation": 0, "scale": {"x": 1, "y": 1}, "last_known_local_bounds": {"position": {"x": -80, "y": -70}, "size": {"x": 160, "y": 140}}},
{"file_id": "terrain_1", "type": "terrain", "asset_id": "ground", "position": {"x": 0, "y": 0}, "rotation": 0, "scale": {"x": 1, "y": 1}, "finish_id": "finish_ground_wood"}
],
"event_rules": [],
"metadata": {}
}
```
The document may contain internal `sky_neutral` and `finish_ground_legacy` IDs to reproduce the prior blank/background and green Ground. They are valid for loading/migration but are not extra child-facing picker cards. For wall panels, `position` is the rectangle center, `size` has strictly positive finite x/y, and rotation is fixed at zero. Group resize writes the new size and center while leaving wall root scale at `(1, 1)`. For artwork, `scale` is finite, positive, and uniform for Phase 19 editing; `rotation` is finite and preserved for existing/scripted entries and repair previews, though the Phase 19 UI offers no rotation control. Depth IDs are exactly `far`, `near`, `floor`; Near is the placement default.
The manual **Erase wall area** tool adds one optional wall field, `motif_origin_offset: {x, y}`, recording the vector from a fragment's saved `position` to its source wall's origin; absent means `(0, 0)`, so existing v1.5 scenes remain valid and no version bump or migration is needed. When a rectangle intersects a wall panel, transform its four corners into that panel's local space, intersect with the finite local bounds, and replace the original with up to four non-overlapping rectangular fragments outside the intersection. Copy the finish, tint, placement ordering, and source-wall lineage to every fragment. Render each fragment's surface pattern using its current world origin, not the inherited source origin, so brick courses remain on the same world-space lattice across the new fragment boundaries. Remove the source panel when the intersection covers it fully; do not touch artwork or non-wall objects. This is visual editing only: no glass, collision, doorway, Ground, or physics behavior is inferred. Each valid release commits immediately through the existing document-dirty signal; Done/Cancel/RMB/Esc exits the tool, and canceling a held drag discards only that uncommitted drag. There is no Stage Undo in Phase 19.
`last_known_local_bounds` is cached geometry metadata, updated when a live asset loads and retained unchanged if it later goes missing. Include stroke width when deriving it, so horizontal/vertical line-only artwork still has a positive visible footprint without inventing collision geometry. It supports a repair footprint and safe group movement; it is not embedded artwork. Missing or malformed optional bounds fall back to a compact world marker and disable group resize until repaired. `display_name` is a short saved fallback label. A repaired placement changes only its selected `asset_id`, name, and known bounds after explicit confirmation; its position, scale, rotation, depth, and unrelated placements remain. The default repair is one placement. A future or optional explicit “Use for all matching places” path must show a count and ask separately before changing other references.
`StageSceneIO._migrate_to_current()` accepts v1.0 through v1.4 and adds `sky_neutral` plus `finish_ground_legacy` to old Ground entries, without changing old Prop role and draw-order migrations. v1.5 validation checks top-level Sky ID, object type payloads, finite vectors and numbers, positive wall sizes and artwork scales, allowed finish/depth IDs, type-safe color strings, unique `file_id`, and canonicalized asset paths. Malformed optional cached art bounds produce a warning and the no-footprint repair marker, rather than rejecting otherwise valid scene content. The adapter creates a missing Scenery node when the source cannot load; missing source is a warning and repair state, not document rejection. Unknown object types follow the existing warning/skip convention only after structural validation. The existing safe write and autosave paths write the same v1.5 snapshot. Manual Save asks to Keep or Cancel a pending group preview; background autosave waits until the preview is resolved. On mode switch, cancel the preview before the current PLAY-entry autosave. Draft values are never serialized.
## Visual layers, Sky, and finishes
The semantic draw order is Sky → wall panels → Far art → Near art → terrain → floor art → characters and Props. `StageDrawOrder` applies bands to direct `World` children. Set all existing terrain varieties to the terrain band for consistent physical-surface drawing; their collision and input remain unchanged. Prop `-1/0/1` choices and World child order among legacy depth-zero Props and stickmen are preserved. Reapply the policy on spawn, load, depth change, and reset. Selection must use the same semantic tier order plus smaller actual art footprint preference, rather than assuming child insertion order expresses depth.
`StageSkyView` lives in its own `CanvasLayer` behind `World`, not under the camera or a wall. Its draw rectangle follows viewport size, so panning/zooming cannot reveal an edge. Use deterministic vector gradient bands, stars, clouds, sun/moon and shapes with bounded point counts. The preset controls only this view. Rendering should be static between sky or viewport changes; no per-frame random placement, animation, parallax, or tint propagation. Sky thumbnails use the same drawing definitions at card size so the choice predicts the Stage.
`StageSurfaceRenderer` defines finite vector motifs with a world-aligned origin and fixed world-unit pitch. Wall and Ground may share Brick/Wood definitions but use context-aware fill/outline colors and scale. `StageWallPanel._draw()` uses its current finite rectangle as a clip and asks the renderer for only intersecting motif cells. For brick walls, cull to the camera-visible local rectangle; if that visible area exceeds 12,000 cells, increase the brick pitch uniformly for all visible courses instead of truncating rows or columns. This keeps large walls fully patterned and fragments continuous without unbounded draw work. Ground renders within its existing rectangle geometry; existing `CollisionPolygon2D` and navigation surfaces are never rebuilt merely for a finish change. The selected **New Ground look** is passed into both `StageTerrainPainter` ghost creation and each committed Ground block; it never repaints earlier blocks. During group resize, wall motif lines retain their pitch and phase. Ground blocks adjacent on a shared edge use the same world phase; shared same-finish interior borders are suppressed or visually joined so a painted run reads as one surface. The neighbor check refreshes on place, delete, move, load, and per-piece finish change, and considers only nearby Ground rectangles; it does not change occupancy/collision. Other motif types remain bounded by the camera-visible region or a maximum cell count per finite panel so `_draw()` work stays limited.
## Selection, input, and transform state
```mermaid
stateDiagram-v2
[*] --> OrdinaryEdit
OrdinaryEdit --> Chooser: Scenery palette
Chooser --> PlaceArt: artwork chosen
Chooser --> DrawWall: wall look chosen
Chooser --> EditScenery: Edit placed scenery
PlaceArt --> OrdinaryEdit: Done / Cancel / RMB / Esc
DrawWall --> OrdinaryEdit: Done / Cancel / RMB / Esc
EditScenery --> GroupPreview: Move together / Resize together
GroupPreview --> EditScenery: Keep changes / Cancel / RMB / Esc
EditScenery --> OrdinaryEdit: Done editing / Esc
```
The controller tracks one active Scenery tool at a time, the number of committed copies this session, and whether a wall/touch draft is active. A passive artwork ghost is never a committed edit. Right-click/Done/Cancel follow the approved [exit matrix](phase_19_scenery_backgrounds_ui_ux.md#placement-exit-cancel-and-mode-changes): committed pieces stay; an active draft is discarded; a late release cannot commit it. Existing terrain and Prop placement are canceled when a Scenery tool takes input. DIRECT/PLAY clears Scenery ghosts and Edit Scenery selection.
`StageSelection` filters both click and box paths. Ordinary EDIT includes existing actionable objects and excludes Scenery broad bounds. In Edit Scenery, candidate geometry comes from a wall rectangle or the full transformed vector art: filled polygons use point-in-polygon, open strokes use a screen-scaled segment tolerance, and missing art uses its marker or known footprint. A small cutout wins over a broad wall even if both are candidates; unresolved overlaps use the shared “Pieces here” popup. The small explicit missing-art **Find artwork** badge is a separate 44 px screen-space hit target that can open Edit Scenery without making the entire missing footprint selectable in ordinary EDIT. Sky never enters either selection scope.
For one selected artwork, a uniform scale changes root `scale` within finite positive bounds. For a wall, resizing changes `panel_size`, with a minimum visible world size and no mirrored/negative rectangle. Pointer/touch handle hit areas are at least 44 logical pixels after camera zoom compensation. `StageGizmos` can share outline/handle drawing helpers; a Scenery-specific adapter is justified for rectangle sizing, no rotation control, and the pending group preview. It must still use `SandboxTheme.overlays` selection colors and the Stage halo style, rather than duplicating visual tokens.
Group preview snapshots `global_position`, `rotation`, artwork `scale` or wall `panel_size`, and the initial world bounds of each selected member. For move, derive one delta from the selected union anchor, apply Snap once to that anchor, then add the same delta to every snapshot position. For resize, compute one positive factor around the opposite union corner `P`; for each artwork center `x`, set `x' = P + factor * (x - P)` and `scale' = factor * scale`; for each wall center/size, use the same center formula and `size' = factor * size`. Derive every preview frame from the snapshots, never the previous preview frame. Reject factor `<= 0`, any collapsed member, or any out-of-range art scale. Buttons use the same formula with top-left fixed. Missing members with no known footprint may move but block group resize with the approved explanation. Mouse release leaves the candidate preview pending. `Keep changes` saves each authored transform, marks dirty once, and returns to Edit Scenery. `Cancel`, RMB, or Esc restores all snapshots exactly and leaves the same selection. A Save request opens a Keep/Cancel decision for the preview and resumes Save only after explicit resolution; tool/mode changes cancel the preview. A temporary group selection and preview are never written to `.scene`.
## Phase 19 addition: authoring size and single-placement transforms
This addition implements the approved [Stage-relative sizing flow](phase_19_scenery_backgrounds_ui_ux.md#stage-relative-authoring-size-in-prop-editor) inside Phase 19. The standard standing guide spans **939.5 world units** from head-circle top to ankle line; write `H := 939.5` as one Prop Editor reference constant derived from `WholeStickmanPreview.GUIDE_JOINTS` and `GUIDE_HEAD_RADIUS`, so the UI never assumes the old 96-unit Normalize Size target. The presets are starting points based on the artwork's **longest visible dimension**, including stroke: Small = `0.35 H`, Room = `1.0 H`, Big = `2.0 H`. This covers both tall and wide drawings without forcing a wide rug or skyline to be one stickman tall. Each **Make bigger** step multiplies by `1.1`; **Make smaller** multiplies by `1 / 1.1`, so opposite steps return to the same size within floating-point tolerance. The labels are approximate comparison choices, not an inferred real-world category or a maximum size. Artist size inference is part of authoring/review instructions; no runtime name parsing, asset size tag, or automatic normalization is added.
### Prop Editor authoring transaction
`PropEditor` (`class_name PropEditor extends Control`) replaces the Arrange toolbar's immediate `_on_normalize_size_pressed()` action and `NORMALIZED_MAX_EXTENT` with **Size for Stage**. The action is disabled when the document has no drawable geometry or its longest visible extent is not finite and positive; its tooltip says **“Add visible artwork to use Size for Stage.”** It owns the toolbar controls, Keep/Cancel routing, and the existing `_dirty`/autosave boundary. `PropCanvas` (`class_name PropCanvas extends Control`) owns a temporary deep copy of its complete shape stack and the supplied editable-anchor dictionary; its normal `shapes` and the editor's `_anchors` remain the committed document while previewing. A small typed API is sufficient:
```gdscript
func begin_stage_size_preview(anchors: Dictionary) -> bool
func preview_stage_size(factor: float) -> bool
func commit_stage_size_preview() -> Dictionary # {"changed": bool, "anchors": Dictionary}
func cancel_stage_size_preview() -> void
func is_stage_size_preview_active() -> bool
func get_visible_shapes_bounds() -> Rect2 # Includes outline/stroke width.
func fit_stage_size_view(bounds: Rect2, padding_px: float) -> void
```
The preview records original internal shape dictionaries, anchors, canvas pan/zoom, and the entry bounds once. Preset factor is `target_longest_edge / entry_longest_edge`; fine buttons adjust the current absolute factor, but **every rendered candidate is derived from the entry snapshot**, never scaled from the previous candidate. The shared `origin` anchor remains at `(0, 0)`. Scale all polygon/line points, circle points and radius, each shape's `outline_width` (including line-only art), and every editable anchor position by the same factor about the document origin. Preserve colors, shape order, vertex flags, `reshaped`, anchor roles, and anchor angles. This is the established whole-document pivot, so Size for Stage does not silently recenter the saved origin or detach grips from their art. Reject a candidate atomically if factor, coordinate, radius, stroke width, anchor position, or visible extent is nonfinite or outside the existing representable coordinate/size limits; leave the last valid preview visible and the committed document untouched. Do not clamp one shape or anchor independently. Zero-width strokes remain zero. A no-op factor of 1 leaves the document clean when it was clean on entry.
Draw the size candidate in `PropCanvas._on_canvas_draw()` and then draw the guide **after all artwork**, with dark halo and light core for the bones, head, and floor line. Derive the comparison floor from the **candidate's current visual bottom**; align both guide ankles to that y and place the figure just to the artwork's left (or over its left portion when width is tight), so offset and centered authoring coordinates compare correctly without translating the artwork. With an empty canvas, draw the guide at its normal origin and ankle line; Size for Stage remains disabled until artwork exists. The floor cue can follow a changed candidate bottom. On entry, compute a union of candidate artwork plus the guide/floor and frame it with a bounded margin. Refit that union after a preset choice and when a fine step would clip it; do not reframe on every draw or override a child's deliberate pan/zoom while the union remains visible. The sizing view may temporarily fit below the canvas's normal manual `MIN_ZOOM` on narrow viewports and restores the prior pan/zoom on exit. Outside the sizing view, show the same foreground guide and floor whenever **Show Stickman Guide** is enabled. During sizing, force guide drawing on without changing or persisting that checkbox; restore its user preference on exit. The guide is canvas drawing only, never a saved shape, anchor, or Stage object.
**Keep size** validates the full candidate, swaps the complete shapes/anchors once, emits one document change, refreshes inspector/thumbnail state, and starts the existing dirty/autosave timer once if the result differs from the snapshot. **Cancel**, Esc, mode switch, New/Load, return to Stage, or closing the editor discards the candidate before another document action; no preview geometry enters Save/autosave. A Save request while the sizing view is open asks the child in the existing sizing controls to **Keep size** or **✕ Cancel** first, then the child can use ordinary Save/Save As; no new save dialog or asset-copy policy is introduced. `Transform Whole Prop`, Attach, Reshape, and canvas editing cannot run concurrently with this sizing transaction. The present whole-prop Transform action remains available in Arrange after sizing.
```mermaid
stateDiagram-v2
Arrange --> SizePreview: Size for Stage / snapshot
SizePreview --> SizePreview: Small, Room, Big, smaller, bigger / derive from snapshot
SizePreview --> Arrange: Keep size / validate, commit once, dirty once
SizePreview --> Arrange: Cancel or Esc / discard, no dirty
SizePreview --> Arrange: leave editor or switch mode / discard, no dirty
```
### Direct Stage move and resize of one Scenery piece
`StageSceneryController` (`class_name StageSceneryController extends Node`) takes ownership of the currently selected Scenery drag, replacing `SandboxStage`'s local `_scenery_drag_snapshots`/`_scenery_dragging` move path. It reuses its existing group snapshot math and `document_change_requested` signal but keeps the single gesture's commit boundary separate: release commits immediately; group release still leaves a candidate requiring **Keep changes**. Suggested typed controller contract:
```gdscript
signal single_gesture_changed(active: bool)
func begin_single_transform(node: Node2D, kind: StringName, pointer: Vector2, dragged_corner: Vector2 = Vector2.INF) -> bool
func preview_single_transform(pointer: Vector2) -> bool
func commit_single_transform() -> bool
func cancel_single_transform() -> void
func is_single_transform_active() -> bool
func step_single_size(node: Node2D, factor: float) -> bool
```
`kind` is exactly `&"move"` or `&"resize"`; the controller accepts only one selected `StageWallPanel` or `StageSceneryArtwork`. Snapshot `{node: Node2D, position: Vector2, rotation: float, scale: Vector2, panel_size: Vector2 when wall, bounds: Rect2, pointer: Vector2, dragged_corner: Vector2}`. This is transient in-memory state, never a `.scene` payload. A missing artwork without known bounds can move but cannot resize. Before applying any candidate, validate the whole result: finite position and scale, positive artwork scale components within the current Stage range, positive wall dimensions above `MIN_PANEL_SIZE`, valid node still in Edit Scenery, and a nondegenerate starting AABB **for resize**. A move preserves any existing positive nonuniform scale. A resize multiplies both scale components by one uniform factor, preserving the ratio of an imported/scripted placement without normalizing it. Rejecting a frame retains the last valid preview. An unchanged release emits no document change.
For move, derive `raw_delta = pointer - pointer0` from the snapshot. With Snap on, align the **visible world AABB's top-left corner** rather than the node's often offset origin: `delta = snap(bounds0.position + raw_delta) - bounds0.position`; apply `position0 + delta`. This preserves the existing top-left grid-alignment promise for placed wall panels and artwork. For uniform resize, take the world AABB corner opposite the dragged corner as the fixed point `P`. With `D = dragged_corner - P`, use `factor = 1 + (pointer - pointer0).dot(D) / D.length_squared()`; reject nonpositive/invalid factors. For artwork with saved local geometry, `position' = P + factor * (position0 - P)` and `scale' = scale0 * factor`, preserving rotation and depth. For walls, use the same position formula and `panel_size' = panel_size0 * factor`, leaving root scale at 1 so Brick/Wood motif pitch does not stretch. The fixed world corner stays fixed even when artwork's authored origin is offset. **Smaller**/**Bigger** in the selected-piece card call `step_single_size()` with reciprocal factors `1/1.1` and `1.1`, top-left pinned; each valid press is an immediate independent commit for that placement only. Other artwork instances, walls, Ground, Props, characters, and their interaction state are untouched.
`StageGizmos` supplies selection outline and corner drawing/hit geometry, using existing `SandboxTheme.overlays` colors and dark-halo/light-core selection language. Extend its Scenery adapter with at least **44 × 44 logical-pixel** hit regions converted through camera zoom, prioritizing a corner hit over the body; do not reuse its current `PropBlock`-only SCALE gesture or origin-pivot formula. The Stage root arbitrates input once: selected corner starts resize, selected body starts move, and group preview has priority while active. `StageSceneryController` previews directly on the selected node, then emits `document_change_requested` exactly once on changed release; `SandboxStage` updates its authored-state/save path and card. Esc, right-click, visible capture-bar **✕ Cancel**, mode/tool switch, selection removal, or node deletion restores the snapshot and clears the gesture. An input-generation token or equivalent active-state guard must consume a mouse/touch release after cancellation so it cannot commit an old draft. Manual Save first cancels an active single gesture and saves only prior commits; background autosave waits for it to end. Group preview retains its existing separate Keep/Cancel save gate.
```mermaid
flowchart LR
Pointer[Corner/body press] --> Hit[StageGizmos hit: corner first]
Hit --> Begin[SceneryController snapshot]
Begin --> Preview[Pointer motion from snapshot]
Preview --> Release[Release: commit once]
Preview --> Cancel[Esc/RMB/capture Cancel: restore]
Release --> Dirty[Stage dirty + scene v1.5 placement transform]
Cancel --> Idle[No dirty; stale release ignored]
```
The selected artwork card adopts `StageInspectorPanel` for fixed header/scrolling body, `StagePanelChoiceGroup` for Far/Near/On the floor, and `StageInspectorPanel.style_action_button()` for Smaller/Bigger and Erase. The selected wall card uses the same shell and action styling. Both call `set_safe_rect()` and preserve the compact Settings reopen behavior. `StagePanelToggleRow` is reserved for actual booleans; no new panel control is needed for size. The already shared `StageUseTargetCaptureBar.set_capture_actions()` shows **✕ Cancel** only while a single drag is held; group Keep/Cancel remains unchanged. New sizing controls in Prop Editor use its existing Arrange toolbar and `PropTheme`, because that editor is a distinct workflow from an in-stage selected-object card. No duplicate dialog, capture-bar, choice, or button styling is introduced.
The existing `.scene` v1.5 `position`, `scale`, `rotation`, and wall `size` fields already persist these per-placement changes; `.prop` v1.1 already stores points, circle radius, outline width, and anchors. This addition requires no schema bump and performs **no load-time resizing or migration of asset geometry or placement scale**. Saving an explicitly revised `.prop` at its existing path intentionally updates all live-reference Scenery and Props that use that path when reloaded; ordinary Save and Save As remain the only asset save actions. A scene's saved placement scale continues to multiply its current referenced art. There is no new asset-version enforcement, copy/update prompt, automatic compensation factor, or runtime name-based size inference.
## Asset catalog, tags, thumbnails, and missing-art repair
`PropFile` advances to v1.1 with optional top-level `tags: Array[String]`. It accepts v1.0 files with `tags = []`, normalizes a fixed set `nature`, `buildings`, `walls`, `roads`, `objects`, deduplicates, and writes only known IDs. Tags describe appearance, never physics. The same file remains the only vector authoring format; no `.scenery` file or new asset library root appears. Keep cutouts as immediate children of `content/props/` for existing scans; unapproved art stays under ignored `content/review/artist/<slug>/` and must not enter browser results. `ContentPaths.canonicalize()` handles saved paths, including old `props/` paths, while preserving external paths.
Split library eligibility by role without duplicating parsing: a shared parse/cache step returns `{id: String, path: String, name: String, shapes: Array[Dictionary], tags: Array[String], visual_bounds: Rect2, material_preset: int, collision_hull: PackedVector2Array}`. Scenery accepts nonempty visible vector geometry, including open lines and transparent-window frames, via `PropVisuals.build()`/shape bounds. A closed filled shape needs three noncollinear points; an open stroke needs two distinct points, positive width, and visible color. These rules accept line art without accepting a blank `.prop`. Props retain the existing valid closed-hull requirement. The existing `StageAssetBrowser` scan, asynchronous thumbnail queue, `ThumbnailCache`, and `PropThumbnail.render_shapes()` are reused for either role; a line-only artwork thumbnail must not go through `flatten_to_payload()` as a fake collision shape. Scenery keeps authored per-shape colors rather than applying a Prop material tint. All artwork includes untagged assets; Unsorted appears when appropriate. The same tag filters are available in Props, but its entries remain physically eligible only. Do not scan review directories or recursively change the existing immediate-child library behavior.
On scene open, load every live artwork reference. A missing/invalid source leaves a `StageSceneryArtwork` node with `missing = true`, saved transform/depth, remembered name, and last known footprint. Render a neutral translucent footprint with dashed light/dark edge when bounds exist, otherwise a compact world marker, with a constant-screen-size picture/question icon and “Artwork not found” label. It stays subdued in DIRECT/PLAY and is never solid. Show one themed missing-assets summary for the load with the UI spec’s **Show scenery** / **Later** actions, while retaining separate Prop/stickman warning semantics. The Scenery piece list has a **Needs artwork** section.
**Find artwork…** uses the established `.prop` chooser; **Choose another artwork** uses the common Artwork gallery. The candidate must pass Scenery visual validation before a ghost appears at the saved position/size/orientation/depth. The explicit **Use this artwork here?** confirmation commits only the selected placement. Cancel/failure leaves the original path, marker, and scene dirty state untouched. Replacing art marks dirty once and refreshes the known bounds, name, and visual tree; the original position, scale, rotation, and depth remain. A changed source file is picked up on reopen for every live reference; there is no embedded snapshot. The optional File details disclosure may show the path for an adult. The simple one-placement repair is required; an all-matching bulk repair is optional and must be explicitly confirmed with its count.
## Starter scenes and document lifecycle
Keep versioned starter documents under `content/stages/starters/` with stable IDs for Blank, Town Street, and Living Room. They are ordinary `.scene` snapshots, not locked nested instances. Blank may be assembled from the current neutral empty-scene defaults; Town Street and Living Room data use the same wall, artwork, terrain, Prop, Sky, and camera schema as a saved child scene. The bundled files reference only explicitly approved production art. A window is a genuine gap between wall rectangles with a transparent frame and Sky behind it; no cutting or glass behavior is inferred. Furniture Props receive deliberate still/block/support roles from the normal Prop contract, without promising Sit.
The current `PendingFileAction.NEW_SCENE` unsaved Save/Discard/Cancel gate remains authoritative. After it resolves, show the shared starter gallery **before** replacing the current document. Canceling the gallery leaves the existing scene and post-gate dirty state. Selecting a starter preflights its scene data, loads ordinary editable objects, resets `current_path` to empty, stamps a fresh creation time, proposes its editable name, and marks the new document unsaved. Failed starter validation leaves the prior scene intact and shows one themed error. No starter is inserted into another scene. A first-save path follows the existing Save As flow. CLI scene playback continues to load `.scene` directly, with no starter-specific runtime branch.
## UI component and theme reuse
| Surface | Reuse or shared extension | Feature-local work / justified exception |
| --- | --- | --- |
| File → New gate | `StageChrome` File menu; `StageModalViews` unsaved dialog; `StageDocumentController` pending action | Starter-choice result and fresh-document handoff only. |
| Starter, Scenery, Sky, finish and replacement galleries | Extract a responsive shared card-gallery body from `AssetSelector`/`scenes/stage/asset_selector.tscn`; retain `AssetCard`, `StageAssetBrowser`, thumbnail pipeline | Tabs, descriptive text, and filter meaning are feature-specific. Replace the current fixed four-column/12-per-page and `FOCUS_NONE` card limitation in the shared path: adaptive columns, scroll, native focus, visible focus ring, and keyboard activation. Non-placeable cards disable card drag. |
| Browse tags and authoring tags | One shared wrapping chip/choice treatment using native Buttons and `FlowContainer`/`HFlowContainer`, `SandboxTheme` popup tokens | Single-select browser filter versus multi-select authoring tags have distinct state rules. Do not use vertical `StagePanelChoiceGroup` as a chip row. |
| Wall color | Extend `ColorSwatchSelector` for wrap/selected/focus; native color picker in a `StageModalViews` themed window | Chosen wall color remains local to the placement/selected panel. |
| Selected artwork, missing art, wall, Ground, multi-selection | Direct use of `StageInspectorPanel`, `StagePanelChoiceGroup`, `stage_panels` tokens and `StagePropSettingsView` dismissal/compact Settings precedent | Domain rows and commands stay in a Stage Scenery/Ground view adapter. `StagePanelToggleRow` is used only for a real boolean, not depth or finish. |
| Member list / overlap popup / wall-area eraser | `StageInspectorPanel` action styling; generalize `StageModalViews.show_objects_here()` labels and candidate filter; reuse `StageUseTargetCaptureBar` for the eraser prompt and Done/Cancel | Membership, piece labels, the red rectangle preview, and wall subtraction are Scenery-specific. The selected wall card starts the tool; no new dialog, overlap popup, or capture-bar styling. |
| Placement/group capture | Extract themed responsive shell from `StageUseTargetCaptureBar`, preserving mode-pill-safe position, prompt/Cancel grouping, action stacking | Use-target and Scenery wording/actions stay in callers. No copied capture-bar CSS/StyleBox code. |
| Cursor and ghosts | `StageChrome` tooltip safe placement, `StagePlacementOverlay`, terrain ghost presentation, `SandboxTheme.overlays` | The draft geometry, validity, and missing marker are feature-specific. |
| Make artwork | Existing `PropEditor`, `PropCanvas`, `.prop` Save/recovery and `PropThumbnail` | Add a typed `AuthoringContext { PROP, SCENERY }`, `signal artwork_saved(path: String)`, and `signal return_requested`. In Scenery context hide material/mass/bounce, attachment, and other action settings; keep shape/color/name/tags/Save. |
The shared UI contracts should remain small and typed. Extract `scripts/shared/ui/asset_gallery_popup.gd`, `class_name AssetGalleryPopup extends PopupPanel`, as the fixed header/Close and scrollable responsive `AssetCard` grid used by the current `AssetSelector` and Phase 19 choosers. It emits `item_selected(entry: Dictionary)` and `cancelled`, and exposes `open_gallery(title: String, entries: Array[Dictionary]) -> void`, `set_thumbnail(entry_id: String, texture: Texture2D) -> void`, and `focus_entry(entry_id: String) -> void`. A card entry has `{id: String, label: String, thumbnail: Texture2D|null, helper: String, draggable: bool}`; the caller maps `id` to its domain item. `AssetSelector` retains its public `open(kind, entries)` facade and uses the shared gallery, so existing Prop/Stickman callers are not broken. `StageAssetBrowser` retains scanning/caching and adds a role-safe filtered query such as `scan_entries(kind: String, tag: StringName = &"") -> Array[Dictionary]`; no gallery owns asset parsing.
Extract `scripts/shared/stage_ui/stage_capture_bar.gd`, `class_name StageCaptureBar extends PanelContainer`, from the `StageUseTargetCaptureBar` shell. Its intended interface is `signal action_pressed(id: StringName)`, `func configure(stage_theme: SandboxTheme, mode: int, top_bar_height: float) -> void`, `func set_prompt(text: String) -> void`, `func set_actions(actions: Array[Dictionary]) -> void`, and `func hide_capture() -> void`; each action dictionary has `{id: StringName, label: String, prominent: bool}`. Keep `StageUseTargetCaptureBar` as a thin Use-specific wrapper or composition client preserving `cancel_requested` and `change_prop_requested`. Generalize `StageModalViews.show_objects_here()` with a typed title/label provider while retaining its existing facade and `object_selected(node: Node2D)` signal. Existing `StageInspectorPanel`/`StagePanelChoiceGroup` APIs need no new domain knowledge: caller-owned cards call `set_header`, `body`, `set_safe_rect`, `request_fit`, and `add_choice`/`select_no_signal` directly. `ColorSwatchSelector` may change its layout container to `FlowContainer` while preserving `configure(colors: Array[Color], selected: Color, theme: Variant)`, `set_selected`, and `color_selected(color: Color)`.
All new popup/native-window instances receive `SandboxTheme.control_theme()` explicitly, then `StageModalViews.apply_dialog_theme()` for child font overrides. The latter does not assign a window theme. Extend `SandboxTheme.DEFAULT_CONFIG` and `sandbox_theme.json` only for shared popup/gallery/chip/capture/selection measurements and colors. Use local StyleBox copies; do not mutate the cached shared `Theme`. Selected, hover, focus, and disabled remain distinguishable. All actionable controls and handle hit regions meet the approved minimum 44 × 44 logical pixels. Viewport-safe rectangles keep the mode pill and Close reachable; long content scrolls under a fixed header. The existing queue/rule/speech popups are distinct workflows and are not recast as gallery or inspector shells.
## Stage-hosted Prop Editor lifecycle
Instantiate the existing `scenes/editors/prop/prop_editor.tscn` in a full-viewport Stage-hosted UI layer, set Scenery context before its first user interaction, then focus its first drawing/name control. While active, `SandboxStage` routes no world selection, placement, camera mode, or shortcuts to the hidden Stage; dialogs and editor input remain usable. The underlying Stage document, dirty flag, selection, and current scene path remain in memory. The editor keeps its own `.prop` autosave and unsaved-work gate. Its F6 standalone exit still quits its own run; in hosted context Close/Cancel emits `return_requested` to remove the editor view and restore Stage input/focus without quitting the Stage. A successful Save emits `artwork_saved(path)` after the file write; **Place it as Scenery** returns to the Artwork chooser with a fresh scan/thumbnail generation and the saved asset focused. Merely opening/canceling the editor never changes the Stage document. A saved new `.prop` outside the Stage is a separate asset write, so returning without placement leaves the Stage unchanged.
## Implementation and art gates
1. Extend data boundaries first: `.prop` tags and role-safe visual scanning; scene v1.5 migration/validation; semantic draw order; inert visual nodes and shared finish/Sky definitions. Validate old scene/Prop compatibility before UI wiring.
2. Generalize the existing card gallery, tag treatment, capture bar, overlap popup, color treatment, confirmation style, and theme tokens. Build the Phase 19 chooser and inspectors from those shared controls. Avoid a parallel popup/card system.
3. Connect placement, selection scopes, missing repair, Ground finish, and group snapshot previews through `StageSceneryController` and the existing Stage save/mode gates. Add the starter gallery and Stage-hosted Prop Editor return path.
4. The original Phase 19 art manifest used `$asset-creation` and approved production assets. **This sizing amendment requires no new vector/raster deliverables**: it draws the existing stickman guide and floor cue in Prop Editor and extends existing Stage handles. An artist may deliberately revise a particular `.prop` through the ordinary asset-review workflow, but implementation does not batch resize built-ins or reopen the original art approval gate merely because this amendment is signed off.
5. Run syntax/import checks and focused existing suites after each integration slice. Add focused behavior checks where actual world state is at issue. Perform render-capable Stage/editor captures and real input-path checks before reporting visual/interactive outcomes as agent-verified.
Relevant existing regression starting points are `tests/test_phase19_scenery.gd`, `tests/test_phase10_prop_integration.gd`, `tests/test_prop_editor_file.gd`, `tests/test_prop_editor_canvas.gd`, `tests/test_prop_editor_round3.gd`, `tests/test_content_paths.gd`, and `tests/test_phase17a_prop_roles.gd`; run focused affected suites rather than the full historical matrix for each sizing edit. The original Phase 19 fixture covers scenery persistence and group behavior; extend focused coverage for Size for Stage's atomic Keep/Cancel and a real one-piece Stage resize, including late release after cancellation. Exercise production input and save/reopen paths, not only helper calculations.
## Observable acceptance and verification routes
Each row names a child path and a repeatable observation. Automated checks may create in-memory fixtures, but should use actual Stage spawning, document, input, and PLAY paths where practical; node/signal existence alone does not establish behavior.
| Child path / scenario | Observable outcome and measurement |
| --- | --- |
| File → New → cancel starter, then Town Street; save/reopen; repeat Living Room | Existing scene survives chooser Cancel. Each chosen starter is unsaved, individually editable, and preserves Sky, separate panels/art/Props/Ground and positions after save/reopen. Living Room Sky is visible through an actual wall gap. |
| Scenery → Artwork → Nature → Tree; place twice, change depth, RMB; reopen | Two distinct inert copies persist at saved transforms/depths. RMB removes ghost, preserves committed copies, returns to ordinary EDIT. Check `PhysicsDirectSpaceState2D` contacts, navigation candidates, and event participation remain absent for scenery while a normal Prop remains functional. |
| Make line-only artwork in Stage-hosted editor; Save → Place it as Scenery | `.prop` with open strokes appears in Scenery and has a valid thumbnail; it does not enter Props without valid closed collision geometry. Stage unsaved state and focus survive authoring return. Tags filter it without changing its behavior. |
| Draw Brick wall, resize it; paint a Wood Ground run, restyle one block | Wall is finite/inert; brick pitch stays constant. Ground collision and walking path remain valid; wood motifs line up across neighbors, shared seams do not interrupt the run, and only the selected block changes to the new finish. Measure character feet/contact over several physics ticks before and after restyling. |
| Ordinary EDIT click/box in front of broad wall; enter Edit Scenery; select art and wall through Pieces here | Gameplay objects remain reachable in ordinary EDIT. Scenery-only selection finds art ahead of broad panels, includes missing art in Edit Scenery, and never selects Sky. Real pointer/touch/keyboard activation produces the expected selection and popup dismissal. |
| Living Room: select two walls, window frame, rug; move preview then RMB; resize preview then Keep; Save/reopen | RMB restores each exact snapshot transform. Keep applies one positive factor around the opposite fixed corner, preserves artwork proportions/depth and wall motif pitch, leaves Ground/furniture untouched, and persists member transforms without a saved group. Save while pending asks Keep/Cancel and never writes draft values. |
| Prop Editor → Arrange → Size for Stage on a tall refrigerator, wide rug, and small item; try each preset/fine buttons, Cancel once and Keep once | The artwork and standard guide remain fully visible at ordinary and narrow viewport sizes. Longest visible edge reaches approximately `0.35H`, `1H`, or `2H` while proportions, circle radii, line widths, and editable anchor positions share the same factor; the foreground halo guide's ankles meet the floor cue at the candidate bottom. Cancel restores byte-equivalent authored geometry/anchors and the prior dirty state; Keep commits once. A Big preset cannot clip the guide merely because opening began on small artwork. Save/reopen the `.prop` through the normal path and compare geometry/anchors. |
| Edit Scenery → select two copies of one artwork; drag a corner on one, release; then cancel another drag with Esc/RMB/touch Cancel and release the pointer | The opposite visible corner stays at the same world position; only the edited placement changes, and it commits on release without Keep. A canceled drag restores its snapshot and the late release makes no change. With Snap on, a moved artwork or wall's visible top-left lands on a grid intersection. Smaller then Bigger returns near the initial bounds. Save/reopen and check the two copies retain distinct scales, while Props/Ground/PLAY behavior remains unchanged. A missing artwork without known bounds shows no resize handle. |
| Open a pre-amendment `.prop` and `.scene`, then explicitly overwrite one referenced `.prop` through ordinary Save | Opening alone does not rescale artwork or placement values. The explicit source overwrite changes every live reference to that source on reopen, while each scene placement retains its saved scale; there is no hidden compensation, copy prompt, or version migration. |
| Move a referenced `.prop` aside, reopen, repair selected missing artwork; cancel once then confirm | One neutral marker remains at saved position/depth, summary appears once, ordinary EDIT targets are not stolen, cancel keeps path/dirty state, and confirmed repair changes only that placement. Missing footprint/no-footprint group resize rules and PLAY marker presentation hold. Restore source and reopen to confirm live-reference visual update. |
| Select each Sky, pan/zoom, PLAY/stop, reopen | Sky fills screen without an edge or flicker; foreground pixel colors are unchanged by Sky choice. Layer order remains Sky/wall/Far/Near/Ground/floor/actors through camera movement, simulation, and reload; ghosts/handles/capture bars disappear in PLAY. |
Visual acceptance requires opening the affected scene in a render-capable Godot GUI, capturing the named chooser, normal/zoomed Stage, missing-art, group-preview, **Prop Editor sizing guide at Small/Big and narrow width, active single-piece handles/capture bar**, and PLAY states, and inspecting the pixels. Record capture location, scene/state, and observation. Interactive acceptance requires exercising the actual controls and input events, including RMB/Esc/touch cancellation, button sizing, grid-aligned move, commit on release, and a late release after cancellation. If GUI or input automation is unavailable after an attempted route, name the unverified outcomes and hand the user a focused reproducible check; a headless parse or screenshot file alone is insufficient. Phase 19 user/editor verification remains open until the child checks the integrated behavior.
## Trade-offs and future work
- Independent nodes preserve the current editable direct-child document model and avoid using `PropBlock` as a fake inert body. The cost is a separate artwork visual node and explicit scene-object decoding.
- Live `.prop` references keep scenes compact and make art edits visible on reopen. They can change a scene's appearance after the source file changes; the saved name/footprint and repair flow cover missing files, not version pinning. A future immutable asset-version policy would need a separate decision.
- Stage-relative authoring size is geometry stored in `.prop`, while a Stage Scenery resize is an instance transform stored in `.scene`. This keeps legacy loads stable and lets two placements of one source have different sizes. It also means an intentional overwrite of a live `.prop` updates all references; the user chose to retain ordinary Save/Save As without a new copy/update decision. The authoring preview scales outline width and editable anchors with geometry and uses origin as its pivot; its floor guide follows visual bounds solely for comparison.
- A one-piece Stage drag commits on release for speed; a multi-piece group retains Keep/Cancel because its wider effect is harder to judge during a gesture. Both use exact snapshots before commit, while Stage-wide Undo remains deferred to the separate phase logged in technical debt.
- Rebased terrain drawing enables the required floor-art tier without changing Prop-to-character depth semantics. It changes terrain's relative visual position beneath Props; physics and navigation stay unchanged and must be regression checked.
- Vector motifs retain clarity and world alignment without repeated raster textures, at the cost of careful clipping/culling and seam management. Keep motif definitions centralized so wall/Ground previews match runtime drawing.
- Stage-wide Undo/Redo deserves one command history across placement, deletion, style, repair, and group commits. The Phase 19 Keep/Cancel preview is a local transaction only. Whole-set transform needs its own design because Ground physics, character/Prop placement, rules, and camera all have different transform invariants.