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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0vrrhknni6hi
|
||||
Reference in New Issue
Block a user