- 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.
41 KiB
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:
- Prop Editor-only phase. This phase implements anchor authoring,
.propround-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. - Fixed origin pivot. Every prop has exactly one reserved anchor named
origin, with rolepivot, at(0, 0). It cannot be deleted, renamed, moved, or assigned another role. Legacy.propfiles synthesize it on load. - 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 newAttachmode. Arrange and Reshape retain their current labels, behavior, and tests. - Add a normalized role-based anchor dictionary to
.propv1.0. - Preserve legacy
.propcompatibility by synthesizing the fixedoriginpivot. - 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
.proploading/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, orcustomanchors. 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
.sceneschema or save/load change.
3. Existing constraints
PropCanvas.EditModecurrently has onlyTRANSFORMandPOINTS, andset_edit_mode()clamps every non-POINTS value to TRANSFORM.PropEditorderives every save/autosave from_build_json_data(), so anchor document state must enter through that single path.PropFile.FILE_VERSIONis"1.0"; additive fields such asreshapedalready use backward-compatible v1.0 evolution.PropBlockandPropUtils.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.StickmanRigcontains 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.StickmanRigmirrors 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_HandandRight_Handare 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
{
"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:
{
"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_degmust be finite and is wrapped into[-180.0, 180.0).z_index_offsetis clamped to[-5, 5].- Unknown roles become
custom; unknown hand values becomeboth. - The
originentry is overwritten with the canonical fixed pivot on every normalization. - Any non-origin entry claiming role
pivotis preserved but downgraded tocustom, 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:
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:
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
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:
{
"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:
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 ofCanvasWorkspace);Snap Anchors(CheckBox, bound bidirectionally to the same_snap_enabledvalue 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:
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:
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:
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 Gizmosis 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
griporemittershows 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_changedduring 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:
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
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:
- Load
res://stickmen/test.stkthroughStickmanFactory.load_stk(). - Instantiate
res://scenes/stickman_rig.tscn, callset_physics_process(false)on the rig before it enters the tree, and apply valid.stkdata throughStkRigAdapter.apply(). If the file cannot be loaded, keep the scene's authored master visuals rather than failing the editor. - Add the rig only to
PreviewViewport's privatePreviewWorld; after_ready(), stop itsAnimationPlayer, callstop_queue()thenclear_queue(), retain the defaultANIMATEDstate, and set every descendantCollisionObject2Dlayer and mask to0. - 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. - Keep the
Skeleton2Dmodification stack andRemoteTransform2Ddrivers 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:
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:
- Resolve
Body/LeftLowerArmorBody/RightLowerArmas the solved hanging-frame visual. - Resolve the matching lower
Bone2Dand read its fittedlength. - Compute elbow =
lower_visual.to_global(Vector2.ZERO)and wrist =lower_visual.to_global(Vector2(0, lower_length)). - Direction =
(wrist - elbow).normalized(); build a proper (positive determinant) socket transform whose +X axis follows that direction and whose origin is wrist. - 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_onlyforces LEFT and disables RIGHT inHandOption.right_onlyforces RIGHT and disables LEFT.bothenables 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:
- call
PreviewRig.set_facing_profile(LEFT)to reuse bend flags and Body ordering; - restore
PreviewRig.scale = Vector2.ONE; - mirror
PreviewRigRoot.scale.x = -1so reflection occurs after the positive-scale skeleton solves; - explicitly enable the two arm
SkeletonModification2DTwoBoneIKentries for preview posing; - keep
PreviewPropoutsidePreviewRigRoot, mirror its generated geometry, and align it to the reflected socket withsolve_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:
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-1in 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.sizefollowsPreviewViewportContainer.size.- Default frame is
UPPER_BODY, fitting the union of head/torso/arms and current prop with a32 pxscreen margin. FULL_FIGUREfits a preview-local union of the transformedBody/*visual bounds and current prop AABB with the same margin.PropAttachPreviewowns this bounds helper; it must not import or callStageSpawner.- The local bounds helper walks visible descendants recursively: transform each
Polygon2D.polygonpoint and eachLine2D.pointsentry intoPreviewWorldcoordinates, expanding line bounds by half the drawn width.UPPER_BODYfilters to Head/Torso/arm branches;FULL_FIGUREincludes everyBody/*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
PropCanvaskeeps its existing pan/zoom/input unchanged.
Convert pointer positions through the preview viewport rather than the authoring canvas:
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:
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:
ItemListof anchor IDs (the list itself scrolls);Add Anchor,Rename,Deletebuttons;- Role
OptionButton; - X/Y
SpinBoxvalues (-100000..100000, step1); - Rotation
SpinBox(-180..<180, step1) plus a matching slider if it can share the same no-recursion update guard; - Z Offset
SpinBox(-5..5, integer); - Allowed Hands
OptionButton; - 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 or90°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_anchorstoPropFile.to_file_dict()._apply_json_data()assigns the normalized model anchors to both_anchorsand canvas._do_new()resets anchors todefault_anchors()and selectsorigin.- Canvas
anchors_changedupdates_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
.propmtime/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
.propdiscovery, 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
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
PropThemefont sizes, minimum row heights, text, muted text, border, and accent tokens. - Do not mutate the global project theme.
InspectorScrollfills the inspector column vertically and scrolls when the anchor controls do not fit.PropAttachPreviewuses the same local_ui_themetokens as the editor; the viewport background usescanvas_background_color()and the panel/header use existing border/text/ muted/accent tokens.- The preview split is user-resizable, never shrinks below
360 pxwhile 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:
- default anchors contain only canonical origin;
- legacy file without anchors synthesizes origin;
- origin tampering is overwritten and extra pivot roles downgrade to custom;
- finite/default/clamp/wrap validation;
- ID normalization and collision suffixes;
- serialize/parse round-trip asserts coordinate and rotation values, not only counts;
- role and allowed-hand queries;
- X-mirrored anchor position/direction math;
- all four quick-preset layouts for horizontal, vertical, and empty bounds;
PropCanvas.EditMode.ATTACHpersists through clear/load and blocks shape drag routing;- fixed origin cannot begin translate/rotate;
- snapped and unsnapped anchor translation plus Ctrl rotation snapping;
- preview rig lives under its private
SubViewportworld and adds nothing to a Sandbox world; - preview disables rig physics ticking/collisions and animation playback while leaving the Skeleton2D modification stack enabled;
- preview visual creates one node per authored shape and never creates
PropBlockor collision; left_only/right_onlyforce the compatible preview hand whilebothpermits either;- the derived wrist transform (lower-arm visual origin plus fitted lower-bone length) maps the selected anchor to the solved wrist within tolerance;
- manual preview target clamping respects derived arm reach and leaves the inactive target at its captured rest position;
- arm auto-motion changes only the active hand and Reset Arm restores both rest targets;
- 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; - selecting
secondary_grippreviews it with exactly one hand and never moves the other; - 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.gdtests/test_prop_editor_canvas.gdtests/test_prop_editor_theme.gdtests/test_prop_editor_thumb.gdtests/test_prop_editor_round3.gdtests/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.gdprop_anchor_overlay.gdprop_attach_preview.gdprop_file.gdprop_canvas.gdprop_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
PropAnchorDataand schema tests.PropFileround-trip and legacy normalization.- Append Attach to the existing mode system; add overlay math/drawing, inspector, presets, and save/autosave integration while preserving Arrange/Reshape.
- Add the isolated rig-backed
PropAttachPreview, shared editor transform solver, derived wrist transform, IK interaction, and camera/layout behavior without changing rig/runtime files. - 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.