Implement Phase 10a - Prop Scaling

- Added specifications for Phase 10a, introducing permanent size changes to `.prop` documents and reversible uniform instance scaling in Sandbox EDIT mode.
- Updated `PropCanvas`, `PropAnchorData`, `PropEditor`, `StageGizmos`, `SandboxStage`, and `StageSelection` classes to support new scaling features.
- Implemented document transactions for scaling, including normalization to a 96 px maximum dimension.
- Introduced a new **Transform Whole Prop** tool in the Prop Editor for moving, rotating, and resizing props.
- Created unit tests for the new scaling functionality, validating shape scaling, anchor transformations, and whole-prop interactions.
- Ensured compatibility with existing prop formats and maintained the integrity of the document structure during scaling operations.
This commit is contained in:
2026-09-22 22:23:31 -04:00
parent 97f3730413
commit 0a7f97cec7
18 changed files with 1782 additions and 78 deletions
+25 -2
View File
@@ -242,6 +242,13 @@ preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
`prop_block.gd`, `prop_canvas.gd`, `thumbnails/prop_thumbnail.gd`, and
`thumbnails/thumbnail_cache.gd` via `preload` consts. `_apply_json_data(json, is_recovery =
false) -> String` returns `""` on success or an error string (dialog on failure).
- **Phase 10a final controls:** the Arrange top bar orders **Transform Whole Prop**,
**Normalize Size**, **Center Prop**, and **Show Stickman Guide**. The numeric scale textbox
and Apply Scale button are not exposed. Transform Whole Prop is Arrange-only and previews
whole-document move, origin-based rotation, and uniform resize; Snap rounds move deltas,
Ctrl snaps rotation to 15°, editable anchors follow, and Esc cancels the preview. Reshape
and Attach hide Transform Whole Prop and Normalize Size; Center Prop and Show Stickman Guide
remain available.
- `scripts/prop_canvas.gd` — `class_name PropCanvas`, `extends Control`; the Prop Editor's
**multi-shape vector canvas** (Phase 9, **not used by the editor**), mirroring
`BodyPartPanel`'s proven input model (cursor-centered zoom, pinch/pan gestures, middle-mouse
@@ -317,6 +324,11 @@ preview_area.size * 0.5`, so it also re-centers on window resize; it remains a
~2 px wider) + a **bright core pass** (`SELECTION_CORE_COLOR`, white, original width). The
pair is visible on **any** background (the Round 6 fill-adaptive `contrast_color_for` was
removed because it could not know what sits *behind* a selected shape).
- **Phase 10a whole-prop transform:** the canvas exposes typed move/rotate/resize request
signals and renders a union gizmo while Transform Whole Prop is armed. Body, rotation-dot,
and corner gestures use snapshot-only previews and commit one atomic document edit on
release; `set_whole_prop_transform_enabled()` and related transform names are the preferred
API. Resize-named compatibility aliases remain internally for existing controller paths.
- `scripts/prop_file.gd` — `class_name PropFile`, `extends RefCounted`; the static `.prop`
schema + converters (Phase 9, **not used by the editor**). Consts `FILE_VERSION := "1.0"`,
`FILE_EXT := ".prop"`, `DEFAULT_DIR := "res://props"`, `SUPPORTED_VERSIONS: ["1.0"]`,
@@ -897,6 +909,11 @@ PackedVector2Array`, `radius: float`, `fill_color`, `outline_color`, `outline_wi
matching direct-state contact row as world-space normal/position, with a center-direction
fallback when timing leaves no row. The physics signal covers prop-vs-prop and Phase 4.5
stickman-prop contacts; the old geometric feet-point stickman test is removed.
- **Phase 10a instance scaling:** Sandbox EDIT exposes four uniform corner crosses only for
a single `PropBlock`. During PLAY, the authored root scale is moved into child visuals and
collision geometry, including the custom one-hull path and nonuniform circle fallback;
returning to EDIT restores the authored root scale. Preset mass and material physics stay
unchanged.
- `scripts/trigger_area.gd` — `class_name TriggerArea`, `extends Node2D`; a **placeable sensor**
(Phase 4, **not used by the editor**). `@export size: Vector2` (default 96×96), `get_area_rect()
-> Rect2` (centered local-space rectangle), and `_draw()` (translucent green fill +
@@ -973,7 +990,10 @@ KNOCK_UP_VELOCITY = (0, -450))` (mass-scaled `apply_central_impulse` on every ra
`global_position`/`global_rotation` + zeroed velocity. Because the freeze+teleport can take a
physics frame or two to settle, `_restore_authored_state()` is also re-asserted over the next
few `_physics_process` frames (`_restore_frames_left`) — so every Play session starts from and
returns to the same authored state. **Placement ghost:** while a palette
returns to the same authored state. **Phase 10a scale lifecycle:** prop corner-scale gestures
use the existing transform signal path, synchronize the frozen body during EDIT, save the
authored scale on commit, and retain that scale across PLAY → EDIT → PLAY. **Placement ghost:**
while a palette
item is active, `set_placement_mode(id)` spawns a translucent (`modulate.a = 0.5`),
non-colliding (`collision_layer/mask = 0`, frozen) copy of the object reparented out of
`World` into `_ghost_holder`; `_process()` tracks it to the cursor (+ snap) via
@@ -1245,7 +1265,10 @@ page_count}` helper. `_build_cell(entry)` returns a `Button` with a `TextureRect
`_rotate_start_rotation + (current − start angle)`, with holding **Ctrl** snapping to
15° increments via `snappedf(new_rotation, deg_to_rad(15.0))`.
`_draw()` draws a selection outline for **every** selected node. Pure `_draw()` +
distance-based hit-testing (no `Area2D`); `enum Handle { NONE, TRANSLATE, ROTATE }`.
distance-based hit-testing (no `Area2D`); `enum Handle { NONE, TRANSLATE, ROTATE, SCALE }`.
**Phase 10a** adds four constant-screen-size corner crosses for a single `PropBlock`; the
`Handle.SCALE` drag applies one positive uniform factor around the root origin through the
existing transform signals. Other selections keep the existing gizmos.
`transform_committed(nodes: Array[Node2D])` emitted on drag end. `set_enabled(false)` hides +
disables in PLAY. Also draws the box-select marquee via `set_box_rect(rect)`.
- `scripts/stage_grid.gd` — `class_name StageGrid`, `extends Node2D`; the optional world-space
+8 -1
View File
@@ -885,7 +885,14 @@ Each entry in the `shapes` array:
## File format (`.prop`)
The **Prop Editor** (Phase 9) authors vector prop assets saved as `.prop` files, stored under `res://props/*.prop`. Files are UTF-8 JSON, pretty-printed with tab indentation, versioned, and designed to remain **backward compatible** — the format stores the editor's full multi-shape stack, while a deterministic converter (`PropFile.flatten_to_payload()`) collapses it into the single-shape payload `PropUtils.spawn_prop()` consumes at runtime (frontmost closed polygon → else circle → else the first shape; lines are editor-only and never become collision geometry).
The **Prop Editor** (Phase 9, extended by Phase 10a) authors vector prop assets saved as `.prop` files, stored under `res://props/*.prop`. Files are UTF-8 JSON, pretty-printed with tab indentation, versioned, and designed to remain **backward compatible** — the format stores the editor's full multi-shape stack, while a deterministic converter (`PropFile.flatten_to_payload()`) collapses it into the single-shape payload `PropUtils.spawn_prop()` consumes at runtime (frontmost closed polygon → else circle → else the first shape; lines are editor-only and never become collision geometry).
In the Prop Editor, Arrange exposes **Transform Whole Prop**, **Normalize Size**,
**Center Prop**, and **Show Stickman Guide** in that order. Whole-prop move,
origin-based rotation, and uniform resize preview from a snapshot and commit once;
Snap rounds movement, Ctrl snaps rotation to 15°, and Esc cancels. The Sandbox
Stage has a separate single-prop corner scale gesture whose visual and collision
bounds remain synchronized during PLAY.
```json
{
+50 -8
View File
@@ -20,9 +20,10 @@ The window is divided into these areas:
| Area | Purpose |
| --- | --- |
| `📁 File` and `👁️ View` | File operations and canvas view reset |
| `Grid`, `Snap`, and `🎯 Center Prop` | Shared grid, snapping, and prop placement settings |
| `Grid` and `Snap` | Shared grid spacing and snapping settings |
| `Prop Name` | The name stored in the `.prop` file |
| Mode bar | Switch between `🖐️ Arrange`, `✏️ Reshape`, and `📌 Attach` |
| Arrange controls | Whole-prop transform, normalization, centering, and the stickman guide |
| Authoring canvas | Draw, select, transform, and reshape the prop |
| Inspector | Material, shape, primitive, and anchor controls |
| Holding Preview | A private stickman-and-prop preview shown in Attach mode |
@@ -75,11 +76,6 @@ The Holding Preview has its own `Reset View` button; see [Holding Preview](#hold
| --- | --- |
| `Grid` | Sets the visible grid spacing and the snap increment, from 1 to 512. |
| `Snap` | Enables or disables snapping for shape transforms and anchor movement. |
| `🎯 Center Prop` | Moves the complete prop so the combined shape bounds are centered on the fixed local origin. It also translates every non-origin anchor by the same amount and focuses the canvas on the origin. |
Center Prop is a deliberate one-time command. The editor does not recenter
automatically after drawing, reshaping, or moving a shape. Empty and already
centered props are unchanged.
## Prop name and modes
@@ -96,6 +92,38 @@ Arrange is the mode for selecting, moving, rotating, scaling, mirroring, and
ordering complete shapes. The Add Shape inspector and the shape Erase button
are available in this mode.
#### Arrange controls
The Arrange mode top bar keeps the whole-prop actions in this order:
1. `✥ Transform Whole Prop`
2. `📏 Normalize Size`
3. `🎯 Center Prop`
4. `Show Stickman Guide`
The scale textbox and Apply Scale button are not part of the editor. Use
Normalize Size for the standard document size, or use Transform Whole Prop for
direct canvas manipulation. Normalize Size fits the shape-point bounds to a
96 px maximum dimension while preserving the fixed origin and proportional
anchor offsets. Center Prop separately moves the shape bounds and editable
anchors around the canonical origin. It is a deliberate one-time command: the
editor does not recenter automatically after drawing, reshaping, or moving a
shape, and empty or already centered props are unchanged.
With Transform Whole Prop enabled, drag visible shape geometry to move the
whole prop, drag the dot below the union bounds to rotate it around the
canonical origin, or drag a corner cross to resize it uniformly. Snap rounds
the move delta to the current grid. Hold **Ctrl** while rotating to snap to
15-degree increments. Grip and other editable anchor markers follow each
operation. The tool previews from the starting document and commits once when
the gesture is released; **Esc** cancels the active preview. The canonical
origin itself does not move.
Transform Whole Prop and Normalize Size are hidden in Reshape and Attach.
Center Prop and Show Stickman Guide remain available in those modes. Turning
Transform Whole Prop off restores the selected shape and normal per-shape
Arrange gizmos.
When a shape is selected, the canvas draws:
- a two-tone selection outline and bounding box;
@@ -113,7 +141,9 @@ the selected shape.
### `✏️ Reshape`
Reshape is for editing individual vertices. It does not move the whole shape
and does not show the Add Shape section or Erase Shape button. When the canvas
and does not show the Add Shape section, Erase Shape button, Normalize Size,
or Transform Whole Prop. Center Prop and Show Stickman Guide remain available.
When the canvas
is empty, it does not show the Arrange-only “Right-click to add a shape” hint.
- Original vertices are filled circles.
@@ -133,7 +163,9 @@ private stickman Holding Preview. It does not add gameplay attachment or
physics behavior to Sandbox Stage.
Attach mode shows the Attach toolbar and the Anchors inspector. Shape editing
controls are hidden, but the prop remains visible on the authoring canvas.
controls are hidden, as are Normalize Size and Transform Whole Prop. Center Prop
and Show Stickman Guide remain available, and the prop remains visible on the
authoring canvas.
## Grid, Snap, and canvas navigation
@@ -491,10 +523,20 @@ Load dialogs report malformed or unsupported files through the Error dialog.
### Arrange transforms and context menu
- [ ] Confirm the Arrange top bar order is Transform Whole Prop, Normalize Size,
Center Prop, then Show Stickman Guide.
- [ ] Drag a selected shape body to translate it.
- [ ] Drag the rotation dot and confirm the shape rotates continuously.
- [ ] Hold Ctrl while rotating and confirm 15-degree stepping.
- [ ] Drag each scale cross; hold Ctrl and confirm uniform scaling.
- [ ] Enable Transform Whole Prop; drag visible geometry, the rotation dot, and
each corner cross, confirming that all shapes and editable anchors follow.
- [ ] With Snap enabled, move the whole prop and confirm the move delta follows
the current grid; hold Ctrl while rotating and confirm 15-degree snapping.
- [ ] Press Esc during a whole-prop gesture and confirm the preview cancels
without changing the document.
- [ ] Switch to Reshape or Attach and confirm Transform Whole Prop and
Normalize Size are hidden while Center Prop and Show Stickman Guide remain.
- [ ] Use Copy, right-click empty canvas, and Paste; confirm the pasted shape
is offset and selected.
- [ ] Test Flip Left-Right and Flip Up-Down.
+6
View File
@@ -53,6 +53,8 @@ This document tracks known technical debt, optimization opportunities, and minor
| 41 | **Phase 10 custom prop collision uses one convex hull** — the planned Sandbox prop renders every authored layer, but collision encloses all valid closed-shape points in one solid hull. | Low | Planned | User-approved in `specs/phase_10_prop_integration.md`: one fixture keeps dynamic rigid bodies stable and avoids overlapping per-shape contacts. Hollows and concavities collide as filled space; revisit compound collision only if playtesting shows material misses. `PropFile.flatten_to_payload()` remains for legacy single-shape consumers; #29 stays open until implementation lands. (2026-09-21) |
| 42 | **Phase 10 Browse keeps external `.prop` paths as scene asset IDs** — arbitrary filesystem files are used in place rather than copied into `res://props/`. | Medium | Planned | This matches stickman Browse and avoids filename/import conflicts, but scenes depend on the external path remaining valid. A missing custom prop becomes an inert placeholder that preserves its original scene entry and rule references through later saves; restoring the file and reloading resolves it. Existing missing-stickman rule skipping is unchanged. See `specs/phase_10_prop_integration.md`. (2026-09-21) |
| 43 | **Phase 10 thumbnail invalidation relies on path plus mtime** — custom cache keys will hash the full canonical path and include file modification time. | Low | Planned | This prevents equal basenames in different folders from sharing a PNG, while retaining cheap refresh checks. A filesystem with coarse timestamp resolution can miss two same-tick edits; content hashing is deferred unless observed. Cleanup must not remove built-in or unrelated external thumbnails. See `specs/phase_10_prop_integration.md`. (2026-09-21) |
| 44 | **Phase 10a Sandbox prop scaling depends on rigid-body collision scale behavior** — the approved corner gesture changes `PropBlock.scale`, so visual and physics bounds must agree for built-in circles/polygons and custom one-hull props. | Medium | Resolved | `PropBlock.begin_play_instance_scale()` transfers authored scale into child visuals and collision geometry during PLAY, including custom one-hull props and the nonuniform circle fallback, then `end_play_instance_scale()` restores the authored root scale in EDIT. `tests/test_phase10a_prop_scaling.gd` verifies scaled polygon and circle extents, rotated polygon contacts, dynamic PLAY contacts, and legacy nonuniform circle ellipse contacts. (2026-09-22) |
| 45 | **Phase 10a geometric normalization excludes stroke and physical mass distribution** — Normalize Size fits the union of shape points to 96 px on its longest axis, scaling about the fixed prop origin while leaving outline width unchanged. | Low | Planned | This preserves anchor offsets and current Center Prop AABB semantics; an off-origin prop remains proportionally off-origin. A future visual-stroke fit or center-of-mass sizing tool would require a separate contract. Existing #39 tracks AABB versus physical center; #37 tracks missing document-wide undo. See `specs/phase_10a_prop_scaling.md`. (2026-09-21) |
---
@@ -100,6 +102,10 @@ This document tracks known technical debt, optimization opportunities, and minor
| 2026-09-20 | Phase 9b Prop Centering specification written (`specs/phase_9b_prop_centering.md`). Approved architecture: hide canonical origin from editable anchors while retaining it in `.prop` v1.0; draw a passive constant-screen-size `(0,0)` marker in all three modes; add an always-visible Center Prop document transaction; center the authoring camera on origin for startup/New/load/reset and preserve zoom after centering; and warn explicit saves with Center & Save / Save Anyway / Cancel when displacement exceeds `max(1024 px, 2 × bounds diagonal)`. Autosave stays silent and automatic recenter-on-edit is explicitly rejected. Logged #39 (AABB authoring center is not a future physics center of mass); #37 continues to track document-wide undo. |
| 2026-09-21 | Phase 9c Prop Editor UX specification written (`specs/phase_9c_prop_editor_ux.md`). Approved architecture: a single Front Pose / Walk Left / Walk Right preview-motion selector with dynamic Pause/Resume; manual arm IK pauses gait before taking target ownership; modal typed anchor rename; Show Other Markers controls origin plus unselected anchors; one global Snap source; theme-driven `#262C39` inspector; container-driven unclipped chrome; simplified full-figure preview with no blue border. The preview directly plays the proven in-place gait/mirror policy and remains isolated from navigation, physics, actions, and Sandbox state. Logged #40 (exclusive gait/manual hand-target ownership). |
| 2026-09-21 | Phase 10 custom prop integration specification written (`specs/phase_10_prop_integration.md`) after user approval of full layered visuals with one convex collision hull, direct external-path Browse, and missing-prop placeholders that preserve scene entries and rule references. The design reuses Sandbox prop freeze/restore and save flows, extends PropLibrary with a merged scan while retaining its four-template API, and gives custom thumbnails path-unique mtime keys. Logged #41–#43; #29 remains open pending implementation. |
| 2026-09-21 | Phase 10a Prop Scaling specification written (`specs/phase_10a_prop_scaling.md`) after approval of origin-based Normalize Size and single-`PropBlock` uniform corner scaling around its local origin; an initial numeric document-scale control was removed during final UI refinement. The editor scales points, circle radius, and non-origin anchors as one transaction; a world-space 1:1 stickman guide defaults OFF and persists only in editor settings. Sandbox reuses its existing scene scale and PLAY→EDIT authored-state lifecycle. Logged #44 for physics collision verification and #45 for geometry-only fit; #37 and #39 remain open. |
| 2026-09-22 | Phase 10a whole-prop drag amendment approved (`specs/phase_10a_prop_scaling.md` §3.4). In Arrange, a visible **Resize Whole Prop** toggle displays one union-bounds outline with large handles; dragging previews all shape points, circle radii, and non-origin anchors uniformly about canonical `(0,0)` from a captured snapshot. Release calls the existing document-scale transaction once; Esc, mode switch, toggle OFF, New, Load, and Clear discard a live preview. The preview stays outside authoritative save/autosave state, and the toggle adds no `.prop` field or fourth primary mode. Existing per-shape Arrange behavior remains when the toggle is OFF. Document-wide undo stays deferred under #37; this drag should become one future undo command. |
| 2026-09-22 | Phase 10a whole-prop transform extension approved and implemented (`specs/phase_10a_prop_scaling.md` §3.4). The prior Resize Whole Prop toggle becomes one **Transform Whole Prop** tool that mirrors Arrange: drag visible geometry to move the whole document, drag the union rotation dot to rotate it about canonical `(0,0)`, and drag a corner for the existing uniform origin-based resize. Move translates every point and non-origin anchor (snapping the baseline delta when Snap is ON); rotate transforms every point/anchor position and adds the angle to editable anchor orientations; circle radius changes only on resize. All gestures preview from snapshots outside authoritative save state and commit once on release; Esc cancellation, selection restoration, mode lifecycle, `.prop` v1.0, and deferred document undo remain unchanged. |
| 2026-09-22 | Phase 10a implementation and final UI were user verified. The Arrange top bar is ordered **Transform Whole Prop**, **Normalize Size**, **Center Prop**, **Show Stickman Guide**; the numeric scale textbox and Apply Scale button are removed. Transform Whole Prop and Normalize Size are hidden in Reshape and Attach, while Center Prop and Show Stickman Guide remain available. Whole-prop move, 15-degree Ctrl rotation, uniform resize, anchor following, Snap movement, and Esc cancellation are documented in `docs/prop_editor_user_guide.md`; focused tests and the Prop Editor startup smoke test passed. |
---
+118
View File
@@ -0,0 +1,118 @@
Here is the revised architectural specification incorporating your exact adjustments for your architect agent:
---
# Architectural Spec: Dual-Layer Prop Scaling System (Revised)
Status: **implemented and user verified** (2026-09-22).
This specification outlines the architecture for supporting prop scaling across two layers: permanent template scaling in the **Prop Editor** and contextual instance scaling in the **Sandbox Stage**.
---
## 1. System Architecture & Division of Responsibilities
```
┌─────────────────────────────────────────────────────────┐
│ PROP EDITOR (Phase 9) │
│ • Permanent template geometry mutation │
│ • Vertex & radius coordinate transformations │
│ • Anchor offset auto-scaling │
│ • Stickman guide silhouette (Default: OFF) │
└────────────────────────────┬────────────────────────────┘
│ Saves .prop file
▼
┌─────────────────────────────────────────────────────────┐
│ SANDBOX STAGE (Phase 10) │
│ • Contextual instance scaling via corner gizmos only │
│ • Locked aspect ratio (uniform scaling) │
│ • Physics mass decoupled from visual scale │
└────────────────────────────┴────────────────────────────┘
```
| Layer | Target Entity | Operation Type | Scaling Behavior | Data Persistence |
| ----------------- | ----------------------------- | --------------------------- | ---------------------------------------------------------------- | ------------------------------------------------ |
| **Prop Editor** | Template Asset (`.prop`) | Permanent geometry mutation | Bakes scale into raw vector vertices, radii, and anchor offsets. | Persists into `.prop` file. |
| **Sandbox Stage** | Object Instance (`PropBlock`) | Node transform scaling | Pure gizmo scaling with **locked aspect ratio**. | Persists into `.scene` file (`scale: { x, y }`). |
---
## 2. Prop Editor Implementation (Template Sizing)
### A. Document Geometry Scale Tool
- **Scope:** Modifies raw geometry arrays (`points` for polygons/lines, `radius` for circles) in the shape stack.
- **Anchor Recalculation:** All registered anchor offset positions (`x`, `y`) automatically multiply by the scale factor so grip, pivot, and emitter points maintain exact relative alignment.
- **Normalize Size Action:** Adds a canvas action (📏 **Normalize Size**) alongside 🎯 **Center Prop** that computes the bounding box union of all shapes and uniformly scales the document to fit within a standard $96 \times 96\text{ px}$ canvas box.
### B. Stickman Visual Reference Silhouette
- **Overlay Guide:** Renders a semi-transparent 1:1 Stickman figure silhouette on the canvas background for scale context against full character rigs ($\approx 336 \times 940\text{ px}$).
- **Default State:** Toggleable via the UI / canvas controls and **defaults to OFF**.
- **View Aid Only:** Non-selectable, non-interactive visual guide that is excluded from `.prop` file serialization.
---
## 3. Sandbox Stage Implementation (Instance Sizing)
### A. Gizmo-Driven Scaling (No UI SpinBoxes / No Presets)
- **Corner Scale Handles:** Extends `StageGizmos` in `EDIT` mode to render corner cross handles on selected prop bounding boxes.
- **Aspect Ratio Locking:** Dragging any corner handle scales the object while strictly **locking the aspect ratio** (uniform scaling).
- **Streamlined UI:** No numeric scale SpinBoxes or preset buttons (`S`/`M`/`L`) are added to the stage UI; all scaling is performed intuitively directly on the canvas via gizmo manipulation.
### B. Mode Lifecycle & Physics Rules
- **Decoupled Mass Contract:** Scaling a prop adjusts node visual geometry (`Polygon2D`, `Line2D`) and collision bounds (`CollisionPolygon2D`, `CollisionShape2D`). RigidBody `mass` remains independent, governed strictly by the assigned Material Preset (Wood, Metal, Rubber, Cardboard).
- **Lifecycle State Handling (`PLAY` $\rightarrow$ `EDIT`):** Transitioning from `PLAY` back to `EDIT` mode restores the authored `position` and `rotation` and zeroes linear/angular velocity, but **does not reset or alter the node scale**. The authored scale set during editing is retained as-is.
---
## 4. Implementation Roadmap
1. **Phase A (Prop Editor Tooling):**
- Implement document-wide geometry and anchor scaling logic in `PropEditor`.
- Add the toggleable Stickman Reference Silhouette (**default OFF**) and "Normalize Size" canvas button.
2. **Phase B (Sandbox Gizmo Scaling):**
- Extend `StageGizmos` with corner scale handles that lock aspect ratio during drag operations.
- Ensure `StageSelection` AABB bounding boxes recalculate accurately to account for scaled instances.
3. **Phase C (Persistence & Verification):**
- Verify scene save/load serialization of scaled `PropBlock` instances.
- Confirm that `EDIT` $\rightarrow$ `PLAY` $\rightarrow$ `EDIT` mode switches restore authored position/rotation while keeping object scale intact.
## 5. Verified final Prop Editor controls
The Arrange mode top bar presents these controls in order:
1. **Transform Whole Prop**
2. **Normalize Size**
3. **Center Prop**
4. **Show Stickman Guide**
The numeric scale textbox and Apply Scale button were removed. Transform Whole
Prop previews and commits whole-document move, rotation, and uniform resize
gestures around the canonical origin. Grid Snap rounds move deltas, Ctrl snaps
rotation to 15-degree increments, editable anchors follow the geometry, and Esc
cancels an active preview. Normalize Size fits the shape-point bounds to a 96 px
maximum dimension. Transform Whole Prop and Normalize Size are hidden in Reshape
and Attach; Center Prop and Show Stickman Guide remain available in all modes.
Sandbox instance scaling remains a separate prop-only uniform corner-gizmo
operation. It retains the authored scene scale, preset-governed mass, and
PLAY-to-EDIT position/rotation restoration behavior.
+109 -26
View File
@@ -19,8 +19,8 @@
},
"material_preset": "rubber",
"metadata": {
"created_at": "2026-09-21T16:00:00",
"modified_at": "2026-09-21T16:00:00"
"created_at": "2026-09-22T00:26:21",
"modified_at": "2026-09-22T00:26:21"
},
"prop_name": "Baseball",
"shapes": [
@@ -30,23 +30,72 @@
"outline_color": "#A0A0A0FF",
"outline_width": 2.0,
"points": [
{ "x": 35.0, "y": 0.0 },
{ "x": 30.31, "y": 17.5 },
{ "x": 17.5, "y": 30.31 },
{ "x": 0.0, "y": 35.0 },
{ "x": -17.5, "y": 30.31 },
{ "x": -30.31, "y": 17.5 },
{ "x": -35.0, "y": 0.0 },
{ "x": -30.31, "y": -17.5 },
{ "x": -17.5, "y": -30.31 },
{ "x": 0.0, "y": -35.0 },
{ "x": 17.5, "y": -30.31 },
{ "x": 30.31, "y": -17.5 }
{
"x": 25.1999988555908,
"y": 0.0
},
{
"x": 21.8231983184814,
"y": 12.5999994277954
},
{
"x": 12.5999994277954,
"y": 21.8231983184814
},
{
"x": 0.0,
"y": 25.1999988555908
},
{
"x": -12.5999994277954,
"y": 21.8231983184814
},
{
"x": -21.8231983184814,
"y": 12.5999994277954
},
{
"x": -25.1999988555908,
"y": 0.0
},
{
"x": -21.8231983184814,
"y": -12.5999994277954
},
{
"x": -12.5999994277954,
"y": -21.8231983184814
},
{
"x": 0.0,
"y": -25.1999988555908
},
{
"x": 12.5999994277954,
"y": -21.8231983184814
},
{
"x": 21.8231983184814,
"y": -12.5999994277954
}
],
"radius": 35.0,
"radius": 25.1996288299561,
"reshaped": false,
"shape_type": "circle",
"vertex_flags": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": false,
@@ -54,14 +103,31 @@
"outline_color": "#D62828FF",
"outline_width": 2.0,
"points": [
{ "x": -12.0, "y": -28.0 },
{ "x": -22.0, "y": -14.0 },
{ "x": -22.0, "y": 14.0 },
{ "x": -12.0, "y": 28.0 }
{
"x": -8.64000034332275,
"y": -20.1599998474121
},
{
"x": -15.8400001525879,
"y": -10.0799999237061
},
{
"x": -15.8400001525879,
"y": 10.0799999237061
},
{
"x": -8.64000034332275,
"y": 20.1599998474121
}
],
"reshaped": true,
"shape_type": "line",
"vertex_flags": [0, 0, 0, 0]
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": false,
@@ -69,14 +135,31 @@
"outline_color": "#D62828FF",
"outline_width": 2.0,
"points": [
{ "x": 12.0, "y": -28.0 },
{ "x": 22.0, "y": -14.0 },
{ "x": 22.0, "y": 14.0 },
{ "x": 12.0, "y": 28.0 }
{
"x": 8.64000034332275,
"y": -20.1599998474121
},
{
"x": 15.8400001525879,
"y": -10.0799999237061
},
{
"x": 15.8400001525879,
"y": 10.0799999237061
},
{
"x": 8.64000034332275,
"y": 20.1599998474121
}
],
"reshaped": true,
"shape_type": "line",
"vertex_flags": [0, 0, 0, 0]
"vertex_flags": [
0,
0,
0,
0
]
}
],
"version": "1.0"
+111 -27
View File
@@ -13,14 +13,14 @@
"role": "grip",
"rotation_deg": 0.0,
"x": 0.0,
"y": -42.5,
"y": -82.066291809082,
"z_index_offset": 1
}
},
"material_preset": "wood",
"metadata": {
"created_at": "2026-09-21T16:00:00",
"modified_at": "2026-09-21T16:00:00"
"created_at": "2026-09-22T00:22:32",
"modified_at": "2026-09-22T00:22:32"
},
"prop_name": "Brown Briefcase",
"shapes": [
@@ -30,18 +30,51 @@
"outline_color": "#26150CFF",
"outline_width": 2.0,
"points": [
{ "x": -25.0, "y": -30.0 },
{ "x": -25.0, "y": -55.0 },
{ "x": 25.0, "y": -55.0 },
{ "x": 25.0, "y": -30.0 },
{ "x": 18.0, "y": -30.0 },
{ "x": 18.0, "y": -47.0 },
{ "x": -18.0, "y": -47.0 },
{ "x": -18.0, "y": -30.0 }
{
"x": -48.2742919921875,
"y": -57.9291458129883
},
{
"x": -48.2742919921875,
"y": -106.20344543457
},
{
"x": 48.2742919921875,
"y": -106.20344543457
},
{
"x": 48.2742919921875,
"y": -57.9291458129883
},
{
"x": 34.7574882507324,
"y": -57.9291458129883
},
{
"x": 34.7574882507324,
"y": -90.7556686401367
},
{
"x": -34.7574882507324,
"y": -90.7556686401367
},
{
"x": -34.7574882507324,
"y": -57.9291458129883
}
],
"reshaped": true,
"shape_type": "polygon",
"vertex_flags": [0, 0, 0, 0, 0, 0, 0, 0]
"vertex_flags": [
0,
0,
0,
0,
0,
0,
0,
0
]
},
{
"closed": true,
@@ -49,14 +82,31 @@
"outline_color": "#3B200AFF",
"outline_width": 3.0,
"points": [
{ "x": -70.0, "y": -30.0 },
{ "x": 70.0, "y": -30.0 },
{ "x": 70.0, "y": 40.0 },
{ "x": -70.0, "y": 40.0 }
{
"x": -135.168014526367,
"y": -57.9291458129883
},
{
"x": 135.168014526367,
"y": -57.9291458129883
},
{
"x": 135.168014526367,
"y": 77.2388610839844
},
{
"x": -135.168014526367,
"y": 77.2388610839844
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [0, 0, 0, 0]
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
@@ -64,14 +114,31 @@
"outline_color": "#8B7500FF",
"outline_width": 1.5,
"points": [
{ "x": -45.0, "y": -32.0 },
{ "x": -30.0, "y": -32.0 },
{ "x": -30.0, "y": -22.0 },
{ "x": -45.0, "y": -22.0 }
{
"x": -86.8937225341797,
"y": -61.791088104248
},
{
"x": -57.9291458129883,
"y": -61.791088104248
},
{
"x": -57.9291458129883,
"y": -42.481372833252
},
{
"x": -86.8937225341797,
"y": -42.481372833252
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [0, 0, 0, 0]
"vertex_flags": [
0,
0,
0,
0
]
},
{
"closed": true,
@@ -79,14 +146,31 @@
"outline_color": "#8B7500FF",
"outline_width": 1.5,
"points": [
{ "x": 30.0, "y": -32.0 },
{ "x": 45.0, "y": -32.0 },
{ "x": 45.0, "y": -22.0 },
{ "x": 30.0, "y": -22.0 }
{
"x": 57.9291458129883,
"y": -61.791088104248
},
{
"x": 86.8937225341797,
"y": -61.791088104248
},
{
"x": 86.8937225341797,
"y": -42.481372833252
},
{
"x": 57.9291458129883,
"y": -42.481372833252
}
],
"reshaped": false,
"shape_type": "polygon",
"vertex_flags": [0, 0, 0, 0]
"vertex_flags": [
0,
0,
0,
0
]
}
],
"version": "1.0"
+5
View File
@@ -91,6 +91,11 @@ unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="SizeTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 8
[node name="AttachTools" type="FlowContainer" parent="MainLayout/CanvasPanel/CanvasArea"]
unique_name_in_owner = true
layout_mode = 2
+42
View File
@@ -71,6 +71,48 @@ static func translated_non_origin(anchors: Dictionary, delta: Vector2) -> Dictio
normalized[ORIGIN_ID] = _origin()
return normalized
static func try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary:
if not is_finite(delta.x) or not is_finite(delta.y):
return {"ok": false, "anchors": {}}
var result: Dictionary = normalize_anchors(anchors, false)
for id: String in editable_ids(result):
var anchor: Dictionary = result[id].duplicate(true)
var pos: Vector2 = anchor.get("position", Vector2.ZERO) + delta
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
anchor["position"] = pos; result[id] = anchor
result[ORIGIN_ID] = _origin()
return {"ok": true, "anchors": result}
static func try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary:
if not is_finite(angle_rad): return {"ok": false, "anchors": {}}
var result: Dictionary = normalize_anchors(anchors, false)
for id: String in editable_ids(result):
var anchor: Dictionary = result[id].duplicate(true)
var pos: Vector2 = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle_rad)
if not is_finite(pos.x) or not is_finite(pos.y): return {"ok": false, "anchors": {}}
anchor["position"] = pos
anchor["rotation_deg"] = _wrap_deg(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle_rad))
result[id] = anchor
result[ORIGIN_ID] = _origin()
return {"ok": true, "anchors": result}
## Returns a fully normalized scaled copy, or {"ok": false} without changing
## the caller's data when the operation cannot be represented safely.
static func try_scale_non_origin(anchors: Dictionary, factor: float) -> Dictionary:
if not is_finite(factor) or factor <= 0.0:
return {"ok": false, "anchors": {}}
var normalized: Dictionary = normalize_anchors(anchors, false)
for id: String in editable_ids(normalized):
var anchor: Dictionary = normalized[id].duplicate(true)
var position: Vector2 = anchor.get("position", Vector2.ZERO) * factor
if not is_finite(position.x) or not is_finite(position.y):
return {"ok": false, "anchors": {}}
anchor["position"] = position
normalized[id] = anchor
normalized[ORIGIN_ID] = _origin()
return {"ok": true, "anchors": normalized}
static func normalize_id(candidate: String, anchors: Dictionary) -> String:
var id: String = candidate.strip_edges().to_lower()
var regex := RegEx.new(); regex.compile("[^a-z0-9]+")
+66 -1
View File
@@ -95,6 +95,9 @@ var _collision_shape: CollisionShape2D
var _circle_shape: CircleShape2D
var _custom_shapes: bool = false
var _visual_local_bounds := Rect2()
var _custom_collision_hull: PackedVector2Array = PackedVector2Array()
var _play_instance_scale: Vector2 = Vector2.ONE
var _play_scale_active: bool = false
# ---------------------------------------------------------------------------
# Signals
@@ -257,7 +260,8 @@ func set_custom_shapes(shapes: Array[Dictionary], collision_hull: PackedVector2A
_outline.visible = false
_set_collision_shape_enabled(false)
_set_collision_polygon_enabled(true)
_collision_polygon.polygon = collision_hull
_custom_collision_hull = collision_hull.duplicate()
_collision_polygon.polygon = _custom_collision_hull
var visuals := get_node_or_null(NodePath("Visuals")) as Node2D
if visuals == null:
visuals = Node2D.new(); visuals.name = "Visuals"; add_child(visuals)
@@ -273,6 +277,67 @@ func has_custom_shapes() -> bool:
func get_visual_local_bounds() -> Rect2:
return _visual_local_bounds
## RigidBody2D transforms do not reliably retain scale during simulation. Move
## the authored uniform scale into child visuals and collision geometry for
## PLAY, then restore the root scale on return to EDIT for scene persistence.
func begin_play_instance_scale() -> void:
if _play_scale_active:
return
_play_instance_scale = scale
if _play_instance_scale.is_equal_approx(Vector2.ONE):
return
_play_scale_active = true
scale = Vector2.ONE
_apply_play_scale()
func end_play_instance_scale() -> void:
if not _play_scale_active:
return
_play_scale_active = false
_set_visual_scale(Vector2.ONE)
_apply_shape()
if _custom_shapes and _collision_polygon != null:
_collision_polygon.polygon = _custom_collision_hull
scale = _play_instance_scale
func _apply_play_scale() -> void:
_set_visual_scale(_play_instance_scale)
if _custom_shapes:
if _collision_polygon != null:
_collision_polygon.polygon = _scaled_points(_custom_collision_hull, _play_instance_scale)
return
if shape_type == ShapeType.CIRCLE:
# A non-uniform legacy instance scale makes an ellipse. Use the generated
# polygon hull while simulating so collision follows both axes exactly.
if _collision_polygon != null:
_collision_polygon.polygon = _scaled_points(_circle_loop(), _play_instance_scale)
_set_collision_shape_enabled(false)
_set_collision_polygon_enabled(true)
elif _collision_polygon != null:
_collision_polygon.polygon = _scaled_points(polygon_points, _play_instance_scale)
func _set_visual_scale(value: Vector2) -> void:
if _polygon != null:
_polygon.scale = value
if _outline != null:
_outline.scale = value
var visuals: Node2D = get_node_or_null(NodePath("Visuals")) as Node2D
if visuals != null:
visuals.scale = value
func _scaled_points(points: PackedVector2Array, value: Vector2) -> PackedVector2Array:
var result: PackedVector2Array = PackedVector2Array()
for point: Vector2 in points:
result.append(point * value)
return result
func _circle_loop() -> PackedVector2Array:
var result: PackedVector2Array = PackedVector2Array()
for index: int in CIRCLE_SEGMENTS:
var angle: float = TAU * float(index) / float(CIRCLE_SEGMENTS)
result.append(Vector2(cos(angle), sin(angle)) * radius)
return result
# ---------------------------------------------------------------------------
# Static preset factories
# ---------------------------------------------------------------------------
+346 -4
View File
@@ -30,6 +30,9 @@ signal vertex_created(shape_idx: int, vertex_idx: int)
signal mode_changed(mode: int)
signal anchors_changed(anchors: Dictionary)
signal anchor_selected(anchor_id: String)
signal whole_prop_resize_requested(factor: float)
signal whole_prop_move_requested(delta: Vector2)
signal whole_prop_rotate_requested(angle_rad: float)
# ---------------------------------------------------------------------------
# Enums / constants
@@ -39,6 +42,7 @@ enum ShapeType { POLYGON, CIRCLE, LINE }
enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 }
enum DragMode { NONE, VERTEX, TRANSLATE, ROTATE, SCALE }
enum WholePropDrag { NONE, MOVE, ROTATE, SCALE }
## TRANSFORM = "Move & Resize" (gizmos + shape drag); POINTS = "Edit Points"
## (vertex handles + Create/Remove Point). Gates input and drawing only — the
@@ -69,6 +73,8 @@ const ORIGIN_MARKER_DEFAULT_COLOR := Color(0.6, 0.6, 0.6, 0.7)
const TerrainUtilsScript := preload("res://scripts/terrain_utils.gd")
const PropFileScript := preload("res://scripts/prop_file.gd")
const PropAnchorOverlayScript := preload("res://scripts/prop_anchor_overlay.gd")
const PropAnchorDataScript := preload("res://scripts/prop_anchor_data.gd")
const WholeStickmanPreviewScript := preload("res://scripts/whole_stickman_preview.gd")
const SELECTION_CORE_COLOR := Color.WHITE
const SELECTION_HALO_COLOR := Color(0.0, 0.0, 0.0, 0.85)
@@ -119,6 +125,27 @@ var _edit_mode: EditMode = EditMode.TRANSFORM
var _anchor_overlay: RefCounted
var _origin_marker_color: Color = ORIGIN_MARKER_DEFAULT_COLOR
var _show_origin_marker: bool = true
var _show_stickman_guide: bool = false
var _whole_prop_resize_enabled: bool = false
var _whole_prop_dragging: bool = false
var _whole_prop_start_shapes: Array[Dictionary] = []
var _whole_prop_preview_shapes: Array[Dictionary] = []
var _whole_prop_start_anchors: Dictionary = {}
var _whole_prop_preview_anchors: Dictionary = {}
var _whole_prop_start_bounds: Rect2 = Rect2()
var _whole_prop_start_pointer: Vector2 = Vector2.ZERO
var _whole_prop_corner: Vector2 = Vector2.ZERO
var _whole_prop_factor: float = 1.0
var _whole_prop_drag: WholePropDrag = WholePropDrag.NONE
var _whole_prop_delta: Vector2 = Vector2.ZERO
var _whole_prop_total_angle: float = 0.0
var _whole_prop_previous_angle: float = 0.0
var _whole_prop_preview_angle: float = 0.0
const SCALE_COORDINATE_LIMIT: float = 1000000.0
const WHOLE_PROP_HANDLE_HIT_RADIUS: float = 16.0
const WHOLE_PROP_FACTOR_MIN: float = 0.01
const WHOLE_PROP_FACTOR_MAX: float = 10.0
# Drag state
var _drag_mode: DragMode = DragMode.NONE
@@ -176,6 +203,8 @@ func set_edit_mode(mode: int) -> void:
if m == _edit_mode:
return
_edit_mode = m
if _edit_mode != EditMode.TRANSFORM:
set_whole_prop_resize_enabled(false)
_drag_mode = DragMode.NONE
_dragging_vertex = -1
queue_redraw()
@@ -206,6 +235,51 @@ func set_show_origin_marker(enabled: bool) -> void:
_show_origin_marker = enabled
queue_redraw()
func set_whole_prop_resize_enabled(enabled: bool) -> void:
if _whole_prop_resize_enabled == enabled:
return
cancel_whole_prop_resize()
_whole_prop_resize_enabled = enabled and has_shape_geometry() and _edit_mode == EditMode.TRANSFORM
queue_redraw()
func set_whole_prop_transform_enabled(enabled: bool) -> void:
set_whole_prop_resize_enabled(enabled)
func is_whole_prop_resize_enabled() -> bool:
return _whole_prop_resize_enabled
func is_whole_prop_transform_enabled() -> bool:
return _whole_prop_resize_enabled
func is_whole_prop_resize_dragging() -> bool:
return _whole_prop_dragging
func is_whole_prop_transform_dragging() -> bool:
return _whole_prop_dragging
func cancel_whole_prop_resize() -> void:
_whole_prop_dragging = false
_whole_prop_preview_shapes.clear()
_whole_prop_preview_anchors.clear()
_whole_prop_start_shapes.clear()
_whole_prop_start_anchors.clear()
_whole_prop_factor = 1.0
_whole_prop_drag = WholePropDrag.NONE
_whole_prop_delta = Vector2.ZERO
_whole_prop_total_angle = 0.0
_whole_prop_preview_angle = 0.0
queue_redraw()
func cancel_whole_prop_transform() -> void:
cancel_whole_prop_resize()
func set_show_stickman_guide(enabled: bool) -> void:
_show_stickman_guide = enabled
queue_redraw()
func get_show_stickman_guide() -> bool:
return _show_stickman_guide
func get_shapes_bounds() -> Rect2:
var found: bool = false; var result: Rect2 = Rect2()
for shape: Dictionary in shapes:
@@ -220,6 +294,61 @@ func has_shape_geometry() -> bool:
if _shape_has_geometry(shape): return true
return false
func can_scale_all_shapes(factor: float) -> bool:
if not is_finite(factor) or factor <= 0.0 or is_equal_approx(factor, 1.0) or not has_shape_geometry():
return false
for shape: Dictionary in shapes:
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for point: Vector2 in points:
var scaled: Vector2 = point * factor
if not is_finite(scaled.x) or not is_finite(scaled.y) or absf(scaled.x) > SCALE_COORDINATE_LIMIT or absf(scaled.y) > SCALE_COORDINATE_LIMIT:
return false
if str(shape.get("shape_type", "")) == "circle":
var radius: float = float(shape.get("radius", 0.0)) * factor
if not is_finite(radius) or radius < 0.0 or radius > SCALE_COORDINATE_LIMIT:
return false
return true
func can_translate_all_shapes(delta: Vector2) -> bool:
if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false
for shape: Dictionary in shapes:
for point: Vector2 in (shape.get("points", PackedVector2Array()) as PackedVector2Array):
var moved: Vector2 = point + delta
if not is_finite(moved.x) or not is_finite(moved.y) or absf(moved.x) > SCALE_COORDINATE_LIMIT or absf(moved.y) > SCALE_COORDINATE_LIMIT: return false
return true
func can_rotate_all_shapes(angle_rad: float) -> bool:
if not has_shape_geometry() or not is_finite(angle_rad) or is_zero_approx(angle_rad): return false
for shape: Dictionary in shapes:
for point: Vector2 in (shape.get("points", PackedVector2Array()) as PackedVector2Array):
var rotated: Vector2 = point.rotated(angle_rad)
if not is_finite(rotated.x) or not is_finite(rotated.y) or absf(rotated.x) > SCALE_COORDINATE_LIMIT or absf(rotated.y) > SCALE_COORDINATE_LIMIT: return false
return true
func rotate_all_shapes(angle_rad: float) -> bool:
if not can_rotate_all_shapes(angle_rad): return false
for i: int in shapes.size():
var sd: Dictionary = shapes[i].duplicate(true); var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
for j: int in pts.size(): pts[j] = pts[j].rotated(angle_rad)
sd["points"] = pts; shapes[i] = sd
queue_redraw(); shape_changed.emit(get_shape_data()); return true
func scale_all_shapes(factor: float) -> bool:
if not can_scale_all_shapes(factor):
return false
for i: int in shapes.size():
var shape: Dictionary = shapes[i].duplicate(true)
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for j: int in points.size():
points[j] *= factor
shape["points"] = points
if str(shape.get("shape_type", "")) == "circle":
shape["radius"] = float(shape.get("radius", 0.0)) * factor
shapes[i] = shape
queue_redraw()
shape_changed.emit(get_shape_data())
return true
func translate_all_shapes(delta: Vector2) -> bool:
if not has_shape_geometry() or not is_finite(delta.x) or not is_finite(delta.y) or delta.is_zero_approx(): return false
for i: int in shapes.size():
@@ -249,6 +378,7 @@ func set_origin_marker_color(color: Color) -> void:
func set_shape_data(data: Variant) -> void:
## Import shapes. Accepts an Array[Dictionary] (internal or plain {x,y}
## format) or a single Dictionary.
set_whole_prop_resize_enabled(false)
shapes.clear()
if data is Array:
for item: Variant in data as Array:
@@ -273,6 +403,7 @@ func get_shape_data() -> Array[Dictionary]:
func clear() -> void:
## Remove all shapes without emitting shape_changed.
set_whole_prop_resize_enabled(false)
shapes.clear()
_selected_shape_idx = -1
_drag_mode = DragMode.NONE
@@ -657,6 +788,7 @@ func global_to_world(global_pos: Vector2) -> Vector2:
func _on_canvas_draw() -> void:
draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom))
_draw_grid()
_draw_stickman_guide()
if shapes.is_empty() and _edit_mode == EditMode.TRANSFORM:
var rect := get_rect()
@@ -665,17 +797,21 @@ func _on_canvas_draw() -> void:
if font != null:
draw_string(font, hint_pos, "Right-click to add a shape", HORIZONTAL_ALIGNMENT_CENTER, -1, 14, Color(0.5, 0.5, 0.5, 0.6))
for i: int in shapes.size():
_draw_shape(shapes[i], i == _selected_shape_idx, _dragging_vertex if i == _selected_shape_idx else -1)
var drawn_shapes: Array[Dictionary] = _whole_prop_preview_shapes if _whole_prop_dragging else shapes
for i: int in drawn_shapes.size():
var selected: bool = not _whole_prop_resize_enabled and i == _selected_shape_idx
_draw_shape(drawn_shapes[i], selected, _dragging_vertex if selected else -1)
_draw_origin_marker()
if _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
if _whole_prop_resize_enabled:
_draw_whole_prop_resize_gizmos()
elif _edit_mode == EditMode.TRANSFORM and _selected_shape_idx >= 0 and _selected_shape_idx < shapes.size():
_draw_selection_gizmos(shapes[_selected_shape_idx])
if _edit_mode == EditMode.ATTACH and _anchor_overlay != null:
_anchor_overlay.draw_gizmos(self, _zoom)
func _draw_origin_marker() -> void:
if not _show_origin_marker:
if not _show_origin_marker and not _whole_prop_resize_enabled:
return
var inv_zoom: float = 1.0 / maxf(_zoom, 0.001)
var radius: float = 6.0 * inv_zoom
@@ -687,6 +823,44 @@ func _draw_origin_marker() -> void:
draw_line(Vector2(-radius, 0.0), Vector2(radius, 0.0), _origin_marker_color, 1.25 * inv_zoom)
draw_line(Vector2(0.0, -radius), Vector2(0.0, radius), _origin_marker_color, 1.25 * inv_zoom)
func _draw_stickman_guide() -> void:
if not _show_stickman_guide:
return
var width: float = 2.0 / maxf(_zoom, 0.001)
var color: Color = Color(0.75, 0.82, 0.9, 0.22)
var joints: Dictionary = WholeStickmanPreviewScript.GUIDE_JOINTS
for bone: Array in WholeStickmanPreviewScript.GUIDE_BONES:
var first: String = str(bone[0])
var second: String = str(bone[1])
if joints.has(first) and joints.has(second):
draw_line(joints[first] as Vector2, joints[second] as Vector2, color, width)
if joints.has("Head"):
draw_arc(joints["Head"] as Vector2, WholeStickmanPreviewScript.GUIDE_HEAD_RADIUS, 0.0, TAU, 48, color, width)
func _draw_whole_prop_resize_gizmos() -> void:
var bounds: Rect2 = _whole_preview_bounds() if _whole_prop_dragging else get_shapes_bounds()
if bounds.size == Vector2.ZERO:
return
draw_rect(bounds, SELECTION_HALO_COLOR, false, 4.0 / _zoom)
draw_rect(bounds, SELECTION_CORE_COLOR, false, 2.0 / _zoom)
var half: float = 7.0 / _zoom
for corner: Vector2 in _rect_corners(bounds):
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_HALO_COLOR, 5.0 / _zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_HALO_COLOR, 5.0 / _zoom)
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_CORE_COLOR, 2.0 / _zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_CORE_COLOR, 2.0 / _zoom)
var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET)
draw_circle(rotation_dot, (ROTATION_CIRCLE_RADIUS + 2.0) / _zoom, SELECTION_HALO_COLOR)
draw_circle(rotation_dot, ROTATION_CIRCLE_RADIUS / _zoom, SELECTION_CORE_COLOR)
var anchors: Dictionary = _whole_prop_preview_anchors if _whole_prop_dragging else get_anchor_data()
for id: String in anchors:
if id == "origin":
continue
var anchor: Dictionary = anchors[id]
var pos: Vector2 = anchor.get("position", Vector2.ZERO)
var color: Color = Color(1.0, 0.85, 0.2, 0.9) if str(anchor.get("role", "")) == "grip" else Color(0.8, 0.8, 0.8, 0.7)
draw_circle(pos, 4.0 / _zoom, color)
func _draw_shape(sd: Dictionary, is_selected: bool, dragging_vertex: int) -> void:
var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
@@ -820,6 +994,15 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
else: _anchor_overlay.end_pointer()
return
if mb.button_index == MOUSE_BUTTON_RIGHT: return
if _whole_prop_resize_enabled:
if mb.button_index == MOUSE_BUTTON_LEFT:
if mb.pressed:
_begin_whole_prop_resize(world_pos)
else:
_end_whole_prop_resize()
return
if mb.button_index == MOUSE_BUTTON_RIGHT:
return
if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
_handle_right_click(mb, world_pos)
return
@@ -839,6 +1022,10 @@ func _on_canvas_gui_input(event: InputEvent) -> void:
if _edit_mode == EditMode.ATTACH:
_anchor_overlay.update_pointer(_screen_to_world(mm.position), _zoom, _snap_enabled, _grid_size, mm.ctrl_pressed)
return
if _whole_prop_resize_enabled:
if _whole_prop_dragging:
_update_whole_prop_resize(_screen_to_world(mm.position), mm.ctrl_pressed)
return
_handle_drag_motion(mm, _screen_to_world(mm.position), mm.ctrl_pressed)
@@ -1228,6 +1415,161 @@ func _project_point_onto_segment(point: Vector2, a: Vector2, b: Vector2) -> Vect
func _emit_changed() -> void:
shape_changed.emit(get_shape_data())
func _begin_whole_prop_resize(world_pos: Vector2) -> void:
var bounds: Rect2 = get_shapes_bounds()
if bounds.size == Vector2.ZERO:
return
var corner: Vector2 = _whole_prop_hit_corner(bounds, world_pos)
var drag: WholePropDrag = WholePropDrag.SCALE if corner != Vector2.INF else WholePropDrag.NONE
if drag == WholePropDrag.NONE:
var rotation_dot: Vector2 = Vector2(bounds.get_center().x, bounds.end.y + ROTATION_CIRCLE_OFFSET)
if world_pos.distance_to(rotation_dot) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom: drag = WholePropDrag.ROTATE
if drag == WholePropDrag.NONE:
for i: int in range(shapes.size() - 1, -1, -1):
if _is_point_in_shape(i, world_pos): drag = WholePropDrag.MOVE; break
if drag == WholePropDrag.NONE: return
_whole_prop_dragging = true
_whole_prop_start_shapes = shapes.duplicate(true)
_whole_prop_start_anchors = get_anchor_data().duplicate(true)
_whole_prop_start_bounds = bounds
_whole_prop_start_pointer = world_pos
_whole_prop_corner = corner
_whole_prop_drag = drag
if drag == WholePropDrag.ROTATE:
if world_pos.length_squared() <= 0.0001: cancel_whole_prop_resize(); return
_whole_prop_previous_angle = world_pos.angle()
_whole_prop_factor = 1.0
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
func _update_whole_prop_resize(world_pos: Vector2, ctrl_pressed: bool = false) -> void:
if _whole_prop_drag == WholePropDrag.MOVE:
var delta: Vector2 = world_pos - _whole_prop_start_pointer
if _snap_enabled: delta = Vector2(snappedf(delta.x, float(_grid_size)), snappedf(delta.y, float(_grid_size)))
if delta.is_zero_approx():
_whole_prop_delta = Vector2.ZERO
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
return
if not can_translate_all_shapes(delta) or not bool(PropAnchorDataScript.try_translate_non_origin(_whole_prop_start_anchors, delta).get("ok", false)): return
_whole_prop_delta = delta; _whole_prop_preview_shapes = _translated_shape_copy(_whole_prop_start_shapes, delta); _whole_prop_preview_anchors = _translated_anchor_copy(_whole_prop_start_anchors, delta); queue_redraw(); return
if _whole_prop_drag == WholePropDrag.ROTATE:
if world_pos.length_squared() <= 0.0001: return
var current: float = world_pos.angle(); _whole_prop_total_angle += wrapf(current - _whole_prop_previous_angle, -PI, PI); _whole_prop_previous_angle = current
var angle: float = snappedf(_whole_prop_total_angle, deg_to_rad(15.0)) if ctrl_pressed else _whole_prop_total_angle
if is_zero_approx(angle):
_whole_prop_preview_angle = 0.0
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
return
if not can_rotate_all_shapes(angle) or not bool(PropAnchorDataScript.try_rotate_non_origin(_whole_prop_start_anchors, angle).get("ok", false)): return
_whole_prop_preview_angle = angle
_whole_prop_preview_shapes = _rotated_shape_copy(_whole_prop_start_shapes, angle); _whole_prop_preview_anchors = _rotated_anchor_copy(_whole_prop_start_anchors, angle); queue_redraw(); return
var direction: Vector2 = _whole_prop_corner - _whole_prop_start_bounds.get_center()
var denominator: float = direction.length_squared()
if denominator <= 0.0001:
return
var requested: float = 1.0 + (world_pos - _whole_prop_start_pointer).dot(direction) / denominator
var factor: float = clampf(requested, WHOLE_PROP_FACTOR_MIN, WHOLE_PROP_FACTOR_MAX)
if not is_finite(factor):
return
if is_equal_approx(factor, 1.0):
_whole_prop_factor = 1.0
_whole_prop_preview_shapes = _whole_prop_start_shapes.duplicate(true)
_whole_prop_preview_anchors = _whole_prop_start_anchors.duplicate(true)
queue_redraw()
return
if not can_scale_all_shapes(factor) or not bool(PropAnchorDataScript.try_scale_non_origin(_whole_prop_start_anchors, factor).get("ok", false)):
return
_whole_prop_factor = factor
_whole_prop_preview_shapes = _scaled_shape_copy(_whole_prop_start_shapes, factor)
_whole_prop_preview_anchors = _scaled_anchor_copy(_whole_prop_start_anchors, factor)
queue_redraw()
func _end_whole_prop_resize() -> void:
if not _whole_prop_dragging:
return
var factor: float = _whole_prop_factor; var drag: WholePropDrag = _whole_prop_drag; var delta: Vector2 = _whole_prop_delta; var angle: float = _whole_prop_preview_angle
cancel_whole_prop_resize()
if drag == WholePropDrag.MOVE and not delta.is_zero_approx(): whole_prop_move_requested.emit(delta)
elif drag == WholePropDrag.ROTATE and not is_zero_approx(angle): whole_prop_rotate_requested.emit(angle)
elif drag == WholePropDrag.SCALE and not is_equal_approx(factor, 1.0):
whole_prop_resize_requested.emit(factor)
func _scaled_shape_copy(source: Array[Dictionary], factor: float) -> Array[Dictionary]:
var result: Array[Dictionary] = source.duplicate(true)
for i: int in result.size():
var shape: Dictionary = result[i]
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
for j: int in points.size():
points[j] *= factor
shape["points"] = points
if str(shape.get("shape_type", "")) == "circle":
shape["radius"] = float(shape.get("radius", 0.0)) * factor
result[i] = shape
return result
func _scaled_anchor_copy(source: Dictionary, factor: float) -> Dictionary:
var result: Dictionary = source.duplicate(true)
for id: String in result:
if id == "origin":
continue
var anchor: Dictionary = result[id]
anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) * factor
result[id] = anchor
return result
func _translated_shape_copy(source: Array[Dictionary], delta: Vector2) -> Array[Dictionary]:
var result: Array[Dictionary] = source.duplicate(true)
for i: int in result.size():
var sd: Dictionary = result[i]; var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
for j: int in pts.size(): pts[j] += delta
sd["points"] = pts; result[i] = sd
return result
func _translated_anchor_copy(source: Dictionary, delta: Vector2) -> Dictionary:
var result: Dictionary = source.duplicate(true)
for id: String in result:
if id != "origin": var anchor: Dictionary = result[id]; anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2) + delta; result[id] = anchor
return result
func _rotated_shape_copy(source: Array[Dictionary], angle: float) -> Array[Dictionary]:
var result: Array[Dictionary] = source.duplicate(true)
for i: int in result.size():
var sd: Dictionary = result[i]; var pts: PackedVector2Array = sd.get("points", PackedVector2Array())
for j: int in pts.size(): pts[j] = pts[j].rotated(angle)
sd["points"] = pts; result[i] = sd
return result
func _rotated_anchor_copy(source: Dictionary, angle: float) -> Dictionary:
var result: Dictionary = source.duplicate(true)
for id: String in result:
if id != "origin":
var anchor: Dictionary = result[id]; anchor["position"] = (anchor.get("position", Vector2.ZERO) as Vector2).rotated(angle); anchor["rotation_deg"] = wrapf(float(anchor.get("rotation_deg", 0.0)) + rad_to_deg(angle), -180.0, 180.0); result[id] = anchor
return result
func _whole_preview_bounds() -> Rect2:
var found: bool = false
var result: Rect2 = Rect2()
for shape: Dictionary in _whole_prop_preview_shapes:
var bounds: Rect2 = _shape_bbox(shape)
if not found:
result = bounds
found = true
else:
result = result.merge(bounds)
return result
func _rect_corners(bounds: Rect2) -> Array[Vector2]:
return [bounds.position, Vector2(bounds.end.x, bounds.position.y), bounds.end, Vector2(bounds.position.x, bounds.end.y)]
func _whole_prop_hit_corner(bounds: Rect2, world_pos: Vector2) -> Vector2:
var threshold: float = WHOLE_PROP_HANDLE_HIT_RADIUS / _zoom
for corner: Vector2 in _rect_corners(bounds):
if world_pos.distance_to(corner) <= threshold:
return corner
return Vector2.INF
func _screen_to_world(pos: Vector2) -> Vector2:
return (pos - _pan_offset) / _zoom
+131 -5
View File
@@ -35,6 +35,7 @@ const SETTINGS_PATH: String = "user://prop_editor_settings.json"
const RECENT_MAX: int = 5
const OFF_ORIGIN_WARNING_MIN_DISTANCE: float = 1024.0
const OFF_ORIGIN_WARNING_BOUNDS_MULTIPLIER: float = 2.0
const NORMALIZED_MAX_EXTENT: float = 96.0
const MENU_NEW := 0
const MENU_LOAD := 2
@@ -86,6 +87,7 @@ static func should_warn_off_origin(has_geometry: bool, bounds: Rect2) -> bool:
@onready var _prop_name_edit: LineEdit = %PropNameEdit
@onready var _canvas: PropCanvas = %PropCanvas
@onready var _mode_bar: HBoxContainer = %ModeBar
@onready var _size_tools: FlowContainer = %SizeTools
@onready var _attach_tools: FlowContainer = %AttachTools
@onready var _canvas_workspace: HSplitContainer = %CanvasWorkspace
@onready var _main_layout: HSplitContainer = %MainLayout
@@ -138,6 +140,10 @@ var _updating_inspector: bool = false
var _grid_spin: SpinBox
var _snap_check: CheckBox
var _center_prop_button: Button
var _normalize_size_button: Button
var _show_stickman_guide_check: CheckBox
var _show_stickman_guide: bool = false
var _whole_prop_resize_button: Button
# Edit-mode toolbar (Round 2/3)
var _mode_group: ButtonGroup
@@ -196,6 +202,7 @@ func _ready() -> void:
_setup_menu_bar()
_build_mode_toolbar()
_build_top_right_controls()
_build_size_tools()
_build_inspector()
_build_attach_tools()
_attach_preview = PropAttachPreviewScene.instantiate() as Control
@@ -212,6 +219,9 @@ func _ready() -> void:
_canvas.mode_changed.connect(_on_canvas_mode_changed)
_canvas.anchors_changed.connect(_on_canvas_anchors_changed)
_canvas.anchor_selected.connect(_on_canvas_anchor_selected)
_canvas.whole_prop_resize_requested.connect(_on_whole_prop_resize_requested)
_canvas.whole_prop_move_requested.connect(_on_whole_prop_move_requested)
_canvas.whole_prop_rotate_requested.connect(_on_whole_prop_rotate_requested)
_canvas.set_grid_size(_theme.grid_size())
_canvas.set_snap_enabled(false)
_canvas.set_has_clipboard(false)
@@ -220,6 +230,7 @@ func _ready() -> void:
var origin_marker_color: Color = _theme.text_muted_color()
origin_marker_color.a = 0.75
_canvas.set_origin_marker_color(origin_marker_color)
_canvas.set_show_stickman_guide(_show_stickman_guide)
_prop_name_edit.text_changed.connect(_on_name_changed)
_prop_name_edit.add_theme_font_size_override("font_size", _theme.font_prop_name())
@@ -251,6 +262,16 @@ func _process(delta: float) -> void:
func _unhandled_key_input(event: InputEvent) -> void:
if event is InputEventKey:
var key := event as InputEventKey
if key.pressed and not key.echo and key.keycode == KEY_ESCAPE:
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if not (focus_owner is LineEdit or focus_owner is TextEdit):
if _canvas.is_whole_prop_resize_dragging():
_canvas.cancel_whole_prop_resize()
get_viewport().set_input_as_handled()
elif _canvas.is_whole_prop_resize_enabled():
_set_whole_prop_resize_enabled(false)
get_viewport().set_input_as_handled()
return
if key.pressed and not key.echo and (key.keycode == KEY_DELETE or key.keycode == KEY_BACKSPACE):
var focus_owner: Control = get_viewport().gui_get_focus_owner() if get_viewport() != null else null
if focus_owner == null or focus_owner is Label:
@@ -325,6 +346,8 @@ func _on_attach_mode_pressed() -> void:
func _on_canvas_mode_changed(mode: int) -> void:
if mode != PropCanvasScript.EditMode.TRANSFORM:
_set_whole_prop_resize_enabled(false)
var entering_attach: bool = mode == PropCanvasScript.EditMode.ATTACH and _last_canvas_mode != PropCanvasScript.EditMode.ATTACH
if entering_attach and _show_preview_check != null:
_show_preview_check.set_pressed_no_signal(true)
@@ -346,6 +369,10 @@ func _sync_mode_buttons(mode: int) -> void:
func _sync_mode_controls(mode: int) -> void:
if _whole_prop_resize_button != null:
_whole_prop_resize_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _normalize_size_button != null:
_normalize_size_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _attach_tools != null:
_attach_tools.visible = mode == PropCanvasScript.EditMode.ATTACH
if _add_section != null:
@@ -353,7 +380,7 @@ func _sync_mode_controls(mode: int) -> void:
if _delete_button != null:
_delete_button.visible = mode == PropCanvasScript.EditMode.TRANSFORM
if _shape_section != null:
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and _canvas.get_selected_index() >= 0
_shape_section.visible = mode != PropCanvasScript.EditMode.ATTACH and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_selected_index() >= 0
if _material_section != null:
_material_section.visible = mode != PropCanvasScript.EditMode.ATTACH
if _anchor_section != null:
@@ -534,6 +561,7 @@ func _push_recent(path: String) -> void:
func _load_settings() -> void:
_recent_props.clear()
_show_stickman_guide = false
if not FileAccess.file_exists(SETTINGS_PATH):
return
var file := FileAccess.open(SETTINGS_PATH, FileAccess.READ)
@@ -543,6 +571,7 @@ func _load_settings() -> void:
file.close()
if not parsed is Dictionary:
return
_show_stickman_guide = bool((parsed as Dictionary).get("show_stickman_guide", false))
var raw_list: Variant = (parsed as Dictionary).get("recent_props", [])
if raw_list is Array:
for entry: Variant in raw_list as Array:
@@ -551,7 +580,7 @@ func _load_settings() -> void:
func _save_settings() -> void:
var data := {"version": 1, "recent_props": _recent_props}
var data := {"version": 1, "recent_props": _recent_props, "show_stickman_guide": _show_stickman_guide}
var file := FileAccess.open(SETTINGS_PATH, FileAccess.WRITE)
if file == null:
return
@@ -614,13 +643,32 @@ func _build_top_right_controls() -> void:
_snap_check.custom_minimum_size.y = float(_theme.button_min_height())
_snap_check.toggled.connect(_on_top_snap_toggled)
_top_right.add_child(_snap_check)
func _build_size_tools() -> void:
_whole_prop_resize_button = Button.new()
_whole_prop_resize_button.text = "✥ Transform Whole Prop"
_whole_prop_resize_button.tooltip_text = "Move, rotate, or resize the whole prop including grip markers."
_whole_prop_resize_button.toggle_mode = true
_whole_prop_resize_button.custom_minimum_size.y = float(_theme.button_min_height())
_whole_prop_resize_button.toggled.connect(_set_whole_prop_resize_enabled)
_size_tools.add_child(_whole_prop_resize_button)
_normalize_size_button = Button.new()
_normalize_size_button.text = "📏 Normalize Size"
_normalize_size_button.custom_minimum_size.y = float(_theme.button_min_height())
_normalize_size_button.pressed.connect(_on_normalize_size_pressed)
_size_tools.add_child(_normalize_size_button)
_center_prop_button = Button.new()
_center_prop_button.text = "🎯 Center Prop"
_center_prop_button.tooltip_text = "Move the whole prop and its anchors to (0,0)."
_center_prop_button.custom_minimum_size.y = float(_theme.button_min_height())
_center_prop_button.mouse_filter = Control.MOUSE_FILTER_STOP
_center_prop_button.pressed.connect(func() -> void: _center_prop_on_origin())
_top_right.add_child(_center_prop_button)
_size_tools.add_child(_center_prop_button)
_show_stickman_guide_check = CheckBox.new()
_show_stickman_guide_check.text = "Show Stickman Guide"
_show_stickman_guide_check.button_pressed = _show_stickman_guide
_show_stickman_guide_check.custom_minimum_size.y = float(_theme.button_min_height())
_show_stickman_guide_check.toggled.connect(_on_stickman_guide_toggled)
_size_tools.add_child(_show_stickman_guide_check)
_refresh_scale_buttons()
func _on_top_grid_changed(value: float) -> void:
@@ -630,6 +678,35 @@ func _on_top_grid_changed(value: float) -> void:
func _on_top_snap_toggled(pressed: bool) -> void:
_canvas.set_snap_enabled(pressed)
func _on_normalize_size_pressed() -> void:
var bounds: Rect2 = _canvas.get_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
if is_finite(extent) and extent > 0.0:
_scale_document(NORMALIZED_MAX_EXTENT / extent)
_refresh_scale_buttons()
func _on_stickman_guide_toggled(enabled: bool) -> void:
_show_stickman_guide = enabled
_canvas.set_show_stickman_guide(enabled)
_save_settings()
func _set_whole_prop_resize_enabled(enabled: bool) -> void:
var allowed: bool = enabled and _canvas.get_edit_mode() == PropCanvasScript.EditMode.TRANSFORM and _canvas.has_shape_geometry()
_canvas.set_whole_prop_resize_enabled(allowed)
if _whole_prop_resize_button != null:
_whole_prop_resize_button.set_pressed_no_signal(allowed)
_sync_mode_controls(_canvas.get_edit_mode())
func _on_whole_prop_resize_requested(factor: float) -> void:
_scale_document(factor)
_refresh_center_prop_button()
func _on_whole_prop_move_requested(delta: Vector2) -> void:
_translate_document(delta)
func _on_whole_prop_rotate_requested(angle_rad: float) -> void:
_rotate_document(angle_rad)
func _build_exit_dialog() -> void:
_exit_dialog = ConfirmationDialog.new()
@@ -710,6 +787,7 @@ func _on_exit_cancel() -> void:
func _do_new() -> void:
_clear_pending_anchor_rename()
_set_whole_prop_resize_enabled(false)
_prop_name_edit.text = ""
_material_preset = PropBlockScript.MaterialPreset.NONE
_preset_option.selected = _material_preset
@@ -1163,7 +1241,7 @@ func _on_delete_shape_pressed() -> void:
func _refresh_inspector_from_selection() -> void:
_updating_inspector = true
var idx := _canvas.get_selected_index()
_shape_section.visible = idx >= 0
_shape_section.visible = idx >= 0 and not _canvas.is_whole_prop_resize_enabled() and _canvas.get_edit_mode() != PropCanvasScript.EditMode.ATTACH
if idx >= 0 and idx < _canvas.shapes.size():
var sd: Dictionary = _canvas.shapes[idx]
_fill_button.color = Color.from_string(str(sd.get("fill_color", "")), Color.GRAY)
@@ -1217,6 +1295,53 @@ func _refresh_center_prop_button() -> void:
if _center_prop_button == null: return
var bounds: Rect2 = _canvas.get_shapes_bounds()
_center_prop_button.disabled = not _canvas.has_shape_geometry() or bounds.get_center().is_zero_approx()
_refresh_scale_buttons()
func _refresh_scale_buttons() -> void:
if _normalize_size_button == null:
return
var bounds: Rect2 = _canvas.get_shapes_bounds()
var extent: float = maxf(bounds.size.x, bounds.size.y)
_normalize_size_button.disabled = not _canvas.has_shape_geometry() or not is_finite(extent) or extent <= 0.0
if _whole_prop_resize_button != null:
_whole_prop_resize_button.disabled = _canvas.get_edit_mode() != PropCanvasScript.EditMode.TRANSFORM or not _canvas.has_shape_geometry()
func _scale_document(factor: float) -> bool:
if not _canvas.can_scale_all_shapes(factor):
return false
var anchor_result: Dictionary = PropAnchorDataScript.try_scale_non_origin(_anchors, factor)
if not bool(anchor_result.get("ok", false)):
return false
var scaled_anchors: Dictionary = anchor_result.get("anchors", {})
var original_anchors: Dictionary = _anchors
_anchors = scaled_anchors
_canvas.set_anchor_data(_anchors)
if not _canvas.scale_all_shapes(factor):
_anchors = original_anchors
_canvas.set_anchor_data(_anchors)
return false
_selected_anchor_id = PropAnchorDataScript.preferred_editable_id(_anchors, _selected_anchor_id)
_canvas.select_anchor(_selected_anchor_id)
_refresh_anchor_inspector()
_refresh_scale_buttons()
_sync_attach_preview()
return true
func _translate_document(delta: Vector2) -> bool:
if not _canvas.can_translate_all_shapes(delta): return false
var result: Dictionary = PropAnchorDataScript.try_translate_non_origin(_anchors, delta)
if not bool(result.get("ok", false)): return false
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
if not _canvas.translate_all_shapes(delta): _anchors = original; _canvas.set_anchor_data(_anchors); return false
_refresh_anchor_inspector(); _sync_attach_preview(); return true
func _rotate_document(angle_rad: float) -> bool:
if not _canvas.can_rotate_all_shapes(angle_rad): return false
var result: Dictionary = PropAnchorDataScript.try_rotate_non_origin(_anchors, angle_rad)
if not bool(result.get("ok", false)): return false
var original: Dictionary = _anchors; _anchors = result.get("anchors", {}); _canvas.set_anchor_data(_anchors)
if not _canvas.rotate_all_shapes(angle_rad): _anchors = original; _canvas.set_anchor_data(_anchors); return false
_refresh_anchor_inspector(); _sync_attach_preview(); return true
func _center_prop_on_origin(focus_view: bool = true) -> bool:
if not _canvas.has_shape_geometry(): return false
@@ -1334,6 +1459,7 @@ func _build_json_data() -> Dictionary:
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
_clear_pending_anchor_rename()
_set_whole_prop_resize_enabled(false)
var model: Dictionary = PropFileScript.from_file_dict(json)
if model.is_empty():
return "The file could not be read: unsupported version or malformed data."
+8 -1
View File
@@ -687,6 +687,8 @@ func _enter_edit_shared() -> void:
var body := node as RigidBody2D
body.freeze_mode = RigidBody2D.FREEZE_MODE_KINEMATIC
body.freeze = true
if node is PropBlock:
(node as PropBlock).end_play_instance_scale()
_restore_authored_state()
# The freeze/teleport can take a physics frame or two to settle in the engine,
# so re-assert the authored transform on the next few physics frames.
@@ -767,6 +769,8 @@ func _enter_play_mode() -> void:
_collision_pairs.clear()
for node: Node2D in _world_children_selectable():
if node is RigidBody2D:
if node is PropBlock:
(node as PropBlock).begin_play_instance_scale()
(node as RigidBody2D).freeze = false
var rig_count := 0
for node: Node2D in _world_children_selectable():
@@ -810,6 +814,8 @@ func _restore_authored_state() -> void:
continue
node.global_position = entry.get("position", node.global_position)
node.global_rotation = entry.get("rotation", node.global_rotation)
if entry.has("scale"):
node.scale = entry["scale"]
if node is STICKMAN_RIG and entry.has("facing_profile"):
(node as STICKMAN_RIG).set_facing_profile(int(entry["facing_profile"]))
if node is RigidBody2D:
@@ -1293,7 +1299,8 @@ func _collect_scene_data(is_autosave: bool = false) -> Dictionary:
elif type == "prop":
var prop := n as PropBlock
entry["asset_id"] = prop.spawn_id
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop.scale)
var prop_scale: Vector2 = authored.get("scale", prop.scale)
entry["scale"] = STAGE_SCENE_IO.vector_to_data(prop_scale)
entry["material_preset"] = prop.material_preset
elif type == "area":
var area := n as TriggerArea
+57 -2
View File
@@ -10,12 +10,13 @@ extends Node2D
## global_rotation so it works for any Node2D. Hidden and disabled in PLAY mode.
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
const PropBlockScript := preload("res://scripts/prop_block.gd")
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
enum Handle { NONE, TRANSLATE, ROTATE }
enum Handle { NONE, TRANSLATE, ROTATE, SCALE }
# ---------------------------------------------------------------------------
# Signals
@@ -39,6 +40,10 @@ const ROTATE_WIDTH := 2.0
const ROTATE_HANDLE_MARGIN_PX := 48.0
const ROTATE_RING_TOLERANCE_PX := 12.0
const SCALE_CROSS_SIZE_PX := 5.0
const SCALE_HIT_RADIUS_PX := 12.0
const MIN_SCALE: float = 0.05
const MAX_SCALE: float = 20.0
# ---------------------------------------------------------------------------
# State
@@ -61,6 +66,10 @@ var _rotate_start_rotation: float = 0.0
var _rotate_start_angle: float = 0.0
var _rotate_center: Vector2 = Vector2.ZERO
var _box_rect: Rect2 = Rect2()
var _scale_start: Vector2 = Vector2.ONE
var _scale_pivot: Vector2 = Vector2.ZERO
var _scale_corner: Vector2 = Vector2.ZERO
var _scale_changed: bool = false
# ---------------------------------------------------------------------------
# Public API
@@ -99,6 +108,10 @@ func hit_test(world_pos: Vector2) -> Handle:
if target == null or not is_instance_valid(target):
return Handle.NONE
var rect := STAGE_SELECTION.get_world_aabb(target)
if target is PropBlockScript:
for corner: Vector2 in _corners(rect):
if world_pos.distance_to(corner) <= SCALE_HIT_RADIUS_PX / _zoom():
return Handle.SCALE
var ring := _rotate_ring_radius(rect)
if absf(world_pos.distance_to(rect.get_center()) - ring) <= ROTATE_RING_TOLERANCE_PX / _zoom():
return Handle.ROTATE
@@ -114,6 +127,14 @@ func begin_drag(world_pos: Vector2) -> void:
_rotate_start_rotation = _targets[0].global_rotation
_rotate_start_angle = (world_pos - _rotate_center).angle()
transform_started.emit(_targets.duplicate())
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
var target: Node2D = _targets[0]
var rect: Rect2 = STAGE_SELECTION.get_world_aabb(target)
_scale_pivot = target.global_position
_scale_corner = _nearest_corner(rect, world_pos)
_scale_start = target.scale
_scale_changed = false
transform_started.emit(_targets.duplicate())
## Begins a translate drag anchored on `node` (used when an object body is
@@ -155,16 +176,30 @@ func drag_to(world_pos: Vector2) -> void:
if Input.is_key_pressed(KEY_CTRL):
new_rotation = snappedf(new_rotation, deg_to_rad(15.0))
target.global_rotation = new_rotation
elif _dragging == Handle.SCALE and _targets.size() == 1 and _targets[0] is PropBlockScript:
var prop: Node2D = _targets[0]
var start: Vector2 = _scale_corner - _scale_pivot
var denominator: float = start.length_squared()
if denominator > 0.0001:
var requested: float = (world_pos - _scale_pivot).dot(start) / denominator
if is_finite(requested):
var lower: float = maxf(MIN_SCALE / maxf(absf(_scale_start.x), 0.0001), MIN_SCALE / maxf(absf(_scale_start.y), 0.0001))
var upper: float = minf(MAX_SCALE / maxf(absf(_scale_start.x), 0.0001), MAX_SCALE / maxf(absf(_scale_start.y), 0.0001))
var next_scale: Vector2 = _scale_start * clampf(requested, lower, upper)
if not next_scale.is_equal_approx(prop.scale):
prop.scale = next_scale
_scale_changed = true
_drag_last = world_pos
transform_updated.emit(_targets.duplicate())
queue_redraw()
func end_drag() -> void:
if _dragging != Handle.NONE and not _targets.is_empty():
if _dragging != Handle.NONE and not _targets.is_empty() and (_dragging != Handle.SCALE or _scale_changed):
transform_committed.emit(_targets.duplicate())
_dragging = Handle.NONE
_drag_anchor = null
_scale_changed = false
_translate_start_positions.clear()
@@ -194,6 +229,13 @@ func _draw() -> void:
if _targets.size() == 1 and is_instance_valid(_targets[0]):
var rect := STAGE_SELECTION.get_world_aabb(_targets[0])
draw_arc(rect.get_center(), _rotate_ring_radius(rect), 0.0, TAU, 64, ROTATE_COLOR, ROTATE_WIDTH / zoom, true)
if _targets[0] is PropBlockScript:
var half: float = SCALE_CROSS_SIZE_PX / zoom
for corner: Vector2 in _corners(rect):
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), Color(0.0, 0.0, 0.0, 0.85), 3.5 / zoom)
draw_line(corner + Vector2(-half, 0.0), corner + Vector2(half, 0.0), SELECTION_COLOR, 1.5 / zoom)
draw_line(corner + Vector2(0.0, -half), corner + Vector2(0.0, half), SELECTION_COLOR, 1.5 / zoom)
if _box_rect.size != Vector2.ZERO:
draw_rect(_box_rect.abs(), HOVER_COLOR, false, HOVER_WIDTH / zoom)
@@ -215,3 +257,16 @@ func _snap(v: Vector2) -> Vector2:
if snap_size <= 0.0:
return v
return Vector2(roundf(v.x / snap_size) * snap_size, roundf(v.y / snap_size) * snap_size)
func _corners(rect: Rect2) -> Array[Vector2]:
return [rect.position, Vector2(rect.end.x, rect.position.y), rect.end, Vector2(rect.position.x, rect.end.y)]
func _nearest_corner(rect: Rect2, world_pos: Vector2) -> Vector2:
var nearest: Vector2 = rect.position
var distance: float = INF
for corner: Vector2 in _corners(rect):
var candidate: float = corner.distance_squared_to(world_pos)
if candidate < distance:
distance = candidate
nearest = corner
return nearest
+499
View File
@@ -0,0 +1,499 @@
# Phase 10a — Prop Scaling
Status: **implemented and user verified** (amended and verified 2026-09-22).
Source: `plans/PHASE_10a_PROP_SCALING.md`. Phase 10a adds two distinct operations:
permanent size changes to a `.prop` document in the Prop Editor, and reversible
uniform instance scaling of one `PropBlock` in Sandbox EDIT mode. The final UI uses
origin-based Normalize Size and direct whole-prop Arrange gestures; it does not expose
a numeric document-scale control. Sandbox keeps single-prop corner handles that scale
around the prop's local origin.
## 1. Existing contracts and scope
- `PropCanvas` stores every shape's `PackedVector2Array` points and computes the
union bounds. `PropEditor` owns the matching anchor dictionary, dirty/autosave
flow, inspector, and the mode-specific top-bar controls. `PropAnchorData`
always synthesizes a fixed `origin` at `(0, 0)`.
- Circles carry both `points` and `radius`; both must remain consistent after a
document-wide uniform scale. Keep `shape_type`, `closed`, `vertex_flags`,
`reshaped`, fill, outline color, and material preset unchanged.
- `StageGizmos` currently has translation and rotation gestures and emits
`transform_started`, `transform_updated`, and `transform_committed`.
`SandboxStage` already uses these signals to sync frozen bodies, save authored
transforms, rebuild spatial cells, and mark the scene dirty.
- `SandboxStage._collect_scene_data()` already writes prop `scale`; scene loading
already reads it. `_restore_authored_state()` restores authored position and
rotation and zeroes rigid-body velocities without changing scale. No `.prop`
or `.scene` version change is required.
In scope: document scale/normalize controls, a passive 1:1 guide in the Prop
Editor, prop-only Sandbox scale handles, and focused verification. Keep the
existing built-in/custom prop rendering and one-hull custom collision policy.
Do not add Sandbox numeric scale fields, presets, new authoring modes, or
document-wide undo/redo. Debt item #37 continues to track undo.
The 2026-09-22 amendment replaces the visible, Arrange-only **Resize Whole
Prop** toggle with **Transform Whole Prop**. One union gizmo moves, rotates,
or uniformly resizes the complete document. It does not change Sandbox scaling.
## 2. Class and API contracts
| Class / file | Required contract |
| --- | --- |
| `class_name PropCanvas extends Control` (`scripts/prop_canvas.gd`) | Keep `can_scale_all_shapes()` / `scale_all_shapes()`, and add equivalent validated document helpers `can_translate_all_shapes(delta: Vector2) -> bool`, `translate_all_shapes(delta: Vector2) -> bool`, `can_rotate_all_shapes(angle_rad: float) -> bool`, and `rotate_all_shapes(angle_rad: float) -> bool`. Successful commit helpers mutate every shape and emit `shape_changed(shapes: Array[Dictionary])` exactly once; preflight/no-op/failure never mutates or emits. Add the transient whole-prop tool API in §3.4. `get_shapes_bounds() -> Rect2` remains authoritative for its union gizmo. |
| `class_name PropAnchorData extends RefCounted` (`scripts/prop_anchor_data.gd`) | Keep pure `try_scale_non_origin()`, and add pure `try_translate_non_origin(anchors: Dictionary, delta: Vector2) -> Dictionary` and `try_rotate_non_origin(anchors: Dictionary, angle_rad: float) -> Dictionary`, each returning `{ "ok": bool, "anchors": Dictionary }`. Translation changes editable positions only. Rotation rotates editable positions about `(0,0)` and adds the angle to their wrapped `rotation_deg`. Every helper regenerates canonical `origin` unchanged and rejects invalid results without partially modifying input. Existing `translated_non_origin()` may delegate to the validated helper. |
| `class_name PropEditor extends Control` (`scripts/prop_editor.gd`) | Own three atomic document transactions: `_translate_document(delta: Vector2) -> bool`, `_rotate_document(angle_rad: float) -> bool`, and existing `_scale_document(factor: float) -> bool`. Each prevalidates shapes and anchors, applies the anchor copy, commits canvas geometry once, rolls anchors back on an unexpected canvas failure, and refreshes inspector/preview/buttons through the existing shape-change path. Connect the typed request signals in §3.4. Persist only guide visibility in editor settings. |
| `class_name StageGizmos extends Node2D` (`scripts/stage_gizmos.gd`) | Extend `enum Handle` with `SCALE`. Draw and hit-test four corner crosses only when `_targets.size() == 1` and `_targets[0] is PropBlock`; keep hit radii and strokes in screen pixels via camera zoom. Capture start `scale: Vector2`, world origin, selected corner, and cursor at gesture start. Use one positive uniform multiplier through the drag; emit the existing transform signals. |
| `class_name SandboxStage extends Node2D` (`scripts/sandbox_stage.gd`) | Keep the current EDIT pointer routing and existing transform-signal handlers. Route scale gestures through the same physics-sync and authored-state commit path as move/rotate. No save/load or PLAY→EDIT reset rewrite is needed unless verification exposes a defect. |
| `class_name StageSelection extends RefCounted` (`scripts/stage_selection.gd`) | Its existing `get_world_aabb(node: Node2D) -> Rect2` transforms visual points to world space. Verify scaled/rotated built-in and custom props; change the method only if a specific failing case appears. |
No new persistent shape fields are required. The whole-prop tool adds three
typed commit-request signals. Use static type hints for new fields, parameters,
local values where inference is ambiguous, and return values. The signal
interfaces are:
```gdscript
signal shape_changed(shapes: Array[Dictionary])
signal transform_started(nodes: Array[Node2D])
signal transform_updated(nodes: Array[Node2D])
signal transform_committed(nodes: Array[Node2D])
signal whole_prop_move_requested(delta: Vector2)
signal whole_prop_rotate_requested(angle_rad: float)
signal whole_prop_resize_requested(factor: float)
```
## 3. Prop Editor document transaction
### 3.1 Explicit scale and 96 × 96 normalization
Do not expose a numeric scale textbox or Apply Scale button. In Arrange, the
mode top bar presents **Transform Whole Prop**, **📏 Normalize Size**, **🎯 Center
Prop**, and **Show Stickman Guide**, in that order. Normalize Size is a
document operation, not saved UI state: it uniformly fits valid shape-point
bounds to a 96 px maximum dimension around `(0, 0)`, preserves proportional
anchor offsets, and leaves outline width unchanged. Disable it when there is no
valid shape geometry. Transform Whole Prop supplies direct move, rotate, and
uniform resize gestures; its detailed snapshot and commit contract is in §3.4.
Transform Whole Prop and Normalize Size are hidden in Reshape and Attach, while
Center Prop and Show Stickman Guide remain available.
Normalize uses the shape-point union already returned by `get_shapes_bounds()`:
```gdscript
var extent: float = maxf(bounds.size.x, bounds.size.y)
var factor: float = 96.0 / extent
```
For a nonempty finite bounds with `extent > 0`, apply this factor uniformly
around `(0, 0)`. The resulting maximum geometry extent is 96 world pixels;
the smaller dimension retains its aspect ratio. A line with one nonzero
dimension is valid. Empty or point-only geometry, zero extent, nonfinite
values, or an output coordinate/radius that cannot be represented safely make
the action a no-op. Do not silently clamp the factor and miss the 96 px target.
Use a reasonable coordinate safety cap documented in code rather than relying
on floating-point overflow. Normalization does **not** call **Center Prop**:
off-origin content stays off-origin at its new proportional offset.
### 3.2 Geometry and anchor invariants
The transaction computes its result from a deep copy before updating either
owner. Transform every point of every polygon, line, and circle as `point *=
factor`. For every circle, also set `radius *= factor`. Do not regenerate
circle points: that would erase an intentionally reshaped circle or move its
center. Preserve `reshaped` exactly as stored. Keep `vertex_flags` in their
original index order. Outline width is a style value and stays unchanged;
the 96 px target measures shape points, not stroke thickness. Preserve the
canonical origin while multiplying all editable anchor positions by the same
factor. Anchor rotations and metadata stay unchanged.
Call `_canvas.can_scale_all_shapes(factor)` and
`PropAnchorData.try_scale_non_origin(_anchors, factor)` before changing either
owner. A failed preflight leaves both unchanged. Apply the prevalidated anchor
copy without emitting a separate document edit, then commit the canvas shapes
with the same factor; this synchronous path must use the same canvas validation
as preflight. Let its one `shape_changed` emission use the existing
`PropEditor._on_canvas_shape_changed()` dirty/autosave/preview refresh path.
Refresh selected anchor controls and any disabled button state afterward.
The scale operation must not reset pan/zoom, selection, edit mode, clipboard,
or guide visibility. Both successful actions mark the document dirty once;
changing the guide toggle does not.
Example with factor `2`:
```text
Before: circle points centered at (20, 10), radius 12;
primary_grip.position = (8, -3), origin = (0, 0)
After: circle points centered at (40, 20), radius 24;
primary_grip.position = (16, -6), origin = (0, 0)
```
Save, autosave, recovery, and load keep the existing `.prop` v1.0 shape and
anchor representation. `PropFile._serialize_shape()` must continue to coerce
export-format point dictionaries; never assign JSON arrays directly to a
`PackedVector2Array`.
### 3.3 Stickman scale guide
Add a **Show Stickman Guide** `CheckBox` in the Prop Editor canvas controls.
Default is OFF for a fresh settings file. Persist the Boolean as
`show_stickman_guide` in `user://prop_editor_settings.json` alongside
`recent_props`; a missing key means `false`, and old settings files remain
valid. Apply the loaded value during `_ready()` after the controls exist.
Toggling calls `PropCanvas.set_show_stickman_guide()`, saves settings, and
redraws, without changing `_dirty`, autosave, `.prop` data, or scene data.
Draw the same 1:1 head circle and bone-line reference already described by
`WholeStickmanPreview.GUIDE_JOINTS`, `GUIDE_BONES`, and
`GUIDE_HEAD_RADIUS`. Prefer preloading that source's constants or extracting
a shared constant-only guide source if compilation requires it; do not copy
the numerical joint table into a second implementation. Draw raw master
coordinates with `Hips = Vector2.ZERO` after `_draw_grid()` and before user
shapes, under the canvas's existing pan/zoom transform. This keeps a 96 px
prop directly comparable with the approximately 940 px standing figure in
every view. Use muted translucent strokes with width divided by `_zoom` for
legibility; keep the guide noninteractive and out of hit-testing, shape bounds,
thumbnails, and `.prop` serialization. Existing origin marker and selection
gizmos remain above it. New/load/reset do not forcibly change its saved
visibility preference.
### 3.4 Arrange-only Transform Whole Prop tool (2026-09-22 decision)
**Interaction and scope.** Replace **↗ Resize Whole Prop** with one prominent
toggle labeled **✥ Transform Whole Prop** in the Arrange mode top bar. It is available only in Arrange
(`EditMode.TRANSFORM`) when valid shape geometry exists. It starts OFF, remains
armed after each successful gesture, is session-only, and is excluded from
settings and `.prop` data. This extends the already learned per-shape Arrange
mapping to the full document:
| Target | Gesture | Whole-document result |
| --- | --- | --- |
| Any visible shape body | Drag | Move all shapes and editable anchors. Transparent gaps inside the union box do nothing. |
| Dot below union bounds | Drag | Rotate all shapes and editable anchors about canonical `(0,0)`. Ctrl snaps to 15°. |
| Any of four union corners | Drag | Uniformly resize all shapes and editable anchors about canonical `(0,0)`. |
Hit-test in the strict order **corner → rotation dot → visible shape body**.
This keeps small handles usable when they overlap geometry. Suppress per-shape
selection, translation/rotation/scale gizmos, and right-click menus while the
tool is armed. Preserve `_selected_shape_idx` internally and restore its
selection/inspector when the toggle turns OFF. Existing wheel/pinch zoom and
middle-button/touchpad pan remain available during the tool.
Draw one two-tone union-bounds outline, four large constant-screen-size corner
crosses (at least 12 px hit radius), and the familiar constant-screen-size
rotation dot below the union AABB. Keep the canonical origin marker visible;
if **Show Other Markers** hid it, restore that preference when the tool exits.
Also draw small passive markers for every non-origin anchor, with grip markers
clearly visible. During a gesture, the union outline and markers follow the
temporary preview. These markers are noninteractive and excluded from bounds,
hit-testing, thumbnails, and serialization. The tooltip must say that body,
dot, and corners move, rotate, and resize the whole prop including grip markers.
**Typed Canvas API and signals.** Replace resize-specific tool methods with:
```gdscript
enum WholePropDrag { NONE, MOVE, ROTATE, SCALE }
func set_whole_prop_transform_enabled(enabled: bool) -> void
func is_whole_prop_transform_enabled() -> bool
func is_whole_prop_transform_dragging() -> bool
func cancel_whole_prop_transform() -> void
signal whole_prop_move_requested(delta: Vector2)
signal whole_prop_rotate_requested(angle_rad: float)
signal whole_prop_resize_requested(factor: float)
```
`set_whole_prop_transform_enabled(false)` cancels a live preview before
disarming. `cancel_whole_prop_transform()` clears the active drag, snapshots,
preview arrays, accumulated rotation, and pending payload without changing
authoritative data or the armed state. `PropEditor` connects the three signals
to `_translate_document`, `_rotate_document`, and `_scale_document`. Do not
emit `anchors_changed` for these document gestures.
The implementation also retains resize-named compatibility aliases internally
(`set_whole_prop_resize_enabled`, `is_whole_prop_resize_enabled`,
`is_whole_prop_resize_dragging`, and `cancel_whole_prop_resize`) so existing
controller paths continue to target the same Transform Whole Prop state. They
are not separate user-facing tools.
**Snapshot and preview contract.** On pointer-down, capture deep starting
copies of `shapes: Array[Dictionary]` and normalized anchors, starting union
bounds, pointer, selected handle, and any gesture-specific state. Render all
motion from those starting copies into temporary preview shape/anchor arrays.
Do not replace authoritative `shapes` or editor `_anchors` before release;
therefore `get_shape_data()`, save, autosave, and recovery always see the
pre-drag document if their timer runs during a drag. Preview motion can redraw
each input event but never emits edit signals or arms autosave.
All gestures preserve shape type, layer order, `closed`, `vertex_flags`,
`reshaped`, fill/outline, and outline width, plus anchor role, z offset,
allowed hands, and IDs. Canonical `origin` remains at `(0,0)` with rotation
`0°`. Circle behavior is explicit: move and rotate transform its stored points
but leave `radius` unchanged; resize transforms its points and multiplies
`radius`. Never regenerate circle points.
**Move math.** Capture `start_pointer`; compute from the baseline every frame:
```gdscript
var delta: Vector2 = current_pointer - start_pointer
if snap_enabled:
delta = Vector2(snappedf(delta.x, grid_size), snappedf(delta.y, grid_size))
preview_point = start_point + delta
preview_anchor.position = start_anchor.position + delta
```
Snap the **delta**, so all layers and anchors retain their relative placement
even when the starting document is off-grid. Anchor `rotation_deg` and circle
radius remain unchanged. Moving the whole prop means moving authored geometry
and editable anchor markers relative to the fixed document pivot; the canonical
origin itself does not translate. A zero delta is a no-op.
**Rotate math.** The pivot is always canonical `Vector2.ZERO`, independent of
the union-bounds center. Capture the pointer angle around origin and accumulate
wrapped frame deltas so crossing `-PI/PI` cannot jump:
```gdscript
var frame_delta: float = wrapf(current_angle - previous_angle, -PI, PI)
total_angle += frame_delta
previous_angle = current_angle
var preview_angle: float = snappedf(total_angle, deg_to_rad(15.0)) if ctrl_pressed else total_angle
preview_point = start_point.rotated(preview_angle)
preview_anchor.position = start_anchor.position.rotated(preview_angle)
preview_anchor.rotation_deg = wrapf(start_anchor.rotation_deg + rad_to_deg(preview_angle), -180.0, 180.0)
```
Reject pointer-down or motion whose angle around origin is nonfinite or too
close to origin for stable measurement. Always render the captured baseline
rotated by `preview_angle`; never rotate the prior preview. The rotation dot's
union-bounds location is only an input affordance and does not change the pivot.
Circle radius and `reshaped` remain unchanged. An approximately zero angle is
a no-op.
**Resize math.** Preserve the implemented uniform resize rule. Compute the
gesture direction from the starting union center and selected starting corner,
but measure pointer travel from the exact captured press position:
```gdscript
var direction: Vector2 = start_corner - start_bounds.get_center()
var pointer_delta: Vector2 = current_pointer - start_pointer
var factor: float = 1.0 + pointer_delta.dot(direction) / direction.length_squared()
```
The factor is exactly `1.0` on pointer-down even when the press is off the
handle center. Apply it to points, circle radii, and editable anchor positions
about `(0,0)`; anchor rotations stay unchanged. Clamp to positive finite
`0.01..10.0`, satisfy canvas coordinate/radius and anchor preflight, and never
cross zero or flip geometry. Reject a near-zero direction denominator. An
approximately `1.0` factor is a no-op.
**Commit and cancellation.** On left release, capture the final payload, clear
the temporary preview, and emit exactly one matching typed request signal.
The controller transaction prevalidates geometry and anchors, applies both
atomically, and lets the canvas's single `shape_changed` emission mark dirty,
arm autosave once, refresh inspector/preview/buttons, and retain the armed
Transform Whole Prop tool. An invalid/no-op release emits nothing. On an
unexpected canvas commit failure, restore the original anchors and leave the
document unchanged.
Esc during a drag cancels only that preview and keeps the tool armed. Esc while
armed but idle turns the tool OFF. Toggling OFF, changing to Reshape/Attach,
New, Load, or Clear cancels a live preview and disarms before changing mode or
document. Route Esc through `PropEditor._unhandled_key_input()` while respecting
dialogs and text fields, and consume it. A committed gesture is not reverted by
Esc because document-wide UndoRedo remains deferred under debt #37. Each move,
rotate, or resize gesture must become one future shapes-plus-anchors undo unit.
```text
Arrange, shape scope Transform Whole Prop ON
selected-shape bbox all-shape union bbox
body = move one shape body = move entire document
dot = rotate one shape dot = rotate entire document
corners = resize one shape corners = resize entire document
pointer down → deep snapshots → temporary preview from baseline
→ release → one typed controller transaction
→ shape_changed once → dirty/autosave once → tool remains armed
↘ Esc/mode/data change → discard preview, no dirty change
```
## 4. Sandbox Stage instance gesture
### 4.1 Visual and input contract
When exactly one `PropBlock` is selected in EDIT, draw four crosses on the
corners of `StageSelection.get_world_aabb(prop)`. Use a contrasting backing
and bright core so they remain visible on light/dark props, and a 12 screen px
hit radius. Handle hit-testing precedes the existing rotate ring, then normal
body selection; an active placement mode keeps the current gizmo precedence.
No scale handles appear for multi-selection, terrain, trigger areas,
stickmen, missing-prop placeholders, DIRECT, or PLAY. The existing rotation
ring and body translation remain available.
On pointer-down, record the selected corner's world point, the prop's
`global_position` as pivot, and its starting `scale: Vector2`. For each drag
update, project the cursor vector onto the start-corner vector around the
fixed pivot:
```gdscript
var start: Vector2 = start_corner_world - pivot_world
var current: Vector2 = cursor_world - pivot_world
var requested: float = current.dot(start) / start.length_squared()
var new_scale: Vector2 = start_scale * positive_clamped_multiplier
```
Guard near-zero denominators/nonfinite values. Clamp the multiplier so the
absolute values of both final scale components stay within `0.05..20.0` and
never cross zero; retain the signs of any previously saved scale. The gesture
multiplies both axes by **the same positive factor**, preserving the instance's
starting aspect ratio even if an older `.scene` contains nonuniform scale.
It changes only `prop.scale`, never `global_position`, `global_rotation`,
template vertices, radius, anchors, `material_preset`, or `mass`. Compute from
the captured starting scale on every motion event rather than multiplying the
previous frame, avoiding drift. Draw from the updated world AABB while moving.
One release emits one `transform_committed` and enters the existing dirty/save
pipeline. No motion or invalid gesture must not produce a scale edit; keep
the existing move/rotate behavior unless a focused regression needs repair.
### 4.2 Physics and lifecycle
The `PropBlock` root is a `RigidBody2D`; its child visuals and collision
geometry inherit its node transform. `material_preset` remains the sole mass
source (`PropBlock.mass_for()`), so scale must not recalculate mass. During
EDIT drag, use the existing `_on_gizmo_transform_updated()`/
`_sync_editable_physics_transform()` path to keep the frozen physics body in
sync. On commit, `_on_transform_committed()` saves authored state and rebuilds
grid cells. PLAY may move/rotate the prop physically; returning to EDIT must
restore authored position/rotation and zero velocities while retaining the
authored scale. Saving during PLAY must serialize authored position/rotation
and the selected scale as the current code already does.
Godot warns that scaling collision shapes can produce unexpected physics
behavior. Verify polygon and circle contacts at several positive scales in
the project engine, including a rotated prop and PLAY→EDIT→PLAY. If the
`RigidBody2D` root transform does not give matching collisions, adapt the
per-instance collision construction while keeping the scene's scale field,
visual size, preset mass, and source `.prop` unchanged. A visually scaled prop
with stale collision is not an acceptable implementation.
## 5. Data and signal flow
```mermaid
flowchart LR
UI[Whole-prop body, dot, or corner drag] --> V[Snapshot-only live preview]
V -->|one typed request on release| E[PropEditor validates transaction]
N[Normalize Size] --> E
E --> A[PropAnchorData transforms non-origin anchors]
E --> C[PropCanvas transforms every shape]
C -->|shape_changed once| D[Dirty + autosave + preview refresh]
D --> P[Existing PropFile v1.0 save]
P --> L[Sandbox PropBlock instance]
G[StageGizmos prop corner drag] -->|uniform prop.scale| S[Stage transform signals]
S --> F[Frozen-body sync + authored state + dirty]
F --> I[Existing .scene scale field]
```
```text
Sandbox EDIT selection
├─ one PropBlock → AABB + rotate ring + four scale crosses
└─ any other selection → existing AABB/rotate behavior, no scale crosses
PLAY → EDIT → PLAY
authored {position, rotation, scale} → simulate → restore position/rotation
and zero velocities → retain authored scale → simulate at retained size
```
Example persisted values remain additive to existing formats:
```json
{"version":"1.0","shapes":[{"shape_type":"circle","points":[{"x":24,"y":0},{"x":0,"y":24},{"x":-24,"y":0},{"x":0,"y":-24}],"radius":24,"reshaped":false}],"anchors":{"origin":{"role":"pivot","x":0,"y":0},"primary_grip":{"role":"grip","x":12,"y":-4}}}
```
```json
{"type":"prop","file_id":"prop_1","asset_id":"res://props/ball.prop","position":{"x":100,"y":200},"rotation":0,"scale":{"x":1.5,"y":1.5},"material_preset":2}
```
The JSON fragments show only the fields relevant to scaling; ordinary
serializer fields remain present in real files.
## 6. Implementation order and acceptance
1. Keep the existing scale transaction, guide, and whole-prop resize behavior;
add validated geometry/anchor move and rotate helpers plus atomic editor
transactions.
2. Rename and extend the Arrange-only toggle/gizmo with body move and rotation
dot gestures, retaining snapshot preview and resize behavior.
3. Implement prop-only Stage scale crosses and pointer gesture through the
existing transform signal flow. Leave persistence/lifecycle code alone
unless an assertion fails.
4. Run Godot headless syntax checks for every changed script and focused
headless tests. Per project instructions, use
`Godot_v4.7.1-stable_win64_console.exe --headless --check-only --script
res://scripts/<changed>.gd --path C:\Godot4\stickman`; if class caches need
refresh, run a headless editor scan first, then repeat checks. Fix failures
before handoff.
Acceptance checks:
- A `200%` document-scale transaction doubles all point coordinates, circle radii, and
editable anchor offsets, keeps origin `(0,0)`, and produces one dirty edit.
A reshaped circle remains reshaped; style widths, flags, and order remain.
- Normalize a 192 × 48 bounds to 96 × 24; a nonzero off-origin center scales
proportionally and is not silently centered. Invalid/empty geometry is a
no-op. Save/load and autosave/recovery retain exact point and anchor values.
- The guide starts hidden with no settings file, can be enabled in all modes,
stays aligned with world origin through pan/zoom, survives editor restart
through settings, and never enters a `.prop` file or a thumbnail.
- In Arrange, **Transform Whole Prop** shows one union outline, four large
corners, and a rotation dot. Body drag moves all points and non-origin
anchors; with Snap ON, its baseline-derived delta lands on the current grid.
Circle radius and anchor angles do not change. Canonical origin stays fixed.
- Rotation-dot drag rotates all points and anchor positions about `(0,0)` and
adds the same wrapped angle to every editable anchor's `rotation_deg`.
Circle radius/primitive state stay fixed, crossing ±180° is continuous, and
Ctrl produces 15° increments.
- Corner drag retains the verified uniform origin-based resize behavior,
including circle radius and anchor positions, without changing anchor angle.
- During every gesture, authoritative shape/anchor export stays at the baseline
while the union outline, shapes, and visible markers show preview copies.
Release performs one matching editor transaction, one `shape_changed`, and
one dirty/autosave arm. No-op release and cancellation emit nothing.
- Esc cancels an active drag but leaves the tool armed; idle Esc, toggle OFF,
mode switch, New, Load, or Clear cancel and disarm without a dirty edit. The
tool stays armed after a successful commit. Turning it OFF restores the
former selected shape and per-shape Arrange behavior.
- Focused headless tests assert numeric point/radius/anchor values, anchor-angle
wrapping, snap deltas, signal counts, baseline immutability during preview,
resize regression, no-op/cancel behavior, and selection/mode restoration.
- Exactly one selected prop exposes four usable crosses. Dragging retains its
aspect ratio and pivot, never flips it, changes collision bounds along with
visuals, and leaves material mass unchanged. Other object types and multiple
selection do not expose scale handles.
- Save/reload retains prop scale. PLAY→EDIT restores position/rotation and
zeroes velocities while retaining scale; a second PLAY collides at the
retained size. Existing rotation, translation, selection, and custom-prop
rendering still work.
## 7. Trade-offs and deferred work
- Origin-based sizing is predictable for anchor offsets and preserves the
fixed `origin`, but an off-origin prop moves relative to its bbox center
when resized. **Center Prop** remains the explicit remedy; no auto-centering.
- Geometry bounds exclude outline stroke and physics center of mass, matching
current Center Prop semantics and debt item #39.
- Stage scaling uses one rigid-body root transform and the existing `.scene`
scale field. Physics accuracy must be proved in the project engine; a
per-instance collider adjustment is a contingency, not a separate user UI.
- No document-wide undo is added; debt item #37 remains open.
- One visible **Transform Whole Prop** toggle avoids three competing subtools,
a hidden modifier key, or a fourth primary mode. Its body/dot/corner mapping
mirrors per-shape Arrange. Each completed move, rotate, or resize is one
future undo unit; Esc provides cancellation before commit, while existing
save/recovery protects committed work until document-wide undo is designed.
- Move translates authored geometry and non-origin anchors relative to the
fixed canonical pivot. Rotate uses that same pivot and changes anchor
orientation as well as position. This consistency benefits runtime mounting,
though an off-origin prop may orbit widely; **Center Prop** remains explicit.
The focused Phase 10a suite and Prop Editor startup smoke test passed, and the
user verified the final Prop Editor layout and controls in the Godot Editor.
+7 -1
View File
@@ -112,17 +112,23 @@ func _test_custom_prop_lifecycle() -> void:
await process_frame
var prop: PropBlock = stage._spawner.spawn_prop_asset(TEMP_PROP_PATH, Vector2(160, -40))
prop.rotation = deg_to_rad(20.0)
prop.scale = Vector2(1.5, 1.5)
stage._finalize_placed_node(prop)
var authored_position: Vector2 = prop.global_position
var authored_rotation: float = prop.global_rotation
var authored_scale: Vector2 = prop.scale
_check(prop.freeze, "custom prop is frozen in EDIT")
stage.set_mode(stage.StageMode.PLAY)
_check(not prop.freeze, "custom prop unfreezes in PLAY")
var play_scene: Dictionary = stage._collect_scene_data()
var play_entry: Dictionary = (play_scene.get("objects", []) as Array)[0]
var saved_scale: Vector2 = Vector2(float((play_entry.get("scale", {}) as Dictionary).get("x", 0.0)), float((play_entry.get("scale", {}) as Dictionary).get("y", 0.0)))
_check(saved_scale.is_equal_approx(authored_scale), "save during PLAY preserves authored prop scale")
prop.global_position += Vector2(90, 70)
prop.global_rotation += 0.4
prop.linear_velocity = Vector2(50, 20)
prop.angular_velocity = 2.0
stage.set_mode(stage.StageMode.EDIT)
_check(prop.freeze and prop.global_position.is_equal_approx(authored_position) and is_equal_approx(prop.global_rotation, authored_rotation) and prop.linear_velocity.is_zero_approx() and is_zero_approx(prop.angular_velocity), "PLAY to EDIT restores custom prop authored pose and clears velocity")
_check(prop.freeze and prop.global_position.is_equal_approx(authored_position) and is_equal_approx(prop.global_rotation, authored_rotation) and prop.scale.is_equal_approx(authored_scale) and prop.linear_velocity.is_zero_approx() and is_zero_approx(prop.angular_velocity), "PLAY to EDIT restores authored pose, keeps scale, and clears velocity")
stage.queue_free()
await process_frame
+193
View File
@@ -0,0 +1,193 @@
extends SceneTree
const PROP_CANVAS := preload("res://scripts/prop_canvas.gd")
const PROP_ANCHORS := preload("res://scripts/prop_anchor_data.gd")
const STAGE_GIZMOS := preload("res://scripts/stage_gizmos.gd")
const PROP_BLOCK := preload("res://scripts/prop_block.gd")
const STAGE_SELECTION := preload("res://scripts/stage_selection.gd")
var failures: int = 0
var checks: int = 0
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var canvas: PropCanvas = PROP_CANVAS.new()
root.add_child(canvas)
canvas.set_shape_data([{"shape_type": "circle", "points": PackedVector2Array([Vector2(8, 4), Vector2(4, 8), Vector2(0, 4), Vector2(4, 0)]), "radius": 4.0, "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0]), "reshaped": true}])
var signal_state: Dictionary = {"count": 0}
canvas.shape_changed.connect(func(_shapes: Array[Dictionary]) -> void: signal_state["count"] = int(signal_state["count"]) + 1)
_check(canvas.can_scale_all_shapes(2.0), "valid document scale preflights")
_check(canvas.scale_all_shapes(2.0), "valid document scale commits")
var scaled: Dictionary = canvas.shapes[0]
var points: PackedVector2Array = scaled["points"]
_check(points[0].is_equal_approx(Vector2(16, 8)) and is_equal_approx(float(scaled["radius"]), 8.0) and bool(scaled["reshaped"]), "scale preserves circle points, radius, and reshaped flag")
_check(int(signal_state["count"]) == 1, "document scale emits one shape change")
var identity_result: bool = canvas.scale_all_shapes(1.0)
_check(not identity_result, "identity scale emits no edit")
var anchors: Dictionary = {"origin": {"role": "pivot", "position": Vector2(12, 4)}, "primary_grip": {"role": "grip", "position": Vector2(8, -3), "rotation_deg": 20.0, "z_index_offset": 2, "allowed_hands": "left_only"}}
var anchor_result: Dictionary = PROP_ANCHORS.try_scale_non_origin(anchors, 2.0)
var grip: Dictionary = anchor_result["anchors"]["primary_grip"]
_check(bool(anchor_result["ok"]) and (grip["position"] as Vector2).is_equal_approx(Vector2(16, -6)) and (anchor_result["anchors"]["origin"] as Dictionary)["position"] == Vector2.ZERO, "anchor scale preserves metadata and canonical origin")
_check(not bool(PROP_ANCHORS.try_scale_non_origin(anchors, -1.0)["ok"]), "invalid anchor scale is rejected")
canvas.set_shape_data([{"shape_type": "polygon", "points": PackedVector2Array([Vector2(96, 48), Vector2(288, 48), Vector2(288, 96), Vector2(96, 96)]), "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0])}])
var normalization_factor: float = 96.0 / maxf(canvas.get_shapes_bounds().size.x, canvas.get_shapes_bounds().size.y)
_check(canvas.scale_all_shapes(normalization_factor), "normalization scale commits")
var normalized_bounds: Rect2 = canvas.get_shapes_bounds()
_check(is_equal_approx(maxf(normalized_bounds.size.x, normalized_bounds.size.y), 96.0) and normalized_bounds.get_center().is_equal_approx(Vector2(96, 36)), "normalization reaches 96 px without centering off-origin geometry")
canvas.set_shape_data([{"shape_type": "polygon", "points": PackedVector2Array([Vector2(10, 10), Vector2(30, 10), Vector2(30, 30), Vector2(10, 30)]), "closed": true, "vertex_flags": PackedInt32Array([0, 0, 0, 0])}])
canvas.set_anchor_data({"origin": {"role": "pivot", "position": Vector2.ZERO}, "primary_grip": {"role": "grip", "position": Vector2(8, 4)}})
var whole_resize: Dictionary = {"count": 0, "factor": 0.0}
canvas.whole_prop_resize_requested.connect(func(factor: float) -> void: whole_resize["count"] = int(whole_resize["count"]) + 1; whole_resize["factor"] = factor)
canvas.set_whole_prop_resize_enabled(true)
canvas._begin_whole_prop_resize(Vector2(30, 30))
canvas._update_whole_prop_resize(Vector2(40, 40))
_check(canvas.shapes[0]["points"] is PackedVector2Array and (canvas.shapes[0]["points"] as PackedVector2Array)[2].is_equal_approx(Vector2(30, 30)), "whole-prop drag previews without mutating document geometry")
canvas._end_whole_prop_resize()
_check(int(whole_resize["count"]) == 1 and float(whole_resize["factor"]) > 1.0, "whole-prop drag requests one uniform commit on release")
canvas.set_whole_prop_resize_enabled(true)
canvas._begin_whole_prop_resize(Vector2(30, 30))
canvas._update_whole_prop_resize(Vector2(40, 40))
canvas._update_whole_prop_resize(Vector2(30, 30))
canvas._end_whole_prop_resize()
_check(int(whole_resize["count"]) == 1, "whole-prop drag returning to start does not commit")
canvas.set_whole_prop_resize_enabled(true)
canvas._begin_whole_prop_resize(Vector2(30, 30))
canvas._update_whole_prop_resize(Vector2(40, 40))
canvas.cancel_whole_prop_resize()
_check(not canvas.is_whole_prop_resize_dragging() and int(whole_resize["count"]) == 1, "whole-prop cancellation discards preview without commit")
var whole_move: Dictionary = {"count": 0, "delta": Vector2.ZERO}
var whole_rotate: Dictionary = {"count": 0, "angle": 0.0}
canvas._zoom = 1.0
canvas.whole_prop_move_requested.connect(func(delta: Vector2) -> void: whole_move["count"] = int(whole_move["count"]) + 1; whole_move["delta"] = delta)
canvas.whole_prop_rotate_requested.connect(func(angle: float) -> void: whole_rotate["count"] = int(whole_rotate["count"]) + 1; whole_rotate["angle"] = angle)
canvas._whole_prop_dragging = true
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.MOVE
canvas._whole_prop_delta = Vector2(11, 9)
canvas._end_whole_prop_resize()
_check(int(whole_move["count"]) == 1 and (whole_move["delta"] as Vector2).is_equal_approx(Vector2(11, 9)), "whole-prop body drag requests baseline move")
canvas.set_snap_enabled(true)
canvas.set_grid_size(15)
canvas._whole_prop_dragging = true
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.MOVE
canvas._whole_prop_start_pointer = Vector2.ZERO
canvas._whole_prop_start_shapes = canvas.shapes.duplicate(true)
canvas._whole_prop_start_anchors = canvas.get_anchor_data().duplicate(true)
canvas._update_whole_prop_resize(Vector2(17, 13))
var moved_preview: PackedVector2Array = canvas._whole_prop_preview_shapes[0]["points"]
var moved_anchor: Vector2 = (canvas._whole_prop_preview_anchors["primary_grip"] as Dictionary)["position"]
_check(moved_preview[0].is_equal_approx(Vector2(25, 25)) and moved_anchor.is_equal_approx(Vector2(23, 19)) and (canvas.shapes[0]["points"] as PackedVector2Array)[0].is_equal_approx(Vector2(10, 10)), "whole-prop move previews snapped baseline delta without mutation")
canvas._update_whole_prop_resize(Vector2.ZERO)
canvas._end_whole_prop_resize()
_check(int(whole_move["count"]) == 1, "whole-prop move returning to start does not commit")
canvas.set_snap_enabled(false)
canvas._begin_whole_prop_resize(Vector2(20, 56))
canvas._update_whole_prop_resize(Vector2(-56, 20))
canvas._end_whole_prop_resize()
_check(int(whole_rotate["count"]) == 1 and absf(float(whole_rotate["angle"])) > 0.1, "whole-prop rotation dot requests wrapped rotation")
canvas._whole_prop_dragging = true
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.ROTATE
canvas._whole_prop_start_shapes = canvas.shapes.duplicate(true)
canvas._whole_prop_start_anchors = canvas.get_anchor_data().duplicate(true)
canvas._whole_prop_previous_angle = 3.13
canvas._whole_prop_total_angle = 0.0
canvas._update_whole_prop_resize(Vector2(cos(-3.13), sin(-3.13)))
var wrapped_angle: float = canvas._whole_prop_preview_angle
var rotated_anchor: Dictionary = canvas._whole_prop_preview_anchors["primary_grip"]
_check(absf(wrapped_angle) < 0.1 and not (rotated_anchor["position"] as Vector2).is_equal_approx(Vector2(8, 4)) and not is_equal_approx(float(rotated_anchor["rotation_deg"]), 0.0), "whole-prop rotation wraps across pi and rotates anchor position and angle")
canvas._update_whole_prop_resize(Vector2(cos(3.13), sin(3.13)))
canvas._end_whole_prop_resize()
_check(int(whole_rotate["count"]) == 1, "whole-prop rotation returning to start does not commit")
canvas._whole_prop_dragging = true
canvas._whole_prop_drag = PROP_CANVAS.WholePropDrag.ROTATE
canvas._whole_prop_start_shapes = canvas.shapes.duplicate(true)
canvas._whole_prop_start_anchors = canvas.get_anchor_data().duplicate(true)
canvas._whole_prop_previous_angle = 0.0
canvas._whole_prop_total_angle = 0.0
canvas._update_whole_prop_resize(Vector2(cos(deg_to_rad(22.0)), sin(deg_to_rad(22.0))), true)
var snapped_preview_angle: float = canvas._whole_prop_preview_angle
canvas._end_whole_prop_resize()
_check(is_equal_approx(snapped_preview_angle, deg_to_rad(15.0)) and is_equal_approx(float(whole_rotate["angle"]), deg_to_rad(15.0)) and int(whole_rotate["count"]) == 2, "Ctrl rotation commits the 15-degree preview angle")
var prop: PropBlock = PROP_BLOCK.new()
prop.polygon_points = PackedVector2Array([Vector2(-10, -10), Vector2(10, -10), Vector2(10, 10), Vector2(-10, 10)])
prop.freeze = true
root.add_child(prop)
await process_frame
var gizmos: StageGizmos = STAGE_GIZMOS.new()
root.add_child(gizmos)
gizmos.set_targets([prop])
var bounds: Rect2 = STAGE_SELECTION.get_world_aabb(prop)
var corner: Vector2 = bounds.end
_check(gizmos.hit_test(corner) == STAGE_GIZMOS.Handle.SCALE, "single prop exposes scale corner")
gizmos.begin_drag(corner)
gizmos.end_drag()
_check(not gizmos.is_dragging(), "stationary scale click ends without a persistent drag")
var initial_scale: Vector2 = prop.scale
gizmos.begin_drag(corner)
gizmos.drag_to(prop.global_position + (corner - prop.global_position) * 2.0)
gizmos.end_drag()
_check(is_equal_approx(prop.scale.x / initial_scale.x, 2.0) and is_equal_approx(prop.scale.y / initial_scale.y, 2.0), "corner drag applies uniform scale")
await physics_frame
var space: PhysicsDirectSpaceState2D = root.get_world_2d().direct_space_state
var polygon_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
polygon_inside.position = prop.global_position + Vector2(19.0, 0.0)
var polygon_outside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
polygon_outside.position = prop.global_position + Vector2(21.0, 0.0)
var polygon_inside_hits: Array[Dictionary] = space.intersect_point(polygon_inside)
var polygon_outside_hits: Array[Dictionary] = space.intersect_point(polygon_outside)
_check(not polygon_inside_hits.is_empty() and polygon_outside_hits.is_empty(), "scaled polygon collision matches its visual extent")
prop.global_rotation = deg_to_rad(20.0)
prop.begin_play_instance_scale()
prop.freeze = false
for frame: int in 3:
await physics_frame
var rotated_local_inside: Vector2 = Vector2(19.0, 0.0).rotated(prop.global_rotation)
var rotated_local_outside: Vector2 = Vector2(21.0, 0.0).rotated(prop.global_rotation)
polygon_inside.position = prop.global_position + rotated_local_inside
polygon_outside.position = prop.global_position + rotated_local_outside
_check(not space.intersect_point(polygon_inside).is_empty() and space.intersect_point(polygon_outside).is_empty(), "dynamic rotated polygon collision retains its scaled extent")
var circle: PropBlock = PROP_BLOCK.new()
circle.shape_type = PROP_BLOCK.ShapeType.CIRCLE
circle.radius = 10.0
circle.global_position = Vector2(100.0, 100.0)
circle.scale = Vector2(2.0, 2.0)
circle.freeze = true
root.add_child(circle)
await physics_frame
var circle_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
var circle_outside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "scaled circle collision matches its visual extent")
circle.begin_play_instance_scale()
circle.freeze = false
for frame: int in 3:
await physics_frame
circle_inside.position = circle.global_position + Vector2(19.0, 0.0)
circle_outside.position = circle.global_position + Vector2(21.0, 0.0)
_check(not space.intersect_point(circle_inside).is_empty() and space.intersect_point(circle_outside).is_empty(), "dynamic circle collision retains its scaled extent")
circle.freeze = true
circle.end_play_instance_scale()
circle.scale = Vector2(2.0, 1.0)
circle.begin_play_instance_scale()
await physics_frame
var ellipse_inside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
ellipse_inside.position = circle.global_position + Vector2(0.0, 9.0)
var ellipse_outside: PhysicsPointQueryParameters2D = PhysicsPointQueryParameters2D.new()
ellipse_outside.position = circle.global_position + Vector2(0.0, 11.0)
_check(not space.intersect_point(ellipse_inside).is_empty() and space.intersect_point(ellipse_outside).is_empty(), "legacy non-uniform circle scale uses matching ellipse collision")
circle.queue_free()
prop.queue_free()
gizmos.queue_free()
canvas.queue_free()
print("RESULT: %d checks, %d failures" % [checks, failures])
quit(1 if failures > 0 else 0)
func _check(condition: bool, label: String) -> void:
checks += 1
if condition:
print("PASS: " + label)
else:
failures += 1
print("FAIL: " + label)
+1
View File
@@ -0,0 +1 @@
uid://c0vrrhknni6hi