diff --git a/docs/prop_editor_user_guide.md b/docs/prop_editor_user_guide.md new file mode 100644 index 0000000..65abb93 --- /dev/null +++ b/docs/prop_editor_user_guide.md @@ -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. diff --git a/docs/tech_debt_and_optimizations.md b/docs/tech_debt_and_optimizations.md index addec26..3a16a70 100644 --- a/docs/tech_debt_and_optimizations.md +++ b/docs/tech_debt_and_optimizations.md @@ -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). | --- diff --git a/plans/PHASE_10_PROP_INTEGRATION.md b/plans/PHASE_10_PROP_INTEGRATION.md new file mode 100644 index 0000000..6b1dfdf --- /dev/null +++ b/plans/PHASE_10_PROP_INTEGRATION.md @@ -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. diff --git a/prop_theme.json b/prop_theme.json index 50b59c9..91a1010 100644 --- a/prop_theme.json +++ b/prop_theme.json @@ -41,7 +41,8 @@ "text": "#F2F2F2", "text_muted": "#9A9A9A", "border": "#FFFFFF33", - "accent": "#4FA3FF" + "accent": "#4FA3FF", + "inspector_background": "#262C39" } } } diff --git a/props/baseball.prop b/props/baseball.prop new file mode 100644 index 0000000..7762ad6 --- /dev/null +++ b/props/baseball.prop @@ -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" +} \ No newline at end of file diff --git a/props/briefcase.prop b/props/briefcase.prop new file mode 100644 index 0000000..010cf49 --- /dev/null +++ b/props/briefcase.prop @@ -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" +} \ No newline at end of file diff --git a/scenes/prop_attach_preview.tscn b/scenes/prop_attach_preview.tscn new file mode 100644 index 0000000..f648ee3 --- /dev/null +++ b/scenes/prop_attach_preview.tscn @@ -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") diff --git a/scenes/prop_editor.tscn b/scenes/prop_editor.tscn index 4354e78..59b1a55 100644 --- a/scenes/prop_editor.tscn +++ b/scenes/prop_editor.tscn @@ -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 diff --git a/scripts/prop_anchor_data.gd b/scripts/prop_anchor_data.gd new file mode 100644 index 0000000..1f954a7 --- /dev/null +++ b/scripts/prop_anchor_data.gd @@ -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 diff --git a/scripts/prop_anchor_data.gd.uid b/scripts/prop_anchor_data.gd.uid new file mode 100644 index 0000000..1c9ac74 --- /dev/null +++ b/scripts/prop_anchor_data.gd.uid @@ -0,0 +1 @@ +uid://bhlmhegchc1cu diff --git a/scripts/prop_anchor_overlay.gd b/scripts/prop_anchor_overlay.gd new file mode 100644 index 0000000..e4afbb1 --- /dev/null +++ b/scripts/prop_anchor_overlay.gd @@ -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) diff --git a/scripts/prop_anchor_overlay.gd.uid b/scripts/prop_anchor_overlay.gd.uid new file mode 100644 index 0000000..51173fc --- /dev/null +++ b/scripts/prop_anchor_overlay.gd.uid @@ -0,0 +1 @@ +uid://cj2xh11d1wk7j diff --git a/scripts/prop_attach_preview.gd b/scripts/prop_attach_preview.gd new file mode 100644 index 0000000..6d4e012 --- /dev/null +++ b/scripts/prop_attach_preview.gd @@ -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) diff --git a/scripts/prop_attach_preview.gd.uid b/scripts/prop_attach_preview.gd.uid new file mode 100644 index 0000000..7453cfb --- /dev/null +++ b/scripts/prop_attach_preview.gd.uid @@ -0,0 +1 @@ +uid://bthsk33yw5rr8 diff --git a/scripts/prop_canvas.gd b/scripts/prop_canvas.gd index c7c8599..efeb771 100644 --- a/scripts/prop_canvas.gd +++ b/scripts/prop_canvas.gd @@ -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(): diff --git a/scripts/prop_editor.gd b/scripts/prop_editor.gd index 2716ad6..b762f99 100644 --- a/scripts/prop_editor.gd +++ b/scripts/prop_editor.gd @@ -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: diff --git a/scripts/prop_file.gd b/scripts/prop_file.gd index 4933023..839486d 100644 --- a/scripts/prop_file.gd +++ b/scripts/prop_file.gd @@ -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", {})), } diff --git a/scripts/prop_theme.gd b/scripts/prop_theme.gd index 28669f8..b7911a7 100644 --- a/scripts/prop_theme.gd +++ b/scripts/prop_theme.gd @@ -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() diff --git a/specs/phase_9a_prop_attach.md b/specs/phase_9a_prop_attach.md new file mode 100644 index 0000000..487119b --- /dev/null +++ b/specs/phase_9a_prop_attach.md @@ -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. diff --git a/specs/phase_9b_prop_centering.md b/specs/phase_9b_prop_centering.md new file mode 100644 index 0000000..e0da753 --- /dev/null +++ b/specs/phase_9b_prop_centering.md @@ -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. diff --git a/specs/phase_9c_prop_editor_ux.md b/specs/phase_9c_prop_editor_ux.md new file mode 100644 index 0000000..a7ab68f --- /dev/null +++ b/specs/phase_9c_prop_editor_ux.md @@ -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. diff --git a/tests/test_phase9a_prop_attach.gd b/tests/test_phase9a_prop_attach.gd new file mode 100644 index 0000000..9c62da3 --- /dev/null +++ b/tests/test_phase9a_prop_attach.gd @@ -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)) diff --git a/tests/test_phase9a_prop_attach.gd.uid b/tests/test_phase9a_prop_attach.gd.uid new file mode 100644 index 0000000..31186a9 --- /dev/null +++ b/tests/test_phase9a_prop_attach.gd.uid @@ -0,0 +1 @@ +uid://c4e5j6k0jm0hw diff --git a/tests/test_phase9b_prop_centering.gd b/tests/test_phase9b_prop_centering.gd new file mode 100644 index 0000000..793f0d4 --- /dev/null +++ b/tests/test_phase9b_prop_centering.gd @@ -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))) diff --git a/tests/test_phase9b_prop_centering.gd.uid b/tests/test_phase9b_prop_centering.gd.uid new file mode 100644 index 0000000..69deb51 --- /dev/null +++ b/tests/test_phase9b_prop_centering.gd.uid @@ -0,0 +1 @@ +uid://qpqqxm4acs2g diff --git a/tests/test_phase9c_prop_editor_ux.gd b/tests/test_phase9c_prop_editor_ux.gd new file mode 100644 index 0000000..0c342c0 --- /dev/null +++ b/tests/test_phase9c_prop_editor_ux.gd @@ -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) diff --git a/tests/test_phase9c_prop_editor_ux.gd.uid b/tests/test_phase9c_prop_editor_ux.gd.uid new file mode 100644 index 0000000..957ba90 --- /dev/null +++ b/tests/test_phase9c_prop_editor_ux.gd.uid @@ -0,0 +1 @@ +uid://ofaxq8iw1n1c diff --git a/tests/test_prop_editor_round3.gd b/tests/test_prop_editor_round3.gd index 594e883..4c7ae41 100644 --- a/tests/test_prop_editor_round3.gd +++ b/tests/test_prop_editor_round3.gd @@ -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") diff --git a/tools/.gitignore b/tools/.gitignore new file mode 100644 index 0000000..21d0b89 --- /dev/null +++ b/tools/.gitignore @@ -0,0 +1 @@ +.venv/ diff --git a/tools/sync_doc/sync_sources.py b/tools/sync_doc/sync_sources.py new file mode 100644 index 0000000..80831e3 --- /dev/null +++ b/tools/sync_doc/sync_sources.py @@ -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()) \ No newline at end of file