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,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.
|
||||
Reference in New Issue
Block a user