Add regression tests for prop attachment, centering, and editor UX
- Implemented test_phase9a_prop_attach.gd to validate anchor schema, alignment math, and preview isolation. - Implemented test_phase9b_prop_centering.gd to check canonical origin presentation, bounds, centering, and save threshold. - Implemented test_phase9c_prop_editor_ux.gd to assess the user experience in the prop editor, including anchor renaming and UI responsiveness. - Added corresponding .uid files for each test. - Created sync_sources.py script to manage markdown file synchronization with the notebook. - Added .gitignore for virtual environment files.
This commit is contained in:
@@ -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.
|
||||||
@@ -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) |
|
| 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) |
|
| 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) |
|
| 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-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-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-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). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Architectural Plan: Dynamic `.prop` Loading & Sandbox Integration
|
||||||
|
|
||||||
|
This document outlines the architectural plan to transition `PropLibrary` from a static registry into a dynamic filesystem-backed library (matching `StickmanLibrary`) and integrate user-created `.prop` files into the Sandbox Stage.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. System Architecture Overview
|
||||||
|
|
||||||
|
The system bridges custom prop files produced by the Prop Editor into the sandbox physics simulation through four primary layers:
|
||||||
|
|
||||||
|
1. **Library & File Scanning (`PropLibrary`):** Scans `res://props/` for `*.prop` files, deserializes them via `PropFile`, flattens multi-shape hierarchies into collision payloads, and merges them with built-in templates.
|
||||||
|
|
||||||
|
2. **Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`):** Computes cache keys using file modification timestamps (`mtime`), renders visual previews offscreen, and streams textures to the selector grid.
|
||||||
|
|
||||||
|
3. **UI Integration (`AssetSelector` & `SandboxStage`):** Presents custom props alongside built-in presets in a unified grid, offering folder scanning, ad-hoc file browsing, and palette live-refresh.
|
||||||
|
|
||||||
|
4. **World Spawning & Physics Lifecycle (`StageSpawner` & `PropBlock`):** Instantiates `RigidBody2D` nodes configured with custom collision geometry and material physical properties (mass, friction, bounce), tracking authored positions across sandbox mode transitions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Component Specifications
|
||||||
|
|
||||||
|
### A. Library Layer (`res://scripts/prop_library.gd`)
|
||||||
|
|
||||||
|
- **Dual-Source Registry:** Maintain built-in templates (Crate, Ball, Plank, Triangle) as default presets while scanning `res://props/*.prop` on demand.
|
||||||
|
|
||||||
|
- **Entry Model Schema:** Standardize entry dictionaries returned to the UI:
|
||||||
|
- **Identifier (`id`):** Resource path (`res://props/chair.prop`) for custom files, or string key (`crate`) for templates.
|
||||||
|
|
||||||
|
- **Display Name (`name`):** Extracted from `.prop` metadata, with fallback to capitalized file basename.
|
||||||
|
|
||||||
|
- **Material Preset & Label:** Physical properties index (Wood, Metal, Rubber, Cardboard) and human-readable label.
|
||||||
|
|
||||||
|
- **Payload (`payload`):** Flattened shape and collision dictionary generated via `PropFile.flatten_to_payload()`.
|
||||||
|
|
||||||
|
- **Provenance Flags:** Boolean flag indicating whether the entry is custom and string path reference for scene serialization.
|
||||||
|
|
||||||
|
- **Error Handling:** Skip invalid or corrupted `.prop` files gracefully while outputting descriptive warnings.
|
||||||
|
|
||||||
|
### B. Thumbnail Pipeline (`ThumbnailCache` & `PropThumbnail`)
|
||||||
|
|
||||||
|
- **Dynamic Keying Strategy:**
|
||||||
|
- Built-in prop templates compute keys using internal version constants.
|
||||||
|
|
||||||
|
- Custom `.prop` files compute keys combining file basename and filesystem modification timestamp (`FileAccess.get_modified_time`).
|
||||||
|
|
||||||
|
- **Renderer Extension (`PropThumbnail`):** Extend offscreen rendering to support composite shapes and custom vector styling (fill color, outline width, polygons, circles) rather than single primitive shapes.
|
||||||
|
|
||||||
|
- **Stale Cache Cleanup:** Automatically purge superseded PNG thumbnails when custom `.prop` files are edited or deleted.
|
||||||
|
|
||||||
|
### C. World Spawning Pipeline (`StageSpawner`)
|
||||||
|
|
||||||
|
- **Payload Forwarding:** Pass the entry payload directly to `PropUtils.spawn_prop()`, which generates the corresponding `PropBlock` node with `CollisionPolygon2D` or `CollisionShape2D` nodes.
|
||||||
|
|
||||||
|
- **Provenance Tagging:** Store `spawn_id` and source path metadata on spawned instances to support scene serialization and reloading.
|
||||||
|
|
||||||
|
- **Authored Pose Capture:** Save placement positions to instance metadata (`_authored_position`, `_authored_rotation`) during **Edit** mode.
|
||||||
|
|
||||||
|
### D. UI & Sandbox Integration (`AssetSelector` & `SandboxStage`)
|
||||||
|
|
||||||
|
- **Grid Population:** Trigger `PropLibrary.scan()` when opening the prop selector, populating grid cells with thumbnails and display names.
|
||||||
|
|
||||||
|
- **Browse & Refresh Integration:** Connect the "Browse…" button to a file dialog for arbitrary `*.prop` selection, and the "Refresh" button to re-scan `res://props/`.
|
||||||
|
|
||||||
|
- **Lazy Thumbnail Processing:** Process offscreen thumbnail generation non-blockingly during stage frame processing to keep UI interactions responsive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Physics & Sandbox Lifecycle Integration
|
||||||
|
|
||||||
|
Custom `.prop` instances must follow the existing 3-mode sandbox state machine:
|
||||||
|
|
||||||
|
| Stage Mode | Prop Physics State | Interaction Capabilities | Reset Behavior |
|
||||||
|
| ---------- | -------------------------------------------------- | ------------------------ | -------------- |
|
||||||
|
| **Edit** | RigidBody frozen (`freeze = true`, kinematic mode) |
|
||||||
|
|
||||||
|
| Move, rotate, scale, or delete via selection gizmos
|
||||||
|
|
||||||
|
| N/A (Authored state recorded on movement)
|
||||||
|
|
||||||
|
|
|
||||||
|
| **Direct** | RigidBody frozen (`freeze = true`, kinematic mode)
|
||||||
|
|
||||||
|
| Gizmos active for rule/trigger assignment; spawner palette hidden
|
||||||
|
|
||||||
|
| N/A
|
||||||
|
|
||||||
|
|
|
||||||
|
| **Play** | RigidBody unfrozen (`freeze = false`)
|
||||||
|
|
||||||
|
| Full physical simulation, collisions with terrain/stickmen/props
|
||||||
|
|
||||||
|
| Mode exit restores initial authored position/rotation and zeroes linear/angular velocity
|
||||||
|
|
||||||
|
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation Roadmap
|
||||||
|
|
||||||
|
```
|
||||||
|
[ Phase 1: Library & Data Layer ]
|
||||||
|
└─ Extend PropLibrary (res://props/*.prop scanning + PropFile parsing)
|
||||||
|
└─ Define unified Prop Entry Dictionary schema
|
||||||
|
|
||||||
|
[ Phase 2: Thumbnail Pipeline ]
|
||||||
|
└─ Update PropThumbnail to render multi-shape custom payloads
|
||||||
|
└─ Implement modification timestamp cache invalidation in ThumbnailCache
|
||||||
|
|
||||||
|
[ Phase 3: Spawning & UI Integration ]
|
||||||
|
└─ Update StageSpawner to tag custom prop provenance
|
||||||
|
└─ Wire AssetSelector grid refresh and file dialog browsing
|
||||||
|
|
||||||
|
[ Phase 4: Stage Lifecycle & Verification ]
|
||||||
|
└─ Verify Edit -> Play -> Edit position resets for custom props
|
||||||
|
└─ Validate scene saving/loading persistence
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Milestone Checklist for Architect Agent
|
||||||
|
|
||||||
|
1. **Directory Scanning:** `PropLibrary` reads `res://props/` and extracts payloads via `PropFile`.
|
||||||
|
|
||||||
|
2. **Visual Previews:** `PropThumbnail` renders multi-polygon custom vector props to textures.
|
||||||
|
|
||||||
|
3. **Palette Display:** `AssetSelector` displays merged built-in templates and `.prop` files.
|
||||||
|
|
||||||
|
4. **Physics Interaction:** Custom props unfreeze during **Play** mode, participating in physics simulation and collision events.
|
||||||
|
|
||||||
|
5. **State Reset:** Props return to their original authored transforms when switching from **Play** back to **Edit** mode.
|
||||||
+2
-1
@@ -41,7 +41,8 @@
|
|||||||
"text": "#F2F2F2",
|
"text": "#F2F2F2",
|
||||||
"text_muted": "#9A9A9A",
|
"text_muted": "#9A9A9A",
|
||||||
"border": "#FFFFFF33",
|
"border": "#FFFFFF33",
|
||||||
"accent": "#4FA3FF"
|
"accent": "#4FA3FF",
|
||||||
|
"inspector_background": "#262C39"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[gd_scene load_steps=2 format=3]
|
||||||
|
|
||||||
|
[ext_resource type="Script" path="res://scripts/prop_attach_preview.gd" id="1"]
|
||||||
|
|
||||||
|
[node name="PropAttachPreview" type="PanelContainer"]
|
||||||
|
custom_minimum_size = Vector2(360, 0)
|
||||||
|
size_flags_horizontal = 3
|
||||||
|
size_flags_vertical = 3
|
||||||
|
script = ExtResource("1")
|
||||||
+27
-8
@@ -28,7 +28,7 @@ script = ExtResource("1_editor_script")
|
|||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 0
|
anchors_preset = 0
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
offset_bottom = 32.0
|
offset_bottom = 44.0
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
|
|
||||||
[node name="MenuBar" type="MenuBar" parent="TopRow"]
|
[node name="MenuBar" type="MenuBar" parent="TopRow"]
|
||||||
@@ -45,8 +45,8 @@ theme_override_constants/separation = 8
|
|||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 0
|
anchors_preset = 0
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
offset_top = 32.0
|
offset_top = 44.0
|
||||||
offset_bottom = 64.0
|
offset_bottom = 88.0
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
|
|
||||||
[node name="NameLabel" type="Label" parent="TopBar"]
|
[node name="NameLabel" type="Label" parent="TopBar"]
|
||||||
@@ -61,12 +61,13 @@ layout_mode = 2
|
|||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
placeholder_text = "Unnamed Prop"
|
placeholder_text = "Unnamed Prop"
|
||||||
|
|
||||||
[node name="MainLayout" type="HBoxContainer" parent="."]
|
[node name="MainLayout" type="HSplitContainer" parent="."]
|
||||||
|
unique_name_in_owner = true
|
||||||
layout_mode = 1
|
layout_mode = 1
|
||||||
anchors_preset = 15
|
anchors_preset = 15
|
||||||
anchor_right = 1.0
|
anchor_right = 1.0
|
||||||
anchor_bottom = 1.0
|
anchor_bottom = 1.0
|
||||||
offset_top = 68.0
|
offset_top = 92.0
|
||||||
offset_bottom = -28.0
|
offset_bottom = -28.0
|
||||||
grow_horizontal = 2
|
grow_horizontal = 2
|
||||||
grow_vertical = 2
|
grow_vertical = 2
|
||||||
@@ -78,6 +79,7 @@ layout_mode = 2
|
|||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
size_flags_vertical = 3
|
size_flags_vertical = 3
|
||||||
size_flags_stretch_ratio = 2.0
|
size_flags_stretch_ratio = 2.0
|
||||||
|
custom_minimum_size = Vector2(260, 0)
|
||||||
theme_override_styles/panel = SubResource("StyleBox_canvas")
|
theme_override_styles/panel = SubResource("StyleBox_canvas")
|
||||||
|
|
||||||
[node name="CanvasArea" type="VBoxContainer" parent="MainLayout/CanvasPanel"]
|
[node name="CanvasArea" type="VBoxContainer" parent="MainLayout/CanvasPanel"]
|
||||||
@@ -89,7 +91,19 @@ unique_name_in_owner = true
|
|||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
theme_override_constants/separation = 8
|
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
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_horizontal = 3
|
size_flags_horizontal = 3
|
||||||
@@ -98,13 +112,18 @@ mouse_filter = 0
|
|||||||
clip_contents = true
|
clip_contents = true
|
||||||
script = ExtResource("2_canvas_script")
|
script = ExtResource("2_canvas_script")
|
||||||
|
|
||||||
[node name="InspectorColumn" type="VBoxContainer" parent="MainLayout"]
|
[node name="InspectorColumn" type="PanelContainer" parent="MainLayout"]
|
||||||
unique_name_in_owner = true
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_vertical = 3
|
size_flags_vertical = 3
|
||||||
custom_minimum_size = Vector2(300, 0)
|
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
|
unique_name_in_owner = true
|
||||||
layout_mode = 2
|
layout_mode = 2
|
||||||
size_flags_vertical = 3
|
size_flags_vertical = 3
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bhlmhegchc1cu
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://cj2xh11d1wk7j
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bthsk33yw5rr8
|
||||||
+104
-11
@@ -28,6 +28,8 @@ signal vertex_created(shape_idx: int, vertex_idx: int)
|
|||||||
|
|
||||||
## Emitted only when the edit mode actually changes.
|
## Emitted only when the edit mode actually changes.
|
||||||
signal mode_changed(mode: int)
|
signal mode_changed(mode: int)
|
||||||
|
signal anchors_changed(anchors: Dictionary)
|
||||||
|
signal anchor_selected(anchor_id: String)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Enums / constants
|
# Enums / constants
|
||||||
@@ -41,7 +43,7 @@ enum DragMode { NONE, VERTEX, TRANSLATE, ROTATE, SCALE }
|
|||||||
## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points"
|
## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points"
|
||||||
## (vertex handles + Create/Remove Point). Gates input and drawing only — the
|
## (vertex handles + Create/Remove Point). Gates input and drawing only — the
|
||||||
## public math APIs stay mode-agnostic.
|
## public math APIs stay mode-agnostic.
|
||||||
enum EditMode { TRANSFORM, POINTS }
|
enum EditMode { TRANSFORM, POINTS, ATTACH }
|
||||||
|
|
||||||
const HANDLE_RADIUS: float = 3.0
|
const HANDLE_RADIUS: float = 3.0
|
||||||
const HANDLE_RADIUS_SELECTION: float = 8.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 MAX_ZOOM: float = 3.0
|
||||||
const ZOOM_STEP: float = 1.10
|
const ZOOM_STEP: float = 1.10
|
||||||
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
|
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 TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
|
||||||
const PropFileScript := preload("res://scripts/prop_file.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_CORE_COLOR := Color.WHITE
|
||||||
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
|
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 _has_clipboard: bool = false
|
||||||
var _context_menu: PopupMenu
|
var _context_menu: PopupMenu
|
||||||
var _edit_mode: EditMode = EditMode.TRANSFORM
|
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
|
# Drag state
|
||||||
var _drag_mode: DragMode = DragMode.NONE
|
var _drag_mode: DragMode = DragMode.NONE
|
||||||
@@ -142,6 +149,9 @@ func _ready() -> void:
|
|||||||
draw.connect(_on_canvas_draw)
|
draw.connect(_on_canvas_draw)
|
||||||
if not gui_input.is_connected(_on_canvas_gui_input):
|
if not gui_input.is_connected(_on_canvas_gui_input):
|
||||||
gui_input.connect(_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 = PopupMenu.new()
|
||||||
_context_menu.name = "ContextMenu"
|
_context_menu.name = "ContextMenu"
|
||||||
add_child(_context_menu)
|
add_child(_context_menu)
|
||||||
@@ -162,7 +172,7 @@ func _process(delta: float) -> void:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func set_edit_mode(mode: int) -> 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:
|
if m == _edit_mode:
|
||||||
return
|
return
|
||||||
_edit_mode = m
|
_edit_mode = m
|
||||||
@@ -175,6 +185,62 @@ func set_edit_mode(mode: int) -> void:
|
|||||||
func get_edit_mode() -> int:
|
func get_edit_mode() -> int:
|
||||||
return _edit_mode
|
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
|
# Public API — shape data
|
||||||
@@ -193,8 +259,7 @@ func set_shape_data(data: Variant) -> void:
|
|||||||
_selected_shape_idx = -1
|
_selected_shape_idx = -1
|
||||||
_drag_mode = DragMode.NONE
|
_drag_mode = DragMode.NONE
|
||||||
_dragging_vertex = -1
|
_dragging_vertex = -1
|
||||||
_zoom = 1.0
|
reset_view()
|
||||||
_pan_offset = Vector2.ZERO
|
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
|
|
||||||
|
|
||||||
@@ -212,9 +277,7 @@ func clear() -> void:
|
|||||||
_selected_shape_idx = -1
|
_selected_shape_idx = -1
|
||||||
_drag_mode = DragMode.NONE
|
_drag_mode = DragMode.NONE
|
||||||
_dragging_vertex = -1
|
_dragging_vertex = -1
|
||||||
_zoom = 1.0
|
reset_view()
|
||||||
_pan_offset = Vector2.ZERO
|
|
||||||
queue_redraw()
|
|
||||||
|
|
||||||
|
|
||||||
func select(idx: int) -> void:
|
func select(idx: int) -> void:
|
||||||
@@ -566,8 +629,7 @@ func get_snap_enabled() -> bool:
|
|||||||
|
|
||||||
func reset_view() -> void:
|
func reset_view() -> void:
|
||||||
_zoom = 1.0
|
_zoom = 1.0
|
||||||
_pan_offset = Vector2.ZERO
|
focus_world_point(Vector2.ZERO)
|
||||||
queue_redraw()
|
|
||||||
|
|
||||||
|
|
||||||
func set_has_clipboard(state: bool) -> void:
|
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_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
|
||||||
_draw_grid()
|
_draw_grid()
|
||||||
|
|
||||||
if shapes.is_empty():
|
if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM:
|
||||||
var rect := get_rect()
|
var rect := get_rect()
|
||||||
var hint_pos := _screen_to_world(Vector2(rect.size.x * 0.5 - 90.0, rect.size.y * 0.5))
|
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()
|
var font := get_theme_default_font()
|
||||||
if font != null:
|
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))
|
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():
|
for i: int in shapes.size():
|
||||||
_draw_shape(shapes[i], i == _selected_shape_idx, _dragging_vertex if i == _selected_shape_idx else -1)
|
_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():
|
if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
|
||||||
_draw_selection_gizmos(shapes[_selected_shape_idx])
|
_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:
|
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
|
return
|
||||||
|
|
||||||
var world_pos := _screen_to_world(mb.position)
|
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:
|
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
|
||||||
_handle_right_click(mb, world_pos)
|
_handle_right_click(mb, world_pos)
|
||||||
return
|
return
|
||||||
@@ -753,6 +836,9 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
|
|||||||
_pan_start = mm.position
|
_pan_start = mm.position
|
||||||
queue_redraw()
|
queue_redraw()
|
||||||
return
|
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)
|
_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)
|
bbox = bbox.expand(pt)
|
||||||
return bbox
|
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:
|
func _hit_test_vertex(shape_idx: int, world_pos: Vector2) -> int:
|
||||||
if shape_idx < 0 or shape_idx >= shapes.size():
|
if shape_idx < 0 or shape_idx >= shapes.size():
|
||||||
|
|||||||
+420
-32
@@ -16,20 +16,25 @@ const PropBlockScript := preload("res://scripts/prop_block.gd")
|
|||||||
const PropCanvasScript := preload("res://scripts/prop_canvas.gd")
|
const PropCanvasScript := preload("res://scripts/prop_canvas.gd")
|
||||||
const PropThumbnailScript := preload("res://scripts/thumbnails/prop_thumbnail.gd")
|
const PropThumbnailScript := preload("res://scripts/thumbnails/prop_thumbnail.gd")
|
||||||
const ThumbnailCacheScript := preload("res://scripts/thumbnails/thumbnail_cache.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 PRESET_LABELS: PackedStringArray = ["None", "Wood", "Rubber", "Cardboard", "Metal"]
|
||||||
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
|
const PRIMITIVE_IDS: PackedStringArray = ["box", "plank", "triangle", "line", "circle"]
|
||||||
const PRIMITIVE_LABELS: 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.
|
# Kid-facing mode labels/emoji (Round 3); index == PropCanvas.EditMode.
|
||||||
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"]
|
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape", "Attach"]
|
||||||
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️"]
|
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️", "📌"]
|
||||||
|
|
||||||
const UNTITLED_NAME: String = "Untitled"
|
const UNTITLED_NAME: String = "Untitled"
|
||||||
const AUTOSAVE_PATH: String = "user://prop_editor_autosave.prop"
|
const AUTOSAVE_PATH: String = "user://prop_editor_autosave.prop"
|
||||||
const AUTOSAVE_DEBOUNCE: float = 1.0
|
const AUTOSAVE_DEBOUNCE: float = 1.0
|
||||||
const SETTINGS_PATH: String = "user://prop_editor_settings.json"
|
const SETTINGS_PATH: String = "user://prop_editor_settings.json"
|
||||||
const RECENT_MAX: int = 5
|
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_NEW := 0
|
||||||
const MENU_LOAD := 2
|
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)
|
result.resize(max_count)
|
||||||
return result
|
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
|
# 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 _prop_name_edit: LineEdit = %PropNameEdit
|
||||||
@onready var _canvas: PropCanvas = %PropCanvas
|
@onready var _canvas: PropCanvas = %PropCanvas
|
||||||
@onready var _mode_bar: HBoxContainer = %ModeBar
|
@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 _inspector_list: VBoxContainer = %InspectorList
|
||||||
@onready var _save_dialog: FileDialog = %SaveDialog
|
@onready var _save_dialog: FileDialog = %SaveDialog
|
||||||
@onready var _load_dialog: FileDialog = %LoadDialog
|
@onready var _load_dialog: FileDialog = %LoadDialog
|
||||||
@@ -92,12 +107,15 @@ var _material_preset: int = PropBlockScript.MaterialPreset.NONE
|
|||||||
var _current_path: String = ""
|
var _current_path: String = ""
|
||||||
var _dirty: bool = false
|
var _dirty: bool = false
|
||||||
var _clipboard: Dictionary = {}
|
var _clipboard: Dictionary = {}
|
||||||
|
var _anchors: Dictionary = PropAnchorDataScript.default_anchors()
|
||||||
|
var _selected_anchor_id: String = ""
|
||||||
var _thumb: PropThumbnail
|
var _thumb: PropThumbnail
|
||||||
var _thumb_cache: ThumbnailCache
|
var _thumb_cache: ThumbnailCache
|
||||||
var _ui_theme: Theme
|
var _ui_theme: Theme
|
||||||
|
|
||||||
# Inspector controls
|
# Inspector controls
|
||||||
var _preset_option: OptionButton
|
var _preset_option: OptionButton
|
||||||
|
var _material_section: VBoxContainer
|
||||||
var _mass_label: Label
|
var _mass_label: Label
|
||||||
var _friction_label: Label
|
var _friction_label: Label
|
||||||
var _bounce_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)
|
# Grid settings dialog (removed Round 4 - replaced by top-right controls)
|
||||||
var _grid_spin: SpinBox
|
var _grid_spin: SpinBox
|
||||||
var _snap_check: CheckBox
|
var _snap_check: CheckBox
|
||||||
|
var _center_prop_button: Button
|
||||||
|
|
||||||
# Edit-mode toolbar (Round 2/3)
|
# Edit-mode toolbar (Round 2/3)
|
||||||
var _mode_group: ButtonGroup
|
var _mode_group: ButtonGroup
|
||||||
var _move_button: Button
|
var _move_button: Button
|
||||||
var _points_button: Button
|
var _points_button: Button
|
||||||
|
var _attach_button: Button
|
||||||
|
|
||||||
# Add-shape inspector section (hidden in Reshape mode, Round 3)
|
# Add-shape inspector section (hidden in Reshape mode, Round 3)
|
||||||
var _add_section: VBoxContainer
|
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)
|
# Exit/new dialog + autosave (Round 3/4)
|
||||||
var _exit_dialog: ConfirmationDialog
|
var _exit_dialog: ConfirmationDialog
|
||||||
|
var _off_origin_save_dialog: ConfirmationDialog
|
||||||
var _pending_quit: bool = false
|
var _pending_quit: bool = false
|
||||||
var _pending_new: 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
|
var _autosave_remaining: float = 0.0
|
||||||
|
|
||||||
# Recent props (Round 5)
|
# Recent props (Round 5)
|
||||||
@@ -156,6 +197,12 @@ func _ready() -> void:
|
|||||||
_build_mode_toolbar()
|
_build_mode_toolbar()
|
||||||
_build_top_right_controls()
|
_build_top_right_controls()
|
||||||
_build_inspector()
|
_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()
|
_setup_dialogs()
|
||||||
|
|
||||||
_canvas.shape_changed.connect(_on_canvas_shape_changed)
|
_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.shape_copy_requested.connect(_on_canvas_copy_requested)
|
||||||
_canvas.paste_from_context_requested.connect(_on_canvas_paste_requested)
|
_canvas.paste_from_context_requested.connect(_on_canvas_paste_requested)
|
||||||
_canvas.mode_changed.connect(_on_canvas_mode_changed)
|
_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_grid_size(_theme.grid_size())
|
||||||
_canvas.set_snap_enabled(false)
|
_canvas.set_snap_enabled(false)
|
||||||
_canvas.set_has_clipboard(false)
|
_canvas.set_has_clipboard(false)
|
||||||
_canvas.set_context_menu_font_size(_theme.font_context_menu())
|
_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.text_changed.connect(_on_name_changed)
|
||||||
_prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name())
|
_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())
|
_sync_mode_controls(_canvas.get_edit_mode())
|
||||||
_update_save_status()
|
_update_save_status()
|
||||||
_update_cursor_coords()
|
_update_cursor_coords()
|
||||||
|
_canvas.call_deferred("reset_view")
|
||||||
|
call_deferred("_initialize_inspector_split")
|
||||||
|
_refresh_center_prop_button()
|
||||||
_check_autosave_recovery()
|
_check_autosave_recovery()
|
||||||
|
|
||||||
|
|
||||||
@@ -233,10 +289,13 @@ func _build_mode_toolbar() -> void:
|
|||||||
_mode_group = ButtonGroup.new()
|
_mode_group = ButtonGroup.new()
|
||||||
_move_button = _make_mode_button(PropCanvasScript.EditMode.TRANSFORM)
|
_move_button = _make_mode_button(PropCanvasScript.EditMode.TRANSFORM)
|
||||||
_points_button = _make_mode_button(PropCanvasScript.EditMode.POINTS)
|
_points_button = _make_mode_button(PropCanvasScript.EditMode.POINTS)
|
||||||
|
_attach_button = _make_mode_button(PropCanvasScript.EditMode.ATTACH)
|
||||||
_move_button.pressed.connect(_on_move_mode_pressed)
|
_move_button.pressed.connect(_on_move_mode_pressed)
|
||||||
_points_button.pressed.connect(_on_points_mode_pressed)
|
_points_button.pressed.connect(_on_points_mode_pressed)
|
||||||
|
_attach_button.pressed.connect(_on_attach_mode_pressed)
|
||||||
_move_button.theme = _ui_theme
|
_move_button.theme = _ui_theme
|
||||||
_points_button.theme = _ui_theme
|
_points_button.theme = _ui_theme
|
||||||
|
_attach_button.theme = _ui_theme
|
||||||
|
|
||||||
|
|
||||||
func _make_mode_button(mode: int) -> Button:
|
func _make_mode_button(mode: int) -> Button:
|
||||||
@@ -261,27 +320,62 @@ func _on_move_mode_pressed() -> void:
|
|||||||
func _on_points_mode_pressed() -> void:
|
func _on_points_mode_pressed() -> void:
|
||||||
_canvas.set_edit_mode(PropCanvasScript.EditMode.POINTS)
|
_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:
|
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_buttons(mode)
|
||||||
_sync_mode_controls(mode)
|
_sync_mode_controls(mode)
|
||||||
|
|
||||||
|
|
||||||
func _sync_mode_buttons(mode: int) -> void:
|
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
|
return
|
||||||
_move_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.TRANSFORM)
|
_move_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.TRANSFORM)
|
||||||
_points_button.set_pressed_no_signal(mode == PropCanvasScript.EditMode.POINTS)
|
_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()
|
var accent: Color = _theme.accent_color()
|
||||||
_move_button.add_theme_color_override("font_pressed_color", accent)
|
_move_button.add_theme_color_override("font_pressed_color", accent)
|
||||||
_points_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:
|
func _sync_mode_controls(mode: int) -> void:
|
||||||
|
if _attach_tools != null:
|
||||||
|
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH
|
||||||
if _add_section != null:
|
if _add_section != null:
|
||||||
_add_section.visible = mode != PropCanvasScript.EditMode.POINTS
|
_add_section.visible = mode == PropCanvasScript.EditMode.TRANSFORM
|
||||||
if _delete_button != null:
|
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("hover", "Button", panel_dark)
|
||||||
_ui_theme.set_stylebox("pressed", "Button", panel_pressed)
|
_ui_theme.set_stylebox("pressed", "Button", panel_pressed)
|
||||||
_ui_theme.set_stylebox("focus", "Button", panel)
|
_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("normal", "OptionButton", panel)
|
||||||
_ui_theme.set_stylebox("hover", "OptionButton", panel_dark)
|
_ui_theme.set_stylebox("hover", "OptionButton", panel_dark)
|
||||||
_ui_theme.set_stylebox("pressed", "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)
|
_save_dialog.file_selected.connect(_on_save_file_selected)
|
||||||
_load_dialog.file_selected.connect(_on_load_file_selected)
|
_load_dialog.file_selected.connect(_on_load_file_selected)
|
||||||
_build_exit_dialog()
|
_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:
|
func _build_top_right_controls() -> void:
|
||||||
@@ -470,13 +605,22 @@ func _build_top_right_controls() -> void:
|
|||||||
_grid_spin.max_value = 512
|
_grid_spin.max_value = 512
|
||||||
_grid_spin.value = _theme.grid_size()
|
_grid_spin.value = _theme.grid_size()
|
||||||
_grid_spin.custom_minimum_size.x = 90.0
|
_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)
|
_grid_spin.value_changed.connect(_on_top_grid_changed)
|
||||||
_top_right.add_child(_grid_spin)
|
_top_right.add_child(_grid_spin)
|
||||||
_snap_check = CheckBox.new()
|
_snap_check = CheckBox.new()
|
||||||
_snap_check.text = "Snap"
|
_snap_check.text = "Snap"
|
||||||
_snap_check.button_pressed = false
|
_snap_check.button_pressed = false
|
||||||
|
_snap_check.custom_minimum_size.y = float(_theme.button_min_height())
|
||||||
_snap_check.toggled.connect(_on_top_snap_toggled)
|
_snap_check.toggled.connect(_on_top_snap_toggled)
|
||||||
_top_right.add_child(_snap_check)
|
_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:
|
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.confirmed.connect(_on_exit_save)
|
||||||
_exit_dialog.custom_action.connect(_on_exit_custom_action)
|
_exit_dialog.custom_action.connect(_on_exit_custom_action)
|
||||||
_exit_dialog.canceled.connect(_on_exit_cancel)
|
_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:
|
func _notification(what: int) -> void:
|
||||||
@@ -531,19 +687,7 @@ func _on_exit_save() -> void:
|
|||||||
if _current_path.is_empty():
|
if _current_path.is_empty():
|
||||||
_save_dialog.popup_centered()
|
_save_dialog.popup_centered()
|
||||||
return
|
return
|
||||||
await _save_and_finish(_current_path)
|
_begin_save_request(_current_path, false)
|
||||||
|
|
||||||
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
func _on_exit_custom_action(action: StringName) -> void:
|
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:
|
func _on_exit_cancel() -> void:
|
||||||
_pending_quit = false
|
_pending_quit = false
|
||||||
_pending_new = false
|
_pending_new = false
|
||||||
|
_cancel_pending_save_request()
|
||||||
|
|
||||||
|
|
||||||
func _do_new() -> void:
|
func _do_new() -> void:
|
||||||
|
_clear_pending_anchor_rename()
|
||||||
_prop_name_edit.text = ""
|
_prop_name_edit.text = ""
|
||||||
_material_preset = PropBlockScript.MaterialPreset.NONE
|
_material_preset = PropBlockScript.MaterialPreset.NONE
|
||||||
_preset_option.selected = _material_preset
|
_preset_option.selected = _material_preset
|
||||||
_update_physics_labels()
|
_update_physics_labels()
|
||||||
_canvas.clear()
|
_canvas.clear()
|
||||||
|
_anchors = PropAnchorDataScript.default_anchors()
|
||||||
|
_selected_anchor_id = ""
|
||||||
|
_canvas.set_anchor_data(_anchors)
|
||||||
_clipboard = {}
|
_clipboard = {}
|
||||||
_canvas.set_has_clipboard(false)
|
_canvas.set_has_clipboard(false)
|
||||||
_current_path = ""
|
_current_path = ""
|
||||||
_dirty = false
|
_dirty = false
|
||||||
_clear_autosave()
|
_clear_autosave()
|
||||||
_refresh_inspector_from_selection()
|
_refresh_inspector_from_selection()
|
||||||
|
_refresh_anchor_inspector()
|
||||||
|
_refresh_center_prop_button()
|
||||||
_update_save_status()
|
_update_save_status()
|
||||||
|
|
||||||
|
|
||||||
@@ -588,7 +739,7 @@ func _on_menu_id_pressed(id: int) -> void:
|
|||||||
if _current_path.is_empty():
|
if _current_path.is_empty():
|
||||||
_save_dialog.popup_centered()
|
_save_dialog.popup_centered()
|
||||||
else:
|
else:
|
||||||
await _save_to(_current_path)
|
_begin_save_request(_current_path, true)
|
||||||
MENU_SAVE_AS:
|
MENU_SAVE_AS:
|
||||||
_save_dialog.popup_centered()
|
_save_dialog.popup_centered()
|
||||||
MENU_RESET_VIEWS:
|
MENU_RESET_VIEWS:
|
||||||
@@ -604,10 +755,13 @@ func _build_inspector() -> void:
|
|||||||
var row_height: int = _theme.row_min_height()
|
var row_height: int = _theme.row_min_height()
|
||||||
|
|
||||||
# Material preset section
|
# 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()
|
var material_title := Label.new()
|
||||||
material_title.text = "Material"
|
material_title.text = "Material"
|
||||||
material_title.add_theme_font_size_override("font_size", header_size)
|
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()
|
_preset_option = OptionButton.new()
|
||||||
for label: String in PRESET_LABELS:
|
for label: String in PRESET_LABELS:
|
||||||
@@ -615,14 +769,14 @@ func _build_inspector() -> void:
|
|||||||
_preset_option.selected = _material_preset
|
_preset_option.selected = _material_preset
|
||||||
_preset_option.custom_minimum_size.y = float(_theme.button_min_height())
|
_preset_option.custom_minimum_size.y = float(_theme.button_min_height())
|
||||||
_preset_option.item_selected.connect(_on_preset_selected)
|
_preset_option.item_selected.connect(_on_preset_selected)
|
||||||
_inspector_list.add_child(_preset_option)
|
_material_section.add_child(_preset_option)
|
||||||
|
|
||||||
_mass_label = Label.new()
|
_mass_label = Label.new()
|
||||||
_friction_label = Label.new()
|
_friction_label = Label.new()
|
||||||
_bounce_label = Label.new()
|
_bounce_label = Label.new()
|
||||||
_inspector_list.add_child(_mass_label)
|
_material_section.add_child(_mass_label)
|
||||||
_inspector_list.add_child(_friction_label)
|
_material_section.add_child(_friction_label)
|
||||||
_inspector_list.add_child(_bounce_label)
|
_material_section.add_child(_bounce_label)
|
||||||
_update_physics_labels()
|
_update_physics_labels()
|
||||||
|
|
||||||
# Selected-shape section
|
# Selected-shape section
|
||||||
@@ -736,6 +890,160 @@ func _build_inspector() -> void:
|
|||||||
|
|
||||||
_apply_control_theme(_inspector_list)
|
_apply_control_theme(_inspector_list)
|
||||||
_rebuild_primitive_params()
|
_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:
|
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:
|
func _on_canvas_shape_changed(_shapes: Array[Dictionary]) -> void:
|
||||||
_mark_dirty()
|
_mark_dirty()
|
||||||
_refresh_inspector_from_selection()
|
_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:
|
func _on_canvas_shape_selected(_idx: int) -> void:
|
||||||
@@ -977,10 +1329,11 @@ func _check_autosave_recovery() -> void:
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func _build_json_data() -> Dictionary:
|
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:
|
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||||
|
_clear_pending_anchor_rename()
|
||||||
var model: Dictionary = PropFileScript.from_file_dict(json)
|
var model: Dictionary = PropFileScript.from_file_dict(json)
|
||||||
if model.is_empty():
|
if model.is_empty():
|
||||||
return "The file could not be read: unsupported version or malformed data."
|
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()
|
_update_physics_labels()
|
||||||
var shapes: Array = model.get("shapes", [])
|
var shapes: Array = model.get("shapes", [])
|
||||||
_canvas.set_shape_data(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
|
_dirty = is_recovery
|
||||||
_autosave_remaining = 0.0
|
_autosave_remaining = 0.0
|
||||||
_refresh_inspector_from_selection()
|
_refresh_inspector_from_selection()
|
||||||
|
_refresh_anchor_inspector()
|
||||||
|
_refresh_center_prop_button()
|
||||||
|
_canvas.call_deferred("reset_view")
|
||||||
_update_save_status()
|
_update_save_status()
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -1025,16 +1385,44 @@ func _save_prop_thumbnail() -> void:
|
|||||||
_thumb_cache.save_png(tex, _thumb_cache.prop_file_png(_current_path))
|
_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:
|
func _on_save_file_selected(path: String) -> void:
|
||||||
if path.get_extension().to_lower() != "prop":
|
if path.get_extension().to_lower() != "prop":
|
||||||
path = path.get_basename() + ".prop"
|
path = path.get_basename() + ".prop"
|
||||||
var ok: bool = await _save_to(path, not (_pending_quit or _pending_new))
|
_begin_save_request(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()
|
|
||||||
|
|
||||||
|
|
||||||
func _load_from(path: String) -> void:
|
func _load_from(path: String) -> void:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ extends RefCounted
|
|||||||
|
|
||||||
const PropBlockScript := preload("res://scripts/prop_block.gd")
|
const PropBlockScript := preload("res://scripts/prop_block.gd")
|
||||||
const TerrainUtilsScript := preload("res://scripts/terrain_utils.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_VERSION: String = "1.0"
|
||||||
const FILE_EXT: String = ".prop"
|
const FILE_EXT: String = ".prop"
|
||||||
@@ -61,7 +62,7 @@ static func sanitize_shape(shape: Dictionary, grid_size: float = TerrainUtilsScr
|
|||||||
return result
|
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 = []
|
var serialized: Array = []
|
||||||
for shape: Dictionary in shapes:
|
for shape: Dictionary in shapes:
|
||||||
serialized.append(_serialize_shape(shape))
|
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,
|
"prop_name": prop_name,
|
||||||
"material_preset": preset_to_string(material_preset),
|
"material_preset": preset_to_string(material_preset),
|
||||||
"shapes": serialized,
|
"shapes": serialized,
|
||||||
|
"anchors": PropAnchorDataScript.serialize_anchors(anchors),
|
||||||
"metadata": {"created_at": now, "modified_at": now},
|
"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", "")),
|
"prop_name": str(dict.get("prop_name", "")),
|
||||||
"material_preset": preset,
|
"material_preset": preset,
|
||||||
"shapes": shapes,
|
"shapes": shapes,
|
||||||
|
"anchors": PropAnchorDataScript.normalize_anchors(dict.get("anchors", {})),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ const DEFAULT_CONFIG: Dictionary = {
|
|||||||
"ui": {
|
"ui": {
|
||||||
"fonts": {"body": 18, "header": 24, "menu": 22, "menu_bar": 22, "mode": 26, "status": 18, "prop_name": 20, "context_menu": 22},
|
"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},
|
"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"))
|
return color("ui.colors.accent", Color("#4FA3FF"))
|
||||||
|
|
||||||
|
|
||||||
|
func inspector_background_color() -> Color:
|
||||||
|
return color("ui.colors.inspector_background", Color("#262C39"))
|
||||||
|
|
||||||
|
|
||||||
func invalidate_caches() -> void:
|
func invalidate_caches() -> void:
|
||||||
_warned.clear()
|
_warned.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://c4e5j6k0jm0hw
|
||||||
@@ -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)))
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://qpqqxm4acs2g
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://ofaxq8iw1n1c
|
||||||
@@ -52,7 +52,8 @@ func _test_window_title() -> void:
|
|||||||
func _test_mode_button_text() -> void:
|
func _test_mode_button_text() -> void:
|
||||||
check("arrange label", PROP_EDITOR.mode_button_text(0) == "🖐️ Arrange")
|
check("arrange label", PROP_EDITOR.mode_button_text(0) == "🖐️ Arrange")
|
||||||
check("reshape label", PROP_EDITOR.mode_button_text(1) == "✏️ Reshape")
|
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")
|
check("negative clamps to first", PROP_EDITOR.mode_button_text(-5) == "🖐️ Arrange")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
.venv/
|
||||||
@@ -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())
|
||||||
Reference in New Issue
Block a user