Add regression tests for prop attachment, centering, and editor UX

- Implemented test_phase9a_prop_attach.gd to validate anchor schema, alignment math, and preview isolation.
- Implemented test_phase9b_prop_centering.gd to check canonical origin presentation, bounds, centering, and save threshold.
- Implemented test_phase9c_prop_editor_ux.gd to assess the user experience in the prop editor, including anchor renaming and UI responsiveness.
- Added corresponding .uid files for each test.
- Created sync_sources.py script to manage markdown file synchronization with the notebook.
- Added .gitignore for virtual environment files.
This commit is contained in:
2026-09-21 16:48:24 -04:00
parent e3689c86c8
commit 00abacc7b2
30 changed files with 4903 additions and 55 deletions
+597
View File
@@ -0,0 +1,597 @@
# Prop Editor User Guide
The Prop Editor is a standalone tool for making vector props and saving them as
`.prop` files. It is separate from the main Stickman Editor and Sandbox Stage.
This guide describes the current controls and provides a complete manual test
checklist.
## Start the editor
1. Open `res://scenes/prop_editor.tscn` in the Godot editor.
2. Press **F6** to run the current scene.
The Prop Editor is not the project's configured main scene. If F6 runs a
different scene, open `prop_editor.tscn` first.
## Layout
The window is divided into these areas:
| Area | Purpose |
| --- | --- |
| `📁 File` and `👁️ View` | File operations and canvas view reset |
| `Grid`, `Snap`, and `🎯 Center Prop` | Shared grid, snapping, and prop placement settings |
| `Prop Name` | The name stored in the `.prop` file |
| Mode bar | Switch between `🖐️ Arrange`, `✏️ Reshape`, and `📌 Attach` |
| Authoring canvas | Draw, select, transform, and reshape the prop |
| Inspector | Material, shape, primitive, and anchor controls |
| Holding Preview | A private stickman-and-prop preview shown in Attach mode |
| Status bar | Current prop/preset/dirty state and cursor coordinates |
There are two adjustable splitters:
- Drag the main splitter between the canvas and Inspector to make the Inspector
wider or narrower.
- In Attach mode, drag the splitter between the authoring canvas and Holding
Preview in either direction. The preview keeps a minimum width so its
controls remain usable.
The Inspector starts at its minimum usable width so the canvas has room to
work. Drag the canvas/Inspector splitter to widen it when labels or controls
need more space. Its background is the theme color `#262C39`, intentionally
darker than the canvas; the Inspector also scrolls vertically on smaller
windows.
The Inspector scrolls vertically. On a small window, widen the Inspector or
scroll it rather than expecting every section to fit at once.
## File and View menus
### `📁 File`
| Command | What it does |
| --- | --- |
| `🆕 New` | Clears the current prop and starts a new document. If the current document is dirty, asks whether to save first. |
| `💾 Save` | Saves to the current path. If the prop has never been saved, opens Save As. |
| `📄 Save As...` | Opens the save dialog and changes the current path to the new file. |
| `📂 Load...` | Opens a `.prop` file. A successful load replaces the current document. |
| `🕑 Recent` | Shows up to five most-recently saved or loaded prop paths. Missing files are removed from this list. |
Save dialogs use the `.prop` filter. Save As normalizes a filename so it ends
in `.prop` even if that extension was omitted. Keeping authored props under
`res://props/` is recommended.
### `👁️ View`
| Command | What it does |
| --- | --- |
| `🔍 Reset View` | Resets the authoring canvas zoom and pan to 1x, with the local origin centered in view. It does not reset anchor values or the Holding Preview camera. |
The Holding Preview has its own `Reset View` button; see [Holding Preview](#holding-preview).
### Top-right canvas controls
| Control | What it does |
| --- | --- |
| `Grid` | Sets the visible grid spacing and the snap increment, from 1 to 512. |
| `Snap` | Enables or disables snapping for shape transforms and anchor movement. |
| `🎯 Center Prop` | Moves the complete prop so the combined shape bounds are centered on the fixed local origin. It also translates every non-origin anchor by the same amount and focuses the canvas on the origin. |
Center Prop is a deliberate one-time command. The editor does not recenter
automatically after drawing, reshaping, or moving a shape. Empty and already
centered props are unchanged.
## Prop name and modes
### Prop Name
Type a name in the `Prop Name` field. The name is saved in the file and marks
the document dirty as soon as it changes. An empty name is displayed as
`Unnamed Prop` in the window title/status, and as `Untitled` in the generated
window title when no name is present.
### `🖐️ Arrange`
Arrange is the mode for selecting, moving, rotating, scaling, mirroring, and
ordering complete shapes. The Add Shape inspector and the shape Erase button
are available in this mode.
When a shape is selected, the canvas draws:
- a two-tone selection outline and bounding box;
- a small rotation dot below the bounding-box center;
- four corner scale crosses.
Drag the shape body to translate it. Drag the rotation dot to rotate it. Hold
**Ctrl** while rotating to snap to 15-degree increments. Drag a corner cross
to scale. Hold **Ctrl** while scaling to keep the aspect ratio.
Clicking a shape selects the frontmost shape under the cursor. With the canvas
selected (rather than a text field), **Delete** or **Backspace** also removes
the selected shape.
### `✏️ Reshape`
Reshape is for editing individual vertices. It does not move the whole shape
and does not show the Add Shape section or Erase Shape button. When the canvas
is empty, it does not show the Arrange-only “Right-click to add a shape” hint.
- Original vertices are filled circles.
- User-created vertices are hollow rectangles.
- Drag a vertex to reshape the outline.
- A vertex drag, insertion, or removal marks the shape as reshaped.
- Releasing a vertex drag sanitizes polygon/line points (including grid snap
when enabled, collinear cleanup, and winding cleanup).
- A reshaped circle hides its Radius control and shows `✏️ This shape was
reshaped`. Choosing a new radius regenerates a clean circle and clears that
reshaped state.
### `📌 Attach`
Attach is for authoring named anchors and checking a selected grip on the
private stickman Holding Preview. It does not add gameplay attachment or
physics behavior to Sandbox Stage.
Attach mode shows the Attach toolbar and the Anchors inspector. Shape editing
controls are hidden, but the prop remains visible on the authoring canvas.
## Grid, Snap, and canvas navigation
The top-right `Grid:` SpinBox accepts values from 1 to 512. It controls the
drawn grid spacing and the snap increment. The `Snap` checkbox enables or
disables snapping for canvas transforms and anchor movement.
In Attach mode, the top `Snap` checkbox also controls anchor movement. There is
no separate anchor snapping control. The grid is still available while
snapping is off; it is only a visual guide in that state.
The authoring canvas supports these gestures in every mode:
| Gesture | Result |
| --- | --- |
| Mouse wheel up/down | Cursor-centered zoom in/out, clamped to 0.3x–3.0x |
| Pinch/magnify gesture | Cursor-centered zoom |
| Middle-mouse drag | Pan the canvas |
| Two-finger pan gesture | Pan the canvas |
| Left-click | Mode-dependent selection or editing |
| Right-click | Mode-dependent context menu |
The bottom-right status text shows `X` and `Y` world coordinates while the
cursor is over the authoring canvas. It shows dashes elsewhere.
## Authoring canvas context menus
### Arrange right-click menu
Right-clicking a shape selects the frontmost shape under the cursor and shows:
| Action | Result |
| --- | --- |
| `Copy` | Copies the selected shape into the editor clipboard. |
| `Paste` | Appears when the clipboard contains a shape; inserts a copy offset from the cursor. |
| `Erase` | Removes the selected shape. |
| `Flip Left-Right` | Mirrors the shape around its own bounding-box center horizontally. |
| `Flip Up-Down` | Mirrors the shape around its own bounding-box center vertically. |
| `Bring Forward` | Moves the shape one step toward the front of the drawing stack. |
| `Send Back` | Moves the shape one step toward the back of the drawing stack. |
Right-clicking empty canvas shows:
`Add Box`, `Add Plank`, `Add Triangle`, `Add Line`, and `Add Circle`.
`Paste` is also available there when the clipboard is populated. New shapes
are placed at the clicked world position and become selected.
### Reshape right-click menu
Reshape intentionally has a smaller menu:
| Where you right-click | Result |
| --- | --- |
| A vertex | `Remove Point` |
| Near an outline segment | `Create Point`; the new point is inserted at the cursor's projection onto that segment and flashes briefly in yellow |
| Inside a closed shape | Selects the shape; no menu is shown |
| Empty canvas | Nothing; shapes cannot be added from this mode |
Removing a point from a shape with fewer than two remaining vertices removes
the shape. A two-point polygon becomes an open line.
### Attach right-click behavior
Right-click does not open a shape menu in Attach mode. Select and edit anchors
with left-click, the Attach inspector, or the anchor presets.
## Material and shape Inspector
The Inspector is scrollable and changes with the active mode and selection.
### Material
The Material section is hidden in Attach mode to keep the anchor controls and
Holding Preview readable. Return to Arrange or Reshape to edit the material.
The material preset dropdown contains `None`, `Wood`, `Rubber`, `Cardboard`,
and `Metal`. The preset chooses the default colors used when a new primitive
is added and displays read-only physics information:
- `Mass`
- `Friction`
- `Bounce`
These readouts come from the prop physics tables. They are informational in
the Prop Editor; changing the dropdown does not turn the authoring canvas into
a physics simulation. Save after changing the preset so the new material is
stored in the `.prop` file. The current implementation updates the preset and
readouts immediately; a preset-only change may not show `• Modified` until
another dirty-tracked edit is made.
### Selected Shape
This section appears when a shape is selected in Arrange or Reshape mode.
| Control | What it does |
| --- | --- |
| `Fill` color picker | Changes the selected shape's fill color live. |
| `Outline` color picker | Changes the selected shape's outline color live. |
| `Width` SpinBox | Changes outline width. |
| `Radius` SpinBox | Changes a clean circle's radius and regenerates its points. |
| `Vertices` | Read-only count of the selected shape's points. |
| `Erase Shape` | Deletes the selected shape. It is hidden in Reshape mode. |
The Radius row is only shown for a circle that has not been reshaped. Editing
circle vertices or scaling it non-uniformly changes it into a freeform shape;
the muted reshaped note explains why Radius is unavailable.
### Add Shape
The Add Shape section is available in Arrange mode. Choose a primitive from
the dropdown, adjust its parameters, then press `Add a Shape`.
| Primitive | Parameters |
| --- | --- |
| `Box` | `Size X`, `Size Y` |
| `Plank` | `Length`, `Thickness` |
| `Triangle` | `Base`, `Height` |
| `Line` | `Length` |
| `Circle` | `Radius` |
The new primitive uses the current material's default fill/outline tint and
outline width. `Ball` is not shown in the current menu; Circle is the supported
round primitive in the user interface.
## Anchors and Attach mode
### Anchor concepts
An anchor is a named point in prop-local coordinates. Anchors are saved with
the `.prop` file alongside the shape stack.
Every prop always has one fixed anchor named `origin`:
- role: `pivot`;
- position: `(0, 0)`;
- rotation: `0` degrees;
- Z offset: `0`;
- allowed hands: `both`.
Origin cannot be moved, rotated, renamed, or deleted, and it is not listed as
an editable anchor. A subtle, non-interactive center marker is drawn at the
fixed local origin in Arrange, Reshape, and Attach. It is a canvas reference,
not an anchor gizmo that can be selected.
If an older `.prop` file has no `anchors` section, the loader synthesizes this
origin automatically. Saving that file writes the anchor section back out.
### Attach toolbar
| Control | What it does |
| --- | --- |
| `Show Stickman Preview` | Shows or hides the private Holding Preview pane. |
| `Show Other Markers` | Shows or hides the passive origin marker and unselected anchor markers. The selected anchor remains visible. |
The Holding Preview is enabled by default when Attach mode is entered.
### Anchors list and buttons
The list shows every editable custom anchor. `origin` is intentionally hidden
from this list because it is fixed and cannot be edited.
| Control | What it does |
| --- | --- |
| `Add Anchor` | Creates a normalized ID such as `anchor`, places it at the current shape bounds center, and gives it role `custom` with both hands allowed. |
| `Rename` | Opens a text-entry dialog. Type a new name, then click `Rename` or press **Enter** to confirm; click Cancel or close the dialog to leave the name unchanged. |
| `Delete` | Deletes the selected editable anchor. |
Anchor IDs are lower-case, normalized to identifier-safe characters, limited
to 32 characters, and made unique with a suffix such as `_2` when necessary.
The name is committed only after the rename dialog is confirmed.
### Anchor fields
| Field | Values and behavior |
| --- | --- |
| `Role` | `grip`, `emitter`, `impact`, `mount`, or `custom`. `pivot` is reserved for the hidden fixed origin and is not offered for editable anchors. |
| `Allowed Hands` | `both`, `left_only`, or `right_only`. This affects Holding Preview compatibility. |
| `X`, `Y` | Anchor position in prop-local coordinates. |
| `Rotation` | Anchor rotation in degrees, wrapped to the range -180 through 179. |
| `Z Offset` | Draw-order offset from -5 through 5. |
The X/Y fields can also be edited by dragging the selected marker. For grip
and emitter anchors, the selected marker has a small rotation dot underneath
it. Drag that dot to rotate the anchor; hold **Ctrl** to snap rotation to
15-degree increments. The origin has no rotation dot.
Anchor marker colors identify roles: cyan for grip, red for emitter, pink
diamond for impact, purple for mount, and light gray for custom. The selected
marker has an additional yellow selection ring. The fixed origin uses its own
subtle center marker rather than an editable anchor marker.
### Anchor presets
Presets replace the current non-origin anchor layout and select the first
editable grip:
| Preset | Anchors created |
| --- | --- |
| `One-Handed` | One `primary_grip` |
| `Two-Handed` | `primary_grip` and `secondary_grip` |
| `Tool / Nozzle` | `primary_grip` and `emitter` |
| `Melee Edge` | `primary_grip` and `impact` |
Preset positions are derived from the current shape bounds. Applying a preset
is an edit and must be saved like any other change.
## Holding Preview
Holding Preview appears in Attach mode when `Show Stickman Preview` is on. It
uses a private visual-only rig and displays the complete authored multi-shape
prop when the selected anchor is a grip compatible with the selected hand. The
preview pane is borderless and visually separated from the Inspector by the
layout splitters.
If the selected anchor is not a compatible grip, the prop is hidden and the
hint explains that a compatible grip must be selected. Select a grip in the
Anchors list to see the prop held.
### Preview header controls
| Control | What it does |
| --- | --- |
| `Left Hand` / `Right Hand` | Chooses which hand holds the selected grip. A left-only or right-only grip disables the incompatible choice and forces the compatible hand. |
| `Front Pose` / `Walk Left` / `Walk Right` | Chooses the still front-facing pose or an in-place walking direction. Walking mirrors the preview as needed without changing saved anchor data. |
### Preview footer controls
| Control | What it does |
| --- | --- |
| `Pause Walk` / `Resume Walk` | Pauses or resumes the selected walking animation. The button is disabled for Front Pose. |
| `Reset Arm` | Pauses walking and resets the selected arm's manual IK override. In Front Pose, it returns both preview hand targets to their rest positions. |
| `Reset View` | Reframes the camera around the complete stickman and visible prop. |
### Pose and camera gestures
- Drag the cyan diamond target in the preview viewport to pose the selected
arm. Dragging automatically pauses walking; use Resume Walk to return to the
walk animation.
- The target is clamped to the arm's reachable range, so the IK solver does
not request an impossible wrist position.
- The prop follows the solved wrist and selected grip anchor, including the
anchor's rotation and Z offset.
- Mouse wheel zooms the preview camera around the cursor.
- Middle-mouse drag pans the preview camera.
The preview is for visual authoring only. It does not create a gameplay
`PropBlock`, collision bodies, physics, Sandbox actions, or runtime attachment
state. It currently previews one selected grip and one hand; secondary grips
are saved, but they do not drive a second-hand IK solver.
## Saving, dirty state, and recovery
### Saving an off-origin prop
The editor does not silently recenter a prop. On an explicit Save or Save As,
it warns when the combined shape-bounds center is farther from the origin than
`max(1024 px, 2 × bounds diagonal)`. Choose one of:
| Choice | Result |
| --- | --- |
| `🎯 Center & Save` | Translates all shapes and non-origin anchors together, keeps the fixed origin at `(0, 0)`, focuses the canvas on it, then saves. |
| `💾 Save Anyway` | Saves the current positions unchanged. |
| `❌ Cancel` | Leaves the document and save flow unchanged. |
Autosaves never show this warning and never recenter the prop.
The editor marks the document dirty after changes to the name, shapes, or
anchors. Save after changing the material preset as well; a preset-only change
updates the current model but may not show `• Modified` until another
dirty-tracked edit is made. The status bar shows:
`Prop Name — Material • Modified`
The window title also adds `*` while dirty and includes the current filename
when one exists.
After about one second without further edits, a dirty document is written to
`user://prop_editor_autosave.prop`. This is an internal recovery file, not the
normal saved prop path.
- A successful Save, Load, New, or discard clears the autosave.
- If the editor starts with a usable unsaved autosave, it asks whether to
`🔄 Restore` or `🗑️ Discard` it.
- Restoring keeps the document dirty so it still requires an intentional Save.
- An autosave is silently discarded when its saved source file is already
newer or equal.
- The recent list is stored in
`user://prop_editor_settings.json` and contains at most five paths,
newest first.
Closing the window or choosing New while dirty opens a prompt with `💾 Save`,
`🗑️ Discard`, and `❌ Cancel`. Cancel leaves the document open. Save As and
Load dialogs report malformed or unsupported files through the Error dialog.
## Current limitations and future scope
- Attach mode and Holding Preview are Prop Editor authoring tools only.
Sandbox Stage custom-prop loading, equipping, releasing, physics, collision,
and gameplay effects are future work.
- Preview uses one selected grip and one-hand IK. Two-hand IK, emitter effects,
impact behavior, mount behavior, throwing, and runtime lifecycle are not
implemented here.
- The preview can show the full multi-shape authored prop. Existing runtime
prop spawning still uses a deterministic single-shape conversion: the
frontmost closed polygon, otherwise a circle, otherwise the first shape.
Lines are editor-only and do not become collision geometry in that path.
- There is no document-wide Undo/Redo history yet. Autosave and recovery are
safety features, not an edit history.
- The shape clipboard is an in-memory editor clipboard; it is cleared when a
new document is started and is not saved into `.prop` files.
## Complete manual test checklist
### Launch and layout
- [ ] Open `res://scenes/prop_editor.tscn` and press F6.
- [ ] Confirm the File/View menus, prop-name field, mode buttons, canvas,
Inspector, and status bar are visible.
- [ ] Drag the main canvas/Inspector splitter left and right.
- [ ] Make the window narrow; confirm Inspector content can be scrolled and
controls do not run off the window.
- [ ] Confirm the Inspector starts at its minimum usable width and has a
darker contrasting background than the canvas.
- [ ] Confirm the top `Grid` field and `Snap` button have no clipped top or
bottom edges.
- [ ] Enter Attach mode and drag the canvas/preview splitter both directions.
- [ ] Open `eye.prop` in Arrange mode, switch to Attach, and confirm the
preview does not push into the Prop Name row or bottom status bar.
- [ ] Toggle `Show Stickman Preview` and confirm the preview pane appears and
disappears without affecting the prop document.
### New, naming, material, and status
- [ ] Choose File → New and confirm the canvas is empty except for the subtle
fixed-origin marker visible in all three modes.
- [ ] Type a prop name; confirm the status shows `• Modified` and the window
title gets `*`.
- [ ] Select each material preset and confirm the Mass, Friction, and Bounce
readouts update.
- [ ] Enter Attach mode and confirm the Material section is hidden.
- [ ] Confirm the physics readouts are informational and do not launch a
simulation.
### Add and style shapes
- [ ] In Arrange mode, add a Box, Plank, Triangle, Line, and Circle from the
Add Shape section.
- [ ] Change the primitive parameters before adding each shape.
- [ ] Right-click empty canvas and add each of the same five primitives.
- [ ] Select a shape and change Fill, Outline, and Width; confirm the canvas
updates immediately.
- [ ] Confirm Vertices reports the selected shape's point count.
- [ ] Click Erase Shape and confirm only the selected shape is removed.
### Arrange transforms and context menu
- [ ] Drag a selected shape body to translate it.
- [ ] Drag the rotation dot and confirm the shape rotates continuously.
- [ ] Hold Ctrl while rotating and confirm 15-degree stepping.
- [ ] Drag each scale cross; hold Ctrl and confirm uniform scaling.
- [ ] Use Copy, right-click empty canvas, and Paste; confirm the pasted shape
is offset and selected.
- [ ] Test Flip Left-Right and Flip Up-Down.
- [ ] Stack two or more shapes and test Bring Forward and Send Back.
- [ ] Confirm the two-tone selection outline, bounding box, dot, and crosses
remain visible over light and dark shapes.
### Reshape and navigation
- [ ] Switch to Reshape and confirm Add Shape and Erase Shape are hidden.
- [ ] Drag an original vertex and confirm the outline changes.
- [ ] Right-click a segment and choose Create Point; confirm a hollow vertex
appears at the cursor's projected segment position and flashes yellow.
- [ ] Right-click a vertex and choose Remove Point.
- [ ] Right-click the interior of a closed shape and confirm it selects without
opening a menu.
- [ ] Right-click empty canvas and confirm no menu appears.
- [ ] Reshape a circle and confirm Radius is replaced by the reshaped note.
- [ ] Change Radius after reshaping and confirm a clean circle and Radius row
return.
- [ ] Test wheel zoom, pinch/pan if available, and middle-mouse pan. Use View
→ Reset View and confirm only canvas pan/zoom resets.
### Anchors
- [ ] Switch to Attach and confirm the Attach toolbar and Anchors inspector
appear.
- [ ] Confirm the fixed origin marker is visible in Arrange, Reshape, and
Attach, has no text label, and cannot intercept clicks.
- [ ] Confirm `origin` is not in the editable Anchors list.
- [ ] Add an anchor and confirm it appears in the list at the shape-bounds
center with role `custom`.
- [ ] Rename it with a normalized name; confirm both clicking Rename and
pressing **Enter** commit the change, while Cancel leaves the old name.
- [ ] Add another anchor and confirm IDs remain normalized and unique.
- [ ] Delete a non-origin anchor and confirm it is removed.
- [ ] Test every Role and Allowed Hands option.
- [ ] Confirm Role contains grip, emitter, impact, mount, and custom, but not
pivot.
- [ ] Edit X, Y, Rotation, and Z Offset in the Inspector and confirm the marker
and preview update.
- [ ] Drag a selected anchor marker. Toggle Snap and confirm movement follows
the Grid increment.
- [ ] For a grip or emitter, drag the rotation dot and test Ctrl 15-degree
snapping. Confirm the origin marker has no rotation dot.
- [ ] Toggle Show Other Markers and confirm the origin marker and unselected
markers hide/show while the selected anchor remains visible.
- [ ] Apply each preset and confirm its documented anchors appear.
- [ ] Draw or move a prop far from the origin, click Center Prop, and confirm
shapes and anchors translate together while the prop's relative layout stays
unchanged and the canvas focuses on the center marker.
### Holding Preview and IK
- [ ] Select a grip anchor and confirm the complete authored prop appears in
Holding Preview, not only the cyan target marker.
- [ ] Select a non-grip role and confirm the prop hides with the compatibility
hint.
- [ ] Confirm the hand choices are labeled Left Hand and Right Hand. Set a grip
to left-only and right-only and
confirm the incompatible hand is disabled/forced away.
- [ ] Test Front Pose, Walk Left, and Walk Right. Confirm walking previews move
in place and do not change saved anchor X/rotation values.
- [ ] Drag the cyan target and confirm the arm bends with IK and the prop
follows the solved wrist.
- [ ] Confirm dragging the target pauses walking, then click Resume Walk and
confirm the selected walk resumes.
- [ ] Click Pause Walk and Resume Walk and confirm the button text and motion
state change. Confirm Pause Walk is disabled for Front Pose.
- [ ] Click Reset Arm and confirm walking pauses and the selected arm's manual
IK override resets. In Front Pose, confirm both hand targets return to rest.
- [ ] Wheel-zoom and middle-drag the preview camera.
- [ ] Click Reset View and confirm the complete stickman and visible prop are
framed.
- [ ] Confirm the preview has no gameplay physics or collision response.
### Save, load, recent, autosave, and compatibility
- [ ] Save As with and without typing `.prop`; confirm the final file has one
`.prop` extension.
- [ ] Reload the saved file and confirm name, material, all shapes, colors,
transforms, and anchors survive.
- [ ] Confirm a successful save clears the dirty indicator and updates Recent.
- [ ] Load through File → Recent and confirm the same document returns.
- [ ] Save/load enough files to verify Recent keeps only five, newest first.
- [ ] Move a prop far from the origin and use Save; verify the off-origin
warning offers Center & Save, Save Anyway, and Cancel, and that each choice
has the documented result.
- [ ] Remove or rename a recent file outside the editor; open Recent and
confirm the missing entry is pruned.
- [ ] Make an edit, wait at least one second, close/restart the editor, and
test Restore and Discard in the recovery dialog.
- [ ] Confirm Restore keeps the prop dirty and Discard clears the recovery
file.
- [ ] Test File → New and window close while dirty; verify Save, Discard, and
Cancel each do the expected thing.
- [ ] Load a `.prop` file that has no `anchors` key. Confirm Attach mode
synthesizes the fixed origin, then save and verify anchors are written.
- [ ] Load malformed JSON or an unsupported `version` and confirm the Error
dialog appears without replacing the current valid document.
+8
View File
@@ -45,6 +45,11 @@ This document tracks known technical debt, optimization opportunities, and minor
| 33 | **Prop File save-serialization zeroed every point (fixed, Round 5)** — `PropFile._serialize_shape` assigned an export-format `Array` of `{x,y}` dicts directly to a typed `PackedVector2Array`; Godot's implicit conversion cannot build `Vector2` from a dictionary and silently yielded `Vector2.ZERO` per element, so every `.prop` saved by Round 1-4 wrote all points as `(0,0)` (loading as invisible/degenerate geometry). | High | ✅ Resolved | Round 5: `_serialize_shape` now routes points through `_coerce_points`; regression assertions check point *values* through `to_file_dict` and the full round-trip, plus an end-to-end canvas→save→load test. The previously saved `props/eye.prop` and `props/box.prop` are unrecoverable (coordinates were zeroed at save time) and were re-authored. Lesson: any typed `Packed*Array` assignment from dictionary/JSON data needs a Variant-coercion helper. (2026-09-19) |
| 34 | **Prop Editor per-shape `reshaped` flag** — every shape carries `reshaped` (false by default; set by vertex insert/remove/drag and non-uniform circle scaling, cleared by `set_shape_radius`), which gates primitive-specific inspector fields (today: the circle Radius row, replaced by a muted "reshaped" note). | Low | Open | Round 6 (`specs/phase_9_prop_editor_round6.md`). Only the circle consumes the flag today; future primitive params (box W/H, plank length/thickness) should reuse it instead of adding per-type detection. An "un-reshape" affordance (restoring a clean primitive from its params) is a possible future feature. (2026-09-19) |
| 35 | **Prop Editor selection uses a two-tone halo** — after Round 6's fill-based contrast failed for the reported "shape in front of a white shape" case (the selection extends beyond the shape onto the background), every selection element (outline polyline, bbox, rotation dot, scale crosses, vertex handles) is drawn as a dark backing pass + bright core pass. | Low | Open | Round 7 (`specs/phase_9_prop_editor_round7.md`). The pair is visible on any background but doubles the selection draw calls; negligible at sandbox scale. If a custom selection color is ever wanted (e.g. per-user theme), it should replace the core pass while keeping the halo. (2026-09-19) |
| 36 | **Phase 9a anchor roles have no gameplay consumers yet** — `.prop` assets can author/query `grip`, `emitter`, `impact`, `mount`, and `custom`, but Phase 9a is Prop Editor-only. Secondary grips do not drive two-hand IK, and no role dispatches runtime effects, damage, mounting, or equip behavior. | Medium | Open | User-approved staging in `specs/phase_9a_prop_attach.md`: land a stable authoring/schema seam plus an isolated one-hand IK preview first. A separately approved future runtime phase must design custom-prop loading, equip/release ownership, two-hand IK, emitters, melee impacts, accessory mounting, throwing, and selector/stage persistence without assuming the editor preview is gameplay-authoritative. (2026-09-20) |
| 37 | **Prop Editor still has no document-wide undo/redo** — Phase 9a anchor edits use the existing dirty/autosave/recovery pipeline, but there is no `UndoRedo` history for either shapes or anchors. | Medium | Open | An anchor-only stack was explicitly rejected because Arrange/Reshape edits would remain non-undoable and the UI would be inconsistent. Future work should introduce one document-level command/history boundary covering shapes, anchors, material/name changes, and preset replacement together. (2026-09-20) |
| 38 | **Phase 9a preview does not establish runtime attachment parity** — the Prop Editor uses a private, non-physics, full-multi-shape rig preview with editor-only facing and z-order conventions. It intentionally proves anchor authoring and arm-IK evaluation, not Sandbox loading, collision, physics, mirroring, per-limb interleaving, or attachment lifecycle. | Medium | Open | A future runtime spec must independently validate the anchor transform against gameplay animation/physics and decide its visual representation, sockets or solved-wrist API, mirror strategy, collision/freeze behavior, per-limb draw order, release/ragdoll handling, and multi-shape fidelity. Do not treat preview acceptance tests as runtime tests. (2026-09-20) |
| 39 | **Phase 9b prop centering is geometric AABB centering, not physical center of mass** — `Center Prop` uses the union AABB of internal polygon/line/circle points and deliberately excludes outline expansion, anchors, material mass, and collision geometry. | Low | Open | This is deterministic and appropriate for the fixed authoring origin, but a future physics-pivot workflow may need a separately designed center-of-mass or collision-bounds command. Phase 9b keeps canonical `origin` fixed/serialized, hides it from editing, never auto-centers after edits, and uses an explicit-save warning heuristic of `max(1024 px, 2 × bounds diagonal)`. Document-wide undo remains tracked separately by #37. (2026-09-20) |
| 40 | **Phase 9c walking preview uses exclusive gait-or-manual ownership of the selected hand target** — the private AnimationPlayer owns the target while gait plays; beginning a cyan-target drag pauses the gait before pointer input takes ownership. | Low | Open | This deterministic policy prevents AnimationPlayer and manual IK from overwriting the same Marker2D in one frame. It means an author cannot add a live hand offset while the walk continues. If that becomes valuable, design a preview-only additive offset/blend layer rather than relying on process order. The gait remains an in-place, non-physics authoring preview and is not navigation/runtime parity (#38). (2026-09-21) |
---
@@ -88,6 +93,9 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-09-11 | Phase 4c (Trigger Area Reliability) verified: `sandbox_stage.gd` `entered_area` now requires source + area instance IDs and detects visible world-AABB overlap for ANIMATED stickmen/unfrozen props, edge-triggered per area/movable pair. The prop enumeration scope bug was corrected. `tests/test_phase4c_area.gd` passed all 19 assertions, including transformed-area, leg-level overlap, wildcard/re-entry, speech consequence, stale-target, and deletion-cleanup coverage; editor verification confirmed the speech bubble fires for the configured source stickman. Logged #24 (session-only instance-ID rule references). |
| 2026-09-12 | Phase 4.5 collision bodies verified: ten explicitly synchronized `AnimatableBody2D` proxies replace the old feet-point stickman collision poll; `PropBlock` supplies world-space contact data with shape-cast CCD; stage routing preserves rig→prop rules with per-part pair debouncing; bounded quadrant-aware flinches and PLAY→DIRECT authored-pose restoration were verified. `tests/test_phase45_collision_bodies.gd` passed 223 assertions; Phase 4b (53), Phase 4c (19), and Phase 3c (96) remained green. Reconciled #1/#5 and retained #25–#27 as intentional implementation limitations. |
| 2026-09-17 | Phase 6a (Airborne Momentum, Procedural Air Pose, and Landing Absorption) implemented and user-verified. `StickmanRig` now carries signed ledge momentum with tuned exponential drag (`AIR_DRAG_X = 0.75`), swept horizontal proxy collision, additive free-fall pose with forward-before-lift arms, and height-driven landing absorption: falls below 80 px are soft; 80..900 px maps to 32..160 px torso compression and 270..520 px hand drop over 0.30 s. Independent foot contacts preserve ramps/stairs, while `LANDING_IMPACT` blocks primary queue progression and direct ragdoll remains an immediate preemption. `tests/test_phase6a_airborne_landing.gd` passed 24 assertions; `tests/test_phase4b_terrain.gd` passed 8 assertions. Logged #28 (per-proxy airborne sweep scaling). |
| 2026-09-20 | Phase 9a Prop Attach specification written and refined (`specs/phase_9a_prop_attach.md`). Approved architecture: append Attach to the existing Arrange/Reshape system; additive role-based `.prop` anchors with a fixed synthesized `origin`; and an isolated Prop Editor stickman preview that uses real arm IK while showing the full authored prop. All Sandbox/custom-prop loading, attachment, sockets, physics/collision, lifecycle, and runtime tests are explicitly deferred. Logged #36 (all gameplay consumers deferred), #37 (document-wide undo remains absent), and #38 (editor preview is not a runtime-parity guarantee). Existing #29 remains open independently because the current runtime still flattens multi-shape props. |
| 2026-09-20 | Phase 9b Prop Centering specification written (`specs/phase_9b_prop_centering.md`). Approved architecture: hide canonical origin from editable anchors while retaining it in `.prop` v1.0; draw a passive constant-screen-size `(0,0)` marker in all three modes; add an always-visible Center Prop document transaction; center the authoring camera on origin for startup/New/load/reset and preserve zoom after centering; and warn explicit saves with Center & Save / Save Anyway / Cancel when displacement exceeds `max(1024 px, 2 × bounds diagonal)`. Autosave stays silent and automatic recenter-on-edit is explicitly rejected. Logged #39 (AABB authoring center is not a future physics center of mass); #37 continues to track document-wide undo. |
| 2026-09-21 | Phase 9c Prop Editor UX specification written (`specs/phase_9c_prop_editor_ux.md`). Approved architecture: a single Front Pose / Walk Left / Walk Right preview-motion selector with dynamic Pause/Resume; manual arm IK pauses gait before taking target ownership; modal typed anchor rename; Show Other Markers controls origin plus unselected anchors; one global Snap source; theme-driven `#262C39` inspector; container-driven unclipped chrome; simplified full-figure preview with no blue border. The preview directly plays the proven in-place gait/mirror policy and remains isolated from navigation, physics, actions, and Sandbox state. Logged #40 (exclusive gait/manual hand-target ownership). |
---
+130
View File
@@ -0,0 +1,130 @@
# Architectural Plan: Dynamic `.prop` Loading & Sandbox Integration
This document outlines the architectural plan to transition `PropLibrary` from a static registry into a dynamic filesystem-backed library (matching `StickmanLibrary`) and integrate user-created `.prop` files into the Sandbox Stage.
---
## 1. System Architecture Overview
The system bridges custom prop files produced by the Prop Editor into the sandbox physics simulation through four primary layers:
1. **Library & File Scanning (`PropLibrary`):** Scans `res://props/` for `*.prop` files, deserializes them via `PropFile`, flattens multi-shape hierarchies into collision payloads, and merges them with built-in templates.
2. **Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`):** Computes cache keys using file modification timestamps (`mtime`), renders visual previews offscreen, and streams textures to the selector grid.
3. **UI Integration (`AssetSelector` & `SandboxStage`):** Presents custom props alongside built-in presets in a unified grid, offering folder scanning, ad-hoc file browsing, and palette live-refresh.
4. **World Spawning & Physics Lifecycle (`StageSpawner` & `PropBlock`):** Instantiates `RigidBody2D` nodes configured with custom collision geometry and material physical properties (mass, friction, bounce), tracking authored positions across sandbox mode transitions.
---
## 2. Component Specifications
### A. Library Layer (`res://scripts/prop_library.gd`)
- **Dual-Source Registry:** Maintain built-in templates (Crate, Ball, Plank, Triangle) as default presets while scanning `res://props/*.prop` on demand.
- **Entry Model Schema:** Standardize entry dictionaries returned to the UI:
- **Identifier (`id`):** Resource path (`res://props/chair.prop`) for custom files, or string key (`crate`) for templates.
- **Display Name (`name`):** Extracted from `.prop` metadata, with fallback to capitalized file basename.
- **Material Preset & Label:** Physical properties index (Wood, Metal, Rubber, Cardboard) and human-readable label.
- **Payload (`payload`):** Flattened shape and collision dictionary generated via `PropFile.flatten_to_payload()`.
- **Provenance Flags:** Boolean flag indicating whether the entry is custom and string path reference for scene serialization.
- **Error Handling:** Skip invalid or corrupted `.prop` files gracefully while outputting descriptive warnings.
### B. Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`)
- **Dynamic Keying Strategy:**
- Built-in prop templates compute keys using internal version constants.
- Custom `.prop` files compute keys combining file basename and filesystem modification timestamp (`FileAccess.get_modified_time`).
- **Renderer Extension (`PropThumbnail`):** Extend offscreen rendering to support composite shapes and custom vector styling (fill color, outline width, polygons, circles) rather than single primitive shapes.
- **Stale Cache Cleanup:** Automatically purge superseded PNG thumbnails when custom `.prop` files are edited or deleted.
### C. World Spawning Pipeline (`StageSpawner`)
- **Payload Forwarding:** Pass the entry payload directly to `PropUtils.spawn_prop()`, which generates the corresponding `PropBlock` node with `CollisionPolygon2D` or `CollisionShape2D` nodes.
- **Provenance Tagging:** Store `spawn_id` and source path metadata on spawned instances to support scene serialization and reloading.
- **Authored Pose Capture:** Save placement positions to instance metadata (`_authored_position`, `_authored_rotation`) during **Edit** mode.
### D. UI & Sandbox Integration (`AssetSelector` & `SandboxStage`)
- **Grid Population:** Trigger `PropLibrary.scan()` when opening the prop selector, populating grid cells with thumbnails and display names.
- **Browse & Refresh Integration:** Connect the "Browse…" button to a file dialog for arbitrary `*.prop` selection, and the "Refresh" button to re-scan `res://props/`.
- **Lazy Thumbnail Processing:** Process offscreen thumbnail generation non-blockingly during stage frame processing to keep UI interactions responsive.
---
## 3. Physics & Sandbox Lifecycle Integration
Custom `.prop` instances must follow the existing 3-mode sandbox state machine:
| Stage Mode | Prop Physics State | Interaction Capabilities | Reset Behavior |
| ---------- | -------------------------------------------------- | ------------------------ | -------------- |
| **Edit** | RigidBody frozen (`freeze = true`, kinematic mode) |
| Move, rotate, scale, or delete via selection gizmos
| N/A (Authored state recorded on movement)
|
| **Direct** | RigidBody frozen (`freeze = true`, kinematic mode)
| Gizmos active for rule/trigger assignment; spawner palette hidden
| N/A
|
| **Play** | RigidBody unfrozen (`freeze = false`)
| Full physical simulation, collisions with terrain/stickmen/props
| Mode exit restores initial authored position/rotation and zeroes linear/angular velocity
|
---
## 4. Implementation Roadmap
```
[ Phase 1: Library & Data Layer ]
└─ Extend PropLibrary (res://props/*.prop scanning + PropFile parsing)
└─ Define unified Prop Entry Dictionary schema
[ Phase 2: Thumbnail Pipeline ]
└─ Update PropThumbnail to render multi-shape custom payloads
└─ Implement modification timestamp cache invalidation in ThumbnailCache
[ Phase 3: Spawning & UI Integration ]
└─ Update StageSpawner to tag custom prop provenance
└─ Wire AssetSelector grid refresh and file dialog browsing
[ Phase 4: Stage Lifecycle & Verification ]
└─ Verify Edit -> Play -> Edit position resets for custom props
└─ Validate scene saving/loading persistence
```
### Milestone Checklist for Architect Agent
1. **Directory Scanning:** `PropLibrary` reads `res://props/` and extracts payloads via `PropFile`.
2. **Visual Previews:** `PropThumbnail` renders multi-polygon custom vector props to textures.
3. **Palette Display:** `AssetSelector` displays merged built-in templates and `.prop` files.
4. **Physics Interaction:** Custom props unfreeze during **Play** mode, participating in physics simulation and collision events.
5. **State Reset:** Props return to their original authored transforms when switching from **Play** back to **Edit** mode.
+2 -1
View File
@@ -41,7 +41,8 @@
"text": "#F2F2F2",
"text_muted": "#9A9A9A",
"border": "#FFFFFF33",
"accent": "#4FA3FF"
"accent": "#4FA3FF",
"inspector_background": "#262C39"
}
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
},
"primary_grip": {
"allowed_hands": "both",
"role": "grip",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 1
}
},
"material_preset": "rubber",
"metadata": {
"created_at": "2026-09-21T16:00:00",
"modified_at": "2026-09-21T16:00:00"
},
"prop_name": "Baseball",
"shapes": [
{
"closed": true,
"fill_color": "#F5F5F0FF",
"outline_color": "#A0A0A0FF",
"outline_width": 2.0,
"points": [
{ "x": 35.0, "y": 0.0 },
{ "x": 30.31, "y": 17.5 },
{ "x": 17.5, "y": 30.31 },
{ "x": 0.0, "y": 35.0 },
{ "x": -17.5, "y": 30.31 },
{ "x": -30.31, "y": 17.5 },
{ "x": -35.0, "y": 0.0 },
{ "x": -30.31, "y": -17.5 },
{ "x": -17.5, "y": -30.31 },
{ "x": 0.0, "y": -35.0 },
{ "x": 17.5, "y": -30.31 },
{ "x": 30.31, "y": -17.5 }
],
"radius": 35.0,
"reshaped": false,
"shape_type": "circle",
"vertex_flags": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#D62828FF",
"outline_width": 2.0,
"points": [
{ "x": -12.0, "y": -28.0 },
{ "x": -22.0, "y": -14.0 },
{ "x": -22.0, "y": 14.0 },
{ "x": -12.0, "y": 28.0 }
],
"reshaped": true,
"shape_type": "line",
"vertex_flags": [0, 0, 0, 0]
},
{
"closed": false,
"fill_color": "#00000000",
"outline_color": "#D62828FF",
"outline_width": 2.0,
"points": [
{ "x": 12.0, "y": -28.0 },
{ "x": 22.0, "y": -14.0 },
{ "x": 22.0, "y": 14.0 },
{ "x": 12.0, "y": 28.0 }
],
"reshaped": true,
"shape_type": "line",
"vertex_flags": [0, 0, 0, 0]
}
],
"version": "1.0"
}
+93
View File
@@ -0,0 +1,93 @@
{
"anchors": {
"origin": {
"allowed_hands": "both",
"role": "pivot",
"rotation_deg": 0.0,
"x": 0.0,
"y": 0.0,
"z_index_offset": 0
},
"primary_grip": {
"allowed_hands": "both",
"role": "grip",
"rotation_deg": 0.0,
"x": 0.0,
"y": -42.5,
"z_index_offset": 1
}
},
"material_preset": "wood",
"metadata": {
"created_at": "2026-09-21T16:00:00",
"modified_at": "2026-09-21T16:00:00"
},
"prop_name": "Brown Briefcase",
"shapes": [
{
"closed": true,
"fill_color": "#3D2314FF",
"outline_color": "#26150CFF",
"outline_width": 2.0,
"points": [
{ "x": -25.0, "y": -30.0 },
{ "x": -25.0, "y": -55.0 },
{ "x": 25.0, "y": -55.0 },
{ "x": 25.0, "y": -30.0 },
{ "x": 18.0, "y": -30.0 },
{ "x": 18.0, "y": -47.0 },
{ "x": -18.0, "y": -47.0 },
{ "x": -18.0, "y": -30.0 }
],
"reshaped": true,
"shape_type": "polygon",
"vertex_flags": [0, 0, 0, 0, 0, 0, 0, 0]
},
{
"closed": true,
"fill_color": "#6D3C19FF",
"outline_color": "#3B200AFF",
"outline_width": 3.0,
"points": [
{ "x": -70.0, "y": -30.0 },
{ "x": 70.0, "y": -30.0 },
{ "x": 70.0, "y": 40.0 },
{ "x": -70.0, "y": 40.0 }
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [0, 0, 0, 0]
},
{
"closed": true,
"fill_color": "#D4AF37FF",
"outline_color": "#8B7500FF",
"outline_width": 1.5,
"points": [
{ "x": -45.0, "y": -32.0 },
{ "x": -30.0, "y": -32.0 },
{ "x": -30.0, "y": -22.0 },
{ "x": -45.0, "y": -22.0 }
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [0, 0, 0, 0]
},
{
"closed": true,
"fill_color": "#D4AF37FF",
"outline_color": "#8B7500FF",
"outline_width": 1.5,
"points": [
{ "x": 30.0, "y": -32.0 },
{ "x": 45.0, "y": -32.0 },
{ "x": 45.0, "y": -22.0 },
{ "x": 30.0, "y": -22.0 }
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [0, 0, 0, 0]
}
],
"version": "1.0"
}
+9
View File
@@ -0,0 +1,9 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/prop_attach_preview.gd" id="1"]
[node name="PropAttachPreview" type="PanelContainer"]
custom_minimum_size = Vector2(360, 0)
size_flags_horizontal = 3
size_flags_vertical = 3
script = ExtResource("1")
+27 -8
View File
@@ -28,7 +28,7 @@ script = ExtResource("1_editor_script")
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_bottom = 32.0
offset_bottom = 44.0
grow_horizontal = 2
[node name="MenuBar" type="MenuBar" parent="TopRow"]
@@ -45,8 +45,8 @@ theme_override_constants/separation = 8
layout_mode = 1
anchors_preset = 0
anchor_right = 1.0
offset_top = 32.0
offset_bottom = 64.0
offset_top = 44.0
offset_bottom = 88.0
grow_horizontal = 2
[node name="NameLabel" type="Label" parent="TopBar"]
@@ -61,12 +61,13 @@ layout_mode = 2
size_flags_horizontal = 3
placeholder_text = "Unnamed Prop"
[node name="MainLayout" type="HBoxContainer" parent="."]
[node name="MainLayout" type="HSplitContainer" parent="."]
unique_name_in_owner = true
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
offset_top = 68.0
offset_top = 92.0
offset_bottom = -28.0
grow_horizontal = 2
grow_vertical = 2
@@ -78,6 +79,7 @@ layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
size_flags_stretch_ratio = 2.0
custom_minimum_size = Vector2(260, 0)
theme_override_styles/panel = SubResource("StyleBox_canvas")
[node name="CanvasArea" type="VBoxContainer" parent="MainLayout/CanvasPanel"]
@@ -89,7 +91,19 @@ unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="PropCanvas" type="Control" parent="MainLayout/CanvasPanel/CanvasArea"]
[node name="AttachTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 12
[node name="CanvasWorkspace" type="HSplitContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
size_flags_vertical = 3
split_offset = 520
[node name="PropCanvas" type="Control" parent="MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace"]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
@@ -98,13 +112,18 @@ mouse_filter = 0
clip_contents = true
script = ExtResource("2_canvas_script")
[node name="InspectorColumn" type="VBoxContainer" parent="MainLayout"]
[node name="InspectorColumn" type="PanelContainer" parent="MainLayout"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
custom_minimum_size = Vector2(300, 0)
size_flags_horizontal = 3
[node name="InspectorList" type="VBoxContainer" parent="MainLayout/InspectorColumn"]
[node name="InspectorScroll" type="ScrollContainer" parent="MainLayout/InspectorColumn"]
layout_mode = 2
size_flags_vertical = 3
[node name="InspectorList" type="VBoxContainer" parent="MainLayout/InspectorColumn/InspectorScroll"]
unique_name_in_owner = true
layout_mode = 2
size_flags_vertical = 3
+132
View File
@@ -0,0 +1,132 @@
class_name PropAnchorData
extends RefCounted
## Normalized, JSON-safe prop-anchor model shared by authoring tools.
const ORIGIN_ID: String = "origin"
const ROLE_STRINGS: PackedStringArray = ["pivot", "grip", "emitter", "impact", "mount", "custom"]
const ALLOWED_HAND_STRINGS: PackedStringArray = ["both", "left_only", "right_only"]
enum Hand { LEFT, RIGHT }
static func default_anchors() -> Dictionary:
return {ORIGIN_ID: _origin()}
static func normalize_anchors(raw: Variant, warn: bool = true) -> Dictionary:
var result: Dictionary = {}
if raw is Dictionary:
for raw_id: Variant in (raw as Dictionary):
if not (raw as Dictionary)[raw_id] is Dictionary:
if warn: push_warning("PropAnchorData: skipped malformed anchor.")
continue
var id: String = normalize_id(str(raw_id), result)
if id == ORIGIN_ID:
continue
var source: Dictionary = (raw as Dictionary)[raw_id] as Dictionary
var role: String = str(source.get("role", "custom")).to_lower()
if not ROLE_STRINGS.has(role) or role == "pivot":
if role == "pivot" and warn: push_warning("PropAnchorData: non-origin pivot changed to custom.")
role = "custom"
var hand: String = str(source.get("allowed_hands", "both")).to_lower()
if not ALLOWED_HAND_STRINGS.has(hand): hand = "both"
result[id] = {"role": role, "position": Vector2(_finite(source.get("x", source.get("position", Vector2.ZERO)), true), _finite(source.get("y", source.get("position", Vector2.ZERO)), false)), "rotation_deg": _wrap_deg(_finite(source.get("rotation_deg", 0.0))), "z_index_offset": clampi(int(source.get("z_index_offset", 0)), -5, 5), "allowed_hands": hand}
result[ORIGIN_ID] = _origin()
return result.duplicate(true)
static func serialize_anchors(anchors: Dictionary) -> Dictionary:
var normalized: Dictionary = normalize_anchors(anchors, false)
var result: Dictionary = {}
for id: String in normalized:
var anchor: Dictionary = normalized[id]
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
result[id] = {"role": str(anchor.get("role", "custom")), "x": pos.x, "y": pos.y, "rotation_deg": float(anchor.get("rotation_deg", 0.0)), "z_index_offset": int(anchor.get("z_index_offset", 0)), "allowed_hands": str(anchor.get("allowed_hands", "both"))}
return result
static func editable_ids(anchors: Dictionary) -> Array[String]:
var result: Array[String] = []
for id: String in normalize_anchors(anchors, false):
if id != ORIGIN_ID:
result.append(id)
return result
static func preferred_editable_id(anchors: Dictionary, preferred_id: String = "") -> String:
var normalized: Dictionary = normalize_anchors(anchors, false)
if preferred_id != ORIGIN_ID and normalized.has(preferred_id):
return preferred_id
if normalized.has("primary_grip") and str(normalized["primary_grip"].get("role", "")) == "grip":
return "primary_grip"
for id: String in editable_ids(normalized):
if str(normalized[id].get("role", "")) == "grip":
return id
var ids: Array[String] = editable_ids(normalized)
return ids[0] if not ids.is_empty() else ""
static func translated_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
var normalized: Dictionary = normalize_anchors(anchors, false)
if not is_finite(delta.x) or not is_finite(delta.y):
push_warning("PropAnchorData: ignored non-finite anchor translation.")
return normalized
for id: String in editable_ids(normalized):
var anchor: Dictionary = normalized[id].duplicate(true)
anchor["position"] = anchor.get("position", Vector2.ZERO) + delta
normalized[id] = anchor
normalized[ORIGIN_ID] = _origin()
return normalized
static func normalize_id(candidate: String, anchors: Dictionary) -> String:
var id: String = candidate.strip_edges().to_lower()
var regex := RegEx.new(); regex.compile("[^a-z0-9]+")
id = regex.sub(id, "_", true).strip_edges().trim_prefix("_").trim_suffix("_")
if id.is_empty() or not id[0].is_valid_identifier(): id = "anchor"
id = id.substr(0, 32)
if id == ORIGIN_ID and not anchors.has(id): return id
var base: String = id; var suffix: int = 2
while anchors.has(id) or id == ORIGIN_ID:
id = base.substr(0, 32 - str(suffix).length()) + "_" + str(suffix); suffix += 1
return id
static func ids_for_role(anchors: Dictionary, role: String) -> Array[String]:
var ids: Array[String] = []
for id: String in normalize_anchors(anchors, false):
if str(normalize_anchors(anchors, false)[id].get("role", "")) == role: ids.append(id)
return ids
static func compatible_grip_ids(anchors: Dictionary, hand: int) -> Array[String]:
var result: Array[String] = []
for id: String in ids_for_role(anchors, "grip"):
var allowed: String = str(normalize_anchors(anchors, false)[id].get("allowed_hands", "both"))
if allowed == "both" or (hand == Hand.LEFT and allowed == "left_only") or (hand == Hand.RIGHT and allowed == "right_only"): result.append(id)
return result
static func local_transform(anchor: Dictionary, mirror_x: bool = false) -> Transform2D:
var position: Vector2 = anchor.get("position", Vector2.ZERO)
var angle: float = deg_to_rad(float(anchor.get("rotation_deg", 0.0)))
if mirror_x: position.x = -position.x; angle = PI - angle
return Transform2D(angle, position)
static func solve_attachment_transform(target_transform: Transform2D, anchor: Dictionary, positive_scale: Vector2, mirror_x: bool) -> Transform2D:
var effective: Transform2D = local_transform(anchor, mirror_x)
var scale: Vector2 = Vector2(maxf(absf(positive_scale.x) if is_finite(positive_scale.x) else 1.0, 0.0001), maxf(absf(positive_scale.y) if is_finite(positive_scale.y) else 1.0, 0.0001))
var basis := Transform2D(target_transform.get_rotation() - effective.get_rotation(), Vector2.ZERO).scaled(scale)
basis.origin = target_transform.origin - basis.basis_xform(effective.origin)
return basis
static func preset_layout(preset: String, bounds: Rect2) -> Dictionary:
var center: Vector2 = bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO
var horizontal: bool = bounds.size.x >= bounds.size.y
var length: float = bounds.size.x if horizontal else bounds.size.y
var angle: float = 0.0 if horizontal else 90.0
var endpoint: Vector2 = Vector2(bounds.end.x, center.y) if horizontal else Vector2(center.x, bounds.end.y)
var offset: Vector2 = (Vector2(minf(length * 0.2, 32.0), 0.0) if horizontal else Vector2(0.0, minf(length * 0.2, 32.0)))
var make := func(role: String, pos: Vector2) -> Dictionary: return {"role": role, "position": pos, "rotation_deg": angle, "z_index_offset": 0, "allowed_hands": "both"}
var out: Dictionary = default_anchors()
match preset:
"one_handed": out["primary_grip"] = make.call("grip", center)
"two_handed": out["primary_grip"] = make.call("grip", center - offset); out["secondary_grip"] = make.call("grip", center + offset)
"tool_nozzle": out["primary_grip"] = make.call("grip", center); out["emitter"] = make.call("emitter", endpoint)
"melee_edge": out["primary_grip"] = make.call("grip", center); out["impact"] = make.call("impact", endpoint)
return out
static func _origin() -> Dictionary: return {"role": "pivot", "position": Vector2.ZERO, "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
static func _finite(value: Variant, x: bool = true) -> float:
var n: float = value.x if value is Vector2 and x else (value.y if value is Vector2 else float(value))
return n if is_finite(n) else 0.0
static func _wrap_deg(value: float) -> float: return fposmod(value + 180.0, 360.0) - 180.0
+1
View File
@@ -0,0 +1 @@
uid://bhlmhegchc1cu
+101
View File
@@ -0,0 +1,101 @@
class_name PropAnchorOverlay
extends RefCounted
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
enum DragMode { NONE, TRANSLATE, ROTATE }
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
const MARKER_RADIUS: float = 9.0
const HIT_RADIUS: float = 15.0
const ROTATE_OFFSET: float = 28.0
const ROTATION_DOT_RADIUS: float = 5.0
const ROTATION_DOT_HIT_RADIUS: float = 12.0
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
var _selected_id: String = ""
var _show_inactive: bool = true
var _drag_mode: DragMode = DragMode.NONE
var _drag_offset: Vector2 = Vector2.ZERO
func set_anchor_data(data: Variant) -> void:
_anchors = PropAnchorDataScript.normalize_anchors(data)
if _selected_id == PropAnchorDataScript.ORIGIN_ID or not _anchors.has(_selected_id): _selected_id = ""
func get_anchor_data() -> Dictionary: return _anchors.duplicate(true)
func select_anchor(anchor_id: String) -> void:
var next_id: String = anchor_id if anchor_id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(anchor_id) else ""
if next_id != _selected_id:
_selected_id = next_id
anchor_selected.emit(_selected_id)
func get_selected_anchor_id() -> String: return _selected_id
func set_show_inactive(enabled: bool) -> void: _show_inactive = enabled
func hit_test_anchor(world_pos: Vector2, zoom: float) -> String:
for id: String in _anchors:
if id == PropAnchorDataScript.ORIGIN_ID: continue
if not _visible(id): continue
var anchor: Dictionary = _anchors[id]
if world_pos.distance_to(anchor.get("position", Vector2.ZERO)) <= HIT_RADIUS / maxf(zoom, 0.001): return id
return ""
func begin_pointer(world_pos: Vector2, zoom: float) -> bool:
# The dot is intentionally outside the marker hit target, so test it before
# ordinary anchor selection. This matches the shape transform gizmo model.
if not _selected_id.is_empty() and _anchors.has(_selected_id):
var selected: Dictionary = _anchors[_selected_id]
var role: String = str(selected.get("role", "custom"))
var selected_pos: Vector2 = selected.get("position", Vector2.ZERO)
var dot: Vector2 = selected_pos + Vector2(0.0, ROTATE_OFFSET / maxf(zoom, 0.001))
if (role == "grip" or role == "emitter") and world_pos.distance_to(dot) <= ROTATION_DOT_HIT_RADIUS / maxf(zoom, 0.001):
_drag_mode = DragMode.ROTATE
return true
var id: String = hit_test_anchor(world_pos, zoom)
if id.is_empty(): return false
select_anchor(id)
var anchor: Dictionary = _anchors[id]
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
var role: String = str(anchor.get("role", "custom"))
_drag_mode = DragMode.TRANSLATE; _drag_offset = pos - world_pos
return true
func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void:
if _drag_mode == DragMode.NONE or _selected_id.is_empty(): return
var anchor: Dictionary = _anchors[_selected_id].duplicate(true)
if _drag_mode == DragMode.TRANSLATE:
var pos: Vector2 = world_pos + _drag_offset
if snap: pos = Vector2(snappedf(pos.x, grid_size), snappedf(pos.y, grid_size))
anchor["position"] = pos
else:
var angle: float = rad_to_deg((world_pos - anchor.get("position", Vector2.ZERO)).angle())
if ctrl: angle = snappedf(angle, 15.0)
anchor["rotation_deg"] = fposmod(angle + 180.0, 360.0) - 180.0
_anchors[_selected_id] = anchor
anchors_changed.emit(get_anchor_data())
func end_pointer() -> void:
if _drag_mode != DragMode.NONE: anchors_changed.emit(get_anchor_data())
_drag_mode = DragMode.NONE
func draw_gizmos(canvas: CanvasItem, zoom: float) -> void:
for id: String in _anchors:
if id == PropAnchorDataScript.ORIGIN_ID: continue
if _visible(id): _draw_marker(canvas, id, _anchors[id], zoom, id == _selected_id)
func _visible(id: String) -> bool: return id != PropAnchorDataScript.ORIGIN_ID and (_show_inactive or id == _selected_id)
func _draw_marker(canvas: CanvasItem, id: String, anchor: Dictionary, zoom: float, selected: bool) -> void:
var pos: Vector2 = anchor.get("position", Vector2.ZERO); var radius: float = MARKER_RADIUS / maxf(zoom, 0.001)
var role: String = str(anchor.get("role", "custom")); var color: Color = {"pivot": Color.YELLOW, "grip": Color.CYAN, "emitter": Color.RED, "impact": Color.HOT_PINK, "mount": Color.MEDIUM_PURPLE}.get(role, Color.LIGHT_GRAY)
canvas.draw_circle(pos, radius + 2.0 / zoom, Color(0, 0, 0, 0.85))
if role == "pivot": canvas.draw_line(pos - Vector2(radius, 0), pos + Vector2(radius, 0), color, 2.0 / zoom); canvas.draw_line(pos - Vector2(0, radius), pos + Vector2(0, radius), color, 2.0 / zoom)
elif role == "impact": canvas.draw_colored_polygon(PackedVector2Array([pos + Vector2(0,-radius), pos + Vector2(radius,0), pos + Vector2(0,radius), pos + Vector2(-radius,0)]), color)
else: canvas.draw_circle(pos, radius, color, false, 2.0 / zoom)
if role == "grip" or role == "emitter":
var direction: Vector2 = Vector2.RIGHT.rotated(deg_to_rad(float(anchor.get("rotation_deg", 0.0)))) * radius * 2.0
canvas.draw_line(pos, pos + direction, color, 2.0 / zoom)
if selected:
var dot: Vector2 = pos + Vector2(0.0, ROTATE_OFFSET / zoom)
canvas.draw_line(pos, dot, Color(0, 0, 0, 0.85), 4.0 / zoom)
canvas.draw_line(pos, dot, color, 2.0 / zoom)
canvas.draw_circle(dot, (ROTATION_DOT_RADIUS + 2.0) / zoom, Color(0, 0, 0, 0.85))
canvas.draw_circle(dot, ROTATION_DOT_RADIUS / zoom, color)
if selected: canvas.draw_arc(pos, radius + 5.0 / zoom, 0.0, TAU, 20, Color.YELLOW, 2.0 / zoom)
+1
View File
@@ -0,0 +1 @@
uid://cj2xh11d1wk7j
+548
View File
@@ -0,0 +1,548 @@
class_name PropAttachPreview
extends PanelContainer
## Private editor visualization. It never creates a PropBlock or touches Sandbox state.
enum PreviewHand { LEFT, RIGHT }
enum PreviewMotion { FRONT_POSE, WALK_LEFT, WALK_RIGHT }
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
const StickmanFactoryScript := preload("res://scripts/stickman_factory.gd")
const StkRigAdapterScript := preload("res://scripts/stk_rig_adapter.gd")
const RigScene := preload("res://scenes/stickman_rig.tscn")
const RIG_CENTER := Vector2(340.0, 575.0)
const TARGET_RADIUS := 14.0
var _viewport: SubViewport
var _viewport_container: SubViewportContainer
var _world: Node2D
var _rig_root: Node2D
var _rig: StickmanRig
var _prop: Node2D
var _target_marker: Polygon2D
var _camera: Camera2D
var _hint: Label
var _walk_playback_button: Button
var _reset_arm_button: Button
var _reset_view_button: Button
var _hand_option: OptionButton
var _motion_option: OptionButton
var _shapes: Array[Dictionary] = []
var _anchors: Dictionary = {}
var _selected: String = ""
var _hand: int = PreviewHand.RIGHT
var _motion: PreviewMotion = PreviewMotion.FRONT_POSE
var _walk_paused: bool = true
var _manual_arm_override: bool = false
# Retained only for old test harnesses; Phase 9c UI uses _walk_paused.
var _animate: bool = false
var _dragging_target: bool = false
var _camera_dragging: bool = false
var _last_pointer := Vector2.ZERO
var _hand_target_rests_local: Dictionary = {}
var _camera_zoom := 1.0
var _fitted_zoom := 1.0
func _ready() -> void:
custom_minimum_size.x = 360.0
var surface := StyleBoxFlat.new()
surface.bg_color = Color(0.075, 0.09, 0.12, 1.0)
surface.set_border_width_all(0)
surface.set_corner_radius_all(5)
add_theme_stylebox_override("panel", surface)
_build_ui()
_build_private_world()
_reframe_camera()
func _build_ui() -> void:
var layout := VBoxContainer.new()
layout.size_flags_vertical = Control.SIZE_EXPAND_FILL
add_child(layout)
var header := FlowContainer.new()
layout.add_child(header)
var title := Label.new()
title.text = "Holding Preview"
title.custom_minimum_size.x = 132.0
header.add_child(title)
_hand_option = OptionButton.new()
_hand_option.name = "HandOption"
_hand_option.add_item("Left Hand", PreviewHand.LEFT)
_hand_option.add_item("Right Hand", PreviewHand.RIGHT)
_hand_option.select(PreviewHand.RIGHT)
_hand_option.item_selected.connect(set_preview_hand)
header.add_child(_hand_option)
_motion_option = OptionButton.new()
_motion_option.name = "MotionOption"
_motion_option.add_item("Front Pose", PreviewMotion.FRONT_POSE)
_motion_option.add_item("Walk Left", PreviewMotion.WALK_LEFT)
_motion_option.add_item("Walk Right", PreviewMotion.WALK_RIGHT)
_motion_option.item_selected.connect(set_preview_motion)
header.add_child(_motion_option)
_hint = Label.new()
_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
layout.add_child(_hint)
_viewport_container = SubViewportContainer.new()
# The pane keeps its 360 px width, but its height must yield to the editor's
# fixed name/status rows when Attach mode makes the preview visible.
_viewport_container.custom_minimum_size = Vector2(360.0, 0.0)
_viewport_container.size_flags_vertical = Control.SIZE_EXPAND_FILL
_viewport_container.stretch = true
_viewport_container.gui_input.connect(_on_viewport_gui_input)
layout.add_child(_viewport_container)
var footer := FlowContainer.new()
layout.add_child(footer)
_walk_playback_button = Button.new()
_walk_playback_button.name = "WalkPlaybackButton"
_walk_playback_button.pressed.connect(toggle_walk_playback)
footer.add_child(_walk_playback_button)
_reset_arm_button = Button.new()
_reset_arm_button.text = "Reset Arm"
_reset_arm_button.pressed.connect(reset_arm_pose)
footer.add_child(_reset_arm_button)
_reset_view_button = Button.new()
_reset_view_button.text = "Reset View"
_reset_view_button.pressed.connect(_reframe_camera)
footer.add_child(_reset_view_button)
_apply_hit_target_metrics(36.0)
func set_ui_hit_target_height(height: float) -> void:
_apply_hit_target_metrics(maxf(height, 36.0))
func _apply_hit_target_metrics(height: float) -> void:
for option: OptionButton in [_hand_option, _motion_option]:
if option != null:
option.custom_minimum_size = Vector2(88.0, height)
option.mouse_filter = Control.MOUSE_FILTER_STOP
option.focus_mode = Control.FOCUS_NONE
for button: Button in [_walk_playback_button, _reset_arm_button, _reset_view_button]:
if button != null:
button.custom_minimum_size = Vector2(112.0, height)
button.mouse_filter = Control.MOUSE_FILTER_STOP
button.focus_mode = Control.FOCUS_NONE
func _build_private_world() -> void:
_viewport = SubViewport.new()
_viewport.name = "PreviewViewport"
_viewport.transparent_bg = false
_viewport.render_target_update_mode = SubViewport.UPDATE_ALWAYS
_viewport_container.add_child(_viewport)
_viewport_container.resized.connect(_resize_viewport)
_world = Node2D.new()
_world.name = "PreviewWorld"
_viewport.add_child(_world)
_rig_root = Node2D.new()
_rig_root.name = "PreviewRigRoot"
_rig_root.position = RIG_CENTER
_world.add_child(_rig_root)
_rig = RigScene.instantiate() as StickmanRig
_rig.set_physics_process(false)
_rig_root.add_child(_rig)
var data: Dictionary = StickmanFactoryScript.load_stk("res://stickmen/test.stk")
if not data.is_empty():
StkRigAdapterScript.apply(data, _rig)
_prop = Node2D.new()
_prop.name = "PreviewProp"
_world.add_child(_prop)
_target_marker = Polygon2D.new()
_target_marker.polygon = PackedVector2Array([Vector2(0, -10), Vector2(10, 0), Vector2(0, 10), Vector2(-10, 0)])
_target_marker.color = Color(0.25, 0.9, 1.0, 0.85)
_world.add_child(_target_marker)
_camera = Camera2D.new()
_camera.name = "PreviewCamera"
_world.add_child(_camera)
_camera.make_current()
_disable_collisions(_rig)
call_deferred("_finish_rig_setup")
func _finish_rig_setup() -> void:
var player := _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer
if player != null:
player.stop()
_rig.stop_queue()
_rig.clear_queue()
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
var target := _target_for(hand)
if target != null:
_hand_target_rests_local[hand] = target.position
_apply_motion_pose()
_update_target_marker()
_rebuild_prop()
_reframe_camera()
func set_document(shapes: Array[Dictionary], anchors: Dictionary, selected_anchor_id: String) -> void:
_shapes = shapes.duplicate(true)
_anchors = anchors.duplicate(true)
_selected = selected_anchor_id
_rebuild_prop()
_reframe_camera()
func set_selected_anchor(anchor_id: String) -> void:
_selected = anchor_id
_rebuild_prop()
func set_preview_hand(hand: int) -> void:
_hand = clampi(hand, PreviewHand.LEFT, PreviewHand.RIGHT)
_manual_arm_override = false
_apply_motion_pose()
_rebuild_prop()
func set_preview_motion(motion: int) -> void:
_motion = clampi(motion, PreviewMotion.FRONT_POSE, PreviewMotion.WALK_RIGHT)
_manual_arm_override = false
_apply_motion_pose(true)
_rebuild_prop()
_reframe_camera()
# Compatibility facade for Phase 9a callers. The visible Facing control was
# removed in 9c; callers now map to the equivalent preview motion.
func set_facing_profile(profile: int) -> void:
if profile == StickmanRig.FacingProfile.LEFT:
set_preview_motion(PreviewMotion.WALK_LEFT)
elif profile == StickmanRig.FacingProfile.RIGHT:
set_preview_motion(PreviewMotion.WALK_RIGHT)
else:
set_preview_motion(PreviewMotion.FRONT_POSE)
func set_walk_paused(paused: bool) -> void:
if _motion == PreviewMotion.FRONT_POSE:
_walk_paused = true
_update_walk_button()
return
var player := _animation_player()
_walk_paused = paused
if player != null:
if paused:
player.pause()
else:
player.play()
_manual_arm_override = false if not paused else _manual_arm_override
_update_walk_button()
func toggle_walk_playback() -> void:
set_walk_paused(not _walk_paused)
func set_arm_animation_enabled(enabled: bool) -> void:
_animate = enabled
if enabled and _motion == PreviewMotion.FRONT_POSE:
set_preview_motion(PreviewMotion.WALK_RIGHT)
elif not enabled:
set_walk_paused(true)
func reset_arm_pose() -> void:
if _motion != PreviewMotion.FRONT_POSE:
set_walk_paused(true)
var player := _animation_player()
if player != null:
player.seek(player.current_animation_position, true)
else:
_restore_standing_targets()
_manual_arm_override = false
_animate = false
_update_target_marker()
_update_prop_transform()
func is_preview_available() -> bool:
return _rig != null and is_instance_valid(_rig) and _viewport != null
func _process(delta: float) -> void:
if not is_preview_available():
return
_update_target_marker()
_update_prop_transform()
func _animation_player() -> AnimationPlayer:
return _rig.get_node_or_null("AnimationPlayer") as AnimationPlayer if _rig != null else null
func _restore_standing_targets() -> void:
for hand: int in [PreviewHand.LEFT, PreviewHand.RIGHT]:
var target := _target_for(hand)
if target != null and _hand_target_rests_local.has(hand):
target.position = _hand_target_rests_local[hand]
func _apply_motion_pose(restart: bool = false) -> void:
if _rig == null:
return
var player := _animation_player()
if _motion == PreviewMotion.FRONT_POSE:
_rig.set_facing_profile(StickmanRig.FacingProfile.FORWARD)
_rig_root.scale = Vector2.ONE
if player != null: player.stop(true)
_restore_standing_targets()
_walk_paused = true
else:
var profile: int = StickmanRig.FacingProfile.LEFT if _motion == PreviewMotion.WALK_LEFT else StickmanRig.FacingProfile.RIGHT
_rig.set_facing_profile(profile)
_rig.scale = Vector2.ONE
_rig_root.scale = Vector2(-1.0, 1.0) if _motion == PreviewMotion.WALK_LEFT else Vector2.ONE
if player != null:
if restart or player.current_animation != "walk_right":
player.play("walk_right")
player.seek(0.0, true)
else:
player.play()
_walk_paused = false
_enable_arm_ik()
_update_walk_button()
func _update_walk_button() -> void:
if _walk_playback_button == null:
return
var front: bool = _motion == PreviewMotion.FRONT_POSE
_walk_playback_button.disabled = front
_walk_playback_button.text = "Resume Walk" if _walk_paused else "Pause Walk"
_walk_playback_button.tooltip_text = "Choose Walk Left or Walk Right to preview walking." if front else ""
func _hand_target() -> Marker2D:
return _target_for(_hand)
func _target_for(hand: int) -> Marker2D:
if _rig == null:
return null
return _rig.get_node_or_null("IK_Targets/Left_Hand" if hand == PreviewHand.LEFT else "IK_Targets/Right_Hand") as Marker2D
func _lower_arm() -> Bone2D:
if _rig == null:
return null
return _rig.get_node_or_null("Skeleton2D/Torso/LeftUpperArm/LeftLowerArm" if _hand == PreviewHand.LEFT else "Skeleton2D/Torso/RightUpperArm/RightLowerArm") as Bone2D
func _solved_wrist_transform() -> Transform2D:
var lower_visual := _rig.get_node_or_null("Body/LeftLowerArm" if _hand == PreviewHand.LEFT else "Body/RightLowerArm") as Node2D
var lower := _lower_arm()
if lower == null or lower_visual == null:
return Transform2D.IDENTITY
var elbow := lower_visual.to_global(Vector2.ZERO)
var wrist := lower_visual.to_global(Vector2(0.0, lower.length))
var direction := (wrist - elbow).normalized()
if direction.length_squared() < 0.0001:
return Transform2D.IDENTITY
return Transform2D(direction.angle(), wrist)
func _rebuild_prop() -> void:
if _prop == null:
return
for child: Node in _prop.get_children():
child.queue_free()
var anchor: Dictionary = _anchors.get(_selected, {})
var grip_ok := str(anchor.get("role", "")) == "grip" and _grip_allows_hand(anchor)
_update_hand_choices(anchor)
_prop.visible = grip_ok
if _hint != null:
_hint.text = "Drag the cyan target to pose the arm." if grip_ok else "Select a grip anchor compatible with this hand to preview holding."
if not grip_ok:
return
for shape: Dictionary in _shapes:
_add_shape_visual(shape)
_update_prop_transform()
func _add_shape_visual(shape: Dictionary) -> void:
# PropCanvas exports JSON-safe {x,y} dictionaries. Assigning that Array
# directly to PackedVector2Array silently turns every point into zero.
var points := _coerce_points(shape.get("points", PackedVector2Array()))
if points.is_empty() and str(shape.get("shape_type", "polygon")) == "circle":
var radius := maxf(float(shape.get("radius", 24.0)), 0.001)
var center := Vector2.ZERO
for index: int in 24:
points.append(center + Vector2.from_angle(TAU * float(index) / 24.0) * radius)
if points.is_empty():
return
var color := Color.from_string(str(shape.get("fill_color", "#999999")), Color.GRAY)
if int(shape.get("shape_type", 0)) == 2 or not bool(shape.get("closed", true)):
var line := Line2D.new()
line.points = points
line.width = maxf(float(shape.get("outline_width", 2.0)), 1.0)
line.default_color = Color.from_string(str(shape.get("outline_color", "#eeeeee")), Color.WHITE)
_prop.add_child(line)
else:
var polygon := Polygon2D.new()
polygon.polygon = points
polygon.color = color
_prop.add_child(polygon)
func _coerce_points(raw: Variant) -> PackedVector2Array:
if raw is PackedVector2Array:
return raw as PackedVector2Array
var result := PackedVector2Array()
if raw is Array:
for item: Variant in raw:
if item is Vector2:
result.append(item as Vector2)
elif item is Dictionary:
var point: Dictionary = item as Dictionary
result.append(Vector2(float(point.get("x", 0.0)), float(point.get("y", 0.0))))
return result
func _update_prop_transform() -> void:
if _prop == null or not _prop.visible:
return
var anchor: Dictionary = _anchors.get(_selected, {})
var mirror := _motion == PreviewMotion.WALK_LEFT
_prop.global_transform = PropAnchorDataScript.solve_attachment_transform(_solved_wrist_transform(), anchor, Vector2.ONE, mirror)
# PreviewProp follows PreviewRigRoot in tree order, so zero remains visible
# above the stickman; negative puts it behind and positive remains in front.
_prop.z_index = int(anchor.get("z_index_offset", 0))
func _grip_allows_hand(anchor: Dictionary) -> bool:
var allowed := str(anchor.get("allowed_hands", "both"))
return allowed == "both" or (allowed == "left_only" and _hand == PreviewHand.LEFT) or (allowed == "right_only" and _hand == PreviewHand.RIGHT)
func _update_target_marker() -> void:
var target := _hand_target()
if target != null and _target_marker != null:
_target_marker.global_position = target.global_position
_target_marker.visible = _prop == null or _prop.visible
func _resize_viewport() -> void:
if _viewport != null:
# SubViewportContainer.stretch owns the viewport size. Resizing it here
# fights that ownership and produces an engine warning.
_reframe_camera()
func _on_viewport_gui_input(event: InputEvent) -> void:
if _camera == null:
return
if event is InputEventMouseButton:
var mouse := event as InputEventMouseButton
if mouse.button_index == MOUSE_BUTTON_WHEEL_UP or mouse.button_index == MOUSE_BUTTON_WHEEL_DOWN:
var world_before := _screen_to_world(mouse.position)
_camera_zoom = clampf(_camera_zoom * (1.1 if mouse.button_index == MOUSE_BUTTON_WHEEL_UP else 1.0 / 1.1), _fitted_zoom * 0.3, _fitted_zoom * 3.0)
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
_camera.position += world_before - _screen_to_world(mouse.position)
return
var world_pos := _screen_to_world(mouse.position)
if mouse.button_index == MOUSE_BUTTON_LEFT:
if mouse.pressed and world_pos.distance_to(_target_marker.global_position) <= TARGET_RADIUS / _camera_zoom:
_dragging_target = true
if _motion != PreviewMotion.FRONT_POSE:
set_walk_paused(true)
_manual_arm_override = true
elif not mouse.pressed:
_dragging_target = false
if mouse.button_index == MOUSE_BUTTON_MIDDLE:
_camera_dragging = mouse.pressed
_last_pointer = mouse.position
elif event is InputEventMouseMotion:
var motion := event as InputEventMouseMotion
if _dragging_target:
_set_target_clamped(_screen_to_world(motion.position))
elif _camera_dragging:
_camera.position -= (motion.position - _last_pointer) / _camera_zoom
_last_pointer = motion.position
func _screen_to_world(screen: Vector2) -> Vector2:
return _viewport.get_canvas_transform().affine_inverse() * screen
func _set_target_clamped(requested: Vector2) -> void:
var target := _hand_target()
var lower := _lower_arm()
if target == null or lower == null:
return
target.global_position = _clamp_target(requested)
func _clamp_target(requested: Vector2) -> Vector2:
var target := _hand_target()
var lower := _lower_arm()
var upper := lower.get_parent() as Bone2D if lower != null else null
if target == null or lower == null or upper == null:
return requested
var origin := upper.global_position
var offset := requested - origin
var rest: Vector2 = target.to_global(_hand_target_rests_local.get(_hand, Vector2.RIGHT)) - origin
if offset.length_squared() < 0.0001:
offset = rest.normalized() if rest.length_squared() > 0.0001 else Vector2.RIGHT
var min_reach := absf(upper.length - lower.length) + 8.0
var max_reach := maxf(min_reach, upper.length + lower.length - 8.0)
return origin + offset.normalized() * clampf(offset.length(), min_reach, max_reach)
func _update_hand_choices(anchor: Dictionary) -> void:
var allowed := str(anchor.get("allowed_hands", "both"))
if allowed == "left_only" and _hand != PreviewHand.LEFT:
_hand = PreviewHand.LEFT
reset_arm_pose()
if allowed == "right_only" and _hand != PreviewHand.RIGHT:
_hand = PreviewHand.RIGHT
reset_arm_pose()
if _hand_option == null:
return
_hand_option.set_item_disabled(PreviewHand.LEFT, allowed == "right_only")
_hand_option.set_item_disabled(PreviewHand.RIGHT, allowed == "left_only")
_hand_option.select(_hand)
func _enable_arm_ik() -> void:
var skeleton := _rig.get_node_or_null("Skeleton2D") as Skeleton2D
if skeleton == null:
return
var stack := skeleton.get_modification_stack()
if stack == null:
return
for index: int in 2:
var modification := stack.get_modification(index)
if modification is SkeletonModification2DTwoBoneIK:
(modification as SkeletonModification2DTwoBoneIK).enabled = true
func _reframe_camera() -> void:
if _camera == null or _viewport == null or _viewport.size.x < 1:
return
var bounds := _visual_bounds(true)
if bounds.size.length_squared() < 0.001:
return
bounds = bounds.grow(32.0)
_camera.position = bounds.get_center()
_fitted_zoom = minf(float(_viewport.size.x) / bounds.size.x, float(_viewport.size.y) / bounds.size.y)
_fitted_zoom = maxf(_fitted_zoom, 0.001)
_camera_zoom = _fitted_zoom
_camera.zoom = Vector2(_camera_zoom, _camera_zoom)
func _visual_bounds(full_figure: bool) -> Rect2:
var points := PackedVector2Array()
_collect_visual_points(_rig.get_node_or_null("Body"), points, full_figure)
_collect_visual_points(_prop, points, true)
if points.is_empty():
return Rect2(RIG_CENTER - Vector2(200.0, 200.0), Vector2(400.0, 400.0))
var rect := Rect2(points[0], Vector2.ZERO)
for point: Vector2 in points:
rect = rect.expand(point)
return rect
func get_prop_global_bounds() -> Rect2:
return _visual_bounds_for_node(_prop)
func get_preview_world_rect() -> Rect2:
if _viewport == null:
return Rect2()
var transform := _viewport.get_canvas_transform().affine_inverse()
var top_left := transform * Vector2.ZERO
var bottom_right := transform * Vector2(_viewport.size)
return Rect2(top_left, bottom_right - top_left).abs()
func get_solved_wrist_transform() -> Transform2D:
return _solved_wrist_transform()
func _visual_bounds_for_node(node: Node) -> Rect2:
var points := PackedVector2Array()
_collect_visual_points(node, points, true)
if points.is_empty():
return Rect2()
var rect := Rect2(points[0], Vector2.ZERO)
for point: Vector2 in points:
rect = rect.expand(point)
return rect
func _collect_visual_points(node: Node, out: PackedVector2Array, full_figure: bool) -> void:
if node == null or not (node as CanvasItem).visible:
return
var include := full_figure or not (node.name.contains("Leg"))
if include and node is Polygon2D:
for point: Vector2 in (node as Polygon2D).polygon:
out.append((node as Polygon2D).to_global(point))
if include and node is Line2D:
for point: Vector2 in (node as Line2D).points:
out.append((node as Line2D).to_global(point))
for child: Node in node.get_children():
_collect_visual_points(child, out, full_figure)
func _disable_collisions(node: Node) -> void:
if node is CollisionObject2D:
var body := node as CollisionObject2D
body.collision_layer = 0
body.collision_mask = 0
for child: Node in node.get_children():
_disable_collisions(child)
+1
View File
@@ -0,0 +1 @@
uid://bthsk33yw5rr8
+104 -11
View File
@@ -28,6 +28,8 @@ signal vertex_created(shape_idx: int, vertex_idx: int)
## Emitted only when the edit mode actually changes.
signal mode_changed(mode: int)
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
# ---------------------------------------------------------------------------
# Enums / constants
@@ -41,7 +43,7 @@ enum DragMode { NONE, VERTEX, TRANSLATE, ROTATE, SCALE }
## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points"
## (vertex handles + Create/Remove Point). Gates input and drawing only — the
## public math APIs stay mode-agnostic.
enum EditMode { TRANSFORM, POINTS }
enum EditMode { TRANSFORM, POINTS, ATTACH }
const HANDLE_RADIUS: float = 3.0
const HANDLE_RADIUS_SELECTION: float = 8.0
@@ -62,9 +64,11 @@ const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 3.0
const ZOOM_STEP: float = 1.10
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
const ORIGIN_MARKER_DEFAULT_COLOR := Color(0.6, 0.6, 0.6, 0.7)
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
const PropFileScript := preload("res://scripts/prop_file.gd")
const PropAnchorOverlayScript := preload("res://scripts/prop_anchor_overlay.gd")
const SELECTION_CORE_COLOR := Color.WHITE
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
@@ -112,6 +116,9 @@ var _snap_enabled: bool = false
var _has_clipboard: bool = false
var _context_menu: PopupMenu
var _edit_mode: EditMode = EditMode.TRANSFORM
var _anchor_overlay: RefCounted
var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR
var _show_origin_marker: bool = true
# Drag state
var _drag_mode: DragMode = DragMode.NONE
@@ -142,6 +149,9 @@ func _ready() -> void:
draw.connect(_on_canvas_draw)
if not gui_input.is_connected(_on_canvas_gui_input):
gui_input.connect(_on_canvas_gui_input)
_anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.anchors_changed.connect(func(data: Dictionary) -> void: anchors_changed.emit(data); queue_redraw())
_anchor_overlay.anchor_selected.connect(func(anchor_id: String) -> void: anchor_selected.emit(anchor_id); queue_redraw())
_context_menu = PopupMenu.new()
_context_menu.name = "ContextMenu"
add_child(_context_menu)
@@ -162,7 +172,7 @@ func _process(delta: float) -> void:
# ---------------------------------------------------------------------------
func set_edit_mode(mode: int) -> void:
var m: int = EditMode.TRANSFORM if mode != EditMode.POINTS else EditMode.POINTS
var m: int = mode if mode >= EditMode.TRANSFORM and mode <= EditMode.ATTACH else EditMode.TRANSFORM
if m == _edit_mode:
return
_edit_mode = m
@@ -175,6 +185,62 @@ func set_edit_mode(mode: int) -> void:
func get_edit_mode() -> int:
return _edit_mode
func set_anchor_data(data: Variant) -> void:
if _anchor_overlay == null: _anchor_overlay = PropAnchorOverlayScript.new()
_anchor_overlay.set_anchor_data(data)
queue_redraw()
func get_anchor_data() -> Dictionary:
return _anchor_overlay.get_anchor_data() if _anchor_overlay != null else {}
func select_anchor(anchor_id: String) -> void:
if _anchor_overlay != null: _anchor_overlay.select_anchor(anchor_id); queue_redraw()
func get_selected_anchor_id() -> String:
return _anchor_overlay.get_selected_anchor_id() if _anchor_overlay != null else ""
func set_show_inactive_anchors(enabled: bool) -> void:
if _anchor_overlay != null: _anchor_overlay.set_show_inactive(enabled); queue_redraw()
func set_show_origin_marker(enabled: bool) -> void:
_show_origin_marker = enabled
queue_redraw()
func get_shapes_bounds() -> Rect2:
var found: bool = false; var result: Rect2 = Rect2()
for shape: Dictionary in shapes:
if not _shape_has_geometry(shape): continue
var rect: Rect2 = _shape_bbox(shape)
if not found: result = rect; found = true
else: result = result.merge(rect)
return result
func has_shape_geometry() -> bool:
for shape: Dictionary in shapes:
if _shape_has_geometry(shape): return true
return false
func translate_all_shapes(delta: Vector2) -> bool:
if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false
for i: int in shapes.size():
var shape: Dictionary = shapes[i].duplicate(true)
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for j: int in points.size(): points[j] += delta
shape["points"] = points
shapes[i] = shape
queue_redraw()
shape_changed.emit(get_shape_data())
return true
func focus_world_point(world_point: Vector2) -> void:
if not is_finite(world_point.x) or not is_finite(world_point.y): return
_pan_offset = size * 0.5 - world_point * _zoom
queue_redraw()
func set_origin_marker_color(color: Color) -> void:
_origin_marker_color = color
queue_redraw()
# ---------------------------------------------------------------------------
# Public API — shape data
@@ -193,8 +259,7 @@ func set_shape_data(data: Variant) -> void:
_selected_shape_idx = -1
_drag_mode = DragMode.NONE
_dragging_vertex = -1
_zoom = 1.0
_pan_offset = Vector2.ZERO
reset_view()
queue_redraw()
@@ -212,9 +277,7 @@ func clear() -> void:
_selected_shape_idx = -1
_drag_mode = DragMode.NONE
_dragging_vertex = -1
_zoom = 1.0
_pan_offset = Vector2.ZERO
queue_redraw()
reset_view()
func select(idx: int) -> void:
@@ -566,8 +629,7 @@ func get_snap_enabled() -> bool:
func reset_view() -> void:
_zoom = 1.0
_pan_offset = Vector2.ZERO
queue_redraw()
focus_world_point(Vector2.ZERO)
func set_has_clipboard(state: bool) -> void:
@@ -596,19 +658,34 @@ func _on_canvas_draw() -> void:
draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
_draw_grid()
if shapes.is_empty():
if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM:
var rect := get_rect()
var hint_pos := _screen_to_world(Vector2(rect.size.x * 0.5 - 90.0, rect.size.y * 0.5))
var font := get_theme_default_font()
if font != null:
draw_string(font, hint_pos, "Right-click to add a shape", HORIZONTAL_ALIGNMENT_CENTER, -1, 14, Color(0.5, 0.5, 0.5, 0.6))
return
for i: int in shapes.size():
_draw_shape(shapes[i], i == _selected_shape_idx, _dragging_vertex if i == _selected_shape_idx else -1)
_draw_origin_marker()
if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
_draw_selection_gizmos(shapes[_selected_shape_idx])
if _edit_mode == EditMode.ATTACH and _anchor_overlay != null:
_anchor_overlay.draw_gizmos(self, _zoom)
func _draw_origin_marker() -> void:
if not _show_origin_marker:
return
var inv_zoom: float = 1.0 / maxf(_zoom, 0.001)
var radius: float = 6.0 * inv_zoom
var halo: Color = Color(0.0, 0.0, 0.0, 0.75)
draw_circle(Vector2.ZERO, radius + 2.0 * inv_zoom, halo, false, 3.0 * inv_zoom)
draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), halo, 3.0 * inv_zoom)
draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), halo, 3.0 * inv_zoom)
draw_circle(Vector2.ZERO, radius, _origin_marker_color, false, 1.25 * inv_zoom)
draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), _origin_marker_color, 1.25 * inv_zoom)
draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), _origin_marker_color, 1.25 * inv_zoom)
func _draw_shape(sd: Dictionary, is_selected: bool, dragging_vertex: int) -> void:
@@ -737,6 +814,12 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
return
var world_pos := _screen_to_world(mb.position)
if _edit_mode == EditMode.ATTACH:
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed: _anchor_overlay.begin_pointer(world_pos, _zoom)
else: _anchor_overlay.end_pointer()
return
if mb.button_index == MOUSE_BUTTON_RIGHT: return
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
_handle_right_click(mb, world_pos)
return
@@ -753,6 +836,9 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
_pan_start = mm.position
queue_redraw()
return
if _edit_mode == EditMode.ATTACH:
_anchor_overlay.update_pointer(_screen_to_world(mm.position), _zoom, _snap_enabled, _grid_size, mm.ctrl_pressed)
return
_handle_drag_motion(mm, _screen_to_world(mm.position), mm.ctrl_pressed)
@@ -1203,6 +1289,13 @@ func _shape_bbox(sd: Dictionary) -> Rect2:
bbox = bbox.expand(pt)
return bbox
func _shape_has_geometry(sd: Dictionary) -> bool:
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
if pts.size() < 2: return false
for point: Vector2 in pts:
if not is_finite(point.x) or not is_finite(point.y): return false
return true
func _hit_test_vertex(shape_idx: int, world_pos: Vector2) -> int:
if shape_idx < 0 or shape_idx >= shapes.size():
+420 -32
View File
@@ -16,20 +16,25 @@ const PropBlockScript := preload("res://scripts/prop_block.gd")
const PropCanvasScript := preload("res://scripts/prop_canvas.gd")
const PropThumbnailScript := preload("res://scripts/thumbnails/prop_thumbnail.gd")
const ThumbnailCacheScript := preload("res://scripts/thumbnails/thumbnail_cache.gd")
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
const PropAttachPreviewScene := preload("res://scenes/prop_attach_preview.tscn")
const PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"]
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
const PRIMITIVE_LABELS: PackedStringArray = ["Box", "Plank", "Triangle", "Line", "Circle"]
const EDITABLE_ROLE_STRINGS: PackedStringArray = ["grip", "emitter", "impact", "mount", "custom"]
# Kid-facing mode labels/emoji (Round 3); index == PropCanvas.EditMode.
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"]
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️"]
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape", "Attach"]
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️", "📌"]
const UNTITLED_NAME: String = "Untitled"
const AUTOSAVE_PATH: String = "user://prop_editor_autosave.prop"
const AUTOSAVE_DEBOUNCE: float = 1.0
const SETTINGS_PATH: String = "user://prop_editor_settings.json"
const RECENT_MAX: int = 5
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
const MENU_NEW := 0
const MENU_LOAD := 2
@@ -67,6 +72,11 @@ static func update_recent_list(list: Array, path: String, max_count: int = RECEN
result.resize(max_count)
return result
static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
if not has_geometry: return false
var threshold: float = maxf(OFF_ORIGIN_WARNING_MIN_DISTANCE, OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER * bounds.size.length())
return bounds.get_center().length() > threshold
# ---------------------------------------------------------------------------
# On-ready node references
# ---------------------------------------------------------------------------
@@ -76,6 +86,11 @@ static func update_recent_list(list: Array, path: String, max_count: int = RECEN
@onready var _prop_name_edit: LineEdit = %PropNameEdit
@onready var _canvas: PropCanvas = %PropCanvas
@onready var _mode_bar: HBoxContainer = %ModeBar
@onready var _attach_tools: FlowContainer = %AttachTools
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
@onready var _main_layout: HSplitContainer = %MainLayout
@onready var _canvas_panel: PanelContainer = %CanvasPanel
@onready var _inspector_column: PanelContainer = %InspectorColumn
@onready var _inspector_list: VBoxContainer = %InspectorList
@onready var _save_dialog: FileDialog = %SaveDialog
@onready var _load_dialog: FileDialog = %LoadDialog
@@ -92,12 +107,15 @@ var _material_preset: int = PropBlockScript.MaterialPreset.NONE
var _current_path: String = ""
var _dirty: bool = false
var _clipboard: Dictionary = {}
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
var _selected_anchor_id: String = ""
var _thumb: PropThumbnail
var _thumb_cache: ThumbnailCache
var _ui_theme: Theme
# Inspector controls
var _preset_option: OptionButton
var _material_section: VBoxContainer
var _mass_label: Label
var _friction_label: Label
var _bounce_label: Label
@@ -119,19 +137,42 @@ var _updating_inspector: bool = false
# Grid settings dialog (removed Round 4 - replaced by top-right controls)
var _grid_spin: SpinBox
var _snap_check: CheckBox
var _center_prop_button: Button
# Edit-mode toolbar (Round 2/3)
var _mode_group: ButtonGroup
var _move_button: Button
var _points_button: Button
var _attach_button: Button
# Add-shape inspector section (hidden in Reshape mode, Round 3)
var _add_section: VBoxContainer
var _anchor_section: VBoxContainer
var _anchor_list: ItemList
var _anchor_role: OptionButton
var _anchor_hand: OptionButton
var _anchor_x: SpinBox
var _anchor_y: SpinBox
var _anchor_rotation: SpinBox
var _anchor_z: SpinBox
var _rename_anchor_button: Button
var _delete_anchor_button: Button
var _attach_preview: Control
var _show_preview_check: CheckBox
var _inactive_anchors_check: CheckBox
var _rename_anchor_dialog: ConfirmationDialog
var _rename_anchor_edit: LineEdit
var _rename_anchor_source_id: String = ""
var _last_canvas_mode: int = PropCanvasScript.EditMode.TRANSFORM
var _inspector_split_initialized: bool = false
# Exit/new dialog + autosave (Round 3/4)
var _exit_dialog: ConfirmationDialog
var _off_origin_save_dialog: ConfirmationDialog
var _pending_quit: bool = false
var _pending_new: bool = false
var _pending_save_path: String = ""
var _pending_save_with_thumbnail: bool = true
var _autosave_remaining: float = 0.0
# Recent props (Round 5)
@@ -156,6 +197,12 @@ func _ready() -> void:
_build_mode_toolbar()
_build_top_right_controls()
_build_inspector()
_build_attach_tools()
_attach_preview = PropAttachPreviewScene.instantiate() as Control
_canvas_workspace.add_child(_attach_preview)
_apply_control_theme(_attach_preview)
_attach_preview.call("set_ui_hit_target_height", float(_theme.button_min_height()))
_attach_preview.visible = false
_setup_dialogs()
_canvas.shape_changed.connect(_on_canvas_shape_changed)
@@ -163,10 +210,16 @@ func _ready() -> void:
_canvas.shape_copy_requested.connect(_on_canvas_copy_requested)
_canvas.paste_from_context_requested.connect(_on_canvas_paste_requested)
_canvas.mode_changed.connect(_on_canvas_mode_changed)
_canvas.anchors_changed.connect(_on_canvas_anchors_changed)
_canvas.anchor_selected.connect(_on_canvas_anchor_selected)
_canvas.set_grid_size(_theme.grid_size())
_canvas.set_snap_enabled(false)
_canvas.set_has_clipboard(false)
_canvas.set_context_menu_font_size(_theme.font_context_menu())
_canvas.set_anchor_data(_anchors)
var origin_marker_color: Color = _theme.text_muted_color()
origin_marker_color.a = 0.75
_canvas.set_origin_marker_color(origin_marker_color)
_prop_name_edit.text_changed.connect(_on_name_changed)
_prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name())
@@ -180,6 +233,9 @@ func _ready() -> void:
_sync_mode_controls(_canvas.get_edit_mode())
_update_save_status()
_update_cursor_coords()
_canvas.call_deferred("reset_view")
call_deferred("_initialize_inspector_split")
_refresh_center_prop_button()
_check_autosave_recovery()
@@ -233,10 +289,13 @@ func _build_mode_toolbar() -> void:
_mode_group = ButtonGroup.new()
_move_button = _make_mode_button(PropCanvasScript.EditMode.TRANSFORM)
_points_button = _make_mode_button(PropCanvasScript.EditMode.POINTS)
_attach_button = _make_mode_button(PropCanvasScript.EditMode.ATTACH)
_move_button.pressed.connect(_on_move_mode_pressed)
_points_button.pressed.connect(_on_points_mode_pressed)
_attach_button.pressed.connect(_on_attach_mode_pressed)
_move_button.theme = _ui_theme
_points_button.theme = _ui_theme
_attach_button.theme = _ui_theme
func _make_mode_button(mode: int) -> Button:
@@ -261,27 +320,62 @@ func _on_move_mode_pressed() -> void:
func _on_points_mode_pressed() -> void:
_canvas.set_edit_mode(PropCanvasScript.EditMode.POINTS)
func _on_attach_mode_pressed() -> void:
_canvas.set_edit_mode(PropCanvasScript.EditMode.ATTACH)
func _on_canvas_mode_changed(mode: int) -> void:
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
if entering_attach and _show_preview_check != null:
_show_preview_check.set_pressed_no_signal(true)
_last_canvas_mode = mode
_sync_mode_buttons(mode)
_sync_mode_controls(mode)
func _sync_mode_buttons(mode: int) -> void:
if _move_button == null or _points_button == null:
if _move_button == null or _points_button == null or _attach_button == null:
return
_move_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.TRANSFORM)
_points_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.POINTS)
_attach_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.ATTACH)
var accent: Color = _theme.accent_color()
_move_button.add_theme_color_override("font_pressed_color", accent)
_points_button.add_theme_color_override("font_pressed_color", accent)
_attach_button.add_theme_color_override("font_pressed_color", accent)
func _sync_mode_controls(mode: int) -> void:
if _attach_tools != null:
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH
if _add_section != null:
_add_section.visible = mode != PropCanvasScript.EditMode.POINTS
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _delete_button != null:
_delete_button.visible = mode != PropCanvasScript.EditMode.POINTS
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _shape_section != null:
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and _canvas.get_selected_index() >= 0
if _material_section != null:
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH
if _anchor_section != null:
_anchor_section.visible = mode == PropCanvasScript.EditMode.ATTACH
if _attach_preview != null:
_attach_preview.visible = mode == PropCanvasScript.EditMode.ATTACH and (_show_preview_check == null or _show_preview_check.button_pressed)
if _attach_preview.has_method("set_document"):
_attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id)
func _build_attach_tools() -> void:
_show_preview_check = CheckBox.new()
_show_preview_check.text = "Show Stickman Preview"
_show_preview_check.button_pressed = true
_show_preview_check.toggled.connect(func(_enabled: bool) -> void: _sync_mode_controls(_canvas.get_edit_mode()))
_attach_tools.add_child(_show_preview_check)
_inactive_anchors_check = CheckBox.new()
_inactive_anchors_check.text = "Show Other Markers"
_inactive_anchors_check.button_pressed = true
_inactive_anchors_check.toggled.connect(func(enabled: bool) -> void:
_canvas.set_show_inactive_anchors(enabled)
_canvas.set_show_origin_marker(enabled))
_attach_tools.add_child(_inactive_anchors_check)
# ---------------------------------------------------------------------------
@@ -322,6 +416,18 @@ func _build_control_theme() -> void:
_ui_theme.set_stylebox("hover", "Button", panel_dark)
_ui_theme.set_stylebox("pressed", "Button", panel_pressed)
_ui_theme.set_stylebox("focus", "Button", panel)
var inspector_surface := StyleBoxFlat.new()
inspector_surface.bg_color = _theme.inspector_background_color()
inspector_surface.border_color = _theme.border_color()
inspector_surface.set_border_width_all(1)
inspector_surface.content_margin_left = 8
inspector_surface.content_margin_right = 8
_inspector_column.add_theme_stylebox_override("panel", inspector_surface)
var splitter := StyleBoxFlat.new()
splitter.bg_color = _theme.border_color().lightened(0.35)
splitter.content_margin_left = 3
splitter.content_margin_right = 3
_canvas_workspace.add_theme_stylebox_override("grabber", splitter)
_ui_theme.set_stylebox("normal", "OptionButton", panel)
_ui_theme.set_stylebox("hover", "OptionButton", panel_dark)
_ui_theme.set_stylebox("pressed", "OptionButton", panel_dark)
@@ -457,6 +563,35 @@ func _setup_dialogs() -> void:
_save_dialog.file_selected.connect(_on_save_file_selected)
_load_dialog.file_selected.connect(_on_load_file_selected)
_build_exit_dialog()
_rename_anchor_dialog = ConfirmationDialog.new()
_rename_anchor_dialog.title = "Rename Anchor"
_rename_anchor_dialog.ok_button_text = "Rename"
_rename_anchor_dialog.theme = _ui_theme
_rename_anchor_edit = LineEdit.new()
_rename_anchor_edit.placeholder_text = "Anchor name"
_rename_anchor_dialog.add_child(_rename_anchor_edit)
add_child(_rename_anchor_dialog)
_rename_anchor_edit.text_changed.connect(_update_rename_anchor_confirmation)
_rename_anchor_edit.text_submitted.connect(_on_rename_anchor_text_submitted)
_rename_anchor_dialog.confirmed.connect(_commit_anchor_rename)
_rename_anchor_dialog.canceled.connect(_clear_pending_anchor_rename)
func _on_rename_anchor_text_submitted(_text: String) -> void:
if _rename_anchor_dialog == null or not _rename_anchor_dialog.visible:
return
var ok_button := _rename_anchor_dialog.get_ok_button()
if not ok_button.disabled:
ok_button.pressed.emit()
func _initialize_inspector_split() -> void:
if _inspector_split_initialized or _main_layout == null:
return
var inspector_minimum: float = _inspector_column.get_combined_minimum_size().x
if _main_layout.size.x <= inspector_minimum:
call_deferred("_initialize_inspector_split")
return
_main_layout.split_offset = maxi(0, int(_main_layout.size.x - inspector_minimum - _main_layout.get_theme_constant("separation")))
_inspector_split_initialized = true
func _build_top_right_controls() -> void:
@@ -470,13 +605,22 @@ func _build_top_right_controls() -> void:
_grid_spin.max_value = 512
_grid_spin.value = _theme.grid_size()
_grid_spin.custom_minimum_size.x = 90.0
_grid_spin.custom_minimum_size.y = float(_theme.button_min_height())
_grid_spin.value_changed.connect(_on_top_grid_changed)
_top_right.add_child(_grid_spin)
_snap_check = CheckBox.new()
_snap_check.text = "Snap"
_snap_check.button_pressed = false
_snap_check.custom_minimum_size.y = float(_theme.button_min_height())
_snap_check.toggled.connect(_on_top_snap_toggled)
_top_right.add_child(_snap_check)
_center_prop_button = Button.new()
_center_prop_button.text = "🎯 Center Prop"
_center_prop_button.tooltip_text = "Move the whole prop and its anchors to (0,0)."
_center_prop_button.custom_minimum_size.y = float(_theme.button_min_height())
_center_prop_button.mouse_filter = Control.MOUSE_FILTER_STOP
_center_prop_button.pressed.connect(func() -> void: _center_prop_on_origin())
_top_right.add_child(_center_prop_button)
func _on_top_grid_changed(value: float) -> void:
@@ -499,6 +643,18 @@ func _build_exit_dialog() -> void:
_exit_dialog.confirmed.connect(_on_exit_save)
_exit_dialog.custom_action.connect(_on_exit_custom_action)
_exit_dialog.canceled.connect(_on_exit_cancel)
_off_origin_save_dialog = ConfirmationDialog.new()
_off_origin_save_dialog.title = "🎯 Center Prop?"
_off_origin_save_dialog.dialog_text = "This prop is far from (0,0). Centering it keeps its shapes and anchors together.\n\nWhat would you like to do?"
_off_origin_save_dialog.ok_button_text = "🎯 Center & Save"
_off_origin_save_dialog.add_button("💾 Save Anyway", false, "save_anyway")
_off_origin_save_dialog.get_cancel_button().text = "❌ Cancel"
_off_origin_save_dialog.theme = _ui_theme
add_child(_off_origin_save_dialog)
_off_origin_save_dialog.confirmed.connect(func() -> void: await _execute_pending_save(true))
_off_origin_save_dialog.custom_action.connect(func(action: StringName) -> void:
if action == &"save_anyway": await _execute_pending_save(false))
_off_origin_save_dialog.canceled.connect(_cancel_pending_save_request)
func _notification(what: int) -> void:
@@ -531,19 +687,7 @@ func _on_exit_save() -> void:
if _current_path.is_empty():
_save_dialog.popup_centered()
return
await _save_and_finish(_current_path)
func _save_and_finish(path: String) -> void:
var ok: bool = await _save_to(path, false)
if not ok:
return
if _pending_quit:
_pending_quit = false
get_tree().quit()
elif _pending_new:
_pending_new = false
_do_new()
_begin_save_request(_current_path, false)
func _on_exit_custom_action(action: StringName) -> void:
@@ -561,20 +705,27 @@ func _on_exit_custom_action(action: StringName) -> void:
func _on_exit_cancel() -> void:
_pending_quit = false
_pending_new = false
_cancel_pending_save_request()
func _do_new() -> void:
_clear_pending_anchor_rename()
_prop_name_edit.text = ""
_material_preset = PropBlockScript.MaterialPreset.NONE
_preset_option.selected = _material_preset
_update_physics_labels()
_canvas.clear()
_anchors = PropAnchorDataScript.default_anchors()
_selected_anchor_id = ""
_canvas.set_anchor_data(_anchors)
_clipboard = {}
_canvas.set_has_clipboard(false)
_current_path = ""
_dirty = false
_clear_autosave()
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_update_save_status()
@@ -588,7 +739,7 @@ func _on_menu_id_pressed(id: int) -> void:
if _current_path.is_empty():
_save_dialog.popup_centered()
else:
await _save_to(_current_path)
_begin_save_request(_current_path, true)
MENU_SAVE_AS:
_save_dialog.popup_centered()
MENU_RESET_VIEWS:
@@ -604,10 +755,13 @@ func _build_inspector() -> void:
var row_height: int = _theme.row_min_height()
# Material preset section
_material_section = VBoxContainer.new()
_material_section.add_theme_constant_override("separation", 6)
_inspector_list.add_child(_material_section)
var material_title := Label.new()
material_title.text = "Material"
material_title.add_theme_font_size_override("font_size", header_size)
_inspector_list.add_child(material_title)
_material_section.add_child(material_title)
_preset_option = OptionButton.new()
for label: String in PRESET_LABELS:
@@ -615,14 +769,14 @@ func _build_inspector() -> void:
_preset_option.selected = _material_preset
_preset_option.custom_minimum_size.y = float(_theme.button_min_height())
_preset_option.item_selected.connect(_on_preset_selected)
_inspector_list.add_child(_preset_option)
_material_section.add_child(_preset_option)
_mass_label = Label.new()
_friction_label = Label.new()
_bounce_label = Label.new()
_inspector_list.add_child(_mass_label)
_inspector_list.add_child(_friction_label)
_inspector_list.add_child(_bounce_label)
_material_section.add_child(_mass_label)
_material_section.add_child(_friction_label)
_material_section.add_child(_bounce_label)
_update_physics_labels()
# Selected-shape section
@@ -736,6 +890,160 @@ func _build_inspector() -> void:
_apply_control_theme(_inspector_list)
_rebuild_primitive_params()
_build_anchor_inspector()
func _build_anchor_inspector() -> void:
_anchor_section = VBoxContainer.new()
_anchor_section.add_theme_constant_override("separation", 6)
_inspector_list.add_child(_anchor_section)
var title := Label.new(); title.text = "Anchors"; title.add_theme_font_size_override("font_size", _theme.font_header()); _anchor_section.add_child(title)
_anchor_list = ItemList.new(); _anchor_list.custom_minimum_size.y = 140.0; _anchor_list.item_selected.connect(func(index: int) -> void: _select_anchor(_anchor_list.get_item_text(index))); _anchor_section.add_child(_anchor_list)
var actions := HBoxContainer.new(); _anchor_section.add_child(actions)
for label: String in ["Add Anchor", "Rename", "Delete"]:
var button := Button.new(); button.text = label; button.custom_minimum_size.y = float(_theme.button_min_height()); actions.add_child(button)
if label == "Add Anchor": button.pressed.connect(_on_add_anchor_pressed)
elif label == "Rename":
_rename_anchor_button = button
button.pressed.connect(_on_rename_anchor_pressed)
else:
_delete_anchor_button = button
button.pressed.connect(_on_delete_anchor_pressed)
_anchor_role = _anchor_option("Role", EDITABLE_ROLE_STRINGS, _on_anchor_role_selected)
_anchor_hand = _anchor_option("Allowed Hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS, _on_anchor_hand_selected)
_anchor_x = _anchor_spin("X", -100000.0, 100000.0, _on_anchor_x_changed)
_anchor_y = _anchor_spin("Y", -100000.0, 100000.0, _on_anchor_y_changed)
_anchor_rotation = _anchor_spin("Rotation", -180.0, 179.0, _on_anchor_rotation_changed)
_anchor_z = _anchor_spin("Z Offset", -5.0, 5.0, _on_anchor_z_changed)
_anchor_z.step = 1.0
var presets := FlowContainer.new(); _anchor_section.add_child(presets)
for entry: Dictionary in [{"text":"One-Handed","id":"one_handed"}, {"text":"Two-Handed","id":"two_handed"}, {"text":"Tool / Nozzle","id":"tool_nozzle"}, {"text":"Melee Edge","id":"melee_edge"}]:
var button := Button.new(); button.text = str(entry["text"]); button.pressed.connect(func() -> void: _apply_anchor_preset(str(entry["id"]))); presets.add_child(button)
_refresh_anchor_inspector()
func _anchor_option(label_text: String, entries: PackedStringArray, callback: Callable) -> OptionButton:
var row := HBoxContainer.new()
_anchor_section.add_child(row)
var label := Label.new()
label.text = label_text + ":"
label.custom_minimum_size.x = 84.0
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(label)
var option := OptionButton.new()
option.size_flags_horizontal = Control.SIZE_EXPAND_FILL
for entry: String in entries:
option.add_item(entry)
option.item_selected.connect(callback)
row.add_child(option)
return option
func _anchor_spin(label_text: String, minimum: float, maximum: float, callback: Callable) -> SpinBox:
var row := HBoxContainer.new()
_anchor_section.add_child(row)
var label := Label.new()
label.text = label_text + ":"
label.custom_minimum_size.x = 84.0
label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
row.add_child(label)
var spin := SpinBox.new()
spin.min_value = minimum
spin.max_value = maximum
spin.step = 1.0
spin.size_flags_horizontal = Control.SIZE_EXPAND_FILL
spin.value_changed.connect(callback)
row.add_child(spin)
return spin
func _refresh_anchor_inspector() -> void:
if _anchor_list == null: return
_updating_inspector = true; _anchor_list.clear()
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
for id: String in PropAnchorDataScript.editable_ids(_anchors): _anchor_list.add_item(id); if id == _selected_anchor_id: _anchor_list.select(_anchor_list.item_count - 1)
var anchor: Dictionary = _anchors.get(_selected_anchor_id, {})
var editable: bool = not anchor.is_empty()
if not anchor.is_empty():
var role_index: int = EDITABLE_ROLE_STRINGS.find(str(anchor.get("role", "custom")))
_anchor_role.select(role_index if role_index >= 0 else EDITABLE_ROLE_STRINGS.find("custom"))
_anchor_hand.select(PropAnchorDataScript.ALLOWED_HAND_STRINGS.find(str(anchor.get("allowed_hands", "both"))))
var pos: Vector2 = anchor.get("position", Vector2.ZERO); _anchor_x.value = pos.x; _anchor_y.value = pos.y; _anchor_rotation.value = float(anchor.get("rotation_deg", 0.0)); _anchor_z.value = int(anchor.get("z_index_offset", 0))
# OptionButton and Button inherit BaseButton and use `disabled`; SpinBox is a
# Range control and gates editing through `editable` in Godot 4.
for option: OptionButton in [_anchor_role, _anchor_hand]:
option.disabled = not editable
option.tooltip_text = "Add or select an anchor." if not editable else ""
for spin: SpinBox in [_anchor_x, _anchor_y, _anchor_rotation, _anchor_z]:
spin.editable = editable
spin.tooltip_text = "Add or select an anchor." if not editable else ""
if _rename_anchor_button != null:
_rename_anchor_button.disabled = not editable
if _delete_anchor_button != null:
_delete_anchor_button.disabled = not editable
_updating_inspector = false
func _select_anchor(id: String) -> void:
if id != PropAnchorDataScript.ORIGIN_ID and _anchors.has(id): _selected_anchor_id = id; _canvas.select_anchor(id); _refresh_anchor_inspector(); _sync_attach_preview()
func _on_add_anchor_pressed() -> void:
var id: String = PropAnchorDataScript.normalize_id("anchor", _anchors); var bounds: Rect2 = _canvas.get_shapes_bounds(); _anchors[id] = {"role":"custom", "position": bounds.get_center() if bounds.size != Vector2.ZERO else Vector2.ZERO, "rotation_deg":0.0, "z_index_offset":0, "allowed_hands":"both"}; _selected_anchor_id = id; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector()
func _on_rename_anchor_pressed() -> void:
if _selected_anchor_id.is_empty() or not _anchors.has(_selected_anchor_id) or _rename_anchor_dialog == null:
return
_rename_anchor_source_id = _selected_anchor_id
_rename_anchor_edit.text = _rename_anchor_source_id
_update_rename_anchor_confirmation(_rename_anchor_edit.text)
_rename_anchor_dialog.popup_centered(Vector2i(420, 150))
_rename_anchor_edit.call_deferred("grab_focus")
_rename_anchor_edit.call_deferred("select_all")
func _update_rename_anchor_confirmation(value: String) -> void:
if _rename_anchor_dialog != null:
_rename_anchor_dialog.get_ok_button().disabled = value.strip_edges().is_empty()
func _commit_anchor_rename() -> void:
if _rename_anchor_source_id.is_empty() or not _anchors.has(_rename_anchor_source_id):
_clear_pending_anchor_rename()
return
var remaining: Dictionary = _anchors.duplicate(true)
var payload: Dictionary = remaining[_rename_anchor_source_id].duplicate(true)
remaining.erase(_rename_anchor_source_id)
var new_id: String = PropAnchorDataScript.normalize_id(_rename_anchor_edit.text, remaining)
var old_id: String = _rename_anchor_source_id
_clear_pending_anchor_rename()
if new_id == old_id:
return
remaining[new_id] = payload
_anchors = PropAnchorDataScript.normalize_anchors(remaining)
_selected_anchor_id = new_id
_canvas.set_anchor_data(_anchors)
_canvas.select_anchor(new_id)
_mark_dirty()
_refresh_anchor_inspector()
_sync_attach_preview()
func _clear_pending_anchor_rename() -> void:
_rename_anchor_source_id = ""
func _on_delete_anchor_pressed() -> void:
if _selected_anchor_id.is_empty(): return
var ids: Array[String] = PropAnchorDataScript.editable_ids(_anchors)
var old_index: int = ids.find(_selected_anchor_id)
_anchors.erase(_selected_anchor_id)
ids = PropAnchorDataScript.editable_ids(_anchors)
_selected_anchor_id = ids[clampi(old_index - 1, 0, ids.size() - 1)] if not ids.is_empty() else ""
_canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
func _on_anchor_role_selected(index: int) -> void:
if index >= 0 and index < EDITABLE_ROLE_STRINGS.size():
_set_anchor_field("role", EDITABLE_ROLE_STRINGS[index])
func _on_anchor_hand_selected(index: int) -> void: _set_anchor_field("allowed_hands", PropAnchorDataScript.ALLOWED_HAND_STRINGS[index])
func _on_anchor_x_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("position", Vector2(value, _anchor_y.value))
func _on_anchor_y_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("position", Vector2(_anchor_x.value, value))
func _on_anchor_rotation_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("rotation_deg", value)
func _on_anchor_z_changed(value: float) -> void:
if not _updating_inspector: _set_anchor_field("z_index_offset", int(value))
func _set_anchor_field(field: String, value: Variant) -> void:
if _updating_inspector or _selected_anchor_id.is_empty(): return
var anchor: Dictionary = _anchors[_selected_anchor_id].duplicate(true); anchor[field] = value; _anchors[_selected_anchor_id] = anchor; _anchors = PropAnchorDataScript.normalize_anchors(_anchors); _canvas.set_anchor_data(_anchors); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
func _apply_anchor_preset(preset: String) -> void: _anchors = PropAnchorDataScript.preset_layout(preset, _canvas.get_shapes_bounds()); _selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors); _canvas.set_anchor_data(_anchors); _canvas.select_anchor(_selected_anchor_id); _mark_dirty(); _refresh_anchor_inspector(); _sync_attach_preview()
func _apply_control_theme(node: Node) -> void:
@@ -885,6 +1193,50 @@ func _refresh_inspector_from_selection() -> void:
func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void:
_mark_dirty()
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_sync_attach_preview()
func _on_canvas_anchors_changed(anchors: Dictionary) -> void:
_anchors = PropAnchorDataScript.normalize_anchors(anchors)
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _canvas.get_selected_anchor_id())
_mark_dirty()
_refresh_anchor_inspector()
_sync_attach_preview()
func _on_canvas_anchor_selected(anchor_id: String) -> void:
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, anchor_id)
_refresh_anchor_inspector()
_sync_attach_preview()
func _sync_attach_preview() -> void:
if _attach_preview != null and _attach_preview.has_method("set_document"):
_attach_preview.call("set_document", _canvas.get_shape_data(), _anchors, _selected_anchor_id)
func _refresh_center_prop_button() -> void:
if _center_prop_button == null: return
var bounds: Rect2 = _canvas.get_shapes_bounds()
_center_prop_button.disabled = not _canvas.has_shape_geometry() or bounds.get_center().is_zero_approx()
func _center_prop_on_origin(focus_view: bool = true) -> bool:
if not _canvas.has_shape_geometry(): return false
var bounds: Rect2 = _canvas.get_shapes_bounds()
var delta: Vector2 = -bounds.get_center()
if not is_finite(delta.x) or not is_finite(delta.y): return false
if delta.is_zero_approx():
if focus_view: _canvas.focus_world_point(Vector2.ZERO)
return false
_anchors = PropAnchorDataScript.translated_non_origin(_anchors, delta)
_canvas.set_anchor_data(_anchors)
if not _canvas.translate_all_shapes(delta): return false
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
_canvas.select_anchor(_selected_anchor_id)
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_sync_attach_preview()
if focus_view: _canvas.focus_world_point(Vector2.ZERO)
return true
func _on_canvas_shape_selected(_idx: int) -> void:
@@ -977,10 +1329,11 @@ func _check_autosave_recovery() -> void:
# ---------------------------------------------------------------------------
func _build_json_data() -> Dictionary:
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data())
return PropFileScript.to_file_dict(_prop_name_edit.text, _material_preset, _canvas.get_shape_data(), _anchors)
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
_clear_pending_anchor_rename()
var model: Dictionary = PropFileScript.from_file_dict(json)
if model.is_empty():
return "The file could not be read: unsupported version or malformed data."
@@ -991,9 +1344,16 @@ func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
_update_physics_labels()
var shapes: Array = model.get("shapes", [])
_canvas.set_shape_data(shapes)
_anchors = PropAnchorDataScript.normalize_anchors(model.get("anchors", {}))
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors)
_canvas.set_anchor_data(_anchors)
_canvas.select_anchor(_selected_anchor_id)
_dirty = is_recovery
_autosave_remaining = 0.0
_refresh_inspector_from_selection()
_refresh_anchor_inspector()
_refresh_center_prop_button()
_canvas.call_deferred("reset_view")
_update_save_status()
return ""
@@ -1025,16 +1385,44 @@ func _save_prop_thumbnail() -> void:
_thumb_cache.save_png(tex, _thumb_cache.prop_file_png(_current_path))
func _begin_save_request(path: String, with_thumbnail: bool) -> void:
_pending_save_path = path
_pending_save_with_thumbnail = with_thumbnail
if should_warn_off_origin(_canvas.has_shape_geometry(), _canvas.get_shapes_bounds()):
_off_origin_save_dialog.popup_centered()
return
await _execute_pending_save(false)
func _execute_pending_save(center_first: bool) -> void:
var path: String = _pending_save_path
var with_thumbnail: bool = _pending_save_with_thumbnail
if path.is_empty():
_cancel_pending_save_request()
return
if center_first: _center_prop_on_origin(true)
var ok: bool = await _save_to(path, with_thumbnail)
if not ok:
_cancel_pending_save_request()
return
_pending_save_path = ""
_pending_save_with_thumbnail = true
if _pending_quit:
_pending_quit = false
get_tree().quit()
elif _pending_new:
_pending_new = false
_do_new()
func _cancel_pending_save_request() -> void:
_pending_save_path = ""
_pending_save_with_thumbnail = true
_pending_quit = false
_pending_new = false
func _on_save_file_selected(path: String) -> void:
if path.get_extension().to_lower() != "prop":
path = path.get_basename() + ".prop"
var ok: bool = await _save_to(path, not (_pending_quit or _pending_new))
if ok and _pending_quit:
_pending_quit = false
get_tree().quit()
elif ok and _pending_new:
_pending_new = false
_do_new()
_begin_save_request(path, not (_pending_quit or _pending_new))
func _load_from(path: String) -> void:
+4 -1
View File
@@ -9,6 +9,7 @@ extends RefCounted
const PropBlockScript := preload("res://scripts/prop_block.gd")
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
const FILE_VERSION: String = "1.0"
const FILE_EXT: String = ".prop"
@@ -61,7 +62,7 @@ static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScr
return result
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary:
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary], anchors: Dictionary = {}) -> Dictionary:
var serialized: Array = []
for shape: Dictionary in shapes:
serialized.append(_serialize_shape(shape))
@@ -71,6 +72,7 @@ static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[
"prop_name": prop_name,
"material_preset": preset_to_string(material_preset),
"shapes": serialized,
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
"metadata": {"created_at": now, "modified_at": now},
}
@@ -105,6 +107,7 @@ static func from_file_dict(data: Variant) -> Dictionary:
"prop_name": str(dict.get("prop_name", "")),
"material_preset": preset,
"shapes": shapes,
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
}
+5 -1
View File
@@ -42,7 +42,7 @@ const DEFAULT_CONFIG: Dictionary = {
"ui": {
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
"hit_targets": {"button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 56},
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF"},
"colors": {"text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", "accent": "#4FA3FF", "inspector_background": "#262C39"},
},
}
@@ -218,6 +218,10 @@ func accent_color() -> Color:
return color("ui.colors.accent", Color("#4FA3FF"))
func inspector_background_color() -> Color:
return color("ui.colors.inspector_background", Color("#262C39"))
func invalidate_caches() -> void:
_warned.clear()
+877
View File
@@ -0,0 +1,877 @@
# Phase 9a — Role-Based Prop Anchors and IK Authoring Preview
Status: **proposed — awaiting user sign-off** (2026-09-20).
Source: `plans/PHASE_9a_PROP_ATTACH.md`, refined through the spec-pipeline architecture
clarification round.
## 1. Accepted decisions
The user approved these three architecture decisions:
1. **Prop Editor-only phase.** This phase implements anchor authoring, `.prop` round-trip,
role queries, and an isolated rig-backed IK holding preview. All Sandbox Stage integration,
runtime loading, attachment, gameplay, physics, and scene persistence are future work.
2. **Fixed origin pivot.** Every prop has exactly one reserved anchor named `origin`, with
role `pivot`, at `(0, 0)`. It cannot be deleted, renamed, moved, or assigned another role.
Legacy `.prop` files synthesize it on load.
3. **One-hand authoring preview.** The selected grip follows the solved wrist endpoint of an
isolated preview rig. Secondary grips remain serialized/queryable and may be previewed one at
a time, but never drive the opposite hand.
These are requirements, not implementation suggestions.
## 2. Scope
### 2.1 In scope
- Extend the existing `[ Arrange | Reshape ]` toolbar and mode enum with one new `Attach`
mode. Arrange and Reshape retain their current labels, behavior, and tests.
- Add a normalized role-based anchor dictionary to `.prop` v1.0.
- Preserve legacy `.prop` compatibility by synthesizing the fixed `origin` pivot.
- Add anchor list/property editing, add/delete/rename, role selection, quick presets, and
role-specific canvas gizmos.
- Add anchor translation, grid snapping, rotation handles, and an isolated rig-backed preview
where a stickman visibly holds the prop while its selected arm is posed through IK.
- Include anchors in ordinary save/load, Save As, autosave, recovery, and New.
- Add pure role/hand query and transform helpers that the editor preview consumes and future
gameplay systems may reuse.
- Add headless regression coverage plus manual Prop Editor checks.
### 2.2 Explicitly deferred
- **All Sandbox Stage/runtime changes:** custom `.prop` loading/spawning, selector integration,
stage provenance/persistence, hand sockets, `StickmanPropAttachment`, equip/release, held-prop
z ordering, collision/freeze handling, ragdoll/drop behavior, and runtime mirroring.
- Secondary-hand IK and simultaneous two-hand constraint solving in gameplay. The editor preview
remains one-hand-at-a-time.
- Automatic grip choice, reach-to-pickup animation, pickup menus, director actions, or action
queue schema changes.
- Gameplay behavior for `grip`, `emitter`, `impact`, `mount`, or `custom` anchors. This phase
authors and previews metadata only.
- Projectile, particle, light, melee, accessory, throw, or center-of-mass systems.
- Full multi-shape runtime rendering/collision. The existing runtime flattening contract is not
modified; the editor preview may show the full authoring stack.
- Document-wide undo/redo. The Prop Editor currently has no undo system; adding anchor-only
undo would make shape and anchor editing inconsistent.
- Any `.scene` schema or save/load change.
## 3. Existing constraints
- `PropCanvas.EditMode` currently has only `TRANSFORM` and `POINTS`, and
`set_edit_mode()` clamps every non-POINTS value to TRANSFORM.
- `PropEditor` derives every save/autosave from `_build_json_data()`, so anchor document state
must enter through that single path.
- `PropFile.FILE_VERSION` is `"1.0"`; additive fields such as `reshaped` already use
backward-compatible v1.0 evolution.
- `PropBlock` and `PropUtils.spawn_prop()` are single-shape runtime objects, but Phase 9a does not
change or consume them. The editor preview uses non-physics visuals built from all shapes.
- `StickmanRig` contains runtime locomotion/ragdoll/action components. The editor preview may
instantiate it only inside a private SubViewport and must disable gameplay processing and
collisions; no rig/runtime class or scene is modified by this phase.
- `StickmanRig` mirrors its root on X for LEFT, while Godot TwoBoneIK is not mirror-invariant.
The preview must isolate reflection outside the positive-scale solving skeleton.
- `IK_Targets/Left_Hand` and `Right_Hand` are solver goals. Preview attachment must sample the
solved lower-arm visual endpoint, not pretend the target marker itself is the wrist.
## 4. `.prop` schema and normalization
### 4.1 Version policy
Keep `PropFile.FILE_VERSION == "1.0"` and `SUPPORTED_VERSIONS == ["1.0"]`. `anchors` is an
additive field. A missing or malformed `anchors` value is normalized to the mandatory origin
rather than rejecting an otherwise valid legacy file.
`PropFile.to_file_dict()` always emits `anchors`, including the fixed origin. Loading an old
file does not mark the editor dirty merely because the in-memory default was synthesized; its
next explicit save writes the new field.
### 4.2 File representation
```json
{
"version": "1.0",
"prop_name": "Arcane Staff",
"material_preset": "wood",
"shapes": [],
"anchors": {
"origin": {
"role": "pivot",
"x": 0.0,
"y": 0.0,
"rotation_deg": 0.0,
"z_index_offset": 0,
"allowed_hands": "both"
},
"primary_grip": {
"role": "grip",
"x": 0.0,
"y": 18.0,
"rotation_deg": 90.0,
"z_index_offset": 1,
"allowed_hands": "both"
},
"crystal_head": {
"role": "emitter",
"x": 0.0,
"y": -48.0,
"rotation_deg": -90.0,
"z_index_offset": 0,
"allowed_hands": "both"
}
},
"metadata": {}
}
```
Every serialized anchor uses the same six fields. Uniform entries make validation, UI binding,
and future consumers deterministic even when a field is not currently meaningful for a role.
### 4.3 Internal representation
The normalized editor model remains a `Dictionary` keyed by anchor ID, matching the project's
JSON-first asset conventions:
```gdscript
{
"primary_grip": {
"role": "grip",
"position": Vector2(0.0, 18.0),
"rotation_deg": 90.0,
"z_index_offset": 1,
"allowed_hands": "both",
},
}
```
Do not assign JSON `{x, y}` dictionaries directly into typed vector arrays or vector fields.
Parse `x` and `y` explicitly, following the existing `_coerce_points()` serialization lesson.
### 4.4 Registry and validation rules
- Roles: `pivot`, `grip`, `emitter`, `impact`, `mount`, `custom`.
- Allowed hands: `both`, `left_only`, `right_only`.
- IDs use lowercase snake case: `^[a-z][a-z0-9_]{0,31}$`.
- UI input is normalized by trimming, lowercasing, replacing runs of non-alphanumeric
characters with `_`, trimming `_`, and adding `_2`, `_3`, ... until unique.
- Position values must be finite; invalid values become `0.0`.
- `rotation_deg` must be finite and is wrapped into `[-180.0, 180.0)`.
- `z_index_offset` is clamped to `[-5, 5]`.
- Unknown roles become `custom`; unknown hand values become `both`.
- The `origin` entry is overwritten with the canonical fixed pivot on every normalization.
- Any non-origin entry claiming role `pivot` is preserved but downgraded to `custom`, with one
warning for that normalization pass. No user data coordinates are discarded.
- Malformed non-dictionary entries are skipped with a warning.
Anchor position is always in canonical prop-local pixels. `rotation_deg` is the direction of
the anchor's local **+X axis**. For a grip, +X points from elbow toward wrist; for an emitter,
it is the output direction. It is orientation metadata, not an instruction to rotate the prop's
stored geometry.
### 4.5 `PropAnchorData` contract
Add `scripts/prop_anchor_data.gd`:
```gdscript
class_name PropAnchorData
extends RefCounted
const ORIGIN_ID: String = "origin"
const ROLE_STRINGS: PackedStringArray = [
"pivot", "grip", "emitter", "impact", "mount", "custom"
]
const ALLOWED_HAND_STRINGS: PackedStringArray = [
"both", "left_only", "right_only"
]
enum Hand { LEFT, RIGHT }
static func default_anchors() -> Dictionary
static func normalize_anchors(raw: Variant, warn: bool = true) -> Dictionary
static func serialize_anchors(anchors: Dictionary) -> Dictionary
static func normalize_id(candidate: String, anchors: Dictionary) -> String
static func ids_for_role(anchors: Dictionary, role: String) -> Array[String]
static func compatible_grip_ids(anchors: Dictionary, hand: int) -> Array[String]
static func local_transform(anchor: Dictionary, mirror_x: bool = false) -> Transform2D
static func solve_attachment_transform(
target_transform: Transform2D,
anchor: Dictionary,
positive_scale: Vector2,
mirror_x: bool
) -> Transform2D
static func preset_layout(preset: String, bounds: Rect2) -> Dictionary
```
All returned dictionaries/arrays that expose document state are deep duplicates. Callers must
not be able to mutate the codec's or canvas's state by retaining a reference.
When `mirror_x` is true, local position becomes `(-x, y)` and the +X direction angle becomes
`PI - angle`. The canonical stored anchor is never mutated.
`solve_attachment_transform()` returns a positive-determinant prop transform satisfying
`prop_transform * effective_anchor_transform == target_transform` for translation and +X
direction. Normalize `positive_scale` to finite absolute components greater than `0.0001`, compute
`prop_angle = target_transform.get_rotation() - effective_anchor_angle`, build the prop basis
from `prop_angle` and that positive scale, then set:
```gdscript
var prop_origin := (
target_transform.origin
- prop_basis.basis_xform(effective_anchor_position)
)
```
The helper ignores scale/skew on `target_transform`; callers supply a pure rotation/translation
target. Mirroring is represented only by the effective anchor plus separately mirrored preview
geometry, never by a negative prop-node scale.
### 4.6 `PropFile` API changes
```gdscript
static func to_file_dict(
prop_name: String,
material_preset: int,
shapes: Array[Dictionary],
anchors: Dictionary = {}
) -> Dictionary
static func from_file_dict(data: Variant) -> Dictionary
```
`from_file_dict()` returns:
```gdscript
{
"prop_name": String,
"material_preset": int,
"shapes": Array[Dictionary],
"anchors": Dictionary,
}
```
`flatten_to_payload()` remains shape-only and unchanged. Anchors are provenance/interaction
metadata, not collision geometry.
## 5. Prop Editor architecture
### 5.1 Node layout
Update `scenes/prop_editor.tscn` without changing unique-name lookup contracts:
```text
PropEditor
├── TopRow
│ ├── MenuBar
│ └── TopRightControls # existing Grid + global Snap source
├── TopBar
├── MainLayout
│ ├── CanvasPanel
│ │ └── CanvasArea
│ │ ├── ModeBar # existing Arrange/Reshape + new Attach
│ │ ├── AttachTools # visible only in Attach
│ │ └── CanvasWorkspace (HSplitContainer)
│ │ ├── PropCanvas # existing authoring canvas
│ │ └── PropAttachPreview # rig-backed holding preview; Attach only
│ └── InspectorColumn
│ └── InspectorScroll # NEW ScrollContainer
│ └── InspectorList # keep %InspectorList
├── SaveDialog / LoadDialog / ErrorDialog
└── StatusBar
```
`AttachTools` contains:
- `Show Stickman Preview` (`CheckBox`, default on, session-only tool state; collapses/opens the
preview side of `CanvasWorkspace`);
- `Snap Anchors` (`CheckBox`, bound bidirectionally to the same `_snap_enabled` value as the
existing top-right Snap checkbox);
- `Show Inactive Gizmos` (`CheckBox`, default on, session-only tool state).
The second Snap control is a synchronized view of one setting, not independent state. Changing
either checkbox updates both without signal recursion and calls `PropCanvas.set_snap_enabled()`.
### 5.2 Modes
Preserve the existing Arrange (`TRANSFORM`) and Reshape (`POINTS`) values and append Attach:
```gdscript
enum EditMode { TRANSFORM, POINTS, ATTACH }
```
`set_edit_mode()` clamps only values outside that enum. Do not renumber or reinterpret the first
two values. ATTACH persists across clear/load just like the existing modes. Append `Attach` /
`📌` to the controller-owned mode-label arrays; do not rebuild or rename Arrange/Reshape. Keep
all three buttons in the existing `ButtonGroup`, with `focus_mode = FOCUS_NONE` to preserve the
current selected styling.
Mode behavior:
| Behavior | Arrange | Reshape | Attach |
| --- | --- | --- | --- |
| Shape body translate | yes | select only | no |
| Shape transform gizmos | yes | no | no |
| Vertex handles/menu | no | yes | no |
| Shape right-click menu | yes | reshape matrix | no |
| Anchor gizmos/input | no | no | yes |
| Wheel/pinch/middle-pan | yes | yes | yes |
Shape selection may remain remembered while Attach is active, but no selection outline, shape
gizmo, or vertex handle is drawn in that mode. Switching back restores the remembered shape
selection.
### 5.3 Composed anchor interaction helper
Add `scripts/prop_anchor_overlay.gd`:
```gdscript
class_name PropAnchorOverlay
extends RefCounted
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
enum DragMode { NONE, TRANSLATE, ROTATE }
func set_anchor_data(data: Variant) -> void
func get_anchor_data() -> Dictionary
func select_anchor(anchor_id: String) -> void
func get_selected_anchor_id() -> String
func set_show_inactive(enabled: bool) -> void
func hit_test_anchor(world_pos: Vector2, zoom: float) -> String
func begin_pointer(world_pos: Vector2, zoom: float) -> bool
func update_pointer(world_pos: Vector2, zoom: float, snap: bool, grid_size: int, ctrl: bool) -> void
func end_pointer() -> void
func draw_gizmos(canvas: CanvasItem, zoom: float) -> void
```
This helper owns anchor selection/drag state and math, while `PropCanvas` remains the only owner
of pan, zoom, coordinate conversion, and `_gui_input`. The canvas routes left-button events to
the helper only in ATTACH and re-emits its two signals as public `PropCanvas` signals. The helper
draws only authoring gizmos; the isolated rig preview in §5.5 owns all pose visualization.
Add to `PropCanvas`:
```gdscript
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
func set_anchor_data(data: Variant) -> void
func get_anchor_data() -> Dictionary
func select_anchor(anchor_id: String) -> void
func get_selected_anchor_id() -> String
func set_show_inactive_anchors(enabled: bool) -> void
func get_shapes_bounds() -> Rect2
```
An empty shape stack must not early-return before anchor drawing. Attach mode still shows the
origin and other anchors on an empty canvas.
### 5.4 Drawing and hit targets
Use code-drawn vector markers; do not add bitmap icon dependencies.
| Role | Marker | Core color |
| --- | --- | --- |
| pivot | crosshair | yellow |
| grip | hand-like circle + short palm bars | cyan |
| emitter | target reticle + direction arrow | red |
| impact | diamond + slash | bright pink |
| mount | ring + center dot | purple |
| custom | hexagon | light gray |
- Draw a dark halo behind every marker/line, matching the existing selection visibility rule.
- Marker radius and hit radius are constant screen sizes (`value / zoom`).
- Draw the selected marker last, with a yellow outer selection ring.
- When `Show Inactive Gizmos` is off, draw/hit-test only the selected anchor. If no anchor is
selected, continue showing the origin so the canvas is not visually empty.
- Pivot is selectable but never draggable and never receives a rotation handle.
- A selected `grip` or `emitter` shows a rotation ring and handle.
- Translation snaps each coordinate with `snappedf(value, grid_size)` only when the shared Snap
setting is enabled.
- Rotation is free by default; Ctrl snaps to 15-degree increments, consistent with Arrange.
- Emit `anchors_changed` during live drag for inspector feedback. Mouse release sanitizes the
final position/angle and emits once more only if sanitization changed it.
- Right click has no Attach-mode context menu; list/inspector buttons own add/delete/rename.
### 5.5 Rig-backed holding and IK preview
The preview must show a recognizable stickman holding the current prop, not a symbolic hand.
It is a separate authoring visualization with its own `SubViewport`, world, rig instance, camera,
pose state, and non-physics prop visual. It reuses the actual rig scene, `StkRigAdapter`, arm IK,
and the pure anchor-alignment math, while remaining isolated from Sandbox/gameplay state.
#### 5.5.1 Class and scene contract
Add `scripts/prop_attach_preview.gd` and `scenes/prop_attach_preview.tscn`:
```gdscript
class_name PropAttachPreview
extends PanelContainer
enum PreviewHand { LEFT, RIGHT }
enum FrameMode { UPPER_BODY, FULL_FIGURE }
func set_document(
shapes: Array[Dictionary],
anchors: Dictionary,
selected_anchor_id: String
) -> void
func set_selected_anchor(anchor_id: String) -> void
func set_preview_hand(hand: int) -> void
func set_facing_profile(profile: int) -> void
func set_arm_animation_enabled(enabled: bool) -> void
func reset_arm_pose() -> void
func frame_view(mode: int) -> void
func is_preview_available() -> bool
```
```text
PropAttachPreview (PanelContainer)
└── PreviewLayout (VBoxContainer)
├── PreviewHeader (HBoxContainer)
│ ├── Title: "Holding Preview"
│ ├── HandOption: Left / Right
│ ├── FacingOption: Forward / Left / Right
│ └── FrameOption: Upper Body / Full Figure
├── PreviewHint (Label)
├── PreviewViewportContainer (SubViewportContainer)
│ └── PreviewViewport (SubViewport)
│ └── PreviewWorld (Node2D)
│ ├── PreviewRigRoot (Node2D; preview-only mirror wrapper)
│ │ └── PreviewRig (dynamically instanced for this editor preview)
│ ├── PreviewProp (Node2D; generated visual only)
│ ├── PreviewOverlay (Node2D; target handle/instructions)
│ └── PreviewCamera (Camera2D)
└── PreviewFooter (HBoxContainer)
├── AnimateArm (CheckBox)
├── ResetArm (Button)
└── ResetView (Button)
```
The preview panel has a `360 px` minimum width and is the right pane of the resizable
`CanvasWorkspace` split. It is visible only in Attach mode and while `Show Stickman Preview` is
enabled. Hiding it must not change document data, selected anchor, or pose state outside this
private preview.
#### 5.5.2 Safe rig reuse and isolation
Create one private preview rig when the preview enters the tree:
1. Load `res://stickmen/test.stk` through `StickmanFactory.load_stk()`.
2. Instantiate `res://scenes/stickman_rig.tscn`, call `set_physics_process(false)` on the rig
before it enters the tree, and apply valid `.stk` data through `StkRigAdapter.apply()`. If the
file cannot be loaded, keep the scene's authored master visuals rather than failing the editor.
3. Add the rig only to `PreviewViewport`'s private `PreviewWorld`; after `_ready()`, stop its
`AnimationPlayer`, call `stop_queue()` then `clear_queue()`, retain the default `ANIMATED`
state, and set every descendant `CollisionObject2D` layer and mask to `0`.
4. Do not set the whole rig subtree to `PROCESS_MODE_DISABLED`, because that would also stop the
skeleton solve. The root physics callback is the gameplay tick boundary; authored component
children have no autonomous process callback.
5. Keep the `Skeleton2D` modification stack and `RemoteTransform2D` drivers active so moving an
IK target solves the real arm and updates the real Body visuals.
The preview instance is never added to Sandbox `World`, never registered with `StageSpawner`,
never serialized, and never connected to stage rules or signals. It must not create a
`PropBlock`, hand socket, or runtime attachment component. Preview pose/facing/hand settings are
session-only UI state. The preview receives deep copies from `PropEditor`, so no preview mutation
can alter the document except through the existing anchor inspector/canvas signals.
This boundary intentionally reuses visual rig/IK behavior but not gameplay ownership, physics,
ragdoll, locomotion, or action execution.
#### 5.5.3 Prop visual and shared alignment math
`PreviewProp` is rebuilt from the editor's complete `shapes[]` stack using one `Polygon2D` or
`Line2D` per shape, in document order. It has no collision node or physics body and therefore
shows all authored shapes even though the staged runtime still flattens collision/render payloads.
Coalesce repeated shape/anchor signals into at most one rebuild/alignment per rendered frame.
Add one pure helper so editor alignment is deterministic, directly testable, and reusable by a
future runtime design if that later specification adopts the same convention:
```gdscript
static func solve_attachment_transform(
target_transform: Transform2D,
anchor: Dictionary,
positive_scale: Vector2,
mirror_x: bool
) -> Transform2D
```
This belongs on `PropAnchorData`. Phase 9a has exactly one production caller
(`PropAttachPreview`). Keeping the transform pure and independent of preview nodes avoids
coupling the schema to the rig scene. A future runtime phase may reuse it only after separately
validating the convention. The preview may mirror its non-physics geometry directly, but
canonical document shapes/anchors remain unchanged.
Do not add HandSocket nodes to the runtime rig. After the skeleton/RemoteTransform update, derive
the preview wrist transform from existing nodes:
1. Resolve `Body/LeftLowerArm` or `Body/RightLowerArm` as the solved hanging-frame visual.
2. Resolve the matching lower `Bone2D` and read its fitted `length`.
3. Compute elbow = `lower_visual.to_global(Vector2.ZERO)` and wrist =
`lower_visual.to_global(Vector2(0, lower_length))`.
4. Direction = `(wrist - elbow).normalized()`; build a proper (positive determinant) socket
transform whose +X axis follows that direction and whose origin is wrist.
5. Pass that transform to `solve_attachment_transform()`.
`StkRigAdapter` already mounts lower-arm visuals into the +Y hanging frame and fits their extent
to the bone length, so this samples the visible endpoint without changing the rig or trusting the
IK goal marker as the solved wrist. If the nodes/direction are invalid, hide the preview prop and
show a non-fatal preview-unavailable hint.
When the selected entry is a grip, align it to this derived wrist transform. A one-frame
engine-order delay is acceptable while dragging; the following frame must be exact. When no grip
is selected, keep the rig visible, hide the prop visual, and show
`Select a grip anchor to preview holding.`
Preview `z_index_offset` relative to the rig root: negative behind, positive in front, and zero
placed immediately before the rig so the hand/body stays visible. This is an authoring convention
only; exact runtime layering is deliberately left to the future runtime specification.
#### 5.5.4 Hand and facing behavior
- Default preview hand is RIGHT and default facing is FORWARD.
- `left_only` forces LEFT and disables RIGHT in `HandOption`.
- `right_only` forces RIGHT and disables LEFT.
- `both` enables both choices and remembers the last session choice.
- Selecting any grip—including `secondary_grip`—previews that one grip as the active one-hand
attachment. It never moves the opposite hand or consumes another grip.
- Changing hand moves only the newly active hand target; first reset both hand targets to their
captured rest positions so poses do not leak between hand choices.
- Facing uses the preview rig's established `FacingProfile`, bend flags, and Body child ordering.
It does not write the document or affect a runtime rig.
- LEFT must exercise `PropAnchorData`'s effective-anchor mirroring so authors can evaluate the
intended facing behavior before a future runtime implementation exists.
The runtime rig deliberately disables idle limb IK under its negative-X LEFT root because Godot's
TwoBoneIK solver is not mirror-invariant. The preview must not bypass that known constraint by
forcing the solver under a reflected skeleton. For LEFT preview:
1. call `PreviewRig.set_facing_profile(LEFT)` to reuse bend flags and Body ordering;
2. restore `PreviewRig.scale = Vector2.ONE`;
3. mirror `PreviewRigRoot.scale.x = -1` so reflection occurs *after* the positive-scale skeleton
solves;
4. explicitly enable the two arm `SkeletonModification2DTwoBoneIK` entries for preview posing;
5. keep `PreviewProp` outside `PreviewRigRoot`, mirror its generated geometry, and align it to the
reflected socket with `solve_attachment_transform(..., mirror_x = true)`.
RIGHT/FORWARD use identity `PreviewRigRoot` and `mirror_x = false`. This establishes the editor's
facing convention while keeping the isolated preview solver stable. Tests must assert that LEFT
leaves `PreviewRig`'s local scale positive and puts the reflection only on the wrapper.
This preserves the approved one-hand authoring scope while allowing every authored grip to be
visually tested with either compatible anatomical hand.
#### 5.5.5 IK interaction
Draw a constant-screen-size cyan target handle at the active
`IK_Targets/Left_Hand` or `IK_Targets/Right_Hand` marker. The user may drag this handle inside the
preview viewport; the real TwoBoneIK modification bends the arm and the preview prop follows the
solved wrist socket.
Clamp manual and animated targets to the reachable annulus around the active upper-arm shoulder:
```text
min reach = abs(upper_length - lower_length) + 8 px
max reach = upper_length + lower_length - 8 px
```
If the drag vector is zero, use the captured rest-pose direction before clamping. This avoids
solver folding/jitter without inventing a second IK implementation.
`Animate Arm` provides a hands-off movement check:
- period: `3.0 s`, sine-eased ping-pong;
- endpoint A: the captured rest target;
- endpoint B: rest target plus `(outward * 120, -120)`, clamped to the same annulus;
- anatomical outward sign: LEFT `+1`, RIGHT `-1` in unmirrored rig-local space;
- starting a manual drag turns animation off;
- Reset Arm turns animation off and restores both hand targets to their captured rest values.
The inactive hand remains at rest. Head, torso, and leg targets remain untouched. No animation
clip plays while the arm target is manipulated.
#### 5.5.6 Camera and input
- `PreviewViewport.size` follows `PreviewViewportContainer.size`.
- Default frame is `UPPER_BODY`, fitting the union of head/torso/arms and current prop with a
`32 px` screen margin.
- `FULL_FIGURE` fits a preview-local union of the transformed `Body/*` visual bounds and current
prop AABB with the same margin. `PropAttachPreview` owns this bounds helper; it must not import
or call `StageSpawner`.
- The local bounds helper walks visible descendants recursively: transform each `Polygon2D.polygon`
point and each `Line2D.points` entry into `PreviewWorld` coordinates, expanding line bounds by
half the drawn width. `UPPER_BODY` filters to Head/Torso/arm branches; `FULL_FIGURE` includes
every `Body/*` branch.
- Camera framing is recalculated on preview resize, explicit Reset View, frame-mode change, or a
document geometry rebuild—not every arm-animation frame. This prevents zoom pumping.
- Mouse wheel performs cursor-centered preview zoom, clamped `[0.3, 3.0]` relative to the fitted
zoom. Middle mouse pans. These controls affect only the preview camera.
- Left drag moves only the active IK handle; clicks elsewhere do not select/edit anchors.
- The parent `PropCanvas` keeps its existing pan/zoom/input unchanged.
Convert pointer positions through the preview viewport rather than the authoring canvas:
```gdscript
var preview_world_pos := (
preview_viewport.get_canvas_transform().affine_inverse()
* preview_container_local_position
)
```
The viewport size must match the container's integer size (clamped to at least `1 × 1`), so this
mapping remains stable after split/window resize.
The preview overlay shows a short hint: `Drag the cyan hand target to test the grip.` It may show
the active anchor ID and hand, but it must not show gameplay/action controls.
### 5.6 Inspector
`PropEditor` owns normalized document state:
```gdscript
var _anchors: Dictionary = PropAnchorData.default_anchors()
var _selected_anchor_id: String = PropAnchorData.ORIGIN_ID
```
The existing Material section stays visible. In ATTACH, hide Selected Shape and Add Shape, and
show a new Anchor section containing:
1. `ItemList` of anchor IDs (the list itself scrolls);
2. `Add Anchor`, `Rename`, `Delete` buttons;
3. Role `OptionButton`;
4. X/Y `SpinBox` values (`-100000..100000`, step `1`);
5. Rotation `SpinBox` (`-180..<180`, step `1`) plus a matching slider if it can share the same
no-recursion update guard;
6. Z Offset `SpinBox` (`-5..5`, integer);
7. Allowed Hands `OptionButton`;
8. Quick Presets buttons: One-Handed, Two-Handed, Tool / Nozzle, Melee Edge.
For `origin`, Rename/Delete and every property control are disabled. Role-specific relevance is
communicated by muted labels, but the uniform data remains editable for non-origin anchors.
Add creates a uniquely named `anchor`, selects it, and defaults to role `custom` at the center of
the current shape bounds (or zero when empty). Delete selects the preceding list entry, falling
back to `origin`. Rename uses the ID normalizer and immediately updates selection.
Quick presets replace all non-origin anchors. If any exist, use a confirmation dialog before
replacement. Layout is derived from the union shape AABB:
- major axis = X when width >= height, else Y;
- direction angle = `0°` for X or `90°` for Y;
- center = bounds center, or zero for empty bounds;
- positive endpoint = right-center or bottom-center;
- quarter offset = `min(major_length * 0.2, 32)`.
Preset contents:
| Preset | Generated anchors |
| --- | --- |
| One-Handed | `primary_grip` at center |
| Two-Handed | `primary_grip` and `secondary_grip` at center ± quarter offset |
| Tool / Nozzle | `primary_grip` at center, `emitter` at positive endpoint |
| Melee Edge | `primary_grip` at center, `impact` at positive endpoint |
All generated anchors use the major-axis direction, `both`, and z offset `0`.
### 5.7 Dirty state, New, save, and recovery
- `_build_json_data()` passes `_anchors` to `PropFile.to_file_dict()`.
- `_apply_json_data()` assigns the normalized model anchors to both `_anchors` and canvas.
- `_do_new()` resets anchors to `default_anchors()` and selects `origin`.
- Canvas `anchors_changed` updates `_anchors`, calls `_mark_dirty()`, and refreshes inspector.
- After shape/anchor/selection changes, pass deep copies to
`PropAttachPreview.set_document()`/`set_selected_anchor()`; the preview coalesces visual work.
- Preview visibility, preview hand, facing, arm animation, camera, inactive-gizmo visibility,
and mode changes are tool state and do not mark the document dirty.
- Add/delete/rename/preset/property changes mark dirty through the same centralized path.
- Existing debounced autosave automatically includes anchors through `_build_json_data()`.
- Recovery restores anchors and keeps the document dirty, matching shape recovery.
- Thumbnails remain geometry-only; changing only anchors may update the `.prop` mtime/cache key
on save, but anchors are not drawn into the thumbnail.
## 6. Future Sandbox/runtime scope — not a Phase 9a deliverable
Phase 9a stops at the `.prop` anchor contract and editor-only preview. It must not modify
`PropBlock`, `PropUtils`, `StageSpawner`, `SandboxStage`, `StageSceneIO`, `StickmanRig`,
`StkRigAdapter`, `scenes/stickman_rig.tscn`, collision/physics behavior, director actions, or
runtime tests.
A later, separately approved specification may consume the saved anchor contract to design:
- custom `.prop` discovery, selector UI, spawning, provenance, missing-asset handling, and stage
scene persistence;
- a runtime held-prop visual/physics representation that addresses the existing multi-shape
flattening limitation;
- explicit wrist sockets or another stable solved-wrist API;
- one-hand and two-hand equip/release ownership;
- facing/mirroring and per-limb draw-order behavior;
- collision masks, freeze/reparent strategy, throwing, ragdoll/drop lifecycle, and save semantics;
- gameplay consumers for emitter/impact/mount/custom roles.
The future runtime spec should reuse the pure normalized anchor data and transform convention
where appropriate, but must validate them against runtime physics and animation rather than
treating the editor preview as authoritative gameplay behavior.
## 7. Signal and data flow
### 7.1 Editor
```mermaid
flowchart TD
Input[Canvas/Inspector input] --> Overlay[PropAnchorOverlay]
Overlay -->|anchors_changed deep copy| Canvas[PropCanvas]
Canvas -->|anchors_changed| Editor[PropEditor document state]
Editor --> Dirty[_mark_dirty + autosave debounce]
Editor --> Inspector[Anchor inspector refresh]
Editor -->|deep-copied shapes/anchors/selection| Preview[PropAttachPreview]
Preview --> PreviewRig[Private SubViewport rig + arm IK]
Preview --> PreviewProp[Non-physics full-shape visual]
Editor --> Save[_build_json_data]
Save --> Codec[PropFile.to_file_dict]
Codec --> Disk[.prop JSON]
```
The diagram ends at the `.prop` file and private preview. No arrow reaches Sandbox/runtime state.
## 8. Theme and UX requirements
- Reuse `PropTheme` font sizes, minimum row heights, text, muted text, border, and accent tokens.
- Do not mutate the global project theme.
- `InspectorScroll` fills the inspector column vertically and scrolls when the anchor controls do
not fit.
- `PropAttachPreview` uses the same local `_ui_theme` tokens as the editor; the viewport
background uses `canvas_background_color()` and the panel/header use existing border/text/
muted/accent tokens.
- The preview split is user-resizable, never shrinks below `360 px` while open, and collapses
completely when hidden so existing Arrange/Reshape canvas space is unchanged.
- Anchor list/buttons meet the existing theme's minimum hit height.
- Disabled origin controls remain readable using muted text, with tooltip: “Origin is the fixed
prop pivot.”
- Add/delete/rename/preset actions must keep keyboard focus from causing the mode-selected style
regression; mode buttons remain `FOCUS_NONE`.
- Status cursor coordinates continue to report prop world coordinates in Attach mode.
- No new image assets are required.
## 9. Tests and engine validation
### 9.1 New headless suite
Add `tests/test_phase9a_prop_attach.gd` (`extends SceneTree`, PASS/FAIL, `quit(0/1)`) covering:
1. default anchors contain only canonical origin;
2. legacy file without anchors synthesizes origin;
3. origin tampering is overwritten and extra pivot roles downgrade to custom;
4. finite/default/clamp/wrap validation;
5. ID normalization and collision suffixes;
6. serialize/parse round-trip asserts coordinate and rotation **values**, not only counts;
7. role and allowed-hand queries;
8. X-mirrored anchor position/direction math;
9. all four quick-preset layouts for horizontal, vertical, and empty bounds;
10. `PropCanvas.EditMode.ATTACH` persists through clear/load and blocks shape drag routing;
11. fixed origin cannot begin translate/rotate;
12. snapped and unsnapped anchor translation plus Ctrl rotation snapping;
13. preview rig lives under its private `SubViewport` world and adds nothing to a Sandbox world;
14. preview disables rig physics ticking/collisions and animation playback while leaving the
Skeleton2D modification stack enabled;
15. preview visual creates one node per authored shape and never creates `PropBlock` or collision;
16. `left_only`/`right_only` force the compatible preview hand while `both` permits either;
17. the derived wrist transform (lower-arm visual origin plus fitted lower-bone length) maps the
selected anchor to the solved wrist within tolerance;
18. manual preview target clamping respects derived arm reach and leaves the inactive target at
its captured rest position;
19. arm auto-motion changes only the active hand and Reset Arm restores both rest targets;
20. FORWARD/RIGHT/LEFT use the same editor transform solver; LEFT reflection lives on
`PreviewRigRoot`, the rig's local scale stays positive, and arm IK remains enabled;
21. selecting `secondary_grip` previews it with exactly one hand and never moves the other;
22. frame-mode/reset/resize recalculate camera framing, while arm-motion ticks do not change
fitted zoom.
### 9.2 Existing regressions
Run all Prop Editor suites listed in `AGENTS.md`. Because the editor-only preview instantiates
the existing rig without changing its runtime contracts, also run the rig modularization suite
as a read-only compatibility regression. At minimum:
- `tests/test_prop_editor_file.gd`
- `tests/test_prop_editor_canvas.gd`
- `tests/test_prop_editor_theme.gd`
- `tests/test_prop_editor_thumb.gd`
- `tests/test_prop_editor_round3.gd`
- `tests/test_phase8_rig_modularization.gd`
No Sandbox Stage suite is part of Phase 9a validation because this phase does not modify or
integrate with Sandbox/runtime systems.
### 9.3 Static checks
Run Godot 4.7.1 `--headless --check-only` for every changed/new script, especially:
- `prop_anchor_data.gd`
- `prop_anchor_overlay.gd`
- `prop_attach_preview.gd`
- `prop_file.gd`
- `prop_canvas.gd`
- `prop_editor.gd`
- the new test suite
If new `class_name` entries are not in the global script cache, run one headless editor rescan
before per-script checks, per technical-debt item #22.
## 10. Manual acceptance checklist
### Prop Editor (F6 `res://scenes/prop_editor.tscn`)
- Existing Arrange/Reshape behavior and labels are unchanged; the appended Attach button renders
and all three selected states remain visible.
- Attach mode shows anchors while hiding every shape selection/vertex/transform gizmo.
- Pan, wheel zoom, pinch, and Reset View remain correct.
- Add/rename/delete work; origin controls are disabled and origin cannot be dragged.
- Anchor translation stays under the cursor at multiple zoom levels and snaps from either Snap
checkbox.
- Grip/emitter rotation ring works; Ctrl snaps 15 degrees.
- Inactive toggle and role marker colors/icons are legible over light and dark props.
- Opening the Stickman Preview shows the real default rig visibly holding the complete multi-shape
prop at the selected grip; selecting a non-grip shows the instructional empty state.
- Left/Right hand choice obeys `allowed_hands`; any selected secondary grip is previewed as a
one-hand grip and the opposite hand stays at rest.
- Dragging the cyan target visibly bends the actual IK arm and keeps the prop locked to the solved
wrist; Animate Arm sweeps smoothly, manual drag cancels it, and Reset Arm restores the pose.
- Forward/Right/Left preview facing catches mirrored anchor offsets without modifying document or
any state outside the private preview.
- Upper Body / Full Figure framing, preview wheel zoom, middle pan, Reset View, split resize, and
show/hide work without affecting the authoring canvas camera.
- Preview z offset visibly swaps the prop behind/in front according to the editor preview
convention.
- Inspector scrolls at a small window size.
- Save/load and crash-recovery preserve anchors exactly; legacy props open with origin.
- Quick presets match horizontal and vertical prop bounds.
## 11. Architectural trade-offs
| Decision | Benefit | Cost / follow-up |
| --- | --- | --- |
| Additive `.prop` v1.0 anchors | Old files remain valid; no migration branch | Version alone cannot advertise anchor capability; consumers query presence/defaults |
| Fixed origin pivot | Stable coordinates and no geometry/COM migration | User cannot choose a custom physics pivot in this phase |
| JSON dictionary + static codec | Matches current project and is easy to inspect | GDScript cannot statically type dictionary fields; normalization boundary is essential |
| Composed anchor helper | Keeps anchor state/hit math out of the already large canvas | Canvas remains the input/coordinate owner and must route Attach events explicitly |
| Isolated rig-backed preview | Shows real IK bending, facing, custom stickman visuals, and the full authored prop | Adds a private SubViewport/rig instance and requires strict disabling of gameplay processing/collision |
| One-hand-at-a-time preview | Keeps anchor evaluation focused and matches the approved authoring scope | It does not solve or demonstrate two-hand constraints |
| Derived visual wrist, no sockets | Reuses the fitted rig while avoiding any rig scene/runtime contract change | Depends on the lower-arm visual's established hanging-frame convention and fitted lower-bone length |
| Editor-only facing convention | Exposes mirrored offset mistakes during authoring without touching gameplay state | A future runtime design must validate facing and draw order independently |
| Full multi-shape preview | Faithfully shows the authored prop inside the editor | It deliberately does not prove how a future runtime representation will render or collide |
| No anchor-only undo | Consistent editor expectations | Full shape + anchor document undo remains future work |
## 12. Implementation order
1. `PropAnchorData` and schema tests.
2. `PropFile` round-trip and legacy normalization.
3. Append Attach to the existing mode system; add overlay math/drawing, inspector, presets, and
save/autosave integration while preserving Arrange/Reshape.
4. Add the isolated rig-backed `PropAttachPreview`, shared editor transform solver, derived wrist
transform, IK interaction, and camera/layout behavior without changing rig/runtime files.
5. Complete save/autosave/recovery integration, the editor-only headless suite, static checks,
and manual hand-off.
Implementation must stop after engine validation and ask the user to verify in Godot. README or
other product documentation updates wait until the user confirms the feature works.
+651
View File
@@ -0,0 +1,651 @@
# Phase 9b — Prop Origin, Centering, and Off-Origin Save Guard
## 1. Status and scope
This specification is the approved design for a focused Prop Editor follow-up to Phase 9a. It
does not implement Sandbox Stage or runtime prop behavior.
Phase 9b makes the canonical prop origin useful without presenting it as a fake-editable anchor:
- hide `origin` from the editable anchor list and all anchor interaction;
- retain and serialize the canonical `origin` at `(0,0)` for schema compatibility;
- draw a subtle, non-interactive `(0,0)` marker in Arrange, Reshape, and Attach;
- add an always-visible `🎯 Center Prop` command beside Grid/Snap;
- center the complete authored document by translating every shape and every non-origin anchor by
the negative union-bounds center;
- focus the authoring camera on `(0,0)` after centering without changing zoom;
- center `(0,0)` in the authoring view on startup, New, load/recovery view reset, and explicit
`Reset View`;
- warn during explicit saves when a non-empty prop is unusually far from the origin;
- never recenter automatically after ordinary shape or anchor edits.
The existing `.prop` file version remains `1.0`. No file migration, Sandbox integration, runtime
attachment, physics-center calculation, or undo/redo system is part of Phase 9b.
## 2. Approved decisions
1. The command is an always-visible `🎯 Center Prop` button in `TopRightControls`, immediately
after the existing Snap control. Its tooltip is `Move the whole prop and its anchors to (0,0).`
2. Centering focuses `(0,0)` at the canvas center while preserving the current zoom. Startup,
New, all existing document-load view resets, and `View → Reset View` put `(0,0)` at the canvas
center; Reset View also restores zoom to `1.0`.
3. An explicit save warns when:
```text
distance(bounds center, origin) > max(1024 px, 2 × bounds diagonal)
```
The warning offers `🎯 Center & Save`, `💾 Save Anyway`, and `❌ Cancel`. Autosave is silent.
4. Centering is opt-in only. Adding, moving, reshaping, loading, and saving never silently alter
document coordinates.
5. The centering point is the axis-aligned union-bounds center of displayed shape geometry, not a
polygon centroid, visual-stroke center, center of mass, or physics pivot.
6. There is no Phase 9b-only undo stack. The command is one explicit document mutation and uses
the existing dirty/autosave/recovery pipeline.
## 3. Existing contracts preserved
- `PropEditor` remains the document coordinator and owns `_anchors`, dirty state, autosave, save
dialogs, and the holding-preview synchronization.
- `PropCanvas` continues to own internal typed shape data, selection, pan/zoom, shape drawing, and
shape mutation.
- `PropAnchorOverlay` remains responsible only for editable non-origin anchor markers and input.
- `PropAnchorData` remains the normalization and JSON-safe anchor helper.
- `PropFile` continues to serialize normalized anchors, including canonical `origin`, and to load
legacy files without an `anchors` section by synthesizing it.
- Arrange, Reshape, and Attach mode behavior is otherwise unchanged.
- Existing grid/snap state does not affect centering. Centering uses the exact bounds-center delta.
- The holding preview stays editor-only. No Sandbox/runtime file or class may be modified.
## 4. Target files and typed contracts
### 4.1 `scripts/prop_anchor_data.gd` — `class_name PropAnchorData`
Add pure static helpers so list filtering, selection fallback, and document translation share one
normalization boundary:
```gdscript
static func editable_ids(anchors: Dictionary) -> Array[String]
static func preferred_editable_id(
anchors: Dictionary,
preferred_id: String = ""
) -> String
static func translated_non_origin(
anchors: Dictionary,
delta: Vector2
) -> Dictionary
```
Contract:
- `editable_ids()` returns normalized IDs except `ORIGIN_ID`, preserving normalized dictionary
iteration order.
- `preferred_editable_id()` returns a valid non-origin `preferred_id` first; otherwise
`primary_grip`, the first grip, the first remaining editable ID, or `""`.
- `translated_non_origin()` returns a deep normalized copy. It adds `delta` only to each
non-origin `position`; roles, rotation, Z offset, and hand compatibility are unchanged. The
returned `origin` is canonical and exactly `Vector2.ZERO`.
- Non-finite deltas are rejected by returning the normalized input unchanged and warning once per
call site or by an equivalent finite guard. UI paths must never supply such a delta.
`default_anchors()`, `normalize_anchors()`, and `serialize_anchors()` keep their existing public
behavior. The origin remains stored because it is the explicit local-coordinate contract; hiding
it is an editor presentation decision.
### 4.2 `scripts/prop_anchor_overlay.gd` — `class_name PropAnchorOverlay`
Change the overlay's selection contract:
```gdscript
func select_anchor(anchor_id: String) -> void
func get_selected_anchor_id() -> String
```
- `_selected_id` defaults to `""`, not `origin`.
- `select_anchor("")` clears selection and emits `anchor_selected("")` only when the selection
changes.
- `select_anchor("origin")` must not make origin editable; it clears or ignores selection.
- `set_anchor_data()` preserves a valid selected non-origin ID. If it disappears, it clears the
selection rather than falling back to origin.
- `hit_test_anchor()`, `_visible()`, `begin_pointer()`, and `draw_gizmos()` always exclude origin.
- Existing marker drawing, translation, rotation, snapping, and
`anchors_changed(anchors: Dictionary)` payloads remain unchanged for editable anchors.
The universal origin marker does not move into this class because it is not an anchor tool.
### 4.3 `scripts/prop_canvas.gd` — `class_name PropCanvas`
Add or tighten these public APIs:
```gdscript
func has_shape_geometry() -> bool
func get_shapes_bounds() -> Rect2
func translate_all_shapes(delta: Vector2) -> bool
func focus_world_point(world_point: Vector2) -> void
func set_origin_marker_color(color: Color) -> void
func reset_view() -> void
```
#### Geometry presence and bounds
`has_shape_geometry()` is true when at least one shape has at least two finite internal points and
is therefore drawable by the current canvas.
`get_shapes_bounds()` returns the axis-aligned union of valid, drawable internal point geometry:
| Shape | Bounds source |
| --- | --- |
| Polygon | All stored vertices |
| Line | All stored endpoints/vertices |
| Circle | Its stored/generated perimeter points |
Every shape imported by `set_shape_data()` already passes through `_coerce_shape()`; a legacy or
external circle with radius but no points receives generated perimeter points there. Bounds must
skip empty/non-drawable point sets rather than treating `Rect2()` as geometry at the origin. The
union excludes outline width. This makes centering match the geometry the author manipulates and
prevents a malformed empty shape from pulling a distant prop's center toward zero.
For no drawable geometry, `get_shapes_bounds()` returns `Rect2()` and callers must consult
`has_shape_geometry()` before interpreting it.
#### Bulk shape translation
`translate_all_shapes(delta)`:
- returns `false` for no drawable geometry, non-finite delta, or an approximately zero delta;
- adds `delta` to every point in every shape with points;
- does not snap, sanitize, reverse winding, regenerate primitives, or change Z order;
- preserves `_selected_shape_idx`, canvas mode, view state, circle `radius`, `reshaped`,
`vertex_flags`, fill/outline fields, and `closed`;
- queues one redraw and emits exactly one `shape_changed(get_shape_data())` after success;
- returns `true` after a successful mutation.
The controller updates its anchors before calling this API. The existing synchronous shape signal
then becomes the one dirty/autosave notification for the complete document transaction.
#### Camera behavior
`focus_world_point(world_point)` preserves `_zoom` and derives:
```gdscript
_pan_offset = size * 0.5 - world_point * _zoom
```
`reset_view()` sets `_zoom = 1.0`, focuses `Vector2.ZERO`, and redraws. `clear()` and the existing
shape-data load reset route use this revised Reset View behavior. Startup calls Reset View after
the canvas has a valid arranged size (deferred by one frame only if its size is still zero).
Window resize does not continually recenter the camera. Once initialized, pan/zoom remains tool
state until New/load/reset/center or direct navigation changes it.
#### Origin marker
`_on_canvas_draw()` draws the marker in every `EditMode`, independent of shape or anchor state.
The existing empty-canvas hint must no longer return before marker drawing.
The marker is a small halo-backed cross/ring plus `(0,0)` label at world `Vector2.ZERO`:
- core radius/cross half-size: approximately `6` screen px;
- core width: `1–1.5` screen px;
- dark halo: approximately `2` screen px wider;
- label offset: approximately `(10, -8)` screen px;
- label size: approximately `12` screen px;
- all world dimensions, offsets, widths, and font size account for `_zoom`, keeping screen size
effectively constant over `[0.3, 3.0]`;
- core color comes from `set_origin_marker_color()`, initialized by `PropEditor` from
`PropTheme.text_muted_color()` with a subtle alpha;
- drawing occurs after ordinary shape geometry and before active transform/anchor gizmos;
- there is no marker hit test, `Control` child, signal, mouse filter, or drag path.
### 4.4 `scripts/prop_editor.gd` — `extends Control`
Add constants and state with explicit types:
```gdscript
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
var _center_prop_button: Button
var _off_origin_save_dialog: ConfirmationDialog
var _pending_save_path: String = ""
var _pending_save_with_thumbnail: bool = true
```
Continue using `_pending_quit` and `_pending_new` as the post-save completion intent. Do not add a
second conflicting completion enum unless implementation proves it makes those existing flows
simpler; there must still be one authoritative pending completion state.
Add focused helpers:
```gdscript
static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool
func _center_prop_on_origin(focus_view: bool = true) -> bool
func _refresh_center_prop_button() -> void
func _begin_save_request(path: String, with_thumbnail: bool) -> void
func _execute_pending_save(center_first: bool) -> void
func _cancel_pending_save_request() -> void
```
`should_warn_off_origin()` uses a strict `>` comparison:
```gdscript
if not has_geometry:
return false
var center_distance: float = bounds.get_center().length()
var threshold: float = maxf(
OFF_ORIGIN_WARNING_MIN_DISTANCE,
OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER * bounds.size.length()
)
return center_distance > threshold
```
### 4.5 `scripts/prop_file.gd` — `class_name PropFile`
No schema or API change is required. Tests must prove that:
- `to_file_dict()` still serializes canonical origin plus every editable anchor;
- `from_file_dict()` still synthesizes origin for legacy `.prop` v1.0 files without anchors;
- centered coordinate values round-trip without changing radius, flags, or anchor metadata.
### 4.6 `scenes/prop_editor.tscn` and theme
No new image asset is needed. The button and warning dialog are built by `PropEditor`, matching
existing dynamic UI construction.
- `🎯 Center Prop` is added after Snap in `%TopRightControls`.
- The button uses the local `_ui_theme`, `button_min_height()`, full-rectangle mouse filtering,
and a concise tooltip. It stays visible in all modes.
- It is disabled when there is no drawable geometry or the bounds center is approximately zero;
it remains visible so the feature is discoverable.
- Refresh enabled state after New, load/recovery, shape change, and successful center.
- The save-warning dialog uses the same local theme. It must not mutate the global GUI theme.
- If minimum window width becomes tight, the existing adjustable Inspector remains the first
source of reclaimed space. Phase 9b must not let the new button force the Inspector offscreen.
## 5. Document centering transaction
### 5.1 Exact state transformation
Given:
```gdscript
bounds = Rect2(Vector2(400, 200), Vector2(200, 100))
center = Vector2(500, 250)
delta = Vector2(-500, -250)
```
The command applies:
```text
shape point (430, 215) → (-70, -35)
primary_grip (510, 245) → ( 10, -5)
emitter (590, 250) → ( 90, 0)
origin (0, 0) → ( 0, 0)
```
All relative vectors are preserved:
```text
(emitter - primary_grip) before == (emitter - primary_grip) after
(shape point - primary_grip) before == (shape point - primary_grip) after
```
### 5.2 Controller sequence
```mermaid
flowchart TD
Click[Center Prop / Center & Save] --> HasGeometry{Drawable geometry?}
HasGeometry -- No --> NoOp[Return false; no dirty state]
HasGeometry -- Yes --> Bounds[Read union bounds once]
Bounds --> Delta[delta = -bounds.center]
Delta --> Meaningful{Meaningful finite delta?}
Meaningful -- No --> Focus[Focus origin if requested]
Meaningful -- Yes --> Anchors[Translate normalized non-origin anchors]
Anchors --> SetAnchors[Set editor + canvas anchor state]
SetAnchors --> Shapes[Canvas translates all points; emits once]
Shapes --> Dirty[Existing handler marks dirty + arms autosave]
Dirty --> Refresh[Refresh inspector, button, and holding preview once]
Refresh --> Focus
```
`_center_prop_on_origin()` must read bounds once before any mutation. It must not calculate a new
center after translating shapes or anchors.
The operation preserves:
- shape and editable-anchor selection;
- shape/anchor ordering;
- active mode;
- current zoom;
- clipboard;
- grid/snap settings;
- material and prop name;
- preview hand/facing/animation preferences.
If the old selection is missing or is legacy `origin`, normalize it through
`preferred_editable_id()`. With no editable anchors, select `""` and let the holding preview show
its existing “select a grip” state.
### 5.3 Dirty, autosave, and preview behavior
- A successful non-zero center produces one logical dirty transition through the single
`shape_changed` emission.
- `_autosave_remaining` is armed once with the existing debounce.
- The editor refreshes the shape inspector and anchor inspector after both halves of the
transaction are in place.
- `_sync_attach_preview()` runs once after shapes and anchors match. In Attach mode, the held prop
must remain on the same grip and merely receive recentered local coordinates.
- A zero-delta or empty command does not mark dirty or write autosave.
- Centering does not immediately write the `.prop`; Save remains explicit.
## 6. Origin visibility and anchor-list behavior
### 6.1 Inspector population
`_refresh_anchor_inspector()` populates `%AnchorList` only from
`PropAnchorData.editable_ids(_anchors)`. It must never infer selection by reading item text for an
origin row because that row no longer exists.
When `_selected_anchor_id == ""`:
- no item is selected;
- Role, Allowed Hands, X, Y, Rotation, and Z Offset are disabled;
- Rename and Delete are disabled;
- a muted hint reads `Add or select an anchor.`;
- Add Anchor and preset buttons remain enabled.
The old disabled-origin tooltip and disabled-origin detail state are removed from the inspector.
The canvas `(0,0)` marker replaces their educational purpose.
### 6.2 Selection transitions
| Transition | Resulting editable selection |
| --- | --- |
| New/empty document | `""` |
| Load/recovery | Preserve valid preferred ID if supplied; otherwise `primary_grip`, first grip, first editable, or `""` |
| One-/Two-Handed preset | `primary_grip` |
| Tool/Nozzle or Melee preset | `primary_grip` |
| Add Anchor | New anchor ID |
| Rename | Renamed ID |
| Delete | Previous visible row, else next visible row, else `""` |
| Center Prop | Preserve current valid editable ID; otherwise preferred fallback |
The holding preview receives `""` when nothing editable is selected. It must never receive origin
as the active attachment anchor.
## 7. Save preflight and pending-state flow
### 7.1 Entry points
Every user-requested save routes through `_begin_save_request(path, with_thumbnail)`:
- File → Save with an existing path;
- Save As after the file dialog returns and the `.prop` extension is normalized;
- save-before-quit;
- save-before-New.
`_save_to()` remains the low-level writer and never opens the warning itself. `_write_autosave()`
continues calling `PropFile.save_safe()` directly and never warns or centers.
### 7.2 Warning dialog
The dialog text must state both the condition and the non-destructive options, for example:
```text
This prop is far from (0,0). Centering it keeps its shapes and anchors together.
What would you like to do?
```
Buttons:
- OK: `🎯 Center & Save`
- custom action `save_anyway`: `💾 Save Anyway`
- Cancel: `❌ Cancel`
Flow:
```mermaid
stateDiagram-v2
[*] --> SaveRequest
SaveRequest --> SaveDirect: empty or within threshold
SaveRequest --> Warning: unusually far
Warning --> Center: Center & Save
Center --> SaveDirect: one center transaction
Warning --> SaveDirect: Save Anyway
Warning --> Editor: Cancel
SaveDirect --> Editor: save failed
SaveDirect --> Editor: normal save succeeded
SaveDirect --> Quit: pending quit + success
SaveDirect --> NewDocument: pending New + success
```
Rules:
- `_pending_save_path` and `_pending_save_with_thumbnail` are set before the warning opens.
- Center & Save invokes `_center_prop_on_origin(true)` and then calls `_save_to()` directly so the
same request cannot warn a second time.
- Save Anyway calls `_save_to()` without coordinate mutation.
- Cancel clears the pending save payload and clears `_pending_quit`/`_pending_new`, returning to
the editor.
- A failed save reports the existing error and clears all pending completion state so a later
unrelated save cannot unexpectedly quit or create a new document.
- A successful ordinary save clears the pending save payload normally.
- A successful pending quit exits; a successful pending New invokes `_do_new()`.
- Warning evaluation uses the same corrected bounds helper as Center Prop and presets. Empty props
do not warn.
- Choosing Save Anyway is scoped to one request. A later explicit save evaluates the rule again.
- Save warning state is tool state and is not serialized.
## 8. Layout and drawing schematic
```text
┌─────────────────────────────────────────────────────────────────────────────┐
│ 📁 File 👁 View Grid [15] Snap [🎯 Center Prop] │
├─────────────────────────────────────────────────────────────────────────────┤
│ Prop Name [...............................................................] │
├───────────────────────────────────────────────────────┬─────────────────────┤
│ [🖐 Arrange] [✏ Reshape] [📌 Attach] │ Inspector │
│ │ │
│ authoring canvas │ Attach list excludes│
│ │ canonical origin │
│ ──○── (0,0) ← passive marker │ │
│ │ │
│ shapes / selection / editable anchor gizmos │ │
└───────────────────────────────────────────────────────┴─────────────────────┘
```
Drawing order inside `PropCanvas`:
```text
grid
→ empty-canvas hint (when applicable)
→ shape geometry and existing selection rendering
→ passive origin marker
→ Arrange transform gizmos OR Attach editable-anchor gizmos
```
The origin marker is never part of the input-routing diagram.
## 9. Signal and state flow
No new public signal is required.
```mermaid
flowchart LR
Button[Center Prop] --> Editor[PropEditor transaction]
Editor --> AnchorData[PropAnchorData.translated_non_origin]
Editor -->|set_anchor_data| Canvas[PropCanvas]
Editor -->|translate_all_shapes| Canvas
Canvas -->|shape_changed: Array Dictionary| Editor
Editor --> Dirty[dirty + autosave debounce]
Editor --> Inspector[refresh inspector]
Editor --> Preview[set_document shapes + anchors + selection]
Editor --> Camera[focus_world_point 0,0]
```
Existing signal payloads remain:
```gdscript
signal shape_changed(shapes: Array[Dictionary])
signal shape_selected(idx: int)
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
```
`anchor_selected("")` is now a valid “no editable anchor selected” payload.
## 10. Edge cases
- **Empty prop:** marker is visible; Center Prop is disabled; save never warns; origin still saves.
- **Only malformed/empty shapes:** treated as no drawable geometry for centering and warning.
- **Single line:** endpoints participate in bounds; their midpoint becomes `(0,0)` after center.
- **Circle:** translate perimeter points only; preserve radius and `reshaped`; no regeneration.
- **Mixed shapes:** compute one union before translating anything, regardless of Z order.
- **Very large prop:** relative threshold prevents an automatic warning merely because its center
is moderately offset compared with its own diagonal.
- **Already centered:** command is disabled/no-op and does not dirty.
- **No custom anchors:** shapes center normally; origin stays fixed; selection remains empty.
- **Legacy file without anchors:** load synthesizes origin, hides it from the list, and exposes the
passive marker; no migration prompt.
- **Legacy selection of origin:** immediately resolves through editable fallback or `""`.
- **Anchor far outside geometry:** it receives the same delta as geometry; it is deliberately not
included in bounds calculation because shapes define the prop's authored center.
- **Snap enabled:** ignored by the bulk translation; exact centering wins over grid alignment.
- **Save during pending quit/New:** warning completion follows the original intent only after
successful save; Cancel returns safely to the current document.
- **Autosave:** writes current coordinates without warning or centering.
- **Pan/zoom:** normal edits never recenter. Center preserves zoom and focuses origin. Reset View
restores zoom `1.0` and focuses origin.
## 11. Automated validation
Add `tests/test_phase9b_prop_centering.gd` as a headless `SceneTree` suite. It must assert values,
not only counts.
### 11.1 Data helpers
1. `editable_ids()` never contains origin.
2. Preferred selection honors a valid current ID, then primary grip, first grip, first editable,
then empty.
3. `translated_non_origin()` preserves all non-position fields and fixes origin at exact zero.
4. Legacy missing-anchor data still normalizes to canonical origin.
5. Serialization still writes canonical origin and `.prop` version remains `1.0`.
### 11.2 Bounds and translation
6. Polygon, line, and circle points all contribute to one expected union AABB.
7. Empty/malformed point sets do not merge an artificial `(0,0)` into distant bounds.
8. Center delta is the exact negative bounds center.
9. Every exported point value changes by exactly delta.
10. Vertex flags, radius, `reshaped`, colors, closed state, and shape order remain unchanged.
11. Shape selection, mode, and zoom remain unchanged by translation.
12. One successful translation emits exactly one `shape_changed`; empty/zero translations emit
none and return false.
13. Grid snap does not alter the center delta.
### 11.3 Editor integration
14. Anchor ItemList excludes origin and its first empty state has no selection.
15. Add/preset/load/delete transitions follow the selection table in §6.2.
16. Overlay hit-testing at `(0,0)` cannot select origin, even with inactive gizmos shown.
17. Centering updates shapes and non-origin anchors together while origin stays zero.
18. Centering marks dirty once, arms autosave, refreshes preview document data, and keeps a valid
editable anchor selected.
19. Empty/already-centered commands do not mark dirty.
20. Centering focuses origin while preserving zoom.
21. startup/New/load/reset place origin at canvas center; Reset View uses zoom `1.0`.
22. Center button is present in all modes and disabled only for empty/already-centered geometry.
### 11.4 Save preflight
23. `should_warn_off_origin(false, Rect2())` is false.
24. Boundary equality does not warn; values just beyond the computed threshold do.
25. The `1024` floor and `2 × diagonal` relative branch are both exercised.
26. Save Anyway does not alter points or anchors.
27. Center & Save applies one center transaction and does not re-open the warning.
28. Cancel clears save/quit/New pending state without writing.
29. Autosave remains warning-free and preserves off-origin coordinates.
30. Successful and failed save completion cannot leave stale pending state.
### 11.5 Existing regressions
Run all Prop Editor suites listed in `AGENTS.md`, especially:
- `tests/test_prop_editor_file.gd`
- `tests/test_prop_editor_theme.gd`
- `tests/test_prop_editor_canvas.gd`
- `tests/test_prop_editor_thumb.gd`
- `tests/test_prop_editor_round3.gd`
- `tests/test_phase9a_prop_attach.gd`
Update Phase 9a expectations that currently select or expose origin in the inspector/overlay. Do
not weaken its schema assertions: origin remains canonical and serialized.
Run Godot 4.7.1 `--headless --check-only` for every changed script and `git diff --check`.
## 12. Manual acceptance checklist
Launch `res://scenes/prop_editor.tscn` with F6 and verify:
1. `(0,0)` starts centered and is shown as a subtle fixed-size marker.
2. The marker remains visible at its world location in Arrange, Reshape, and Attach.
3. It stays approximately the same screen size through min/max zoom and never highlights,
selects, or drags.
4. Reset View restores zoom and places origin in the canvas center.
5. `🎯 Center Prop` remains visible beside Grid/Snap in all modes and its complete rectangle is
clickable.
6. A prop authored far away moves so the union AABB center is exactly on the marker.
7. After centering, current zoom is unchanged and the marker/prop are centered in the view.
8. Mixed polygon, line, and circle props center together without deformation or Z-order change.
9. Every non-origin anchor moves with the prop; rotations, Z offsets, roles, and allowed hands do
not change.
10. A selected grip stays selected and the holding preview continues showing the same prop-to-hand
alignment before and after centering.
11. Origin is absent from the anchor list and cannot be clicked in Attach mode.
12. A new prop with no anchors shows the passive marker and `Add or select an anchor.` state.
13. Presets select `primary_grip`; deleting the final editable anchor leaves no selection.
14. Empty and already-centered documents keep the Center button visible but disabled.
15. Ordinary move/reshape/anchor edits never auto-center the document.
16. A moderately offset or large intentional prop inside the approved threshold saves normally.
17. A small prop beyond the threshold opens the three-choice warning.
18. Center & Save recenters, focuses, and saves; Save Anyway retains original coordinates;
Cancel writes nothing.
19. Save-before-quit and save-before-New honor the same warning and completion behavior.
20. Autosave/recovery never opens the warning and preserves the current coordinates.
21. Save/load round-trips centered points, flags, circle state, and anchors exactly.
22. A legacy `.prop` without anchors opens without migration UI, hides synthesized origin from the
list, and still displays the passive marker.
## 13. Architectural trade-offs and deferred items
| Decision | Benefit | Cost / future work |
| --- | --- | --- |
| Keep serialized fixed origin but hide it | Stable explicit file contract without fake editing UX | A future movable/custom pivot requires a separately versioned design |
| AABB center from internal points | Deterministic across polygon/line/circle and matches canvas geometry | Not a centroid, painted-outline center, or physical center of mass |
| Controller-coordinated transaction | Preserves current shape/anchor ownership and produces one dirty event | Requires disciplined ordering between anchor update and one bulk canvas mutation |
| Passive canvas drawing | Constant-size, allocation-free, and incapable of intercepting input | Visual behavior needs manual acceptance because headless pixel capture is unreliable |
| Heuristic explicit-save warning | Catches accidental remote authoring without blocking autosave | Intentional off-origin props are asked again on each explicit save |
| Focus origin after center | The centered prop remains visible and authoring around the pivot is natural | Center command intentionally changes camera pan, though not zoom |
| No center-specific undo | Avoids an inconsistent partial undo system | Users cannot reverse centering through Ctrl+Z until document-wide undo exists |
Deferred beyond Phase 9b:
- configurable/movable pivots or multiple origins;
- center of mass, collision-based bounds, or material-weighted centering;
- outline-width-aware visual centering;
- persistent “do not warn again” per asset;
- document-wide undo/redo;
- Sandbox/runtime consumption of origin or anchors;
- automatic recenter-on-edit behavior (explicitly rejected).
## 14. Implementation order and pipeline stop
1. Add/test pure anchor helpers and remove origin from overlay interaction.
2. Correct bounds, add bulk translation/focus APIs, and draw the universal marker.
3. Add editor selection fallback, inspector empty state, Center button, and atomic transaction.
4. Route explicit save entry points through the warning/pending-state flow.
5. Add Phase 9b tests, update Phase 9a expectations, run all regressions and static checks.
6. Hand the implementation to the user for Godot Editor verification.
Do not update the user guide or README during implementation handoff. Documentation changes happen
only after the user confirms Phase 9b is verified and working, per the spec-pipeline.
+638
View File
@@ -0,0 +1,638 @@
# Phase 9c — Prop Editor UX and Walking Hold Preview
Status: **approved specification — ready for implementation** (2026-09-21).
Phase 9c is a Prop Editor-only refinement discovered during Phase 9a/9b user verification. It
does not amend their saved-file contracts and does not add Sandbox Stage or gameplay behavior.
## 1. Approved decisions
The user approved these interaction choices:
1. Replace the preview's separate Facing/Frame controls with one **Preview Motion** selector:
`Front Pose`, `Walk Left`, and `Walk Right`, plus a dynamic `Pause Walk` / `Resume Walk`
button.
2. Rename anchors through a modal, focused text-entry dialog. The current automatic
`old_name_renamed` behavior is removed.
3. Rename `Show Inactive Gizmos` to **Show Other Markers**. When off, it hides the passive
origin marker and all unselected anchor markers while the selected editable anchor remains
visible.
This work is named Phase 9c because Phase 9a and 9b are already implemented and verified. It
changes preview motion ownership and established UI contracts rather than retroactively changing
those completed specifications.
## 2. Complete requested refinement set
| # | Requirement | Phase 9c contract |
| --- | --- | --- |
| 1 | Rename accepts a name | Open a modal LineEdit dialog, normalize the entered value, preserve the anchor payload, and select the renamed anchor. |
| 2 | Remove `(0,0)` copy | Keep only the small passive ring/cross at the canonical origin. |
| 3 | Inspector background `#262c39` | Add `ui.colors.inspector_background` to `prop_theme.json` and a validated accessor. |
| 4 | No add-shape hint in Reshape | The empty-canvas right-click hint is Arrange-only. |
| 5 | Clear hand names | Preview items read `Left Hand` and `Right Hand`. |
| 6 | Remove ineffective frame choice | Remove Upper Body/Full Figure and always frame the complete rig plus visible prop. |
| 7 | Replace synthetic arm animation | Preview Front Pose, Walk Left, and Walk Right; the old control becomes Pause/Resume Walk. |
| 8 | Preserve manual arm IK | The cyan target remains draggable; starting a drag pauses gait playback first. |
| 9 | Hide Material in Attach | Group the full Material UI and hide it only in Attach. |
| 10 | Remove duplicate Snap Anchors | Top-row Snap is the sole snap control and source of truth. |
| 11 | Preview checked on Attach entry | Every transition into Attach checks and shows the preview; the user may hide it for that Attach session. |
| 12 | Make marker visibility meaningful | Show Other Markers controls passive origin plus unselected anchor gizmos. |
| 13 | Fix clipped Grid/Snap | Use container-driven top rows, themed minimum heights, and vertical margins. |
| 14 | Remove preview blue border | Retain the dark preview surface but use no colored border. |
## 3. Scope and preserved contracts
### 3.1 In scope
- `PropEditor`, `PropCanvas`, `PropAttachPreview`, Prop Editor scene layout, and PropTheme UI
presentation.
- Preview-only use of the existing `stickman_rig.tscn` animation and IK nodes.
- Regression tests for all new UI state and preview ownership behavior.
### 3.2 Explicitly out of scope
- Any Sandbox Stage, `PropBlock`, spawner, runtime equip/release, physics, collision, terrain,
director, or action-queue change.
- Navigation or actual root translation in the holding preview. Walking is an in-place visual
gait check.
- A new animation asset, animation blending layer, or simultaneous animation/manual ownership
of the same hand IK target.
- `.prop` schema/version changes. Anchor IDs remain the persisted names; no separate display-name
field is added.
- Persistent UI preferences for preview motion, playback, marker visibility, or preview
visibility.
- Document-wide undo/redo, already tracked by debt item #37.
### 3.3 Saved-document behavior
Only a successful anchor rename mutates document data. It marks dirty once, arms the existing
autosave debounce once, refreshes the inspector/canvas/preview, and is included automatically in
ordinary save, Save As, autosave, recovery, and load round trips.
Preview motion, paused state, chosen preview hand, Show Stickman Preview, and Show Other Markers
are session-only tool state. Changing them must not mark dirty, arm autosave, or alter serialized
shape/anchor data. Hiding the Material section never changes the current material preset.
## 4. Existing rig and animation constraints
`stickman_rig.tscn` contains looped `walk_left` and `walk_right` clips. The actual
`StickmanLocomotion.walk_to()` path selects a LEFT/RIGHT facing profile and plays `walk_right`
for both directions. LEFT is represented through the rig's facing/mirroring policy, not by
playing `walk_left` during runtime locomotion.
Phase 9c must follow that proven runtime gait rule:
- `Walk Right`: RIGHT facing, positive preview wrapper, play `walk_right`.
- `Walk Left`: LEFT facing, keep the solving skeleton positive, mirror `PreviewRigRoot`, play
`walk_right`.
- `Front Pose`: FORWARD facing, player stopped, authored standing targets restored.
The existing `walk_left` asset is not deleted or modified. The preview must not call
`StickmanRig.walk_to()`: doing so would introduce NavigationServer/map dependencies and unwanted
root translation. It directly owns only its private AnimationPlayer.
The existing isolation contract remains mandatory:
- rig physics processing disabled;
- action queue stopped and cleared;
- no terrain following or navigation request;
- all collision layers/masks disabled;
- preview prop contains visual CanvasItems only;
- no reference to Sandbox Stage state.
## 5. Target files and typed contracts
| File | Required change |
| --- | --- |
| `scripts/prop_editor.gd` | Rename dialog; mode visibility; one snap control; Attach-entry preview default; marker toggle routing; theme/layout application. |
| `scripts/prop_canvas.gd` | Arrange-only empty hint; origin visibility API; remove origin text. |
| `scripts/prop_attach_preview.gd` | Replace synthetic motion and Frame/Facing UI with the approved motion state machine; preserve manual IK and wrist attachment. |
| `scripts/prop_theme.gd` | Add validated inspector background accessor/fallback. |
| `prop_theme.json` | Add `ui.colors.inspector_background: "#262C39"`. |
| `scenes/prop_editor.tscn` | Container-driven editor chrome and responsive top-row margins/minimum sizing. |
| `scenes/prop_attach_preview.tscn` | No structural requirement beyond retaining the scripted PanelContainer shell. |
| `tests/test_phase9c_prop_editor_ux.gd` | New Phase 9c unit/integration suite. |
| Existing Prop Editor tests | Replace assertions for removed controls and add token/layout regressions where they naturally belong. |
No change is required in `prop_file.gd`, `prop_anchor_data.gd`, `stickman_rig.gd`,
`stickman_locomotion.gd`, or `stickman_rig.tscn` unless implementation uncovers a verified defect
independent of this feature. Such a discovery must be reported rather than folded into Phase 9c.
## 6. Node and layout design
Keep dialogs as direct children of the editor root. Put visible editor chrome under one
full-rect `VBoxContainer` so font and hit-target tokens determine row height instead of fixed
32 px offsets:
```text
PropEditor (Control)
├── EditorChrome (VBoxContainer, full rect)
│ ├── TopRow (HBoxContainer, min height >= button_min_height + margins)
│ │ ├── MenuBar (expand)
│ │ └── TopRightMargin (MarginContainer)
│ │ └── TopRightControls (HBoxContainer)
│ │ ├── Grid label
│ │ ├── Grid SpinBox
│ │ ├── Snap CheckBox
│ │ └── Center Prop Button
│ ├── TopBar (HBoxContainer, themed minimum height)
│ │ ├── Prop Name label
│ │ └── PropNameEdit
│ ├── MainLayout (HSplitContainer, expand/fill)
│ │ ├── CanvasPanel
│ │ │ └── CanvasArea
│ │ │ ├── ModeBar
│ │ │ ├── AttachTools (FlowContainer)
│ │ │ │ ├── Show Stickman Preview
│ │ │ │ └── Show Other Markers
│ │ │ └── CanvasWorkspace (HSplitContainer)
│ │ │ ├── PropCanvas
│ │ │ └── PropAttachPreview
│ │ └── InspectorColumn
│ │ └── InspectorScroll
│ │ └── InspectorList
│ │ ├── MaterialSection
│ │ ├── ShapeSection
│ │ ├── AddSection
│ │ └── AnchorSection
│ └── StatusBar
├── SaveDialog / LoadDialog / ErrorDialog
├── ExitDialog / RecoveryDialog / OffOriginSaveDialog
└── RenameAnchorDialog
```
Existing unique names remain unique so controller lookup continues through `%NodeName`.
Path-based tests must migrate to unique lookup or the revised hierarchy instead of forcing the
old fixed layout to remain.
`TopRightMargin` uses small vertical margins (nominally 4 px top and bottom). Grid SpinBox,
Snap CheckBox, Center Prop, and both top rows use at least `PropTheme.button_min_height()`. The
layout must remain unclipped at the committed 18–22 px font tokens and when a custom theme raises
those font sizes. Attach tools use a `FlowContainer` so the two longer kid-facing labels wrap
instead of extending outside a narrow canvas pane.
## 7. Theme and surface styling
Extend both `PropTheme.DEFAULT_CONFIG` and the committed JSON:
```json
{
"ui": {
"colors": {
"inspector_background": "#262C39"
}
}
}
```
Add:
```gdscript
func inspector_background_color() -> Color:
return color("ui.colors.inspector_background", Color("#262C39"))
```
The InspectorColumn panel style derives its background only from this accessor. Keep the subtle
theme border and split grabber so the adjustable inspector boundary remains legible.
`PropAttachPreview` retains its dark surface but sets all border widths to zero. It must not use
the accent color as a frame border. Pane separation is supplied by the split grabber and the
contrast between preview and inspector backgrounds.
## 8. Anchor rename workflow
### 8.1 Dialog contract
Add typed state:
```gdscript
var _rename_anchor_dialog: ConfirmationDialog
var _rename_anchor_edit: LineEdit
var _rename_anchor_source_id: String = ""
```
`Rename` is enabled only for a selected editable non-origin anchor. Pressing it:
1. stores the selected ID in `_rename_anchor_source_id`;
2. fills the LineEdit with that ID;
3. opens the dialog;
4. defers focus and selects all text so typing replaces the old name.
The dialog title is `Rename Anchor`, confirmation text is `Rename`, and cancel closes without
mutation. The confirm button is disabled when the trimmed entry is empty.
### 8.2 Commit algorithm
On confirm:
```gdscript
var remaining := _anchors.duplicate(true)
var value := remaining[_rename_anchor_source_id].duplicate(true)
remaining.erase(_rename_anchor_source_id)
var new_id := PropAnchorData.normalize_id(_rename_anchor_edit.text, remaining)
```
- Human input is normalized with the existing lowercase-snake-case rules.
- A collision receives the existing deterministic `_2`, `_3`, ... suffix.
- Renaming to the same normalized ID is a no-op: do not dirty or autosave.
- Otherwise insert the unchanged payload at `new_id`, normalize anchors once, replace selection,
update canvas selection, mark dirty once, refresh the inspector, and sync the preview once.
- Position, role, allowed hands, rotation, and Z offset must remain byte/value equivalent.
- Clear `_rename_anchor_source_id` on confirmed commit, cancel, hide, New, and load so a stale
dialog cannot rename another document's anchor.
No `display_name` field is introduced. The normalized ID displayed in the list is the saved ID.
## 9. Marker and canvas behavior
### 9.1 Passive origin marker
Add to `PropCanvas`:
```gdscript
var _show_origin_marker: bool = true
func set_show_origin_marker(enabled: bool) -> void:
_show_origin_marker = enabled
queue_redraw()
```
`_draw_origin_marker()` retains its constant-screen-size dark halo, ring, and cross, but removes
the font lookup and `(0,0)` `draw_string()` call. It remains non-interactive and is drawn at
world `Vector2.ZERO` in Arrange, Reshape, and Attach whenever `_show_origin_marker` is true.
### 9.2 Show Other Markers
The Attach toolbar contains one default-on `Show Other Markers` CheckBox. Its callback routes
the same value to:
```gdscript
_canvas.set_show_inactive_anchors(enabled)
_canvas.set_show_origin_marker(enabled)
```
The anchor overlay continues to draw and hit-test the selected editable anchor when false while
hiding every unselected editable anchor. The origin marker follows the setting in all modes,
including after leaving Attach. The setting is session-only and is not reset by switching modes,
New, load, or save.
### 9.3 Empty-canvas hint
Draw `Right-click to add a shape` only when all are true:
- `shapes.is_empty()`;
- mode is `EditMode.TRANSFORM` (Arrange);
- the canvas font is available.
Reshape and Attach show no add-shape hint. The origin marker is evaluated independently afterward.
## 10. Inspector and mode synchronization
Wrap the existing Material title, preset OptionButton, mass, friction, and bounce labels in
`_material_section: VBoxContainer`.
Mode visibility is authoritative in `_sync_mode_controls(mode)`:
| Section/control | Arrange | Reshape | Attach |
| --- | --- | --- | --- |
| MaterialSection | visible | visible | hidden |
| ShapeSection | selected shape only | selected shape only | hidden |
| AddSection / Erase Shape | visible | hidden | hidden |
| AnchorSection / AttachTools | hidden | hidden | visible |
| Holding Preview | hidden | hidden | visible when checked |
Delete `_snap_anchors_check`, `_on_snap_anchors_toggled()`, and the reverse synchronization from
`_on_top_snap_toggled()`. The existing top Snap CheckBox remains the only writer of
`PropCanvas.set_snap_enabled()`. Anchor drag already consumes the canvas's shared snap state and
grid size; this behavior must not be duplicated in the controller.
Track the last canvas mode. On a real transition from Arrange/Reshape into Attach:
```gdscript
_show_preview_check.set_pressed_no_signal(true)
```
then show/sync the preview. Toggling it off while already in Attach stays off until the user
leaves and re-enters Attach. Repeated inspector refreshes or document updates while remaining in
Attach must not force it back on.
## 11. Holding-preview UI
The dynamic preview hierarchy becomes:
```text
PropAttachPreview
└── VBoxContainer
├── Header (FlowContainer)
│ ├── Holding Preview title
│ ├── HandOption: Left Hand / Right Hand
│ └── MotionOption: Front Pose / Walk Left / Walk Right
├── HintLabel
├── SubViewportContainer
└── Footer (FlowContainer)
├── WalkPlaybackButton: Pause Walk / Resume Walk
├── Reset Arm
└── Reset View
```
Remove `_facing_option`, `_frame_option`, `FrameMode`, and `_animate_check`. The initial hand
remains Right Hand. The initial motion is Front Pose. The control hit rectangles continue to use
the full themed minimum height and `MOUSE_FILTER_STOP`.
The motion choice is a preview tool preference: `set_document()` does not reset it, so authors
can compare multiple props in the same walk. Opening a fresh editor instance begins at Front
Pose. New/load do not serialize or dirty this state.
## 12. Preview motion and IK ownership state machine
### 12.1 Typed state/API
```gdscript
enum PreviewMotion { FRONT_POSE, WALK_LEFT, WALK_RIGHT }
var _motion: PreviewMotion = PreviewMotion.FRONT_POSE
var _walk_paused: bool = true
var _manual_arm_override: bool = false
var _hand_target_rests_local: Dictionary = {}
func set_preview_motion(motion: int) -> void
func set_walk_paused(paused: bool) -> void
func toggle_walk_playback() -> void
func reset_arm_pose() -> void
```
Standing hand target rest values are captured as each Marker2D's **local `position`**, never as
global positions. Animation tracks also write these local positions, so reset remains correct
under the LEFT preview-wrapper mirror.
### 12.2 State transitions
```mermaid
stateDiagram-v2
[*] --> FrontPose
FrontPose --> WalkPlaying: choose Walk Left / Walk Right
WalkPlaying --> WalkPaused: Pause Walk
WalkPlaying --> WalkPaused: begin cyan-target drag
WalkPaused --> WalkPlaying: Resume Walk
WalkPaused --> FrontPose: choose Front Pose
WalkPlaying --> FrontPose: choose Front Pose
WalkPlaying --> WalkPlaying: switch walk direction
WalkPaused --> WalkPlaying: switch walk direction (restart selected gait)
```
Exact transition behavior:
- **Front Pose entry:** stop AnimationPlayer with reset, set FORWARD profile/wrapper, restore all
standing target local positions, enable arm IK, set `_walk_paused = true`, clear manual
override, update marker/prop, and reframe.
- **Walk Left/Right entry:** clear manual override, apply the requested profile/wrapper, enable
arm IK, start `walk_right` from time `0.0`, set `_walk_paused = false`, update button copy,
and reframe. Changing between walk directions restarts at `0.0` for deterministic pose and
mirror state.
- **Pause:** call AnimationPlayer pause without resetting targets or playhead. The currently
solved pose freezes; button becomes `Resume Walk`.
- **Resume:** resume the current walk clip from its saved playhead, clear manual override, and
change the button to `Pause Walk`.
- **Manual drag begin:** if walking, pause first; then set manual override true and allow the
selected hand target to be clamped/dragged. Mouse release ends pointer capture but intentionally
leaves walking paused and preserves the authored inspection pose.
- **Reset Arm in Front:** restore both hand targets from local standing rests, clear manual
override, remain stopped.
- **Reset Arm during a paused walk:** re-seek/reapply the current animation playhead so the
dragged target returns to the frozen gait pose, pause again, clear manual override, and keep
the same direction/playhead.
- **Reset Arm during a playing walk:** first pause, then apply the paused-walk rule.
- **Hand selection/forced compatibility change:** keep the current motion direction and playback
state, clear any manual override by reapplying the current front/gait pose, switch the cyan
marker and prop binding, and do not restart a playing clip unless reapplication requires it.
`Pause Walk` is disabled in Front Pose and carries a tooltip such as `Choose Walk Left or Walk
Right to preview walking.` `Reset Arm` remains enabled when a compatible grip is visible.
### 12.3 Frame ownership and prop following
```mermaid
flowchart LR
Motion[Preview Motion selector] --> Player[Private AnimationPlayer]
Player --> Targets[IK target Marker2D positions]
Drag[Cyan target drag] -->|pauses player, then owns selected target| Targets
Targets --> IK[TwoBoneIK solve]
IK --> Wrist[Solved lower-arm visual endpoint]
Wrist --> Solve[PropAnchorData.solve_attachment_transform]
Solve --> Prop[Full multi-shape preview prop]
```
There is exactly one writer for the selected hand target at a time. Phase 9c deliberately does
not blend manual offsets additively over a moving animation. This avoids frame-order fights
between AnimationPlayer and pointer input.
Every preview frame still updates the cyan marker and derives the held prop transform from
`_solved_wrist_transform()`, never directly from the IK target. It therefore follows arm swing,
paused gait, and manual IK poses. Direction changes retain the positive-scale solver/wrapper
mirror strategy and never mutate stored anchor coordinates.
## 13. Camera behavior
Delete the upper-body filtering branch. `_reframe_camera()` always unions:
- every visible rig polygon/line, including legs;
- every visible authored prop polygon/line/circle visualization.
Grow the union by the existing safe margin, center the Camera2D, and fit it to the viewport.
`Reset View` calls this one function. Wheel zoom and middle-mouse pan remain unchanged. Resizing
the preview pane may refit as it does today. The removed frame choice carries no persisted state.
## 14. Signals and update flow
No public document signal payload changes:
```gdscript
signal shape_changed(shapes: Array[Dictionary])
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
```
Rename flow:
```text
Rename button
-> RenameAnchorDialog + focused LineEdit
-> normalize entered ID against anchors excluding old ID
-> replace dictionary key, retain payload
-> canvas.set_anchor_data + canvas.select_anchor
-> mark dirty once
-> refresh anchor inspector
-> preview.set_document
```
Preview controls communicate internally through direct typed methods because the preview owns
the complete private visualization. No global signal bus or runtime component is introduced.
## 15. Edge cases
- No selected editable anchor: Rename remains disabled and cannot open a stale source ID.
- Rename entry empty/whitespace: confirmation disabled.
- Rename normalizes to current ID: no-op and no dirty event.
- Rename collides after normalization: deterministic numeric suffix; no existing anchor replaced.
- Rename while autosave countdown exists: one ordinary document mutation resets the debounce.
- Origin: never appears in the editable list and cannot be renamed.
- No shapes in Reshape: blank grid plus optional origin marker; no add-shape hint.
- Show Other Markers off with no selection: all editable markers and origin are hidden.
- Show Other Markers off with selection: selected marker and its rotation dot remain visible and
hit-testable; all others and origin are hidden.
- Preview hidden, then Attach exited/re-entered: re-entry checks and shows it again.
- Attach document has no compatible grip: preview shows its existing selection guidance; gait
controls may animate the rig, but prop and cyan hold target remain unavailable as appropriate.
- Hand restriction forces another hand during a paused/manual pose: current preview pose is
reapplied before binding the new hand, preventing stale global target placement.
- Walk paused at loop boundary: resuming remains looped and does not restart unless direction
changes.
- Prop extends far beyond the figure: full framing includes it; no separate frame selector.
- Custom large UI fonts: top controls grow through containers instead of clipping.
- Invalid/missing new theme token: accessor returns exact `#262C39` fallback and warns only under
the existing PropTheme rules.
## 16. Automated validation
Add `tests/test_phase9c_prop_editor_ux.gd` as a headless SceneTree suite and update affected
existing suites. Assertions must verify state and values, not only node counts.
### 16.1 Rename
1. Rename opens a ConfirmationDialog with the current ID selected in a focused LineEdit.
2. Empty/whitespace input disables confirmation.
3. `Main Handle` becomes `main_handle` and preserves every anchor payload field.
4. A collision becomes `main_handle_2` without replacing the existing anchor.
5. Same-normalized-name commit emits no dirty mutation.
6. Successful rename updates list selection, canvas selection, preview selected ID, save output,
and load round trip.
7. Cancel/New/load clears pending rename source and does not mutate the document.
### 16.2 Canvas, modes, and markers
8. Origin drawing has no `(0,0)` string path and remains present by default in all three modes.
9. Show Other Markers false hides origin and unselected anchors in draw/hit-test while preserving
selected anchor interaction.
10. Arrange alone shows the empty-canvas right-click hint.
11. Material is visible in Arrange/Reshape and hidden in Attach without changing preset.
12. Attach toolbar contains no Snap Anchors control; top Snap still snaps anchor dragging by the
configured Grid value.
13. A real transition into Attach checks/shows preview; ordinary refresh while hidden does not
re-check it; leaving and re-entering does.
### 16.3 Theme/layout
14. Default and committed theme return exact inspector background `#262C39`.
15. Custom valid override works; invalid value falls back under current validation.
16. Inspector surface consumes the accessor.
17. Preview panel border widths are all zero.
18. Top rows and Grid/Snap controls meet themed minimum height after layout; their visible rects
are not clipped by parents.
19. Inspector and preview remain adjustable through the existing split panes.
### 16.4 Preview motion and IK
20. Hand items are exactly `Left Hand` and `Right Hand`.
21. Motion items are exactly `Front Pose`, `Walk Left`, and `Walk Right`; no FrameOption or
FacingOption remains.
22. Initial state is FORWARD Front Pose with stopped player and local standing targets.
23. Walk Right selects RIGHT and loop-plays the proven gait clip in place.
24. Walk Left selects LEFT, keeps the solving rig positive, mirrors only PreviewRigRoot, and
loop-plays the proven gait clip in place.
25. Pause preserves current animation position and target values; Resume advances from it.
26. Direction change restarts deterministically at `0.0`.
27. Starting cyan-target drag pauses gait before writing the selected target and leaves it paused
after release.
28. Reset Arm restores standing local target in Front and the frozen animation sample in Walk.
29. Switching/forcing hand keeps motion/play state but clears stale manual override.
30. Held prop continues to map the selected grip exactly to the solved wrist over several walk
frames and after a manual drag.
31. Preview root does not translate during gait; queues stay empty, physics stays disabled,
collision layers/masks stay zero, and preview prop children are non-physics CanvasItems.
32. Reset View frames the complete figure and intersects the visible held-prop bounds.
### 16.5 Regression commands
Run all Prop Editor suites from `AGENTS.md`, particularly:
- `tests/test_prop_editor_file.gd`
- `tests/test_prop_editor_theme.gd`
- `tests/test_prop_editor_canvas.gd`
- `tests/test_prop_editor_thumb.gd`
- `tests/test_prop_editor_round3.gd`
- `tests/test_phase9a_prop_attach.gd`
- `tests/test_phase9b_prop_centering.gd`
Update old assertions that explicitly expect Animate Arm, FrameOption, FacingOption, Snap Anchors,
the blue preview border, or `(0,0)` label. Do not weaken anchor round-trip, origin
canonicalization, wrist alignment, preview isolation, or centering tests.
Run Godot 4.7.1 `--headless --check-only` for every changed script and `git diff --check`.
## 17. Manual acceptance checklist
Launch `res://scenes/prop_editor.tscn` with F6 and verify:
1. Select an editable anchor, click Rename, type a new name, and confirm the entered normalized
name appears; it no longer appends `_renamed` automatically.
2. Rename to a name already in use and confirm a safe numeric suffix is applied.
3. Cancel Rename and confirm nothing changes; rename/save/reload and confirm persistence.
4. The passive center marker has no `(0,0)` text in Arrange, Reshape, or Attach.
5. `Show Other Markers` off hides that passive marker and unselected anchors but retains the
selected anchor and rotation dot; turn it on and confirm they return.
6. Inspector background is visibly `#262C39` and distinct from canvas/preview.
7. Empty Arrange shows `Right-click to add a shape`; empty Reshape does not.
8. Material controls are hidden in Attach and return unchanged in Arrange/Reshape.
9. Attach contains only Show Stickman Preview and Show Other Markers; no Snap Anchors duplicate.
10. Top Snap with a non-default Grid value still snaps an anchor drag.
11. Hide the preview, leave Attach, and re-enter; it is checked and shown again.
12. Grid and Snap have comfortable vertical padding and are not clipped at top or bottom.
13. Resize the outer inspector split and inner preview split in both directions.
14. Preview has a dark surface without a blue border.
15. Hand choices read Left Hand and Right Hand.
16. There is no Upper Body/Full Figure or separate Facing control.
17. Initial preview is a static Front Pose.
18. Choose Walk Right and confirm an in-place full-body walking gait while the complete prop
follows the holding wrist.
19. Choose Walk Left and confirm the stickman faces/walks left without inverted limbs and the
complete prop still follows the wrist.
20. Pause Walk freezes the exact pose and changes to Resume Walk; Resume continues rather than
jumping to the beginning.
21. While walking, drag the cyan hand target. Walking pauses first, the arm follows the pointer,
the prop follows the solved wrist, and the pose remains paused on release.
22. Reset Arm restores the appropriate front/frozen-gait arm pose without changing anchor data.
23. Switch Left Hand/Right Hand and test a `both` grip plus left-only/right-only restrictions.
24. Reset View includes the entire figure and visible prop; wheel zoom and middle-pan still work.
25. Change preview controls and verify the document does not become dirty.
26. Confirm save/load/autosave/recovery retain shapes, material, and renamed anchors exactly.
## 18. Architectural trade-offs
| Decision | Benefit | Cost / future work |
| --- | --- | --- |
| Separate Phase 9c | Preserves completed 9a/9b history and gives behavior changes focused regressions | User guide spans another verified phase before its next update |
| One Preview Motion selector | Removes ineffective/ambiguous combinations and is kid-readable | Facing is no longer independently previewable without gait |
| Runtime gait rule, preview in place | Matches current gameplay direction/mirror semantics without navigation dependencies | Not a terrain, speed, arrival, or root-motion simulation |
| Pause before manual IK | One deterministic owner writes the hand target | No live additive hand posing while gait continues |
| Local rest targets | Stable under wrapper mirroring and matches animation track coordinates | Existing global-rest code must be replaced carefully |
| Full figure only | One reliable frame and fewer controls | Authors cannot request a tighter automatic upper-body crop; wheel zoom remains available |
| Modal rename | Focused, touch-friendly, and hard to trigger accidentally | One additional dialog lifecycle must be cleared on document changes |
| One snap source | Eliminates contradictory UI state | Attach toolbar no longer exposes snap next to anchors; top control must remain visible |
| Show Other Markers controls origin too | Toggle has visible meaning even with one anchor | A setting changed in Attach also affects the passive marker in other modes |
| Container-driven chrome | Adapts to theme/font changes and prevents clipping | Scene hierarchy and path-based tests require a targeted update |
## 19. Implementation order and pipeline handoff
1. Add theme token/accessor and convert editor chrome to responsive containers.
2. Group mode sections; remove duplicate/obsolete controls; implement marker visibility and hint
rules.
3. Add the rename dialog and document transaction tests.
4. Replace preview synthetic motion with the exact PreviewMotion/playback/IK state machine.
5. Add Phase 9c tests, update affected expectations, run all regressions/static checks.
6. Hand the implementation to the user for Godot Editor verification.
Do not update the user guide or README during implementation handoff. After the user confirms
Phase 9c is verified and working, the writer must revise `docs/prop_editor_user_guide.md` to
remove the old automatic Rename, Snap Anchors, Facing, Frame, and Animate Arm instructions and
document the approved Phase 9c controls.
+178
View File
@@ -0,0 +1,178 @@
extends SceneTree
## Phase 9a editor-only regression: anchor schema, alignment math, and preview isolation.
const ANCHORS := preload("res://scripts/prop_anchor_data.gd")
const PROP_FILE := preload("res://scripts/prop_file.gd")
const PREVIEW_SCENE := preload("res://scenes/prop_attach_preview.tscn")
const EDITOR_SCENE := preload("res://scenes/prop_editor.tscn")
const OVERLAY := preload("res://scripts/prop_anchor_overlay.gd")
var _checks: int = 0
var _failures: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_anchor_round_trip()
_test_anchor_validation_and_presets()
_test_alignment_and_mirror()
_test_anchor_rotation_overlay()
await _test_private_preview()
await _test_editor_anchor_inspector_constructs()
print("RESULT: %s (%d assertions)" % ["ALL PASSED" if _failures == 0 else "%d FAILED" % _failures, _checks])
quit(0 if _failures == 0 else 1)
func _check(label: String, condition: bool) -> void:
_checks += 1
if condition:
print("PASS: " + label)
else:
_failures += 1
print("FAIL: " + label)
func _test_anchor_round_trip() -> void:
var raw := {"primary_grip": {"role": "grip", "x": 12.0, "y": -8.0, "rotation_deg": 90.0, "z_index_offset": 2, "allowed_hands": "right_only"}}
var normalized: Dictionary = ANCHORS.normalize_anchors(raw)
_check("legacy/default origin is synthesized", normalized.has("origin") and normalized["origin"].get("position") == Vector2.ZERO)
_check("grip remains compatible only with right", ANCHORS.compatible_grip_ids(normalized, ANCHORS.Hand.RIGHT).has("primary_grip") and not ANCHORS.compatible_grip_ids(normalized, ANCHORS.Hand.LEFT).has("primary_grip"))
var file_data: Dictionary = PROP_FILE.to_file_dict("Grip", 0, [], normalized)
var loaded: Dictionary = PROP_FILE.from_file_dict(file_data)
_check("file anchors round trip", loaded.get("anchors", {}).get("primary_grip", {}).get("position") == Vector2(12.0, -8.0))
func _test_anchor_validation_and_presets() -> void:
var raw := {"origin": {"role": "grip", "x": 50}, "Bad ID!!": {"role": "pivot", "position": Vector2(4, 5), "rotation_deg": 721.0, "z_index_offset": 99}}
var normalized: Dictionary = ANCHORS.normalize_anchors(raw, false)
_check("origin is immutable canonical pivot", normalized["origin"].get("role") == "pivot" and normalized["origin"].get("position") == Vector2.ZERO)
_check("IDs normalize and non-origin pivots downgrade", normalized.has("bad_id") and normalized["bad_id"].get("role") == "custom")
_check("rotation and z sanitize", is_equal_approx(float(normalized["bad_id"].get("rotation_deg")), 1.0) and int(normalized["bad_id"].get("z_index_offset")) == 5)
_check("duplicate ID gets suffix", ANCHORS.normalize_id("bad id", normalized) == "bad_id_2")
for preset: String in ["one_handed", "two_handed", "tool_nozzle", "melee_edge"]:
var layout: Dictionary = ANCHORS.preset_layout(preset, Rect2(-50, -10, 100, 20))
_check("preset %s retains origin" % preset, layout.has("origin") and layout["origin"].get("role") == "pivot")
func _test_alignment_and_mirror() -> void:
var anchor := {"position": Vector2(20.0, 5.0), "rotation_deg": 30.0}
var target := Transform2D(deg_to_rad(70.0), Vector2(300.0, 200.0))
var solved := ANCHORS.solve_attachment_transform(target, anchor, Vector2(2.0, 3.0), false)
var effective := ANCHORS.local_transform(anchor)
_check("attachment maps anchor origin to wrist", solved * effective.origin == target.origin)
_check("attachment keeps positive determinant", solved.determinant() > 0.0)
var mirrored := ANCHORS.local_transform(anchor, true)
_check("mirror reflects anchor X without mutation", mirrored.origin == Vector2(-20.0, 5.0) and anchor.get("position") == Vector2(20.0, 5.0))
func _test_anchor_rotation_overlay() -> void:
var overlay := OVERLAY.new()
overlay.set_anchor_data({"grip": {"role": "grip", "position": Vector2(10.0, 10.0), "rotation_deg": 0.0}})
overlay.select_anchor("grip")
_check("rotation dot starts outside translate marker", overlay.begin_pointer(Vector2(10.0, 38.0), 1.0) and int(overlay.get("_drag_mode")) == 2)
overlay.update_pointer(Vector2(110.0, 36.8), 1.0, false, 1, true)
overlay.end_pointer()
var grip: Dictionary = overlay.get_anchor_data()["grip"]
_check("rotation dot Ctrl snaps to 15 degrees", is_equal_approx(float(grip.get("rotation_deg")), 15.0))
overlay.select_anchor("origin")
_check("fixed origin has no rotation drag", not overlay.begin_pointer(Vector2(0.0, 28.0), 1.0) and int(overlay.get("_drag_mode")) != 2)
func _test_private_preview() -> void:
var preview := PREVIEW_SCENE.instantiate() as Control
root.add_child(preview)
await process_frame
await process_frame
var anchors := ANCHORS.normalize_anchors({"grip": {"role": "grip", "x": 0.0, "y": 0.0, "rotation_deg": 0.0, "z_index_offset": 1, "allowed_hands": "both"}})
var shapes: Array[Dictionary] = [{"shape_type": 0, "points": PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(0, 15)]), "fill_color": "#ff0000", "closed": true}]
preview.call("set_document", shapes, anchors, "grip")
_check("preview creates a private rig viewport", bool(preview.call("is_preview_available")))
var rig_root := preview.get("_rig_root") as Node2D
var prop := preview.get("_prop") as Node2D
_check("preview rig keeps positive scale", rig_root != null and rig_root.scale.x > 0.0)
_check("preview is visual-only", prop != null and prop.get_child_count() == 1 and not (prop.get_child(0) is CollisionObject2D))
preview.call("set_facing_profile", 0)
var rig := preview.get("_rig") as Node2D
_check("left reflection is only on wrapper", rig != null and rig.scale.x > 0.0 and rig_root != null and rig_root.scale.x < 0.0)
preview.call("set_preview_hand", 0)
_check("secondary/any compatible grip can use left", int(preview.get("_hand")) == 0)
preview.call("set_document", shapes, ANCHORS.normalize_anchors({"grip": {"role": "grip", "allowed_hands": "right_only"}}), "grip")
_check("right-only grip forces compatible hand", int(preview.get("_hand")) == 1)
var clamped := preview.call("_clamp_target", Vector2.ZERO) as Vector2
_check("reach clamp returns finite target", is_finite(clamped.x) and is_finite(clamped.y))
preview.call("reset_arm_pose")
_check("reset disables animation", not bool(preview.get("_animate")))
preview.queue_free()
func _test_editor_anchor_inspector_constructs() -> void:
var editor := EDITOR_SCENE.instantiate() as Control
editor.size = Vector2(1200.0, 800.0)
root.add_child(editor)
await process_frame
await process_frame
var role := editor.get("_anchor_role") as OptionButton
var x_spin := editor.get("_anchor_x") as SpinBox
var rename := editor.get("_rename_anchor_button") as Button
var delete_button := editor.get("_delete_anchor_button") as Button
var anchor_list := editor.get("_anchor_list") as ItemList
_check("editor constructs an empty editable-anchor inspector", role != null and x_spin != null and anchor_list != null)
_check("no-anchor option fields are disabled", role != null and role.disabled)
_check("no-anchor numeric fields use SpinBox editable gate", x_spin != null and not x_spin.editable)
_check("no-anchor rename/delete actions are disabled", rename != null and delete_button != null and rename.disabled and delete_button.disabled)
_check("canonical origin is hidden from editable anchor list", anchor_list != null and anchor_list.item_count == 0)
var workspace := editor.get_node_or_null("MainLayout/CanvasPanel/CanvasArea/CanvasWorkspace") as HSplitContainer
var main_split := editor.get_node_or_null("MainLayout") as HSplitContainer
_check("preview workspace is a bidirectional split", workspace != null and workspace.get_child_count() == 2 and workspace.get_child(1).custom_minimum_size.x >= 360.0)
_check("inspector is user-resizable", main_split != null and main_split.get_child_count() == 2)
var canvas := editor.get("_canvas") as PropCanvas
canvas.add_primitive("box", Vector2(0.0, 80.0), {"size_x": 160.0, "size_y": 45.0, "fill_color": "#9b6b34"})
canvas.add_primitive("circle", Vector2(55.0, 0.0), {"radius": 18.0, "fill_color": "#ffffff"})
var anchors := ANCHORS.normalize_anchors({"grip": {"role": "grip", "position": Vector2.ZERO, "z_index_offset": 1, "allowed_hands": "both"}})
editor.set("_anchors", anchors)
canvas.set_anchor_data(anchors)
editor.call("_select_anchor", "grip")
editor.call("_on_attach_mode_pressed")
editor.call("_sync_attach_preview")
await process_frame
var preview := editor.get("_attach_preview") as Control
preview.call("_update_prop_transform")
var prop := preview.get("_prop") as Node2D if preview != null else null
_check("editor document flow builds every preview shape", prop != null and prop.visible and prop.get_child_count() == 2)
var prop_bounds := preview.call("get_prop_global_bounds") as Rect2
var view_bounds := preview.call("get_preview_world_rect") as Rect2
var wrist := preview.call("get_solved_wrist_transform") as Transform2D
var grip: Dictionary = anchors["grip"]
_check("preview prop has finite visible global bounds", prop_bounds.size.x > 0.0 and prop_bounds.size.y > 0.0 and is_finite(prop_bounds.position.x) and is_finite(prop_bounds.end.y))
_check("selected grip maps exactly to solved wrist", (prop.global_transform * (grip.get("position", Vector2.ZERO) as Vector2)).is_equal_approx(wrist.origin))
var before_motion := prop.global_position
preview.call("set_arm_animation_enabled", true)
await process_frame
preview.call("_update_prop_transform")
var moved_wrist := preview.call("get_solved_wrist_transform") as Transform2D
_check("held prop follows solved wrist during IK motion", prop.global_position.distance_to(before_motion) > 0.001 and prop.global_position.is_equal_approx(moved_wrist.origin))
_check("framed viewport intersects held prop", view_bounds.intersects(prop_bounds))
_check("preview preserves authored polygon and circle colors", prop.get_child(0) is Polygon2D and (prop.get_child(0) as Polygon2D).color == Color.from_string("#9b6b34", Color.GRAY) and prop.get_child(1) is Polygon2D and (prop.get_child(1) as Polygon2D).color == Color.WHITE)
var rotation_spin := editor.get("_anchor_rotation") as SpinBox
var z_spin := editor.get("_anchor_z") as SpinBox
rotation_spin.value = 47.0
z_spin.value = 3.0
await process_frame
var edited_anchors: Dictionary = editor.get("_anchors")
var edited_grip: Dictionary = edited_anchors["grip"]
_check("inspector rotation and z persist to selected anchor", is_equal_approx(float(edited_grip.get("rotation_deg")), 47.0) and int(edited_grip.get("z_index_offset")) == 3)
_check("inspector refresh retains rotation and z values", is_equal_approx(rotation_spin.value, 47.0) and is_equal_approx(z_spin.value, 3.0))
var round_trip: Dictionary = PROP_FILE.from_file_dict(PROP_FILE.to_file_dict("Grip", 0, [], edited_anchors))
_check("rotation and z save/load round trip", is_equal_approx(float(round_trip["anchors"]["grip"].get("rotation_deg")), 47.0) and int(round_trip["anchors"]["grip"].get("z_index_offset")) == 3)
_check("preview reacts to z edit", prop.z_index == 3)
var preview_style := preview.get_theme_stylebox("panel") as StyleBoxFlat
var inspector := editor.get_node_or_null("MainLayout/InspectorColumn") as PanelContainer
var inspector_style := inspector.get_theme_stylebox("panel") as StyleBoxFlat if inspector != null else null
_check("preview and inspector have distinct surfaces without blue frame", preview_style != null and inspector_style != null and preview_style.bg_color != inspector_style.bg_color and preview_style.border_width_left == 0)
var playback := preview.get("_walk_playback_button") as Button
var reset_arm := preview.get("_reset_arm_button") as Button
var reset_view := preview.get("_reset_view_button") as Button
var hand_option := preview.get("_hand_option") as OptionButton
_check("preview footer controls own full hit rectangles", _has_full_hit_rect(playback) and _has_full_hit_rect(reset_arm) and _has_full_hit_rect(reset_view))
_check("preview header options own full hit rectangles", _has_full_hit_rect(hand_option))
preview.call("set_preview_motion", 2)
preview.call("toggle_walk_playback")
_check("walk playback pauses through its button state", playback != null and playback.text == "Resume Walk" and bool(preview.get("_walk_paused")))
editor.queue_free()
func _has_full_hit_rect(control: Control) -> bool:
return control != null and control.mouse_filter == Control.MOUSE_FILTER_STOP and control.size.x >= control.custom_minimum_size.x and control.size.y >= control.custom_minimum_size.y and Rect2(Vector2.ZERO, control.size).has_point(Vector2(control.size.x - 2.0, control.size.y * 0.5))
+1
View File
@@ -0,0 +1 @@
uid://c4e5j6k0jm0hw
+78
View File
@@ -0,0 +1,78 @@
extends SceneTree
## Phase 9b regression: canonical origin presentation, bounds, centering, and save threshold.
const ANCHORS := preload("res://scripts/prop_anchor_data.gd")
const CANVAS_SCRIPT := preload("res://scripts/prop_canvas.gd")
const EDITOR := preload("res://scripts/prop_editor.gd")
var _checks: int = 0
var _failures: int = 0
var _shape_change_count: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
_test_anchor_helpers()
await _test_canvas_bounds_and_translation()
_test_warning_threshold()
print("RESULT: %s (%d assertions)" % ["ALL PASSED" if _failures == 0 else "%d FAILED" % _failures, _checks])
quit(0 if _failures == 0 else 1)
func _check(label: String, condition: bool) -> void:
_checks += 1
if condition: print("PASS: " + label)
else:
_failures += 1
print("FAIL: " + label)
func _test_anchor_helpers() -> void:
var anchors: Dictionary = ANCHORS.normalize_anchors({
"primary_grip": {"role": "grip", "position": Vector2(400.0, 200.0), "rotation_deg": 30.0, "z_index_offset": 2, "allowed_hands": "left_only"},
"emitter": {"role": "emitter", "position": Vector2(450.0, 220.0), "rotation_deg": -15.0, "z_index_offset": -1, "allowed_hands": "both"},
})
_check("editable IDs exclude canonical origin", ANCHORS.editable_ids(anchors) == ["primary_grip", "emitter"])
_check("preferred anchor favors primary grip", ANCHORS.preferred_editable_id(anchors) == "primary_grip")
var moved: Dictionary = ANCHORS.translated_non_origin(anchors, Vector2(-400.0, -200.0))
_check("translated anchors preserve origin and metadata", moved["origin"].get("position") == Vector2.ZERO and moved["primary_grip"].get("position") == Vector2.ZERO and is_equal_approx(float(moved["primary_grip"].get("rotation_deg")), 30.0) and int(moved["emitter"].get("z_index_offset")) == -1)
_check("translated anchors preserve relative vectors", moved["emitter"].get("position") - moved["primary_grip"].get("position") == anchors["emitter"].get("position") - anchors["primary_grip"].get("position"))
func _test_canvas_bounds_and_translation() -> void:
var canvas := CANVAS_SCRIPT.new() as Control
canvas.size = Vector2(800.0, 600.0)
canvas.visible = false
root.add_child(canvas)
await process_frame
canvas.call("set_shape_data", [
{"shape_type": "polygon", "points": [Vector2(400.0, 200.0), Vector2(600.0, 200.0), Vector2(500.0, 300.0)], "closed": true, "vertex_flags": [0, 0, 0]},
{"shape_type": "line", "points": [Vector2(620.0, 260.0), Vector2(680.0, 260.0)], "closed": false, "vertex_flags": [0, 0]},
{"shape_type": "circle", "points": [], "radius": 30.0, "closed": true, "vertex_flags": []},
{"shape_type": "polygon", "points": [], "closed": true, "vertex_flags": []},
])
# Place the generated circle explicitly, verifying its perimeter participates in bounds.
var circle: Dictionary = canvas.shapes[2]
circle["points"] = PackedVector2Array([Vector2(770.0, 200.0), Vector2(830.0, 200.0), Vector2(800.0, 230.0), Vector2(800.0, 170.0)])
canvas.shapes[2] = circle
var bounds: Rect2 = canvas.call("get_shapes_bounds") as Rect2
_check("bounds union includes polygon line and circle geometry", canvas.call("has_shape_geometry") and bounds.position == Vector2(400.0, 170.0) and bounds.end == Vector2(830.0, 300.0))
_shape_change_count = 0
canvas.shape_changed.connect(_on_shape_changed)
var selected_before: int = canvas.call("get_selected_index") as int
var did_move: bool = bool(canvas.call("translate_all_shapes", Vector2(-615.0, -235.0)))
_check("bulk translate commits once", did_move and _shape_change_count == 1)
var after: Rect2 = canvas.call("get_shapes_bounds") as Rect2
_check("bulk translation centers exact union bounds", after.get_center().is_zero_approx())
var rejected_zero: bool = not bool(canvas.call("translate_all_shapes", Vector2.ZERO))
_check("bulk translation retains selection and rejects zero delta", int(canvas.call("get_selected_index")) == selected_before and rejected_zero and _shape_change_count == 1)
canvas.call("focus_world_point", Vector2.ZERO)
_check("focus centers origin without changing zoom", (canvas.get("_pan_offset") as Vector2).is_equal_approx(canvas.size * 0.5) and is_equal_approx(float(canvas.get("_zoom")), 1.0))
canvas.queue_free()
func _on_shape_changed(_shapes: Array[Dictionary]) -> void:
_shape_change_count += 1
func _test_warning_threshold() -> void:
_check("empty geometry never warns", not EDITOR.should_warn_off_origin(false, Rect2(5000.0, 0.0, 20.0, 20.0)))
_check("strict threshold does not warn at 1024", not EDITOR.should_warn_off_origin(true, Rect2(1014.0, -10.0, 20.0, 20.0)))
_check("far small prop warns", EDITOR.should_warn_off_origin(true, Rect2(1025.0, -10.0, 20.0, 20.0)))
_check("large bounds use diagonal-scaled threshold", not EDITOR.should_warn_off_origin(true, Rect2(1500.0, -500.0, 2000.0, 1000.0)))
+1
View File
@@ -0,0 +1 @@
uid://qpqqxm4acs2g
+111
View File
@@ -0,0 +1,111 @@
extends SceneTree
const THEME := preload("res://scripts/prop_theme.gd")
const CANVAS_SCENE := preload("res://scenes/prop_editor.tscn")
const PREVIEW_SCENE := preload("res://scenes/prop_attach_preview.tscn")
const PROP_FILE := preload("res://scripts/prop_file.gd")
var _failed: int = 0
var _count: int = 0
func _init() -> void:
call_deferred("_run")
func _check(label: String, value: bool) -> void:
_count += 1
if value:
print("PASS: %s" % label)
else:
_failed += 1
push_error("FAIL: %s" % label)
func _run() -> void:
var theme := THEME.new()
_check("default inspector background is #262C39", theme.inspector_background_color() == Color("#262C39"))
var preview := PREVIEW_SCENE.instantiate() as Control
root.add_child(preview)
await process_frame
await process_frame
var hand := preview.find_child("HandOption", true, false) as OptionButton
var motion := preview.find_child("MotionOption", true, false) as OptionButton
_check("hand labels are kid-readable", hand != null and hand.get_item_text(0) == "Left Hand" and hand.get_item_text(1) == "Right Hand")
_check("motion selector has approved choices", motion != null and motion.item_count == 3 and motion.get_item_text(0) == "Front Pose" and motion.get_item_text(1) == "Walk Left" and motion.get_item_text(2) == "Walk Right")
_check("obsolete preview controls removed", preview.find_child("FacingOption", true, false) == null and preview.find_child("FrameOption", true, false) == null)
preview.call("set_preview_motion", 2)
_check("walk right enters active gait", int(preview.get("_motion")) == 2 and not bool(preview.get("_walk_paused")))
preview.call("toggle_walk_playback")
_check("walk toggle pauses gait", bool(preview.get("_walk_paused")))
preview.call("set_preview_motion", 0)
_check("front pose stops gait", int(preview.get("_motion")) == 0 and bool(preview.get("_walk_paused")))
preview.queue_free()
var editor := CANVAS_SCENE.instantiate() as Control
editor.size = Vector2(1200.0, 800.0)
root.add_child(editor)
await process_frame
var canvas := editor.get("_canvas") as PropCanvas
var attach_tools := editor.get("_attach_tools") as FlowContainer
var marker := editor.get("_inactive_anchors_check") as CheckBox
_check("attach tools are wrapping flow controls", attach_tools != null)
_check("marker toggle is meaningful wording", marker != null and marker.text == "Show Other Markers")
canvas.set_edit_mode(PropCanvas.EditMode.ATTACH)
await process_frame
var material := editor.get("_material_section") as Control
_check("material hides in Attach", material != null and not material.visible)
canvas.set_edit_mode(PropCanvas.EditMode.TRANSFORM)
_check("material returns in Arrange", material != null and material.visible)
var anchors: Dictionary = {"grip": {"role": "grip", "position": Vector2(10.0, 20.0), "rotation_deg": 12.0, "z_index_offset": 2, "allowed_hands": "both"}}
editor.set("_anchors", anchors)
canvas.set_anchor_data(anchors)
editor.call("_select_anchor", "grip")
var roles := editor.get("_anchor_role") as OptionButton
_check("editable role list omits pivot and maps safely", roles != null and roles.item_count == 5 and roles.get_item_text(0) == "grip" and roles.get_item_text(4) == "custom")
editor.call("_on_rename_anchor_pressed")
var dialog := editor.get("_rename_anchor_dialog") as ConfirmationDialog
var rename_edit := editor.get("_rename_anchor_edit") as LineEdit
rename_edit.text = "Main Handle"
dialog.get_ok_button().emit_signal("pressed")
await process_frame
var renamed: Dictionary = editor.get("_anchors")
_check("dialog confirmation renames normalized anchor", renamed.has("main_handle") and not renamed.has("grip") and str(renamed["main_handle"].get("role")) == "grip")
renamed["other"] = {"role": "custom", "position": Vector2.ZERO, "rotation_deg": 0.0, "z_index_offset": 0, "allowed_hands": "both"}
editor.set("_anchors", renamed)
canvas.set_anchor_data(renamed)
editor.call("_select_anchor", "other")
editor.call("_on_rename_anchor_pressed")
rename_edit.text = "Main Handle"
dialog.get_ok_button().emit_signal("pressed")
await process_frame
var collision_renamed: Dictionary = editor.get("_anchors")
_check("dialog confirmation uses collision suffix", collision_renamed.has("main_handle") and collision_renamed.has("main_handle_2"))
editor.call("_select_anchor", "main_handle_2")
editor.call("_on_rename_anchor_pressed")
rename_edit.text = "Entered Name"
rename_edit.text_submitted.emit(rename_edit.text)
await process_frame
_check("Enter in rename field confirms typed name", (editor.get("_anchors") as Dictionary).has("entered_name") and not (editor.get("_anchors") as Dictionary).has("main_handle_2"))
editor.call("_select_anchor", "entered_name")
editor.call("_on_rename_anchor_pressed")
rename_edit.text = "Cancelled Name"
dialog.emit_signal("canceled")
await process_frame
_check("dialog cancel leaves anchor unchanged", (editor.get("_anchors") as Dictionary).has("entered_name") and str(editor.get("_rename_anchor_source_id")).is_empty())
var main_split := editor.get("_main_layout") as HSplitContainer
var inspector := editor.get("_inspector_column") as PanelContainer
_check("inspector starts at minimum usable width", main_split != null and inspector != null and inspector.size.x >= inspector.get_combined_minimum_size().x)
var eye_data: Dictionary = PROP_FILE.load("res://props/eye.prop")
_check("eye prop fixture is available", not eye_data.is_empty())
editor.call("_apply_json_data", eye_data)
editor.size = Vector2(1200.0, 600.0)
canvas.set_edit_mode(PropCanvas.EditMode.ATTACH)
await process_frame
await process_frame
var top_bar := editor.get_node("TopBar") as Control
var status_bar := editor.get_node("StatusBar") as Control
var holding_preview := editor.get("_attach_preview") as Control
_check("Attach layout stays between name and status bars", main_split.get_global_rect().position.y >= top_bar.get_global_rect().end.y - 1.0 and holding_preview.get_global_rect().end.y <= status_bar.get_global_rect().position.y + 1.0)
var preview_container := holding_preview.get("_viewport_container") as SubViewportContainer
var preview_viewport := holding_preview.get("_viewport") as SubViewport
_check("preview viewport follows flexible pane height", preview_container != null and preview_viewport != null and preview_container.size.y > 0.0 and absf(float(preview_viewport.size.y) - preview_container.size.y) <= 1.0)
editor.queue_free()
print("RESULT: %s (%d assertions)" % ["ALL PASSED" if _failed == 0 else "%d FAILED" % _failed, _count])
quit(0 if _failed == 0 else 1)
+1
View File
@@ -0,0 +1 @@
uid://ofaxq8iw1n1c
+2 -1
View File
@@ -52,7 +52,8 @@ func _test_window_title() -> void:
func _test_mode_button_text() -> void:
check("arrange label", PROP_EDITOR.mode_button_text(0) == "🖐️ Arrange")
check("reshape label", PROP_EDITOR.mode_button_text(1) == "✏️ Reshape")
check("out-of-range clamps to last", PROP_EDITOR.mode_button_text(99) == "✏️ Reshape")
check("attach label", PROP_EDITOR.mode_button_text(2) == "📌 Attach")
check("out-of-range clamps to last", PROP_EDITOR.mode_button_text(99) == "📌 Attach")
check("negative clamps to first", PROP_EDITOR.mode_button_text(-5) == "🖐️ Arrange")
+1
View File
@@ -0,0 +1 @@
.venv/
+98
View File
@@ -0,0 +1,98 @@
import asyncio
from pathlib import Path
from notebooklm import NotebookLMClient
RELATIVE_TARGETS = [
"../../AGENTS.md",
"../../README.md",
"../../plans",
"../../docs"
]
NOTEBOOK_TITLE = "Stickman Studio"
def gather_markdown_files(base_path: Path):
"""Recursively collect all .md files from specified relative paths."""
files_to_upload = []
for rel_path in RELATIVE_TARGETS:
target = (base_path / rel_path).resolve()
if target.is_file() and target.suffix.lower() in [".md", ".markdown"]:
files_to_upload.append(target)
elif target.is_dir():
for md_file in target.rglob("*.md"):
files_to_upload.append(md_file)
else:
print(f"⚠️ Target path not found: {target}")
return list(dict.fromkeys(files_to_upload))
async def main():
script_dir = Path(__file__).parent.resolve()
async with await NotebookLMClient.from_storage() as client:
# 1. Locate 'Stickman Studio'
print(f"🔍 Searching for notebook: '{NOTEBOOK_TITLE}'...")
notebooks = await client.notebooks.list()
target_notebook = next(
(nb for nb in notebooks if getattr(nb, 'title', None) == NOTEBOOK_TITLE or getattr(nb, 'display_name', None) == NOTEBOOK_TITLE),
None
)
if not target_notebook:
print(f"❌ Could not find notebook named '{NOTEBOOK_TITLE}'.")
return
notebook_id = getattr(target_notebook, 'id', None) or getattr(target_notebook, 'name', '')
print(f"✅ Found notebook ID: {notebook_id}")
# 2. Safely filter and delete ONLY non-chat file sources
existing_sources = await client.sources.list(notebook_id=notebook_id)
sources_to_delete = []
for source in existing_sources:
# Check source properties to identify saved chats
mime_type = getattr(source, 'original_mime_type', None) or getattr(source, 'originalMimeType', '')
source_type = getattr(source, 'source_type', None) or getattr(source, 'type', '')
# SAFEGUARD: Keep any source identified as a chat note
is_chat = 'chat' in str(mime_type).lower() or 'chat' in str(source_type).lower()
if is_chat:
source_title = getattr(source, 'title', None) or getattr(source, 'display_name', 'Chat')
print(f"🛡️ Preserving past chat: '{source_title}'")
else:
sources_to_delete.append(source)
if sources_to_delete:
print(f"\n🗑️ Removing {len(sources_to_delete)} file sources (keeping chats intact)...")
for source in sources_to_delete:
source_id = getattr(source, 'id', None) or getattr(source, 'name', '')
source_title = getattr(source, 'title', None) or getattr(source, 'display_name', 'File Source')
await client.sources.delete(notebook_id=notebook_id, source_id=source_id)
print(f" - Removed: {source_title}")
else:
print("ℹ️ No old file sources to remove.")
# 3. Collect target Markdown files
md_files = gather_markdown_files(script_dir)
print(f"\n📁 Found {len(md_files)} local markdown files to attach.")
# 4. Upload Markdown files
for md_path in md_files:
rel_display = md_path.relative_to(script_dir.parent.parent)
print(f" ⬆️ Uploading: {rel_display}")
await client.sources.add_file(
notebook_id=notebook_id,
file_path=str(md_path)
)
print(f"\n🎉 Sync complete! All local docs updated without touching your chat notes.")
if __name__ == "__main__":
asyncio.run(main())