Add headless regression tests for Prop Editor functionality
- Implement tests for PropFile, including preset conversion, file dictionary conversion, round-trip serialization, version rejection, default values for missing keys, flattening, sanitization, and save/load functionality. - Create tests for PropEditor, covering window title formatting, mode button text, safe saving, and recent file list management. - Add tests for PropTheme, validating fallback behavior, invalid input handling, and default values for primitives and UI tokens. - Introduce tests for ThumbnailCache, ensuring proper key formatting, PNG handling, and behavior with missing files.
This commit is contained in:
@@ -0,0 +1,554 @@
|
||||
# Phase 9 — Prop Editor (Vector Prop Authoring Tool)
|
||||
|
||||
Status: **approved** (2026-09-18; all seven decisions and the five open choices resolved per user approval).
|
||||
|
||||
Source plan: `plans/PHASE_9_PROP_EDITOR.md` (four phases: theme/tokens → vector geometry & vertex editing → physics inspector → thumbnail caching).
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and proposed implementation decisions
|
||||
|
||||
This phase adds a **standalone Prop Editor** — the authoring counterpart to the Stickman
|
||||
Editor — for creating, editing, and saving vector prop assets (`.prop` files). It mirrors the
|
||||
existing `stickman_editor.gd` / `body_part_panel.gd` conventions (menu bar, save/load/clear,
|
||||
JSON serialization, canvas with zoom/pan/vertex-edit/gizmos, status bar) but adapts the canvas
|
||||
shape model to prop geometry (polygon / circle / line primitives, per-shape fill + outline +
|
||||
width, material presets) and adds the missing transform gizmos (translate / rotate / scale).
|
||||
|
||||
The seven design decisions below are **approved** (2026-09-18). Each is stated with its
|
||||
rationale and its approved resolution.
|
||||
|
||||
### Decision 1 — Standalone editor scene (recommended)
|
||||
|
||||
**Recommendation:** Add a new standalone `scenes/prop_editor.tscn` + `scripts/prop_editor.gd`
|
||||
(`class_name PropEditor extends Control`), launched via **F6**, **not** the configured
|
||||
`run/main_scene`.
|
||||
|
||||
**Rationale:**
|
||||
- Matches the established pattern: the stickman editor is a standalone `Control` scene; the
|
||||
sandbox stage is a separate scene/tool. The prop editor is an *authoring* tool, not a
|
||||
sandbox concern.
|
||||
- Keeps `sandbox_stage.gd` (already a large controller owning EDIT/DIRECT/PLAY, asset selector,
|
||||
rule builder, terrain, director) decoupled from authoring UI.
|
||||
- Nothing in the plan (Tasks 1.1–4.2) references sandbox placement; `.prop` files only need to
|
||||
round-trip into `PropUtils` *payloads* so placement can come later (see Decision 2).
|
||||
|
||||
**Node layout** (minimal `.tscn` shell, `%UniqueName` markers, UI built in code — mirrors
|
||||
`scenes/stickman_editor.tscn` and `scenes/asset_selector.tscn`):
|
||||
|
||||
```text
|
||||
PropEditor (Control) [script = scripts/prop_editor.gd]
|
||||
├── %MenuBar (MenuBar) File / Edit / View
|
||||
├── %TopBar (HBoxContainer) y = 32..64
|
||||
│ ├── NameLabel (Label "Prop Name:")
|
||||
│ └── %PropNameEdit (LineEdit, placeholder "Unnamed Prop")
|
||||
├── %MainLayout (HBoxContainer) y = 68..-28
|
||||
│ ├── %CanvasPanel (PanelContainer, stretch 2) styled dark bg (StyleBoxFlat)
|
||||
│ │ └── %PropCanvas (Control) [script = scripts/prop_canvas.gd]
|
||||
│ └── %InspectorColumn (VBoxContainer, stretch 1, ~300 px)
|
||||
│ └── %InspectorList (VBoxContainer) controls built in code (Task 1.3 / 3.x)
|
||||
├── %StatusBar (HBoxContainer) bottom 28 px
|
||||
│ ├── %CursorCoords (Label) "X: ### Y: ###"
|
||||
│ └── %SaveStatus (Label) material preset summary / dirty flag
|
||||
├── %SaveDialog (FileDialog, filters "*.prop ; Prop Files")
|
||||
├── %LoadDialog (FileDialog, filters "*.prop ; Prop Files")
|
||||
├── %ClearConfirmDialog (ConfirmationDialog)
|
||||
└── %ErrorDialog (AcceptDialog)
|
||||
```
|
||||
|
||||
### Decision 2 — `.prop` file format: versioned JSON, multi-shape, PropUtils-compatible
|
||||
|
||||
**Recommendation:** A versioned JSON asset at `res://props/*.prop` (new `props/` directory),
|
||||
version `"1.0"`, storing a **multi-shape stack** for full editor fidelity **plus** a
|
||||
**pure flattening converter** so any saved prop can later be placed via the existing
|
||||
`PropUtils.spawn_prop(container, pos, payload, material_preset)`.
|
||||
|
||||
**Rationale:**
|
||||
- The plan (Tasks 2.1, 2.4) is unambiguous: props are built from a **shape stack** with
|
||||
z-order ("Bring Forward"/"Send Back"), mirror, and clipboard — exactly like `BodyPartPanel`.
|
||||
- `PropBlock`/`PropUtils` are single-shape (`type` + `points|radius` + colors). So the file
|
||||
stores the editor's full `shapes[]` and a **deterministic static converter**
|
||||
`PropFile.flatten_to_payload(shapes)` produces the single payload the runtime already consumes.
|
||||
- Backward/forward compatible: the file is versioned and unknown/missing keys fall back
|
||||
gracefully (AGENTS.md rule: never change the meaning of an existing key).
|
||||
|
||||
The exact schema and the flattening rule are in Section 3.
|
||||
|
||||
### Decision 3 — `prop_theme.json` schema + fallback hierarchy
|
||||
|
||||
**Recommendation:** A committed `res://prop_theme.json` (top-level `res://`, matching the
|
||||
existing `res://sandbox_theme.json`), loaded once at editor startup by a new
|
||||
`scripts/prop_theme.gd` (`class_name PropTheme extends RefCounted`) modeled on
|
||||
`SandboxTheme`. Fallback hierarchy: **custom file → `PropTheme.DEFAULT_CONFIG` → hardcoded
|
||||
constants** (see Section 3 diagram).
|
||||
|
||||
**Rationale:**
|
||||
- `SandboxTheme` already implements the exact loader mechanics this needs (deep-merge,
|
||||
`value()`/`color()`/`number()` accessors, warn-once, clamp, invalid-input fallback). A
|
||||
parallel `PropTheme` keeps the two theme surfaces independently versioned and avoids
|
||||
entangling the sandbox theme with prop-editor tokens.
|
||||
- Physics values are deliberately **not** themeable (Decision 5) — only visual tokens and
|
||||
primitive defaults come from the theme.
|
||||
|
||||
### Decision 4 — Dedicated `PropCanvas` (recommended) vs reusing `BodyPartPanel`
|
||||
|
||||
**Recommendation:** A new `scripts/prop_canvas.gd` (`class_name PropCanvas extends Control`)
|
||||
that mirrors `BodyPartPanel`'s zoom/pan/vertex-edit/z-order behavior but adds prop-specific
|
||||
needs.
|
||||
|
||||
**Rationale:**
|
||||
- `BodyPartPanel` is a `PanelContainer` with an embedded title label + single-`Color` picker
|
||||
and a stickman shape schema (`shape_type` ∈ `"line"|"rectangle"|"circle"`, one `color` hex
|
||||
per shape). Props need **per-shape `fill_color` + `outline_color` + `outline_width`**, a
|
||||
`"polygon"` primitive, and **translate/rotate/scale gizmos** (Task 2.5) that `BodyPartPanel`
|
||||
does not implement (it only drags vertices/shape position).
|
||||
- `BodyPartPanel` delegates copy/paste to the editor; the prop canvas owns a local clipboard
|
||||
(Task 2.7). Reusing it would require invasive branching.
|
||||
|
||||
Full public API in Section 3.
|
||||
|
||||
### Decision 5 — Reuse `PropBlock` static preset tables as the single source of truth
|
||||
|
||||
**Recommendation:** `PropBlock.mass_for()`, `PropBlock.physics_material()`, and
|
||||
`PropBlock.tint_for()` remain the single source of truth for Task 1.2 / 3.1. `prop_theme.json`
|
||||
provides only **visual overrides / fallbacks** (material tint colors, outline width, primitive
|
||||
dimensions) and **never** duplicates mass/friction/bounce.
|
||||
|
||||
**Rationale:**
|
||||
- Avoids a second copy of preset physics that could drift from the runtime `PropBlock`.
|
||||
- Keeps "what does a WOOD prop weigh/feel like" in one place; the theme is presentation-only.
|
||||
|
||||
### Decision 6 — Reuse `PropThumbnail` + `ThumbnailCache`; mtime-keyed custom-prop PNGs
|
||||
|
||||
**Recommendation:** Reuse `scripts/thumbnails/prop_thumbnail.gd` (`PropThumbnail`) and
|
||||
`scripts/thumbnails/thumbnail_cache.gd` (`ThumbnailCache`) for Tasks 4.1/4.2. The editor calls
|
||||
`PropThumbnail.render(payload, material_preset)` then `ThumbnailCache.save_png(...)` on every
|
||||
save. Add **one small extension** to `ThumbnailCache`: mtime-keyed paths for custom `.prop`
|
||||
files (`prop_file_key(path)` / `prop_file_png(path)`), mirroring the existing
|
||||
`stickman_key()`/`stickman_png()`, so re-edited custom props invalidate their cached PNG.
|
||||
|
||||
**Rationale:**
|
||||
- `PropThumbnail` already renders the exact payload + preset a saved prop produces, into a
|
||||
200×200 transparent texture. No second renderer is needed.
|
||||
- Built-in template thumbnails stay keyed by `id_v<PROP_VERSION>` (`prop_png(id)`); custom
|
||||
`.prop` files are keyed by basename + mtime (they can be edited and re-saved, so version
|
||||
alone is insufficient).
|
||||
|
||||
Save-side flow in Section 3.
|
||||
|
||||
### Decision 7 — Headless tests for all non-drag behavior; manual verification for gizmo drags
|
||||
|
||||
**Recommendation:** Four headless suites under `tests/` (schema round-trip, theme fallback,
|
||||
canvas math incl. sanitize/clipboard/mirror/z-order, thumbnail cache keying). Interactive
|
||||
vertex/gizmo dragging (Tasks 2.2, 2.5) is manual verification only, because pointer-drag
|
||||
geometry cannot be asserted headlessly and matches existing practice (`test_phase3b_library.gd`
|
||||
etc.).
|
||||
|
||||
Details in Section 4 and the per-suite coverage in Section 2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Targets and ownership
|
||||
|
||||
| Target | Kind | Change |
|
||||
| --- | --- | --- |
|
||||
| `scenes/prop_editor.tscn` | **new** | Minimal shell: `%MenuBar`, `%TopBar`/`%PropNameEdit`, `%MainLayout`/`%CanvasPanel`/`%PropCanvas`/`%InspectorColumn`/`%InspectorList`, `%StatusBar`/`%CursorCoords`/`%SaveStatus`, `%SaveDialog`, `%LoadDialog`, `%ClearConfirmDialog`, `%ErrorDialog`. |
|
||||
| `scripts/prop_editor.gd` (`PropEditor`) | **new** | Main controller: menu bar, save/load/clear, JSON (de)serialization, inspector build + signal fan-out, material-preset application, thumbnail save. |
|
||||
| `scripts/prop_canvas.gd` (`PropCanvas`) | **new** | Multi-shape canvas: zoom/pan, vertex drag, insert/remove vertex, z-order, mirror, clipboard, translate/rotate/scale gizmos, grid/snap, sanitize-on-commit, cursor hit-testing. |
|
||||
| `scripts/prop_theme.gd` (`PropTheme`) | **new** | Theme loader (deep-merge, validated accessors, warn-once), mirrors `SandboxTheme`. |
|
||||
| `scripts/prop_file.gd` (`PropFile`) | **new** | Static `.prop` schema: `to_file_dict`/`from_file_dict`, `save`/`load`, preset string⇄enum, `sanitize_shape`, `flatten_to_payload`. |
|
||||
| `res://prop_theme.json` | **new** | Committed theme file (schema `"1.0"`); editable presentation tokens. |
|
||||
| `scripts/thumbnails/thumbnail_cache.gd` (`ThumbnailCache`) | **modified** | Add `prop_file_key(path)` / `prop_file_png(path)` (mtime-keyed custom-prop PNGs). No change to existing `prop_png`/`stickman_*`. |
|
||||
| `scripts/prop_block.gd` (`PropBlock`) | **unchanged** | `mass_for`/`physics_material`/`tint_for`, `ShapeType`, `MaterialPreset`, color/width constants reused as-is. |
|
||||
| `scripts/prop_utils.gd` (`PropUtils`) | **unchanged** | `spawn_prop`/`_apply_shape_payload`/`create_*` consumed by `PropFile.flatten_to_payload`. |
|
||||
| `tests/test_prop_editor_file.gd` | **new** | `.prop` schema: `from_file_dict`/`to_file_dict` round-trip, version rejection, missing-key defaults, preset string⇄enum, `flatten_to_payload` (polygon/circle/line, empty, z-order preference), `sanitize_shape`. |
|
||||
| `tests/test_prop_editor_theme.gd` | **new** | `PropTheme` fallback hierarchy (file → `DEFAULT_CONFIG` → hardcoded), invalid color/number fallback, physics-not-themeable assertion. |
|
||||
| `tests/test_prop_editor_canvas.gd` | **new** | `PropCanvas` math: insert/remove vertex + vertex-flags, mirror X/Y about bbox center, `bring_forward`/`send_back`, clipboard deep-copy + `CLIPBOARD_PASTE_OFFSET`, sanitize round-trip via `TerrainUtils.sanitize_points`, scale/rotate transform math. |
|
||||
| `tests/test_prop_editor_thumb.gd` | **new** | `ThumbnailCache.prop_file_key`/`prop_file_png` formatting, `prop_png` unchanged, `save_png`→`load_png` round-trip, missing-file null. |
|
||||
| `docs/tech_debt_and_optimizations.md` | **modified** | Log the multi-shape→single-`PropBlock` flattening trade-off and the mtime-keyed custom-prop thumbnail cache note. |
|
||||
|
||||
**Layout/theme note:** the inspector column width, canvas default size, and dialog sizes are
|
||||
open choices (Section 5) — recorded as recommended defaults in Section 3, not baked into the
|
||||
shell.
|
||||
|
||||
---
|
||||
|
||||
## 3. Static contracts
|
||||
|
||||
### 3.1 `.prop` file schema (version `"1.0"`)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"prop_name": "My Crate",
|
||||
"material_preset": "wood",
|
||||
"shapes": [
|
||||
{
|
||||
"shape_type": "polygon",
|
||||
"points": [
|
||||
{ "x": -24.0, "y": -24.0 },
|
||||
{ "x": 24.0, "y": -24.0 },
|
||||
{ "x": 24.0, "y": 24.0 },
|
||||
{ "x": -24.0, "y": 24.0 }
|
||||
],
|
||||
"fill_color": "#8C6133",
|
||||
"outline_color": "#40290F",
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": [0, 0, 0, 0]
|
||||
},
|
||||
{
|
||||
"shape_type": "circle",
|
||||
"radius": 24.0,
|
||||
"fill_color": "#E63333",
|
||||
"outline_color": "#590D0D",
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": []
|
||||
}
|
||||
],
|
||||
"metadata": {
|
||||
"created_at": "2026-09-18T10:00:00",
|
||||
"modified_at": "2026-09-18T10:00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Field contract:
|
||||
|
||||
| Key | Type | Notes |
|
||||
| --- | --- | --- |
|
||||
| `version` | string | `"1.0"`. Unknown → load error (see `SUPPORTED_VERSIONS`). |
|
||||
| `prop_name` | string | Display name; empty falls back to filename basename. |
|
||||
| `material_preset` | string | `"none"\|"wood"\|"rubber"\|"cardboard"\|"metal"`; missing/invalid → `"none"`. |
|
||||
| `shapes` | array of objects | Z-order = array order (index 0 = back, last = front). |
|
||||
| `shapes[i].shape_type` | string | `"polygon"\|"circle"\|"line"`. Missing → `"polygon"`. |
|
||||
| `shapes[i].points` | array of `{x,y}` | Polygon/line vertex list. Missing → `[]`. |
|
||||
| `shapes[i].radius` | float | Circle only; default `24.0`. |
|
||||
| `shapes[i].fill_color` / `outline_color` | `#RRGGBB` / `#RRGGBBAA` string | Defaults from material tint (Section 3.3). |
|
||||
| `shapes[i].outline_width` | float | Default `PropBlock.DEFAULT_OUTLINE_WIDTH` (2.0). |
|
||||
| `shapes[i].closed` | bool | Polygon/circle `true`; line `false`. |
|
||||
| `shapes[i].vertex_flags` | array of int | `0` = original, `1` = user-created (mirrors stickman editor). Length == `points.size()` (circles: empty). |
|
||||
| `metadata` | object | Write-only provenance; ignored on load. |
|
||||
|
||||
### 3.2 `prop_theme.json` schema (version `"1.0"`)
|
||||
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"outline_widths": { "default": 2.0, "min": 0.5, "max": 32.0 },
|
||||
"material_tints": {
|
||||
"wood": { "fill": "#8C6133", "outline": "#40290F" },
|
||||
"rubber": { "fill": "#E63333", "outline": "#590D0D" },
|
||||
"cardboard": { "fill": "#D9B873", "outline": "#66521D" },
|
||||
"metal": { "fill": "#808C99", "outline": "#262E38" },
|
||||
"none": { "fill": "#8C6133", "outline": "#26201E" }
|
||||
},
|
||||
"primitive_defaults": {
|
||||
"box": { "size_x": 48.0, "size_y": 48.0 },
|
||||
"ball": { "radius": 24.0 },
|
||||
"plank": { "length": 160.0, "thickness": 16.0 },
|
||||
"triangle": { "base": 56.0, "height": 48.0 },
|
||||
"line": { "length": 120.0 },
|
||||
"circle": { "radius": 40.0, "segments": 12 }
|
||||
},
|
||||
"canvas": {
|
||||
"grid_size": 15,
|
||||
"background_color": "#1F1F1F",
|
||||
"grid_color": "#FFFFFF26"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Fallback hierarchy** (only visual + defaults are themed; physics is fixed):
|
||||
|
||||
```text
|
||||
material fill/outline tint:
|
||||
prop_theme.json material_tints.<preset>.fill/outline (valid color string)
|
||||
│ missing / invalid
|
||||
▼
|
||||
PropTheme.DEFAULT_CONFIG material_tints.<preset>.fill/outline
|
||||
│
|
||||
▼
|
||||
PropBlock.tint_for(preset) ← hardcoded single source of truth (never overridden by theme)
|
||||
|
||||
outline width default:
|
||||
prop_theme.json outline_widths.default → DEFAULT_CONFIG → PropBlock.DEFAULT_OUTLINE_WIDTH (2.0)
|
||||
|
||||
primitive default dimension (box/ball/plank/triangle/line/circle):
|
||||
prop_theme.json primitive_defaults.<name>.<key> → DEFAULT_CONFIG → PropUtils.create_* defaults
|
||||
|
||||
physics (mass / friction / bounce):
|
||||
PropBlock.mass_for(preset) / PropBlock.physics_material(preset) — NEVER themeable
|
||||
```
|
||||
|
||||
### 3.3 Shared constants and enums
|
||||
|
||||
```gdscript
|
||||
# prop_canvas.gd — editor shape type superset (superset of PropBlock.ShapeType)
|
||||
enum ShapeType { POLYGON, CIRCLE, LINE }
|
||||
enum VertexFlag { ORIGINAL = 0, USER_CREATED = 1 } # mirrors stickman editor
|
||||
|
||||
# gizmo / canvas constants (mirror BodyPartPanel where applicable)
|
||||
const HANDLE_RADIUS := 3.0
|
||||
const HANDLE_RADIUS_SELECTION := 8.0
|
||||
const HANDLE_HOLLOW_SIZE := 6.0
|
||||
const EDGE_HIT_THRESHOLD := 12.0
|
||||
const ROTATE_RING_RADIUS := 40.0 # selection rotate ring (screen-constant / zoom)
|
||||
const SCALE_HANDLE_DISTANCE := 8.0 # corner scale-handle reach
|
||||
const ROTATION_SNAP_DEG := 15.0 # Ctrl-snap (deg_to_rad(15.0))
|
||||
const CLIPBOARD_PASTE_OFFSET := Vector2(16, 16)
|
||||
const DEFAULT_CIRCLE_SEGMENTS := 12 # editor circle primitives
|
||||
const MIN_ZOOM := 0.3
|
||||
const MAX_ZOOM := 3.0
|
||||
const ZOOM_STEP := 1.10
|
||||
const GRID_COLOR := Color(1.0, 1.0, 1.0, 0.15)
|
||||
|
||||
# prop_block.gd — existing authority, reused unchanged
|
||||
# enum ShapeType { POLYGON, CIRCLE }
|
||||
# enum MaterialPreset { NONE, WOOD, RUBBER, CARDBOARD, METAL }
|
||||
# const DEFAULT_FILL_COLOR := Color(0.55, 0.35, 0.15, 1.0)
|
||||
# const DEFAULT_OUTLINE_COLOR := Color(0.15, 0.08, 0.02, 1.0)
|
||||
# const DEFAULT_OUTLINE_WIDTH := 2.0
|
||||
# static func physics_material(preset) -> PhysicsMaterial
|
||||
# static func mass_for(preset) -> float
|
||||
# static func tint_for(preset) -> Color
|
||||
```
|
||||
|
||||
### 3.4 `PropFile` — static schema + converters
|
||||
|
||||
```gdscript
|
||||
class_name PropFile
|
||||
extends RefCounted
|
||||
|
||||
const FILE_VERSION := "1.0"
|
||||
const FILE_EXT := ".prop"
|
||||
const DEFAULT_DIR := "res://props"
|
||||
const SUPPORTED_VERSIONS: Array[String] = ["1.0"]
|
||||
# index == PropBlock.MaterialPreset
|
||||
const PRESET_STRINGS: PackedStringArray = ["none", "wood", "rubber", "cardboard", "metal"]
|
||||
const SHAPE_STRINGS: PackedStringArray = ["polygon", "circle", "line"]
|
||||
|
||||
static func preset_to_string(preset: int) -> String # clamp into range; NONE fallback
|
||||
static func preset_from_string(s: String) -> int # unknown -> MaterialPreset.NONE
|
||||
static func shape_type_to_payload_type(s: String) -> int # "circle"->CIRCLE else POLYGON
|
||||
static func sanitize_shape(shape: Dictionary) -> Dictionary # sanitize polygon/line points via TerrainUtils
|
||||
static func to_file_dict(prop_name: String, material_preset: int, shapes: Array[Dictionary]) -> Dictionary
|
||||
static func from_file_dict(data: Variant) -> Dictionary
|
||||
# returns {} on error; else { "prop_name": String, "material_preset": int, "shapes": Array[Dictionary] }
|
||||
static func flatten_to_payload(shapes: Array[Dictionary]) -> Dictionary
|
||||
# returns a single PropUtils.spawn_prop payload {type, points|radius, fill_color, outline_color, outline_width}
|
||||
static func save(path: String, data: Dictionary) -> bool # FileAccess WRITE + JSON.stringify
|
||||
static func load(path: String) -> Dictionary # {} on read/parse failure (push_warning)
|
||||
```
|
||||
|
||||
**`flatten_to_payload` rule (deterministic, testable):**
|
||||
|
||||
1. Prefer the **frontmost closed polygon** — scan `shapes` from last to first, pick the first
|
||||
with `shape_type == "polygon"` and `closed == true`. Its sanitized `points` become
|
||||
`{ "type": POLYGON, "points": ... }`.
|
||||
2. Else prefer a `circle` shape (last→first) → `{ "type": CIRCLE, "radius": ... }`.
|
||||
3. Else fall back to the **first shape's** points as a polygon (even if a line).
|
||||
4. `fill_color`/`outline_color`/`outline_width` come from the chosen shape; a missing color
|
||||
falls back to the material tint (`PropBlock.tint_for`) / `DEFAULT_OUTLINE_COLOR`.
|
||||
5. Empty `shapes` → `{}` (caller skips spawn, with one warning).
|
||||
|
||||
Lines are editor-only (open, unfilled) and never become collision geometry (see Section 5).
|
||||
|
||||
### 3.5 `PropTheme` — validated loader (mirrors `SandboxTheme`)
|
||||
|
||||
```gdscript
|
||||
class_name PropTheme
|
||||
extends RefCounted
|
||||
|
||||
const SCHEMA_VERSION := "1.0"
|
||||
const DEFAULT_PATH := "res://prop_theme.json"
|
||||
const DEFAULT_CONFIG: Dictionary = { /* mirrors committed prop_theme.json — see 3.2 */ }
|
||||
|
||||
func load_from(path: String = DEFAULT_PATH) -> bool
|
||||
func value(path: String, fallback: Variant = null) -> Variant # dotted-path access
|
||||
func color(path: String, fallback: Color) -> Color # #RRGGBB[AA] validation + warn-once
|
||||
func number(path: String, fallback: float, minimum: float = -INF, maximum: float = INF) -> float
|
||||
func material_tint(preset: int, channel: String) -> Color # channel "fill"|"outline"
|
||||
func outline_width_default() -> float
|
||||
func primitive_default(primitive: String, key: String) -> float # e.g. ("box","size_x")
|
||||
func grid_size() -> int
|
||||
```
|
||||
|
||||
`material_tint(preset, channel)` implements the hierarchy in 3.2: theme file →
|
||||
`DEFAULT_CONFIG` → `PropBlock.tint_for(preset)` (and `.darkened(0.55)` for the outline
|
||||
channel's final fallback, matching `PropBlock._apply_material_preset()`).
|
||||
|
||||
### 3.6 `PropCanvas` — multi-shape canvas + gizmos
|
||||
|
||||
```gdscript
|
||||
class_name PropCanvas
|
||||
extends Control
|
||||
|
||||
signal shape_changed(shapes: Array[Dictionary])
|
||||
signal shape_selected(idx: int)
|
||||
|
||||
# public API
|
||||
func set_shape_data(shapes: Array[Dictionary]) -> void # import; resets zoom/pan/selection
|
||||
func get_shape_data() -> Array[Dictionary] # export (plain dicts, {x,y} points)
|
||||
func clear() -> void # no shape_changed emit (matches clear_shape)
|
||||
func select(idx: int) -> void
|
||||
func deselect() -> void
|
||||
func get_selected_index() -> int
|
||||
func set_grid_size(size: int) -> void
|
||||
func set_snap_enabled(enabled: bool) -> void
|
||||
func reset_view() -> void
|
||||
func add_primitive(primitive: String, center: Vector2, params: Dictionary = {}) -> Dictionary
|
||||
# primitive ∈ "box"|"ball"|"plank"|"triangle"|"line"|"circle"; appends; returns the new shape
|
||||
func insert_vertex(shape_idx: int, vertex_idx: int, world_pos: Vector2) -> void # flags it USER_CREATED
|
||||
func remove_vertex(shape_idx: int, vertex_idx: int) -> void # <2 vertices -> drop shape
|
||||
func bring_forward(idx: int) -> void
|
||||
func send_back(idx: int) -> void
|
||||
func mirror_x(idx: int) -> void # invert X about bbox center
|
||||
func mirror_y(idx: int) -> void # invert Y about bbox center
|
||||
func copy_shape(idx: int) -> Dictionary # deep duplicate (duplicate(true))
|
||||
func paste_shape(source: Dictionary, target_pos: Vector2) -> void # offset by CLIPBOARD_PASTE_OFFSET
|
||||
func translate_selected(delta: Vector2) -> void
|
||||
func rotate_selected(angle_rad: float) -> void # Ctrl snap applied by the drag handler
|
||||
func scale_selected(factor: Vector2) -> void # Ctrl → uniform (locked aspect)
|
||||
func is_cursor_over_drawing(global_pos: Vector2) -> bool
|
||||
func global_to_world(global_pos: Vector2) -> Vector2
|
||||
func set_has_clipboard(state: bool) -> void # enables "Paste" in context menu
|
||||
```
|
||||
|
||||
Internal shape dictionary (editor-space, mirrors `BodyPartPanel`):
|
||||
|
||||
```gdscript
|
||||
{
|
||||
"shape_type": "polygon", # "polygon"|"circle"|"line"
|
||||
"points": PackedVector2Array(), # polygon/line only
|
||||
"radius": 24.0, # circle only
|
||||
"fill_color": "#8C6133", # hex string
|
||||
"outline_color": "#40290F", # hex string
|
||||
"outline_width": 2.0,
|
||||
"closed": true,
|
||||
"vertex_flags": PackedInt32Array(), # 0=original, 1=user-created
|
||||
}
|
||||
```
|
||||
|
||||
Input handling order (mirrors `BodyPartPanel._on_drawing_area_gui_input`): magnify/pinch zoom
|
||||
→ pan gesture → middle-mouse pan → wheel zoom (cursor-centered) → **gizmo hit-test** (rotate
|
||||
ring / scale corner on the selected shape) → right-click context menu (vertex = "Remove
|
||||
Point"; outline = "Create Point"; else "Copy/Paste/Delete/Mirror X/Mirror Y/Bring
|
||||
Forward/Send Back"; empty canvas = "Add Box/Ball/Plank/Triangle/Line/Circle") → left-click
|
||||
vertex drag / shape drag.
|
||||
|
||||
### 3.7 `PropEditor` — controller
|
||||
|
||||
```gdscript
|
||||
class_name PropEditor
|
||||
extends Control
|
||||
|
||||
const PROP_FILE := preload("res://scripts/prop_file.gd")
|
||||
const PROP_THEME := preload("res://scripts/prop_theme.gd")
|
||||
const PROP_THUMBNAIL := preload("res://scripts/thumbnails/prop_thumbnail.gd")
|
||||
const THUMBNAIL_CACHE := preload("res://scripts/thumbnails/thumbnail_cache.gd")
|
||||
|
||||
@onready var _menu_bar: MenuBar = %MenuBar
|
||||
@onready var _prop_name_edit: LineEdit = %PropNameEdit
|
||||
@onready var _canvas: PropCanvas = %PropCanvas
|
||||
@onready var _save_dialog: FileDialog = %SaveDialog
|
||||
@onready var _load_dialog: FileDialog = %LoadDialog
|
||||
@onready var _clear_confirm: ConfirmationDialog = %ClearConfirmDialog
|
||||
@onready var _error_dialog: AcceptDialog = %ErrorDialog
|
||||
@onready var _cursor_coords: Label = %CursorCoords
|
||||
@onready var _save_status: Label = %SaveStatus
|
||||
|
||||
func _ready() -> void
|
||||
func _setup_menu_bar() -> void # File (Save/Load/Clear), Edit (Configure Grid/Snap), View (Reset Views)
|
||||
func _build_inspector() -> void # builds %InspectorList children (Task 1.3 / 3.1 / 3.2)
|
||||
func _on_save_file_selected(path: String) -> void
|
||||
func _on_load_file_selected(path: String) -> void
|
||||
func _on_clear_confirmed() -> void
|
||||
func _build_json_data() -> Dictionary # PropFile.to_file_dict(...)
|
||||
func _apply_json_data(json: Variant) -> String # "" on success, else error text
|
||||
func _refresh_inspector_from_selection() -> void
|
||||
func _apply_material_preset(preset: int) -> void
|
||||
func _save_prop_thumbnail() -> void # async (await render)
|
||||
```
|
||||
|
||||
**Save-side thumbnail flow** (Task 4.2):
|
||||
|
||||
```text
|
||||
Save "res://props/mycrate.prop"
|
||||
1. PropFile.save(path, data) # write .prop
|
||||
2. payload = PropFile.flatten_to_payload(shapes)
|
||||
3. tex = await _prop_thumbnail.render(payload, material_preset)
|
||||
4. if tex != null:
|
||||
_thumb_cache.save_png(tex, _thumb_cache.prop_file_png(path)) # 200x200, user://thumbnails/props/mycrate_<mtime>.png
|
||||
5. (headless / blank) tex == null → skip silently (placeholder on load side)
|
||||
```
|
||||
|
||||
`ThumbnailCache` extension:
|
||||
|
||||
```gdscript
|
||||
func prop_file_key(path: String) -> String: # "<basename>_<mtime>"
|
||||
func prop_file_png(path: String) -> String: # PROP_DIR + "/" + key + ".png"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Task-to-implementation mapping
|
||||
|
||||
| Plan task | Fulfilled by | Acceptance criteria |
|
||||
| --- | --- | --- |
|
||||
| **1.1** Prop design-token schema | `scripts/prop_theme.gd` + `res://prop_theme.json` | File parses and loads on `PropEditor._ready()`; missing keys fall back (file → `DEFAULT_CONFIG` → hardcoded) with no runtime error. |
|
||||
| **1.2** Material preset visual utility | `PropEditor._apply_material_preset()` reading `PropBlock.mass_for`/`physics_material`/`tint_for` (via `PropTheme.material_tint` override) | Selecting a preset populates default mass/friction/bounce (from `PropBlock`) and fill/outline tint. |
|
||||
| **1.3** Inspector theme | `PropEditor._build_inspector()` applying a local `Theme` (from `PropTheme`) to `OptionButton`/`SpinBox`/`ColorPicker`/`Button` | Controls match styling; no global GUI theme or sandbox scene mutation. |
|
||||
| **2.1** Multi-shape creation & primitive controls | `PropCanvas.add_primitive()` + inspector mode-specific params (radius for circle; vertex list for polygon) | Adding a shape appends to the stack; switching primitive modes exposes `radius` vs `points` params. |
|
||||
| **2.2** Vertex drag & reshape | `PropCanvas` vertex hit-test + `InputEventMouseMotion` drag (mirrors `BodyPartPanel._handle_left_press`/`_on_drawing_area_gui_input`) | Dragging updates geometry live; release commits (emits `shape_changed`). |
|
||||
| **2.3** Contextual vertex insert/delete | `PropCanvas.insert_vertex()` / `remove_vertex()` via right-click "Create Point"/"Remove Point" | Insert adds a `USER_CREATED` vertex at edge midpoint; delete closes the segment; <2 vertices drops the shape. |
|
||||
| **2.4** Selection, context menu, z-order | `PropCanvas` hit-testing + context menu + `bring_forward()`/`send_back()` | Left-click highlights bbox/handles; Delete removes; z-order alters draw order. |
|
||||
| **2.5** Translate/rotate/scale gizmos | `PropCanvas` gizmo hit-test + drag handlers (`translate_selected`/`rotate_selected`/`scale_selected`); Ctrl = 15° rotate snap + uniform scale | Drag translates all vertices; Ctrl snaps rotation to 15°; Ctrl locks aspect ratio on scale. |
|
||||
| **2.6** Mirror engine | `PropCanvas.mirror_x()`/`mirror_y()` (invert about bbox center) | Mirror X/Y invert coordinates about the shape center. |
|
||||
| **2.7** Clipboard | `PropCanvas.copy_shape()` (deep dup) + `paste_shape()` (offset `CLIPBOARD_PASTE_OFFSET`) + `set_has_clipboard()` | Copy stores a deep duplicate; paste inserts an offset duplicate; "Paste" disabled when empty. |
|
||||
| **2.8** Live vector sync | `PropCanvas` → `shape_changed` → `PropEditor` inspector writes fill/outline/width back into the shape dict + `queue_redraw` | Modifying fill/outline/width reflects instantly on canvas. |
|
||||
| **2.9** Sanitization | `PropCanvas` commit path + `PropFile.sanitize_shape()` calling `TerrainUtils.sanitize_points()` | Committed polygons are grid-snapped, redundant/collinear-stripped, clockwise. |
|
||||
| **3.1** Material preset picker | Inspector `OptionButton` → `PropEditor._apply_material_preset()` | Changing preset updates displayed mass/friction/restitution (from `PropBlock`). |
|
||||
| **3.2** Color swatches | Inspector `ColorPickerButton`s for fill + outline (override preset colors) | Custom fill/outline override preset tint and update stroke/fill live. |
|
||||
| **4.1** Thumbnail generator | reuse `scripts/thumbnails/prop_thumbnail.gd` (`PropThumbnail.render`) | Renders off-screen 200×200 transparent PNG without affecting main editor. |
|
||||
| **4.2** Disk cache pipeline | `PropEditor._save_prop_thumbnail()` + `ThumbnailCache.prop_file_png`/`save_png`/`load_png` | Save exports `<basename>_<mtime>.png`; load reads cached PNG if source unchanged (mtime key). |
|
||||
|
||||
---
|
||||
|
||||
## 5. Design choices — user-approved resolutions
|
||||
|
||||
1. **Multi-shape → single `PropBlock` flattening.** **Approved rule:** frontmost closed polygon
|
||||
→ else circle → else first shape (spec 3.4, `flatten_to_payload`).
|
||||
2. **Line primitives and physics.** **Approved:** lines are editor-only (drawn, never flattened
|
||||
into collision geometry).
|
||||
3. **Sandbox asset-selector integration.** **Approved direction:** `.prop` files will later
|
||||
appear in the sandbox "Prop" selector; **not** wired this phase.
|
||||
4. **Menu-bar entry in the main editor.** **Approved:** F6-standalone only this phase (no
|
||||
"Prop Editor…" item in the stickman editor menu bar).
|
||||
5. **Default canvas / window size.** **Approved:** canvas 1200×800 with a ~300 px inspector
|
||||
column; window resizable, no size persistence.
|
||||
|
||||
---
|
||||
|
||||
## 6. Architectural alternatives and deferred work
|
||||
|
||||
- **`Control`-based editor vs an in-world Node2D tool:** an in-world (`Node2D`) prop editor
|
||||
would share the sandbox camera/zoom, but props are authored assets, not stage objects; the
|
||||
`Control` canvas reuses the proven `BodyPartPanel` zoom/pan/input model and keeps the file
|
||||
format independent of stage transforms.
|
||||
- **Reuse `BodyPartPanel` directly:** rejected (Decision 4) — the prop shape model (fill +
|
||||
outline + width, polygon primitives, transform gizmos) diverges enough that a dedicated
|
||||
`PropCanvas` is cheaper than branching.
|
||||
- **Physics theme tokens:** deliberately excluded from `prop_theme.json` (Decision 5). If
|
||||
per-project physics tuning is ever wanted, a separate, explicit physics-override file is
|
||||
preferable to silently changing runtime feel via a visual theme.
|
||||
- **Multi-shape `PropBlock`:** a future phase could compose multiple shapes into one prop
|
||||
(multiple `Polygon2D`/`Line2D` children + merged collision). Until then the single-shape
|
||||
`PropBlock` + flattening converter is the contract; recorded in the tech-debt log.
|
||||
|
||||
---
|
||||
|
||||
*Validation will follow the existing pattern: per-script `--check-only`, then each
|
||||
`tests/test_prop_editor_*.gd` headless suite printing PASS/FAIL and `quit(0/1)`. Interactive
|
||||
vertex/gizmo drags are manual F6 verification.*
|
||||
@@ -0,0 +1,447 @@
|
||||
# Phase 9 Prop Editor — Round 2 (Usability & Interaction Fixes)
|
||||
|
||||
Status: **approved** (2026-09-18; all open choices resolved — two deviations: "Create Point"
|
||||
stays **outline-only**, and `prop_theme.json` keeps `version` **"1.0"**).
|
||||
|
||||
Source: five user feedback points against the shipped Round 1 Prop Editor
|
||||
(`scripts/prop_canvas.gd`, `scripts/prop_editor.gd`, `prop_theme.json`).
|
||||
|
||||
---
|
||||
|
||||
## 0. Feedback summary (verbatim intent)
|
||||
|
||||
1. **Rotation is very hard** — replace the ring-above-bbox with the stickman editor's
|
||||
bottom rotation dot (small dot below the bbox bottom-center, generous hit radius,
|
||||
incremental-angle drag, keep Ctrl 15° snap).
|
||||
2. **Vertex node circles are too large and should stay constant screen-size** — diagnose
|
||||
what is actually read as "too large" and shrink the offending gizmos; keep vertex handles
|
||||
at the stickman editor's 3 px.
|
||||
3. **"Create Point" along a segment does nothing** — diagnose the true root cause and give
|
||||
immediate visible feedback; decide whether it is available anywhere on the shape.
|
||||
4. **Mode separation: vertex editing vs transform editing** — design an explicit interaction
|
||||
model (evaluate ≥2 models, recommend one), with a canvas-owned mode + public API.
|
||||
5. **Kid-friendly UI** — bigger text, friendlier plain-language copy, larger hit targets,
|
||||
higher-contrast widget styling, via a `ui` section in `prop_theme.json` (local-theme-only).
|
||||
|
||||
---
|
||||
|
||||
## 1. Diagnoses
|
||||
|
||||
### 1.1 Point 3 — "Create Point" appears to do nothing (root cause + evidence)
|
||||
|
||||
The wiring is correct (`MENU_CREATE_POINT` → `_create_point_on_edge` → `insert_vertex`), so the
|
||||
failure is perceptual, not mechanical. Evidence from `scripts/prop_canvas.gd`:
|
||||
|
||||
1. **Collinear insertion (primary cause).** `_create_point_on_edge` (lines 951–975) inserts the
|
||||
new vertex at the **exact segment midpoint**:
|
||||
```gdscript
|
||||
var midpoint: Vector2 = (pts[best_edge] + pts[(best_edge + 1) % pts.size()]) * 0.5
|
||||
insert_vertex(_selected_shape_idx, insert_idx, midpoint)
|
||||
```
|
||||
On any straight edge (box, plank, triangle side, line) the midpoint lies **on the same line**
|
||||
as its two neighbours. The polygon fill and outline are therefore geometrically identical
|
||||
before/after the insert — nothing changes visually. (The inserted vertex is collinear and
|
||||
would also be stripped by `sanitize_shape()` on the next vertex/rotate/scale commit, since
|
||||
`TerrainUtils.sanitize_points` drops collinear vertices.)
|
||||
|
||||
2. **The new handle is small and low-contrast.** `insert_vertex` flags the vertex
|
||||
`USER_CREATED` (line 298), and `_draw_shape` renders it as a hollow rectangle of
|
||||
`HANDLE_HOLLOW_SIZE / _zoom * 0.5` (≈3 px half-size) in **white** (`HANDLE_COLOR`, line
|
||||
578). Over a light fill or at small zoom this is nearly invisible, so even the handle is not
|
||||
noticed. (Note: the stickman editor color-matches these to the shape — see 1.2.)
|
||||
|
||||
3. **Discoverability.** "Create Point" only appears when `_is_point_near_outline` is true
|
||||
(line 730), i.e. a right-click within `EDGE_HIT_THRESHOLD / _zoom` (12 px) of an outline
|
||||
edge. Right-clicking the body of a filled shape shows Copy/Paste/Delete instead — a kid
|
||||
right-clicks the *middle* of the shape and never sees the option.
|
||||
|
||||
**Recommendation (see §2.4 / §4.3):** insert at the **cursor's projection onto the nearest
|
||||
segment** (clamped), **auto-select the shape**, **emit a `vertex_created` signal**, and flash
|
||||
the new vertex with a short pulsing highlight. Auto-begin-drag is evaluated as an alternative
|
||||
in §5. **User-approved resolution:** "Create Point" stays **outline-only** (the current
|
||||
`EDGE_HIT_THRESHOLD / _zoom` gating is kept).
|
||||
|
||||
### 1.2 Point 2 — what is actually read as "too large" (evidence)
|
||||
|
||||
The vertex handles are **not** the problem: they already divide by `_zoom` (3 px constant,
|
||||
lines 35, 576). The two things a user actually reads as "giant vertex circles" are the other
|
||||
two gizmos drawn on selection (`_draw_selection_gizmos`, lines 582–595):
|
||||
|
||||
| Gizmo | Current draw | Effective on-screen size | Read as |
|
||||
| --- | --- | --- | --- |
|
||||
| Corner **scale handles** | `draw_circle(corner, SCALE_HANDLE_DISTANCE / _zoom, …)` (line 590) | **8 px radius filled circles** (16 px diameter) at the four bbox corners | four "giant vertex handles" |
|
||||
| **Rotate ring** | `draw_arc(ring_center, ROTATE_RING_RADIUS / _zoom, …)` (line 595) + connector line | **40 px-radius ring** floating above the bbox | another "giant handle" |
|
||||
| Vertex handles | `draw_circle(pts[j], HANDLE_RADIUS / _zoom, …)` (line 576) | 3 px — already correct | — |
|
||||
|
||||
Two further divergences from the stickman editor worth fixing while here:
|
||||
|
||||
- Vertex handles are drawn **white** (`HANDLE_COLOR`, line 574) instead of **color-matched**
|
||||
to the shape (`body_part_panel.gd` lines 399–407: `handle_color := color`, yellow while
|
||||
dragging). Color-matching makes 3 px handles legible and distinct from gizmos.
|
||||
- The rotate ring's hit test (line 1097–1103) is a fuzzy `maxf(12/zoom, ring*0.3)` circle —
|
||||
generous but at an awkward location (above the shape), which is why rotation is hard (point 1).
|
||||
|
||||
---
|
||||
|
||||
## 2. Static contracts
|
||||
|
||||
### 2.1 Constants — rotation dot + scale crosses (mirror `whole_stickman_preview.gd`)
|
||||
|
||||
Replace the ring/scale-circle constants. New values are **exactly** the stickman editor's
|
||||
(`whole_stickman_preview.gd` lines 31–35):
|
||||
|
||||
```gdscript
|
||||
# prop_canvas.gd — REMOVE:
|
||||
# const ROTATE_RING_RADIUS: float = 40.0
|
||||
# const SCALE_HANDLE_DISTANCE: float = 8.0
|
||||
# (keep HANDLE_RADIUS / HANDLE_RADIUS_SELECTION / HANDLE_HOLLOW_SIZE / EDGE_HIT_THRESHOLD)
|
||||
|
||||
# prop_canvas.gd — ADD (mirror whole_stickman_preview exactly):
|
||||
const ROTATION_CIRCLE_RADIUS: float = 5.0 # draw radius, divided by _zoom
|
||||
const ROTATION_CIRCLE_HIT_RADIUS: float = 12.0 # hit radius, divided by _zoom
|
||||
const ROTATION_CIRCLE_OFFSET: float = 26.0 # bbox bottom-center -> dot center (world)
|
||||
const SCALE_CROSS_SIZE: float = 5.0 # cross arm length, divided by _zoom
|
||||
const SCALE_CROSS_HIT_RADIUS: float = 12.0 # hit radius, divided by _zoom
|
||||
```
|
||||
|
||||
**Drawing** (`_draw_selection_gizmos`):
|
||||
|
||||
```gdscript
|
||||
var b: Rect2 = _shape_bbox(sd)
|
||||
if b.size == Vector2.ZERO:
|
||||
return
|
||||
# Bounding box (unchanged)
|
||||
draw_rect(b, GIZMO_COLOR, false, 1.5 / _zoom)
|
||||
# Rotation dot BELOW the bbox bottom-center (was the ring above)
|
||||
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
|
||||
draw_circle(rot_center, ROTATION_CIRCLE_RADIUS / _zoom, GIZMO_COLOR)
|
||||
# Corner scale CROSSES (was filled circles)
|
||||
var cross_half := SCALE_CROSS_SIZE / _zoom
|
||||
for corner: Vector2 in [b.position, Vector2(b.end.x, b.position.y), b.end, Vector2(b.position.x, b.end.y)]:
|
||||
draw_line(corner + Vector2(-cross_half, 0), corner + Vector2(cross_half, 0), GIZMO_COLOR, 1.5 / _zoom)
|
||||
draw_line(corner + Vector2(0, -cross_half), corner + Vector2(0, cross_half), GIZMO_COLOR, 1.5 / _zoom)
|
||||
```
|
||||
|
||||
**Rotation dot hit-test** (replaces `_hit_test_rotate_ring`):
|
||||
|
||||
```gdscript
|
||||
func _hit_test_rotation_dot(sd: Dictionary, world_pos: Vector2) -> bool:
|
||||
var b: Rect2 = _shape_bbox(sd)
|
||||
if b.size == Vector2.ZERO:
|
||||
return false
|
||||
var rot_center := Vector2(b.position.x + b.size.x * 0.5, b.end.y + ROTATION_CIRCLE_OFFSET)
|
||||
return world_pos.distance_to(rot_center) <= ROTATION_CIRCLE_HIT_RADIUS / _zoom
|
||||
```
|
||||
|
||||
**Scale cross hit-test** (replaces `_hit_test_scale_corner` threshold):
|
||||
|
||||
```gdscript
|
||||
var threshold: float = SCALE_CROSS_HIT_RADIUS / _zoom # was SCALE_HANDLE_DISTANCE / _zoom
|
||||
```
|
||||
|
||||
### 2.2 Rotation drag — incremental angle accumulation (point 1, hard-to-rotate fix)
|
||||
|
||||
The current drag is **absolute-from-start** (`_rotate_start_angle` set at press, `delta =
|
||||
current_angle − start` each frame, lines 765–770, 841–843) — it jumps when the cursor crosses
|
||||
±180°. Adopt the stickman editor's incremental accumulation
|
||||
(`whole_stickman_preview.gd` lines 767–794):
|
||||
|
||||
```gdscript
|
||||
# State (replace _rotate_start_angle):
|
||||
var _rotate_total_delta: float = 0.0
|
||||
var _rotate_prev_angle: float = 0.0
|
||||
|
||||
# On press (_handle_left_press, rotation branch):
|
||||
_drag_mode = DragMode.ROTATE
|
||||
_rotate_center = _shape_bbox(sd).get_center()
|
||||
_rotate_total_delta = 0.0
|
||||
_rotate_prev_angle = rad_to_deg(_rotate_center.angle_to_point(world_pos))
|
||||
_gizmo_base_points = PackedVector2Array(sd.get("points", PackedVector2Array()))
|
||||
|
||||
# On motion (_handle_drag_motion, DragMode.ROTATE):
|
||||
var current_angle := rad_to_deg(_rotate_center.angle_to_point(world_pos))
|
||||
var frame_delta := current_angle - _rotate_prev_angle
|
||||
if frame_delta > 180.0:
|
||||
frame_delta -= 360.0
|
||||
elif frame_delta < -180.0:
|
||||
frame_delta += 360.0
|
||||
_rotate_total_delta += frame_delta
|
||||
_rotate_prev_angle = current_angle
|
||||
var delta := deg_to_rad(_rotate_total_delta)
|
||||
if ctrl_pressed:
|
||||
delta = snappedf(delta, deg_to_rad(ROTATION_SNAP_DEG))
|
||||
# apply delta to _gizmo_base_points about _rotate_center (unchanged math)
|
||||
```
|
||||
|
||||
### 2.3 Mode separation — enum, state, signals, public API (point 4)
|
||||
|
||||
**Recommended model: (a) two explicit modes** with a kid-friendly segmented toggle. Rationale
|
||||
and rejected alternatives in §2.3.1 and §5.
|
||||
|
||||
```gdscript
|
||||
# prop_canvas.gd
|
||||
enum EditMode { TRANSFORM, POINTS } # kid-facing labels: "Move & Resize" / "Edit Points"
|
||||
|
||||
signal mode_changed(mode: int) # emitted only when the mode actually changes
|
||||
|
||||
var _edit_mode: EditMode = EditMode.TRANSFORM # default on load
|
||||
|
||||
func set_edit_mode(mode: int) -> void:
|
||||
var m: int = TRANSFORM if mode != POINTS else POINTS # clamp anything unknown to TRANSFORM
|
||||
if m == _edit_mode:
|
||||
return
|
||||
_edit_mode = m
|
||||
_drag_mode = DragMode.NONE # cancel any in-progress drag
|
||||
_dragging_vertex = -1
|
||||
queue_redraw()
|
||||
mode_changed.emit(_edit_mode)
|
||||
|
||||
func get_edit_mode() -> int:
|
||||
return _edit_mode
|
||||
```
|
||||
|
||||
**Mode ↔ existing public API mapping** (the mode gates *input*, not the math APIs — tests and
|
||||
programmatic callers keep working in either mode):
|
||||
|
||||
| API | Mode behaviour |
|
||||
| --- | --- |
|
||||
| `insert_vertex` / `remove_vertex` | mode-agnostic (programmatic); only the **context menu** hides "Create/Remove Point" outside POINTS mode |
|
||||
| `translate_selected` / `rotate_selected` / `scale_selected` | mode-agnostic (programmatic; tests) |
|
||||
| `sanitize_shape`, `set_shape_data`, `clear`, `copy_shape`, `paste_shape`, `mirror_x/y`, `bring_forward/send_back` | mode-agnostic |
|
||||
| `select` / `deselect` / `get_selected_index` | mode-agnostic; do **not** change the mode |
|
||||
| `set_shape_data` / `clear` | do **not** reset the mode (tool preference persists, like snap) — see §5 |
|
||||
|
||||
**Drawing rules per mode** (`_draw_shape` + `_draw_selection_gizmos`):
|
||||
|
||||
| Element | TRANSFORM | POINTS |
|
||||
| --- | --- | --- |
|
||||
| Bounding box (`draw_rect`) | ✔ | — (replaced by a light selection outline) |
|
||||
| Rotation dot | ✔ | — |
|
||||
| Scale crosses | ✔ | — |
|
||||
| Vertex handles (color-matched circles / hollow rects) | — | ✔ |
|
||||
| Selection outline polyline (white, `2.5 / _zoom`) | ✔ (light) | ✔ |
|
||||
|
||||
**Hit-test priority per mode** (`_handle_left_press`):
|
||||
|
||||
```text
|
||||
TRANSFORM (default):
|
||||
1. rotation dot -> DragMode.ROTATE
|
||||
2. scale cross corner -> DragMode.SCALE
|
||||
3. shape body (front→back, fill/outline) -> DragMode.TRANSLATE + select
|
||||
4. empty -> deselect
|
||||
|
||||
POINTS:
|
||||
1. vertex (selected shape first, then others front→back) -> DragMode.VERTEX
|
||||
2. shape body -> select only (NO translate) # see §5 open choice
|
||||
3. empty -> deselect
|
||||
```
|
||||
|
||||
**Right-click context menu per mode** (`_handle_right_click`):
|
||||
|
||||
```text
|
||||
POINTS:
|
||||
on a vertex -> "Remove Point" (only item)
|
||||
on a shape -> "Create Point" + Copy/Paste/Delete/Mirror X/Mirror Y/Bring Forward/Send Back
|
||||
empty canvas -> Add Box/Ball/Plank/Triangle/Line/Circle (+ Paste if clipboard)
|
||||
TRANSFORM:
|
||||
on a shape -> Copy/Paste/Delete/Mirror X/Mirror Y/Bring Forward/Send Back (no Create/Remove Point)
|
||||
empty canvas -> Add primitives (+ Paste if clipboard)
|
||||
```
|
||||
|
||||
### 2.3.1 Model evaluation (point 4)
|
||||
|
||||
- **(a) Two explicit modes + segmented toggle — RECOMMENDED.** Predictable, no keyboard, no
|
||||
ambiguity between "grab a vertex" and "move the whole shape". Mode state is canvas-owned with
|
||||
a public API (`set_edit_mode`/`get_edit_mode` + `mode_changed`), so the editor's toggle drives
|
||||
it and the headless suite can assert it. Cost: the kid must learn two modes and switch; the
|
||||
toggle lives next to the canvas (always visible) and the default (`TRANSFORM`) matches the
|
||||
most common first action ("move my shape").
|
||||
- **(b) Automatic context switching — rejected.** Clicking a vertex enters points mode, clicking
|
||||
the body transforms. A vertex sits *on* the body, so the two hit targets overlap; accidental
|
||||
vertex drags while trying to move a shape (and vice-versa) are exactly the conflict the user
|
||||
reported. Harder to assert headlessly, and the "current mode" is invisible until you miss.
|
||||
- **(c) Per-selection mode + inspector "Edit Points" button — rejected as primary.** Clean, but
|
||||
the affordance is hidden in the inspector column and changes per selection, which is less
|
||||
discoverable for 7–12 year-olds than an always-visible toggle. (Its "Edit Points" button is
|
||||
kept as an optional secondary affordance — see §5.)
|
||||
|
||||
### 2.4 Create Point — new public API + feedback (point 3)
|
||||
|
||||
```gdscript
|
||||
signal vertex_created(shape_idx: int, vertex_idx: int)
|
||||
|
||||
# Returns the new vertex index, or -1 if no segment/point could be inserted.
|
||||
func create_point_on_edge(world_pos: Vector2) -> int:
|
||||
# find nearest segment (existing _point_to_segment_distance loop, closed-aware)
|
||||
# projection := a + ab * clampf(ap.dot(ab)/ab_len_sq, 0, 1) # cursor projected onto segment
|
||||
# insert_idx := best_edge + 1 (closed wrap: pts.size())
|
||||
insert_vertex(_selected_shape_idx, insert_idx, projection)
|
||||
vertex_created.emit(_selected_shape_idx, insert_idx)
|
||||
_flash_vertex = insert_idx
|
||||
_flash_remaining = FLASH_DURATION # §2.4.1
|
||||
queue_redraw()
|
||||
return insert_idx
|
||||
```
|
||||
|
||||
`_create_point_on_edge` (private) is refactored to delegate to `create_point_on_edge` (public),
|
||||
so the projection + signal + flash path is directly testable.
|
||||
|
||||
```gdscript
|
||||
const FLASH_DURATION: float = 0.6 # seconds
|
||||
const FLASH_RING_RADIUS: float = 12.0 # max ring radius, divided by _zoom
|
||||
var _flash_vertex: int = -1
|
||||
var _flash_remaining: float = 0.0
|
||||
```
|
||||
|
||||
**2.4.1 Flash feedback (recommended over auto-begin-drag).** `_process(delta)` decrements
|
||||
`_flash_remaining`; `_draw_shape` draws a pulsing ring around the flashing vertex:
|
||||
|
||||
```gdscript
|
||||
if j == _flash_vertex and _flash_remaining > 0.0:
|
||||
var t := 1.0 - _flash_remaining / FLASH_DURATION
|
||||
var ring_r := FLASH_RING_RADIUS * (0.5 + 0.5 * t) / _zoom
|
||||
draw_arc(pts[j], ring_r, 0.0, TAU, 24, Color.YELLOW, 2.0 / _zoom)
|
||||
```
|
||||
|
||||
Rationale: a flash + visible (color-matched, §2.1) hollow handle is non-modal and
|
||||
unmistakeable; auto-begin-drag after a right-click menu choice can feel like the vertex "ran
|
||||
away" or the tool got stuck. (Auto-drag remains an open choice — §5.)
|
||||
|
||||
### 2.5 Kid-friendly UI — theme additions, accessors, copy (point 5)
|
||||
|
||||
**Schema version: user-approved to stay `"1.0"`** (the new top-level `ui` section is additive;
|
||||
no existing-key meaning changes). `PropTheme.load_from` is already version-agnostic (it
|
||||
deep-merges and never rejects on `version`), so an older file without `ui` still loads with
|
||||
fallbacks.
|
||||
|
||||
New section (committed `res://prop_theme.json` and `PropTheme.DEFAULT_CONFIG`):
|
||||
|
||||
```json
|
||||
"ui": {
|
||||
"fonts": {
|
||||
"body": 16,
|
||||
"header": 20,
|
||||
"menu": 16,
|
||||
"status": 14
|
||||
},
|
||||
"hit_targets": {
|
||||
"button_min_height": 36,
|
||||
"row_min_height": 36
|
||||
},
|
||||
"colors": {
|
||||
"text": "#F2F2F2",
|
||||
"text_muted": "#9A9A9A",
|
||||
"border": "#FFFFFF33",
|
||||
"accent": "#4FA3FF"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
New `PropTheme` accessors (clamped, fallback → `DEFAULT_CONFIG`):
|
||||
|
||||
```gdscript
|
||||
func font_body() -> int: return integer("ui.fonts.body", 16, 8, 72)
|
||||
func font_header() -> int: return integer("ui.fonts.header", 20, 8, 96)
|
||||
func font_menu() -> int: return integer("ui.fonts.menu", 16, 8, 72)
|
||||
func font_status() -> int: return integer("ui.fonts.status", 14, 8, 72)
|
||||
func button_min_height() -> int: return integer("ui.hit_targets.button_min_height", 36, 16, 200)
|
||||
func row_min_height() -> int: return integer("ui.hit_targets.row_min_height", 36, 16, 200)
|
||||
func text_color() -> Color: return color("ui.colors.text", Color("#F2F2F2"))
|
||||
func text_muted_color() -> Color: return color("ui.colors.text_muted", Color("#9A9A9A"))
|
||||
func border_color() -> Color: return color("ui.colors.border", Color(1.0, 1.0, 1.0, 0.2))
|
||||
func accent_color() -> Color: return color("ui.colors.accent", Color("#4FA3FF"))
|
||||
```
|
||||
|
||||
`prop_editor.gd` `_build_control_theme()` replaces the hardcoded `14`/`16` font sizes and the
|
||||
hardcoded text/border colors with these tokens; `_build_inspector()` applies `button_min_height`
|
||||
to buttons and `row_min_height` to inspector rows (`custom_minimum_size.y`). Local `Theme`
|
||||
only — **no global GUI mutation**.
|
||||
|
||||
**Copy changes (plain-language, no emojis):**
|
||||
|
||||
| Current | Proposed |
|
||||
| --- | --- |
|
||||
| "Add" (button) | "Add a Shape" |
|
||||
| "Delete Shape" (inspector button) | "Erase Shape" |
|
||||
| "Delete" (context menu) | "Erase" |
|
||||
| "Mirror X" / "Mirror Y" (context menu) | "Flip Left-Right" / "Flip Up-Down" |
|
||||
| "Create Point" / "Remove Point" | keep ("Create Point" / "Remove Point") |
|
||||
| "Bring Forward" / "Send Back" | keep ("Bring Forward" / "Send Back") |
|
||||
| "Selected Shape" / "Add Shape" (section headers) | keep, but rendered at `font_header` |
|
||||
|
||||
("Erase" vs "Delete", and whether to also relabel Mirror as "Flip", are open choices — §5.)
|
||||
|
||||
---
|
||||
|
||||
## 3. Task-to-implementation mapping & acceptance criteria
|
||||
|
||||
| # | Feedback point | Target | Acceptance criteria |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Hard rotation | `scripts/prop_canvas.gd` | `ROTATE_RING_RADIUS` removed; `ROTATION_CIRCLE_RADIUS/HIT_RADIUS/OFFSET` = `5.0/12.0/26.0`; dot drawn below bbox bottom-center at `ROTATION_CIRCLE_RADIUS / _zoom`; hit radius `ROTATION_CIRCLE_HIT_RADIUS / _zoom`; drag uses incremental accumulation (no ±180° jump); Ctrl snaps 15°. |
|
||||
| 2 | Giant circles | `scripts/prop_canvas.gd` | `SCALE_HANDLE_DISTANCE` removed; corner handles drawn as `SCALE_CROSS_SIZE / _zoom` crosses; vertex handles stay `HANDLE_RADIUS / _zoom` (3 px) and become **color-matched** (yellow while dragging), user-created = hollow rect. |
|
||||
| 3 | Create Point | `scripts/prop_canvas.gd` | `create_point_on_edge(world_pos) -> int` inserts at the **cursor projection** (clamped), emits `vertex_created`, flashes the vertex 0.6 s; "Create Point" remains available **near the outline only** (`EDGE_HIT_THRESHOLD / _zoom`, unchanged — user decision); `_create_point_on_edge` delegates to it. |
|
||||
| 4 | Mode separation | `scripts/prop_canvas.gd` + `scripts/prop_editor.gd` | `enum EditMode { TRANSFORM, POINTS }`; default `TRANSFORM`; `set_edit_mode`/`get_edit_mode`/`mode_changed`; drawing + hit-test + context menu obey §2.3; editor renders a segmented toggle ("Move & Resize" / "Edit Points") that drives `set_edit_mode` and syncs from `mode_changed`; shape-editor controls unaffected in both modes. |
|
||||
| 5 | Kid-friendly UI | `scripts/prop_editor.gd` + `scripts/prop_theme.gd` + `prop_theme.json` | `ui` section + accessors per §2.5; font sizes/tokens drive `_build_control_theme`/`_build_inspector` (no hardcoded 14/16); min-height hit targets applied; copy table applied; local theme only. |
|
||||
|
||||
**Test updates — `tests/test_prop_editor_canvas.gd` (headless; public API under test):**
|
||||
|
||||
- **New `_test_edit_mode()`**: default `get_edit_mode() == TRANSFORM`; `set_edit_mode(POINTS)`
|
||||
→ `get_edit_mode() == POINTS` and `mode_changed` fires exactly once; re-setting the same mode
|
||||
fires **no** extra `mode_changed`; unknown value clamps to `TRANSFORM`; `select`/`deselect`/
|
||||
`clear` leave the mode unchanged.
|
||||
- **New `_test_gizmo_constants()`**: assert `ROTATION_CIRCLE_RADIUS == 5.0`,
|
||||
`ROTATION_CIRCLE_HIT_RADIUS == 12.0`, `ROTATION_CIRCLE_OFFSET == 26.0`,
|
||||
`SCALE_CROSS_SIZE == 5.0`, `SCALE_CROSS_HIT_RADIUS == 12.0`, `HANDLE_RADIUS == 3.0`; assert
|
||||
`ROTATE_RING_RADIUS` and `SCALE_HANDLE_DISTANCE` are absent from
|
||||
`PROP_CANVAS.get_script().get_script_constant_map()`.
|
||||
- **New `_test_create_point_on_edge()`**: box at `(100,100)`; `create_point_on_edge(Vector2
|
||||
(124, 76))` (off-midpoint, above the top edge) → returned index ≥ 0, inserted point == the
|
||||
cursor's projection onto the top edge (not the midpoint `(124,76)` itself and not
|
||||
`(124,100)`), `vertex_flags[idx] == USER_CREATED`, and `vertex_created` emitted with the
|
||||
right `(shape_idx, vertex_idx)`.
|
||||
- Existing suites unchanged (all existing APIs remain mode-agnostic).
|
||||
|
||||
**Manual F6 verification:** drag the bottom dot in a full circle (no ±180° flip); scale
|
||||
crosses; create a point then drag it to kink a rectangle edge; toggle modes and confirm
|
||||
gizmos/handles swap and body-drag is inert in POINTS mode; confirm fonts/targets.
|
||||
|
||||
---
|
||||
|
||||
## 4. Files modified
|
||||
|
||||
| File | Changes |
|
||||
| --- | --- |
|
||||
| `scripts/prop_canvas.gd` | Rotation dot + incremental drag; scale crosses; color-matched vertex handles; `EditMode` enum + `set_edit_mode`/`get_edit_mode`/`mode_changed`; mode-gated drawing/hit-test/context-menu; `create_point_on_edge` + `vertex_created` + flash state; "Create Point" anywhere on shape. |
|
||||
| `scripts/prop_editor.gd` | Segmented mode toggle + `mode_changed` wiring; theme-driven fonts/colors/hit-targets (replace hardcoded 14/16); copy changes. |
|
||||
| `scripts/prop_theme.gd` | `ui` section in `DEFAULT_CONFIG` + accessors (§2.5). |
|
||||
| `prop_theme.json` | `version` stays `"1.0"` (user decision); add `ui` section. |
|
||||
| `tests/test_prop_editor_canvas.gd` | Add `_test_edit_mode`, `_test_gizmo_constants`, `_test_create_point_on_edge`. |
|
||||
| `docs/tech_debt_and_optimizations.md` | Log the explicit-mode interaction decision and the "Create Point" feedback design. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Design choices — user-approved resolutions
|
||||
|
||||
1. **Mode names / labels.** **Approved:** `"Move & Resize"` (TRANSFORM) and `"Edit Points"` (POINTS).
|
||||
2. **Default mode on load.** **Approved:** `TRANSFORM` ("Move & Resize").
|
||||
3. **Mode persistence across Clear/Load.** **Approved:** mode persists (tool preference, like snap).
|
||||
4. **Shape-body drag in POINTS mode.** **Approved:** select-only (no translate).
|
||||
5. **Create Point feedback.** **Approved:** insert at cursor projection + auto-select + flash ring (0.6 s).
|
||||
6. **Create Point availability.** **Approved (deviation):** keep **outline-only** (`EDGE_HIT_THRESHOLD / _zoom` gating unchanged).
|
||||
7. **Theme schema version.** **Approved (deviation):** stay `"1.0"` (additive `ui` section only).
|
||||
8. **"Erase" vs "Delete", Mirror→"Flip" relabel.** **Approved:** "Erase Shape"/"Erase", "Flip Left-Right"/"Flip Up-Down"; "Create Point"/"Remove Point"/"Bring Forward"/"Send Back" unchanged.
|
||||
9. **Mode toggle placement.** **Approved:** a segmented two-button toolbar row above the canvas (always visible), synced to `mode_changed`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementation order
|
||||
|
||||
1. `prop_theme.gd` + `prop_theme.json` (theme tokens + accessors) — independent of canvas work.
|
||||
2. `prop_canvas.gd` — constants, rotation dot + incremental drag, scale crosses, color-matched
|
||||
handles (points 1 & 2).
|
||||
3. `prop_canvas.gd` — `create_point_on_edge` + `vertex_created` + flash + "anywhere on shape"
|
||||
(point 3).
|
||||
4. `prop_canvas.gd` — `EditMode` + gating (point 4).
|
||||
5. `prop_editor.gd` — mode toggle + theme-driven fonts/targets + copy (points 4 & 5).
|
||||
6. `tests/test_prop_editor_canvas.gd` — three new suites.
|
||||
7. Parse check + headless run + manual F6 verification; docs/tech-debt note.
|
||||
@@ -0,0 +1,694 @@
|
||||
# Phase 9 Prop Editor — Round 3 (Reshape-Mode Menu, Window Chrome, Exit & Autosave)
|
||||
|
||||
Status: **approved** (2026-09-18; all nine open choices resolved as proposed).
|
||||
|
||||
Source: twelve user feedback points against the shipped Round 2 Prop Editor
|
||||
(`scripts/prop_canvas.gd`, `scripts/prop_editor.gd`, `scripts/prop_theme.gd`,
|
||||
`prop_theme.json`, `scenes/prop_editor.tscn`). Autosave/exit reference:
|
||||
`scripts/sandbox_stage.gd` + `scripts/stage_scene_io.gd`.
|
||||
|
||||
---
|
||||
|
||||
## 0. Feedback summary (verbatim intent)
|
||||
|
||||
1. In "Edit Points" mode, right-clicking some segments shows the add-shape menu
|
||||
instead of Create Point — a segment right-click should always offer creating a point.
|
||||
2. A created point sometimes appears on a different segment; it must appear where the
|
||||
cursor was when created.
|
||||
3. In "Edit Points" mode, the RMB menu must ONLY offer point editing: "Create Point"
|
||||
(segment) / "Remove Point" (vertex). No Copy/Erase/Flip/z-order items.
|
||||
4. In "Edit Points" mode, do NOT allow adding shapes (empty-canvas RMB shows nothing).
|
||||
5. In "Edit Points" mode, the inspector must NOT allow adding or erasing a shape
|
||||
(hide the Add-Shape section and the Erase Shape button; sync on `mode_changed`).
|
||||
6. Rename modes: "Move & Resize" → **"Arrange"**, "Edit Points" → **"Reshape"**
|
||||
(enum values stay TRANSFORM/POINTS — only labels/emoji change).
|
||||
7. Mode toggle becomes a segmented **[ Arrange | Reshape ]** pair **centered** in the
|
||||
ModeBar, with a **bigger font** and a **related emoji** on each button.
|
||||
8. "View → Reset Views" becomes **"View → Reset View"** (singular).
|
||||
9. File/Edit/View menu bar + items get a **bigger font** (theme-driven) plus
|
||||
**kid-friendly emoji** in labels.
|
||||
10. **Window title** = `[Prop name][filename.prop][*]`: name (default **"Untitled"**),
|
||||
filename (basename with `.prop`, only when saved), `*` when modified.
|
||||
11. **Exit dialog**: intercept window close while modified; **Discard / Save / Cancel**
|
||||
with emoji; async save-before-quit.
|
||||
12. **Autosave/recovery**: temp prop file on modification, debounced; startup restore
|
||||
prompt; explicit clear semantics.
|
||||
|
||||
---
|
||||
|
||||
## 1. Diagnoses (verified root causes)
|
||||
|
||||
### 1.1 Point 2 — created point lands on a different segment
|
||||
|
||||
`PropCanvas._get_context_world_pos()` (`scripts/prop_canvas.gd:1102-1108`) discards any
|
||||
stored click position whose world coordinates fall outside the visible `[0,0 .. size/zoom]`
|
||||
rect and replaces it with the **canvas center**:
|
||||
|
||||
```gdscript
|
||||
func _get_context_world_pos() -> Vector2:
|
||||
var click_pos: Variant = _context_menu.get_meta("click_position", Vector2(100.0, 60.0))
|
||||
var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2(100.0, 60.0)
|
||||
var world_size := size / _zoom
|
||||
if world_pos.x < 0.0 or world_pos.y < 0.0 or world_pos.x > world_size.x or world_pos.y > world_size.y:
|
||||
return _screen_to_world(size * 0.5)
|
||||
return world_pos
|
||||
```
|
||||
|
||||
Negative world coordinates are **normal after panning** (shapes live around the world
|
||||
origin). Right-clicking a segment at e.g. `(-40, 120)` stores a valid world position that is
|
||||
then thrown away, so `create_point_on_edge` (called from `_on_context_menu_id_pressed`,
|
||||
`MENU_CREATE_POINT`, line 993) projects the **canvas center** onto whatever segment is nearest
|
||||
*it* — a different segment. **Fix:** remove the bounds fallback entirely (the stored position
|
||||
is always valid world space); only guard non-finite values.
|
||||
|
||||
### 1.2 Point 1 — right-clicking some segments shows the Add-Shape menu
|
||||
|
||||
In POINTS mode, `_handle_right_click` (lines 744-813) targets a shape only through
|
||||
`_is_point_in_shape` (lines 774-778). For **closed** shapes `_is_point_in_shape` is
|
||||
**fill-only** (`Geometry2D.is_point_in_polygon`, lines 1168-1169); for open lines it is a
|
||||
distance-to-outline test. A right-click **on a segment** that lies slightly outside the fill
|
||||
(concave corners, a 12-gon circle's edge chord, just beyond a rectangle edge) therefore falls
|
||||
through to the empty-canvas **Add-Shape** menu (lines 802-813). The existing "Create Point"
|
||||
branch (line 786) is only reachable **after** `_is_point_in_shape` already returned true, so
|
||||
it cannot rescue the case. For open lines, a click beyond `EDGE_HIT_THRESHOLD / _zoom` also
|
||||
falls through. **Fix:** in POINTS mode, replace the fill-only shape gate with
|
||||
**segment-proximity targeting** (see §2.1). Combined with points 3/4 (the empty-canvas Add
|
||||
menu disappears in POINTS mode), the entire fall-through disappears.
|
||||
|
||||
---
|
||||
|
||||
## 2. Static contracts
|
||||
|
||||
### 2.1 POINTS-mode right-click menu matrix + segment-proximity targeting
|
||||
|
||||
New public helper (directly testable):
|
||||
|
||||
```gdscript
|
||||
# prop_canvas.gd — returns the frontmost shape whose segment is within
|
||||
# EDGE_HIT_THRESHOLD / _zoom of world_pos, else -1.
|
||||
func hit_test_segment(world_pos: Vector2) -> int:
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
if _is_point_near_outline(i, world_pos):
|
||||
return i
|
||||
return -1
|
||||
```
|
||||
|
||||
`_is_point_near_outline` already measures distance to the **segments only**
|
||||
(`_distance_to_shape_pts`, lines 1183-1189), so this is the segment-proximity test the fix
|
||||
needs.
|
||||
|
||||
Revised `_handle_right_click` right-click decision table:
|
||||
|
||||
| Mode | Click target | Menu shown |
|
||||
| --- | --- | --- |
|
||||
| POINTS | on a vertex (selected first, then others front→back) | **Remove Point** (only) |
|
||||
| POINTS | on/near a **segment** (within `EDGE_HIT_THRESHOLD/zoom`, front→back) | **Create Point** (only); shape is selected first |
|
||||
| POINTS | interior body of a closed shape (inside fill, *not* near any segment) | **nothing**; shape is selected (no menu pops) |
|
||||
| POINTS | empty canvas | **nothing** (no Add-Shape menu) |
|
||||
| TRANSFORM | on a shape body (fill/outline, front→back) | Copy / Paste / Erase / Flip L-R / Flip U-D / Bring Forward / Send Back (unchanged) |
|
||||
| TRANSFORM | empty canvas | Add Box/Ball/Plank/Triangle/Line/Circle (+ Paste) (unchanged) |
|
||||
|
||||
Revised `_handle_right_click` shape (POINTS branch only; TRANSFORM branch is unchanged):
|
||||
|
||||
```gdscript
|
||||
func _handle_right_click(mb: InputEventMouseButton, world_pos: Vector2) -> void:
|
||||
# --- POINTS mode: point editing only ---
|
||||
if _edit_mode == EditMode.POINTS:
|
||||
# 1. Vertex (selected first, then others front->back) -> Remove Point
|
||||
var context_shape := -1
|
||||
var context_vertex := -1
|
||||
if _selected_shape_idx >= 0:
|
||||
var hit_idx := _hit_test_vertex(_selected_shape_idx, world_pos)
|
||||
if hit_idx >= 0:
|
||||
context_shape = _selected_shape_idx
|
||||
context_vertex = hit_idx
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
if context_shape >= 0:
|
||||
break
|
||||
if i == _selected_shape_idx:
|
||||
continue
|
||||
var hit_idx := _hit_test_vertex(i, world_pos)
|
||||
if hit_idx >= 0:
|
||||
context_shape = i
|
||||
context_vertex = hit_idx
|
||||
_context_menu.clear()
|
||||
if context_shape >= 0 and context_vertex >= 0:
|
||||
_context_menu.set_meta("context_shape", context_shape)
|
||||
_context_menu.set_meta("context_vertex", context_vertex)
|
||||
_context_menu.add_item("Remove Point", MENU_REMOVE_POINT)
|
||||
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
return
|
||||
|
||||
# 2. Segment proximity -> Create Point (selects the shape)
|
||||
var seg_shape := hit_test_segment(world_pos)
|
||||
if seg_shape >= 0:
|
||||
_selected_shape_idx = seg_shape
|
||||
_context_menu.set_meta("context_shape", seg_shape)
|
||||
_context_menu.set_meta("click_position", world_pos)
|
||||
queue_redraw()
|
||||
shape_selected.emit(seg_shape)
|
||||
_context_menu.add_item("Create Point", MENU_CREATE_POINT)
|
||||
_context_menu.popup_on_parent(Rect2(mb.global_position, Vector2.ONE))
|
||||
return
|
||||
|
||||
# 3. Interior body: select only, NO menu
|
||||
var body_shape := -1
|
||||
for i: int in range(shapes.size() - 1, -1, -1):
|
||||
if _is_point_in_shape(i, world_pos):
|
||||
body_shape = i
|
||||
break
|
||||
if body_shape >= 0:
|
||||
_selected_shape_idx = body_shape
|
||||
queue_redraw()
|
||||
shape_selected.emit(body_shape)
|
||||
return # empty canvas -> no menu either
|
||||
|
||||
# --- TRANSFORM mode: existing shape-hit / empty-canvas-add logic (unchanged) ---
|
||||
...
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- The vertex branch keeps precedence over the segment branch (a vertex sits at a segment end).
|
||||
- `_on_context_menu_id_pressed` already routes `MENU_CREATE_POINT` → `create_point_on_edge(
|
||||
_get_context_world_pos())`; the `context_shape` meta is set on the menu so the selected shape
|
||||
is the one `create_point_on_edge` operates on.
|
||||
- Programmatic APIs (`insert_vertex`, `remove_vertex`, `create_point_on_edge`,
|
||||
`hit_test_segment`, `set_edit_mode`/`get_edit_mode`) remain mode-agnostic; only the context
|
||||
menu gates them.
|
||||
|
||||
### 2.2 `_get_context_world_pos` fix (Point 2)
|
||||
|
||||
```gdscript
|
||||
func _get_context_world_pos() -> Vector2:
|
||||
var click_pos: Variant = _context_menu.get_meta("click_position", Vector2.ZERO)
|
||||
var world_pos: Vector2 = click_pos if click_pos is Vector2 else Vector2.ZERO
|
||||
# The stored click position is always a valid world-space coordinate
|
||||
# (from _screen_to_world at click time). Negative coords are normal after
|
||||
# panning — no bounds clamp. Only guard non-finite values.
|
||||
if not is_finite(world_pos.x) or not is_finite(world_pos.y):
|
||||
return _screen_to_world(size * 0.5)
|
||||
return world_pos
|
||||
```
|
||||
|
||||
This also fixes TRANSFORM-mode empty-canvas Add placement (a click far off the origin now
|
||||
places the primitive there instead of silently snapping to center).
|
||||
|
||||
### 2.3 Mode labels/emoji — location, centering, font token (Points 6, 7)
|
||||
|
||||
**Location decision:** the canvas keeps only the enum (`enum EditMode { TRANSFORM, POINTS }`).
|
||||
All human-facing strings (mode labels/emoji, menu labels/emoji, dialog copy) live in
|
||||
`prop_editor.gd` — the UI controller. The canvas stays a string-free widget; the label mapping
|
||||
is a pure static function for headless testing.
|
||||
|
||||
```gdscript
|
||||
# prop_editor.gd
|
||||
const MODE_LABELS: PackedStringArray = ["Arrange", "Reshape"] # index == EditMode
|
||||
const MODE_EMOJI: PackedStringArray = ["🖐️", "✏️"]
|
||||
|
||||
static func mode_button_text(mode: int) -> String:
|
||||
var idx: int = clampi(mode, 0, MODE_LABELS.size() - 1)
|
||||
return "%s %s" % [MODE_EMOJI[idx], MODE_LABELS[idx]]
|
||||
```
|
||||
|
||||
Emoji choice (kid-friendly, distinct, justified):
|
||||
|
||||
| Mode | Emoji | Label | Rationale |
|
||||
| --- | --- | --- | --- |
|
||||
| TRANSFORM | 🖐️ | Arrange | open hand = "grab and move things around" (translate/rotate/scale gizmos) |
|
||||
| POINTS | ✏️ | Reshape | pencil = "draw / edit points" (vertex handles, Create/Remove Point) |
|
||||
|
||||
**Toolbar centering.** The `%ModeBar` `HBoxContainer` (`scenes/prop_editor.tscn` line 78) is
|
||||
the row above the canvas. Set `_mode_bar.alignment = BoxContainer.ALIGNMENT_CENTER` in
|
||||
`_ready()` so the segmented pair sits centered in the canvas width (interpreting "centered
|
||||
vertically" as "centered in the bar" — see §6 open choices). `_make_mode_button` keeps
|
||||
`toggle_mode` + shared `ButtonGroup` (the segmented affordance), drops `SIZE_SHRINK_BEGIN` in
|
||||
favor of the container alignment, and gains a bigger font + min height.
|
||||
|
||||
**Font/hit-target tokens.** Add to `PropTheme.DEFAULT_CONFIG` + committed `prop_theme.json`
|
||||
(additive; `version` stays `"1.0"`):
|
||||
|
||||
```json
|
||||
"ui": {
|
||||
"fonts": { "body": 16, "header": 20, "menu": 18, "menu_bar": 18, "mode": 20, "status": 14 },
|
||||
"hit_targets": { "button_min_height": 36, "row_min_height": 36, "mode_button_min_height": 44 }
|
||||
}
|
||||
```
|
||||
|
||||
New `PropTheme` accessors (clamped, fallback to `DEFAULT_CONFIG`):
|
||||
|
||||
```gdscript
|
||||
func font_mode() -> int: return integer("ui.fonts.mode", 20, 8, 96)
|
||||
func font_menu_bar() -> int: return integer("ui.fonts.menu_bar", 18, 8, 72)
|
||||
func mode_button_min_height() -> int: return integer("ui.hit_targets.mode_button_min_height", 44, 16, 200)
|
||||
```
|
||||
|
||||
`_make_mode_button` applies `add_theme_font_size_override("font_size", _theme.font_mode())`
|
||||
and `custom_minimum_size.y = float(_theme.mode_button_min_height())`, and sets
|
||||
`text = mode_button_text(mode)`.
|
||||
|
||||
### 2.4 Menu bar emoji + font tokens (Points 8, 9)
|
||||
|
||||
`_build_control_theme()` adds a `MenuBar` font size; `_setup_menu_bar()` rewrites titles/items:
|
||||
|
||||
```gdscript
|
||||
# _build_control_theme() addition:
|
||||
_ui_theme.set_font_size("font_size", "MenuBar", _theme.font_menu_bar())
|
||||
# (PopupMenu items already use font_menu, now default 18.)
|
||||
|
||||
# _setup_menu_bar() rewrite (emoji + singular "Reset View"):
|
||||
_menu_bar.set_menu_title(idx_file, "📁 File")
|
||||
file_menu.add_item("💾 Save", MENU_SAVE)
|
||||
file_menu.add_item("📄 Save As...", MENU_SAVE_AS)
|
||||
file_menu.add_item("📂 Load...", MENU_LOAD)
|
||||
file_menu.add_separator()
|
||||
file_menu.add_item("🗑️ Clear", MENU_CLEAR)
|
||||
_menu_bar.set_menu_title(idx_edit, "🔧 Edit")
|
||||
edit_menu.add_item("📏 Configure Grid / Snap...", MENU_GRID_SETTINGS)
|
||||
_menu_bar.set_menu_title(idx_view, "👁️ View")
|
||||
view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS) # was "Reset Views"
|
||||
```
|
||||
|
||||
Proposed menu emoji set (unique within the menu bar; 💾/🗑️ reappear in the exit dialog by
|
||||
design — same meaning):
|
||||
|
||||
| Label | Emoji | Justification |
|
||||
| --- | --- | --- |
|
||||
| File | 📁 | folder container = file operations |
|
||||
| Save | 💾 | floppy disk = classic save (matches exit-dialog Save) |
|
||||
| Save As… | 📄 | page = "write to a new document" |
|
||||
| Load… | 📂 | open folder = open existing |
|
||||
| Clear | 🗑️ | trash = discard content (matches exit-dialog Discard) |
|
||||
| Edit | 🔧 | wrench = tools/settings |
|
||||
| Configure Grid / Snap… | 📏 | ruler = measurement/grid |
|
||||
| View | 👁️ | eye = visibility |
|
||||
| Reset View | 🔍 | magnifier = look again / reset |
|
||||
|
||||
Emoji-in-labels is an established pattern in this codebase (sandbox uses `💾 Save`,
|
||||
`🗑️ Discard`, `📋 Edit Queue…`, `⚡ Edit Rules…`), so it renders via the project's fallback
|
||||
font.
|
||||
|
||||
### 2.5 Window title builder + update points (Point 10)
|
||||
|
||||
Pure, headless-testable builder in `prop_editor.gd`:
|
||||
|
||||
```gdscript
|
||||
const UNTITLED_NAME := "Untitled"
|
||||
|
||||
static func build_window_title(prop_name: String, file_path: String, dirty: bool) -> String:
|
||||
var name := prop_name.strip_edges()
|
||||
if name.is_empty():
|
||||
name = UNTITLED_NAME
|
||||
var title := name
|
||||
if not file_path.is_empty():
|
||||
title += " — " + file_path.get_file() # get_file() keeps the ".prop" extension
|
||||
if dirty:
|
||||
title += " *"
|
||||
return title
|
||||
```
|
||||
|
||||
Examples:
|
||||
|
||||
| name | file_path | dirty | title |
|
||||
| --- | --- | --- | --- |
|
||||
| `""` | `""` | false | `Untitled` |
|
||||
| `"My Crate"` | `""` | true | `My Crate *` |
|
||||
| `"My Crate"` | `res://props/mycrate.prop` | false | `My Crate — mycrate.prop` |
|
||||
| `""` | `res://props/mycrate.prop` | true | `Untitled — mycrate.prop *` |
|
||||
|
||||
Update points — one choke point:
|
||||
|
||||
```gdscript
|
||||
func _update_window_title() -> void:
|
||||
if not is_inside_tree():
|
||||
return
|
||||
get_window().title = build_window_title(_prop_name_edit.text, _current_path, _dirty)
|
||||
|
||||
func _update_save_status() -> void:
|
||||
... existing status-bar text ...
|
||||
_update_window_title() # single re-derivation site
|
||||
```
|
||||
|
||||
`_update_save_status()` is already invoked on every relevant transition (`_on_name_changed`,
|
||||
`_on_canvas_shape_changed`, `_on_add_pressed`, `_save_to`, `_load_from`,
|
||||
`_on_clear_confirmed`, `_apply_json_data`), and `_ready()` calls it once — so the title stays
|
||||
in sync for free. **Extension enforcement:** `_on_save_file_selected` normalizes the path to
|
||||
`.prop` (mirroring sandbox `_on_scene_save_path_selected`) so `get_file()` always includes the
|
||||
extension:
|
||||
|
||||
```gdscript
|
||||
func _on_save_file_selected(path: String) -> void:
|
||||
if path.get_extension().to_lower() != "prop":
|
||||
path = path.get_basename() + ".prop"
|
||||
...
|
||||
```
|
||||
|
||||
### 2.6 Exit dialog flow (Point 11)
|
||||
|
||||
Mirrors sandbox (`auto_accept_quit = false` + `NOTIFICATION_WM_CLOSE_REQUEST` +
|
||||
save-then-quit). The save path is **async only because of the thumbnail render**, so the
|
||||
exit-save path **skips the thumbnail** (cache nicety, not data) — see §6.
|
||||
|
||||
```gdscript
|
||||
# state
|
||||
var _exit_dialog: ConfirmationDialog
|
||||
var _pending_quit: bool = false
|
||||
|
||||
# _ready(): add
|
||||
get_tree().auto_accept_quit = false
|
||||
|
||||
# _setup_dialogs(): build (same _ui_theme as _clear_confirm)
|
||||
_exit_dialog = ConfirmationDialog.new()
|
||||
_exit_dialog.title = "⚠️ Unsaved Changes"
|
||||
_exit_dialog.dialog_text = "You have unsaved changes. Save them before quitting?"
|
||||
_exit_dialog.ok_button_text = "💾 Save"
|
||||
_exit_dialog.add_button("🗑️ Discard", false, "discard")
|
||||
_exit_dialog.get_cancel_button().text = "❌ Cancel" # or add_button(..., true, "cancel")
|
||||
_exit_dialog.theme = _ui_theme
|
||||
add_child(_exit_dialog)
|
||||
_exit_dialog.confirmed.connect(_on_exit_save)
|
||||
_exit_dialog.custom_action.connect(_on_exit_custom_action)
|
||||
_exit_dialog.canceled.connect(_on_exit_cancel)
|
||||
|
||||
func _notification(what: int) -> void:
|
||||
if what == NOTIFICATION_WM_CLOSE_REQUEST:
|
||||
_request_close()
|
||||
|
||||
func _request_close() -> void:
|
||||
if not _dirty:
|
||||
get_tree().quit()
|
||||
return
|
||||
_pending_quit = true
|
||||
_exit_dialog.popup_centered()
|
||||
|
||||
func _on_exit_save() -> void:
|
||||
_exit_dialog.hide()
|
||||
if _current_path.is_empty():
|
||||
_save_dialog.popup_centered() # _pending_quit stays true
|
||||
return
|
||||
await _save_and_maybe_quit(_current_path)
|
||||
|
||||
func _save_and_maybe_quit(path: String) -> void:
|
||||
var ok: bool = await _save_to(path, false) # skip thumbnail on exit-save
|
||||
if ok and _pending_quit:
|
||||
_pending_quit = false
|
||||
get_tree().quit()
|
||||
|
||||
func _on_exit_custom_action(action: StringName) -> void:
|
||||
if action == &"discard":
|
||||
_exit_dialog.hide()
|
||||
_clear_autosave()
|
||||
_pending_quit = false
|
||||
get_tree().quit()
|
||||
|
||||
func _on_exit_cancel() -> void:
|
||||
_pending_quit = false # stay in the editor
|
||||
|
||||
# _on_save_file_selected (save dialog path) — compose with pending-quit:
|
||||
func _on_save_file_selected(path: String) -> void:
|
||||
if path.get_extension().to_lower() != "prop":
|
||||
path = path.get_basename() + ".prop"
|
||||
var ok: bool = await _save_to(path, not _pending_quit) # thumbnail only on normal save
|
||||
if _pending_quit and ok:
|
||||
_pending_quit = false
|
||||
get_tree().quit()
|
||||
```
|
||||
|
||||
`_save_to` changes signature to return success and accept a thumbnail flag:
|
||||
|
||||
```gdscript
|
||||
func _save_to(path: String, with_thumbnail: bool = true) -> bool:
|
||||
var ok: bool = PropFileScript.save(path, _build_json_data())
|
||||
if not ok:
|
||||
_show_error("Could not save the prop to '%s'." % path)
|
||||
return false
|
||||
_current_path = path
|
||||
_dirty = false
|
||||
_clear_autosave()
|
||||
_update_save_status()
|
||||
if with_thumbnail:
|
||||
await _save_prop_thumbnail()
|
||||
return true
|
||||
```
|
||||
|
||||
Flow summary:
|
||||
|
||||
```text
|
||||
Window close (NOTIFICATION_WM_CLOSE_REQUEST)
|
||||
├─ clean -> quit()
|
||||
└─ dirty -> _exit_dialog
|
||||
├─ ❌ Cancel -> _pending_quit=false, stay
|
||||
├─ 🗑️ Discard -> clear autosave, quit()
|
||||
└─ 💾 Save
|
||||
├─ no _current_path -> Save dialog; on select -> save(skip thumb) -> quit
|
||||
└─ has _current_path -> save(skip thumb) -> quit (or show error, no quit)
|
||||
```
|
||||
|
||||
`_ask_confirm`-style theming already exists: the exit dialog uses the same `_ui_theme` and
|
||||
`ConfirmationDialog` shape as `_clear_confirm` (built in `_setup_dialogs()`), and gets the
|
||||
same `⚠️` title convention as the sandbox's unsaved-changes dialog.
|
||||
|
||||
### 2.7 Autosave / recovery (Point 12)
|
||||
|
||||
Mirrors `sandbox_stage.gd` (`AUTOSAVE_PATH`, debounce-free write-on-modify, `write_file_safe`,
|
||||
`_check_autosave_recovery`, stale-check, clear-on-save/load). A small atomic writer is added to
|
||||
`PropFile` (the scene IO's `write_file_safe` is sandbox-specific):
|
||||
|
||||
```gdscript
|
||||
# prop_file.gd — atomic write mirroring StageSceneIO.write_file_safe.
|
||||
static func save_safe(path: String, data: Dictionary) -> bool:
|
||||
var tmp := path + ".tmp"
|
||||
var bak := path + ".bak"
|
||||
var file := FileAccess.open(tmp, FileAccess.WRITE)
|
||||
if file == null:
|
||||
push_warning("PropFile: could not write temporary file '%s'." % tmp)
|
||||
return false
|
||||
file.store_string(JSON.stringify(data, "\t"))
|
||||
file.flush()
|
||||
file.close()
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.remove_absolute(bak)
|
||||
if FileAccess.file_exists(path) and DirAccess.rename_absolute(path, bak) != OK:
|
||||
DirAccess.remove_absolute(tmp)
|
||||
return false
|
||||
if DirAccess.rename_absolute(tmp, path) != OK:
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.rename_absolute(bak, path)
|
||||
return false
|
||||
if FileAccess.file_exists(bak):
|
||||
DirAccess.remove_absolute(bak)
|
||||
return true
|
||||
```
|
||||
|
||||
Editor constants + state:
|
||||
|
||||
```gdscript
|
||||
const AUTOSAVE_PATH := "user://prop_editor_autosave.prop"
|
||||
const AUTOSAVE_DEBOUNCE := 1.0
|
||||
|
||||
var _autosave_remaining: float = 0.0 # > 0 = counting down to a write
|
||||
```
|
||||
|
||||
Dirty setpoints go through a single helper that schedules the autosave:
|
||||
|
||||
```gdscript
|
||||
func _mark_dirty() -> void:
|
||||
_dirty = true
|
||||
_update_save_status()
|
||||
_autosave_remaining = AUTOSAVE_DEBOUNCE
|
||||
|
||||
# _on_canvas_shape_changed / _on_name_changed / _on_add_pressed -> _mark_dirty()
|
||||
```
|
||||
|
||||
Debounce tick (appended to the existing `_process`):
|
||||
|
||||
```gdscript
|
||||
func _process(delta: float) -> void:
|
||||
_update_cursor_coords()
|
||||
if _autosave_remaining > 0.0:
|
||||
_autosave_remaining -= delta
|
||||
if _autosave_remaining <= 0.0:
|
||||
_autosave_remaining = 0.0
|
||||
_write_autosave()
|
||||
|
||||
func _write_autosave() -> void:
|
||||
if not _dirty:
|
||||
return
|
||||
var data := _build_json_data()
|
||||
data["metadata"]["autosave"] = true
|
||||
data["metadata"]["source_path"] = _current_path
|
||||
data["metadata"]["modified_unix"] = Time.get_unix_time_from_system()
|
||||
PropFileScript.save_safe(AUTOSAVE_PATH, data)
|
||||
|
||||
func _clear_autosave() -> void:
|
||||
if FileAccess.file_exists(AUTOSAVE_PATH):
|
||||
DirAccess.remove_absolute(AUTOSAVE_PATH)
|
||||
```
|
||||
|
||||
The autosave never triggers another autosave (it writes the file, does not touch `_dirty` or
|
||||
emit `shape_changed`); `_apply_json_data`/`clear` set `_dirty = false` and do not schedule.
|
||||
|
||||
Startup recovery (called from `_ready()` after dialogs are built; mirrors
|
||||
`sandbox._check_autosave_recovery`, lines 1482-1500):
|
||||
|
||||
```gdscript
|
||||
func _check_autosave_recovery() -> void:
|
||||
if not FileAccess.file_exists(AUTOSAVE_PATH):
|
||||
return
|
||||
var raw: Dictionary = PropFileScript.load(AUTOSAVE_PATH)
|
||||
if raw.is_empty():
|
||||
_clear_autosave(); return
|
||||
var raw_meta: Variant = raw.get("metadata", {})
|
||||
var metadata: Dictionary = raw_meta if raw_meta is Dictionary else {}
|
||||
if not bool(metadata.get("autosave", false)):
|
||||
_clear_autosave(); return
|
||||
var source_path := String(metadata.get("source_path", ""))
|
||||
# Stale if the saved source exists and is newer-or-equal than the autosave.
|
||||
if not source_path.is_empty() and FileAccess.file_exists(source_path) \
|
||||
and int(metadata.get("modified_unix", 0)) <= int(FileAccess.get_modified_time(source_path)):
|
||||
_clear_autosave(); return
|
||||
|
||||
var dialog := ConfirmationDialog.new()
|
||||
dialog.title = "Restore Prop?"
|
||||
dialog.dialog_text = "An unsaved prop was recovered. Restore it?"
|
||||
dialog.ok_button_text = "🔄 Restore"
|
||||
dialog.add_button("🗑️ Discard", false, "discard")
|
||||
dialog.theme = _ui_theme
|
||||
add_child(dialog)
|
||||
dialog.confirmed.connect(func() -> void:
|
||||
_apply_json_data(raw, true)
|
||||
_current_path = source_path
|
||||
_update_save_status()
|
||||
dialog.queue_free())
|
||||
dialog.custom_action.connect(func(action: StringName) -> void:
|
||||
if action == &"discard":
|
||||
_clear_autosave()
|
||||
dialog.queue_free())
|
||||
dialog.popup_centered()
|
||||
```
|
||||
|
||||
`_apply_json_data` gains an `is_recovery` flag so a restored prop stays **dirty** (its content
|
||||
is newer than any saved source):
|
||||
|
||||
```gdscript
|
||||
func _apply_json_data(json: Variant, is_recovery: bool = false) -> String:
|
||||
...
|
||||
_dirty = is_recovery # recovery keeps dirty; normal load clears
|
||||
...
|
||||
```
|
||||
|
||||
**Autosave clear semantics (explicit table):**
|
||||
|
||||
| Event | Autosave cleared? |
|
||||
| --- | --- |
|
||||
| Successful manual save (`_save_to`, non-autosave) | **yes** |
|
||||
| Successful load (`_load_from`) | **yes** |
|
||||
| File → Clear confirmed (`_on_clear_confirmed`) | **yes** |
|
||||
| Exit dialog Discard → quit | **yes** |
|
||||
| Exit dialog Save → successful save → quit | **yes** (via `_save_to`) |
|
||||
| Recovery prompt Discard | **yes** |
|
||||
| Recovery prompt Restore | **no** (kept until a manual save/load/clear) |
|
||||
| Autosave is stale (source newer) at startup | **yes** |
|
||||
|
||||
---
|
||||
|
||||
## 3. Task-to-implementation mapping & acceptance criteria
|
||||
|
||||
| # | Feedback point | Target | Acceptance criteria |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | Segment right-click → Create Point | `prop_canvas.gd` | `hit_test_segment(world_pos) -> int` public; `_handle_right_click` POINTS branch uses segment proximity (not fill-only) per §2.1; Create Point offered for any segment within `EDGE_HIT_THRESHOLD/zoom`; vertex still takes precedence (Remove Point). |
|
||||
| 2 | Created point on correct segment | `prop_canvas.gd` | `_get_context_world_pos` no longer rejects negative/out-of-bounds coords; only non-finite guard; created vertex == cursor projection onto nearest segment. |
|
||||
| 3 | POINTS menu = point editing only | `prop_canvas.gd` | POINTS right-click matrix per §2.1: vertex→Remove Point, segment→Create Point, interior/empty→no menu; no Copy/Erase/Flip/z-order items in POINTS mode. |
|
||||
| 4 | No add menu in POINTS | `prop_canvas.gd` | POINTS empty-canvas right-click pops nothing (early return before the Add-Shape block). |
|
||||
| 5 | Inspector hides Add/Erase in POINTS | `prop_editor.gd` | `_add_section` VBoxContainer wraps the Add-Shape section; `_sync_mode_controls(mode)` hides `_add_section` + `_delete_button` in POINTS and shows them in TRANSFORM; called from `_on_canvas_mode_changed` and `_ready()`. |
|
||||
| 6 | Rename modes | `prop_editor.gd` | `MODE_LABELS = ["Arrange","Reshape"]`; `mode_button_text()` returns emoji + label; enum values unchanged; no canvas change. |
|
||||
| 7 | Segmented centered pair, bigger font, emoji | `prop_editor.gd` + `prop_theme.gd` + `prop_theme.json` | `_mode_bar.alignment = ALIGNMENT_CENTER`; `_make_mode_button` uses `font_mode()` + `mode_button_min_height()` + `mode_button_text(mode)`; tokens `ui.fonts.mode` (20), `ui.hit_targets.mode_button_min_height` (44) present in `DEFAULT_CONFIG` + JSON. |
|
||||
| 8 | "Reset View" singular | `prop_editor.gd` | `view_menu.add_item("🔍 Reset View", MENU_RESET_VIEWS)` (label only). |
|
||||
| 9 | Menu font + emoji | `prop_editor.gd` + `prop_theme.gd` + `prop_theme.json` | MenuBar font token `ui.fonts.menu_bar` (18) applied in `_build_control_theme`; `font_menu` bumped to 18; titles/items per §2.4 emoji table. |
|
||||
| 10 | Window title | `prop_editor.gd` | `build_window_title(name, path, dirty)` static pure; `_update_window_title()` called from `_update_save_status()`; `get_window().title` set; `.prop` extension enforced in `_on_save_file_selected`. |
|
||||
| 11 | Exit dialog | `prop_editor.gd` | `auto_accept_quit = false` in `_ready()`; `_notification(NOTIFICATION_WM_CLOSE_REQUEST)`; themed exit dialog (💾/🗑️/❌); `_save_to(path, with_thumbnail) -> bool`; save-then-quit sequencing; Cancel stays; save failure aborts quit. |
|
||||
| 12 | Autosave/recovery | `prop_editor.gd` + `prop_file.gd` | `AUTOSAVE_PATH`, `AUTOSAVE_DEBOUNCE = 1.0`, `_mark_dirty()` schedules, `_write_autosave()` (metadata.autosave + source_path + modified_unix), `PropFile.save_safe` atomic write, `_check_autosave_recovery()` prompt, `_apply_json_data(is_recovery)`, clear-semantics table. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Files modified
|
||||
|
||||
| File | Changes |
|
||||
| --- | --- |
|
||||
| `scripts/prop_canvas.gd` | `hit_test_segment` (public); `_handle_right_click` POINTS matrix; `_get_context_world_pos` bounds-fix. |
|
||||
| `scripts/prop_editor.gd` | Mode labels/emoji + `mode_button_text`; toolbar centering + font; menu emoji + "Reset View"; window title builder + update points; exit dialog + `auto_accept_quit` + `_notification`; autosave (path/debounce/write/recovery/clear); `_save_to(path, with_thumbnail) -> bool`; `_apply_json_data(is_recovery)`; `_add_section` wrapper + `_sync_mode_controls`. |
|
||||
| `scripts/prop_theme.gd` | `font_mode()`, `font_menu_bar()`, `mode_button_min_height()`; `DEFAULT_CONFIG` `ui` additions; `font_menu` default 16→18. |
|
||||
| `prop_theme.json` | Additive `ui` keys (`fonts.mode`, `fonts.menu_bar`, `fonts.menu` 18, `hit_targets.mode_button_min_height`); `version` stays `"1.0"`. |
|
||||
| `scripts/prop_file.gd` | `save_safe(path, data) -> bool` atomic write. |
|
||||
| `tests/test_prop_editor_canvas.gd` | Add `_test_segment_hit_test` (and optionally `_test_context_world_pos` if the helper is exposed). |
|
||||
| `tests/test_prop_editor_round3.gd` | **new** — title builder, `mode_button_text`, `PropFile.save_safe` round-trip, autosave metadata. |
|
||||
| `docs/tech_debt_and_optimizations.md` | Log exit-save thumbnail skip + autosave debounce + mode-label location decisions. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Test coverage
|
||||
|
||||
**Headless — `tests/test_prop_editor_canvas.gd` (existing suite, public API):**
|
||||
|
||||
- New `_test_segment_hit_test()`: box at `(100,100)` (bbox `(76,76)-(124,124)`);
|
||||
`hit_test_segment(Vector2(110, 70))` (6 px above top edge, within threshold) → `0`;
|
||||
`hit_test_segment(Vector2(100, 100))` (interior, ~24 px from every edge) → `-1`;
|
||||
`hit_test_segment(Vector2(400, 400))` → `-1`.
|
||||
- Existing `_test_create_point_on_edge` already asserts projection + `vertex_created`; the
|
||||
Round 3 fix (stored click position) is verified by a new direct `_get_context_world_pos`
|
||||
assertion only if that helper is exposed (optional — otherwise manual F6 covers it).
|
||||
|
||||
**Headless — `tests/test_prop_editor_round3.gd` (new; static funcs only, no scene instantiation):**
|
||||
|
||||
```gdscript
|
||||
const PE := preload("res://scripts/prop_editor.gd")
|
||||
const PF := preload("res://scripts/prop_file.gd")
|
||||
|
||||
# Title builder (pure static)
|
||||
PE.build_window_title("", "", false) == "Untitled"
|
||||
PE.build_window_title("My Crate", "", true) == "My Crate *"
|
||||
PE.build_window_title("My Crate", "res://props/mycrate.prop", false) == "My Crate — mycrate.prop"
|
||||
PE.build_window_title("", "res://props/mycrate.prop", true) == "Untitled — mycrate.prop *"
|
||||
|
||||
# Mode labels/emoji (pure static, clamping)
|
||||
PE.mode_button_text(0) == "🖐️ Arrange"
|
||||
PE.mode_button_text(1) == "✏️ Reshape"
|
||||
PE.mode_button_text(99) == "✏️ Reshape" # clamps to last
|
||||
|
||||
# save_safe atomic round-trip (PropFile)
|
||||
var p := "user://round3_autosave_test.prop"
|
||||
var d := {"version":"1.0","prop_name":"X","material_preset":"none","shapes":[],
|
||||
"metadata":{"autosave":true,"source_path":"","modified_unix":123}}
|
||||
PF.save_safe(p, d) == true
|
||||
PF.load(p) deep-equals d (JSON round-trip)
|
||||
FileAccess.file_exists(p + ".tmp") == false and FileAccess.file_exists(p + ".bak") == false
|
||||
# overwrite path works, still no leftover
|
||||
```
|
||||
|
||||
**Manual F6 verification (not headless-assertable):** emoji glyph rendering; toolbar centering
|
||||
+ font/height visually; POINTS-mode right-click feel (segment→Create Point, vertex→Remove
|
||||
Point, interior/empty→nothing); Create Point at a **negative** world coordinate after panning
|
||||
(regression for Point 2); inspector Add/Erase hide on mode switch; window title updates across
|
||||
name/save/load/clear/dirty; exit dialog Save/Discard/Cancel incl. save-then-quit; autosave
|
||||
recovery prompt on a simulated unclean shutdown (kill the process with edits, relaunch).
|
||||
|
||||
---
|
||||
|
||||
## 6. Design choices — user-approved resolutions
|
||||
|
||||
1. **Emoji sets.** **Approved as proposed:** buttons 🖐️ Arrange / ✏️ Reshape; menus 📁💾📄📂🗑️🔧📏👁️🔍; exit dialog 💾 Save / 🗑️ Discard / ❌ Cancel; restore 🔄 Restore.
|
||||
2. **"Untitled" vs "Unnamed Prop".** **Approved:** window title default **"Untitled"**; status bar keeps **"Unnamed Prop"**.
|
||||
3. **Filename with extension.** **Approved:** with `.prop` (`get_file()`), extension enforced on save (`.prp` treated as a typo for `.prop`).
|
||||
4. **Autosave debounce.** **Approved:** **1.0 s**.
|
||||
5. **Restore flow.** **Approved:** **prompt** with 🔄 Restore / 🗑️ Discard (mirrors the sandbox).
|
||||
6. **Autosave clear semantics.** **Approved:** per §2.7 table (clear on save/load/clear/discard-quit; keep after Restore; drop stale autosave at startup).
|
||||
7. **Exit-save thumbnail.** **Approved:** **skip** the thumbnail on exit-save.
|
||||
8. **Mode button height.** **Approved:** **44 px** (`mode_button_min_height`) with `font_mode` 20.
|
||||
9. **Centering.** **Approved:** horizontally center the segmented pair in the ModeBar row (`ALIGNMENT_CENTER`).
|
||||
|
||||
---
|
||||
|
||||
*Validation follows the existing pattern: per-script `--check-only`, then
|
||||
`tests/test_prop_editor_canvas.gd` and `tests/test_prop_editor_round3.gd` headless suites
|
||||
printing PASS/FAIL and `quit(0/1)`. Interactive feel (emoji, centering, exit dialog, autosave
|
||||
recovery) is manual F6 verification.*
|
||||
@@ -0,0 +1,52 @@
|
||||
# Phase 9 Prop Editor — Round 4 (Selection Feedback, Bigger Defaults, Status/Menus, New Flow)
|
||||
|
||||
Status: **implemented** (2026-09-19; direct UI round — no open design choices; all nine
|
||||
feedback points applied).
|
||||
|
||||
Source: nine user feedback points against the shipped Round 3 Prop Editor.
|
||||
|
||||
## Feedback summary (verbatim intent)
|
||||
|
||||
1. The Arrange/Reshape selected state was invisible right after clicking until the cursor
|
||||
moved away; make the selected button clearly outlined.
|
||||
2. Mode buttons, File/View menu bar, and menu items need bigger defaults — theme-adjustable.
|
||||
3. Inspector fonts and the Prop Name input need bigger defaults — theme-adjustable.
|
||||
4. Swap the status bar: title left, (X, Y) coordinates right; bigger default text.
|
||||
5. Remove the "Ball" shape — circle covers it.
|
||||
6. RMB context menus need a bigger default font — theme-adjustable.
|
||||
7. When the cursor leaves the canvas frame, coordinates read "X: -, Y: -" instead of blank.
|
||||
8. Replace Edit → Configure Grid / Snap with a grid-size spinbox + snap toggle on the right
|
||||
side of the menu bar row (sandbox-stage pattern).
|
||||
9. Replace File → Clear with File → New at the top of the File menu, prompting to save edited
|
||||
props first.
|
||||
|
||||
## Diagnoses
|
||||
|
||||
- **Point 1 (selection invisibility):** the Button `focus` stylebox draws after `pressed`, and
|
||||
the theme set `focus` = same style as `normal`, masking the pressed look until focus left
|
||||
(cursor moved away). Fix: mode buttons use `focus_mode = FOCUS_NONE`, and the theme's Button
|
||||
`pressed` stylebox now has an accent-colored 2 px border (`panel_pressed`) so the selected
|
||||
mode reads as an outlined box.
|
||||
|
||||
## Changes
|
||||
|
||||
| Target | Change |
|
||||
| --- | --- |
|
||||
| `prop_theme.json` + `scripts/prop_theme.gd` | Defaults bumped: body 18, header 24, menu 22, menu_bar 22, mode 26, status 18; new tokens `ui.fonts.prop_name` (20) and `ui.fonts.context_menu` (22); `mode_button_min_height` 44 → 56. All remain theme-adjustable. |
|
||||
| `scripts/prop_canvas.gd` | Removed the "Add Ball" context-menu item (the `"ball"` primitive stays programmatically available); new `set_context_menu_font_size(size)` applies a font-size override to the RMB menu. |
|
||||
| `scripts/prop_editor.gd` | `panel_pressed` accent-border stylebox; mode buttons `FOCUS_NONE`; `LineEdit` theme font + `font_prop_name` override on the name edit; status bar text swapped (title left via `%SaveStatus`, coords right via `%CursorCoords`) with "X: - Y: -" fallback; `PRIMITIVE_IDS/LABELS` drop "ball"; context-menu font wired from `font_context_menu`; top-right `Grid:` label + SpinBox + `Snap` CheckBox built in `%TopRightControls` (grid dialog + Edit menu removed); File menu is now 🆕 New / 💾 Save / 📄 Save As… / 📂 Load…; `_request_new()` reuses the unsaved-changes dialog (`_pending_new` flag; Save → save-then-new, Discard → new, Cancel → stay); `_do_new()` replaces the old clear flow. |
|
||||
| `scenes/prop_editor.tscn` | New `%TopRow` HBox (y 0..32) containing `%MenuBar` (expand) + `%TopRightControls`; status bar children swapped (`%SaveStatus` left, `%CursorCoords` right); `%ClearConfirmDialog` removed. |
|
||||
| `tests/test_prop_editor_theme.gd` | Updated to the new font/height defaults + new tokens. |
|
||||
|
||||
## Verification
|
||||
|
||||
- `--check-only` clean on all four scripts; all five headless suites green
|
||||
(file 64, theme 53, canvas 87, thumb 10, round3 17); scene smoke (instantiate + title) passes.
|
||||
- Manual F6: selected-mode outline; font sizes; swapped status bar with dashes; grid/snap
|
||||
controls on the menu row; File → New save prompt; no Ball in any Add menu.
|
||||
|
||||
## Notes / tech debt
|
||||
|
||||
- Recorded in `docs/tech_debt_and_optimizations.md` (row 32): mode buttons drop keyboard focus
|
||||
(`FOCUS_NONE`) so the focus stylebox can't mask the pressed state — revisit if keyboard
|
||||
accessibility for the mode toggle is ever required.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Phase 9 Prop Editor — Round 5 (File → Recent + save-serialization fix)
|
||||
|
||||
Status: **implemented** (2026-09-19; direct bugfix + feature round).
|
||||
|
||||
Source: two user feedback points against the shipped Round 4 Prop Editor.
|
||||
|
||||
## Feedback summary
|
||||
|
||||
1. File → Recent: show the last 5 opened props (sandbox-stage pattern).
|
||||
2. Loading `props/eye.prop` draws nothing (the prop name shows, geometry is blank).
|
||||
|
||||
## Diagnosis (point 2 — root cause)
|
||||
|
||||
`PropFile._serialize_shape()` declared
|
||||
|
||||
```gdscript
|
||||
var points: PackedVector2Array = shape.get("points", PackedVector2Array())
|
||||
```
|
||||
|
||||
but the editor always serializes **export-format** shapes whose `points` is an `Array` of
|
||||
`{x, y}` dictionaries. The implicit `Array → PackedVector2Array` conversion cannot construct
|
||||
`Vector2` from a dictionary and **silently yields `Vector2.ZERO` per element**, preserving the
|
||||
count but zeroing every coordinate. Every save therefore wrote all points as `(0, 0)` —
|
||||
verified in `props/eye.prop` and `props/box.prop` (all `x: 0.0, y: 0.0`), which load as a
|
||||
degenerate, invisible shape. The round-1 tests only asserted point *counts* and dict *shape*,
|
||||
so the corruption slipped through.
|
||||
|
||||
**Fix:** `_serialize_shape()` now uses the existing `_coerce_points()` helper (which handles
|
||||
both `PackedVector2Array` and `Array`-of-`{x,y}` inputs). Regression assertions were added so
|
||||
point **values** are verified through `to_file_dict` and the full round-trip.
|
||||
|
||||
The two already-saved user files (`props/eye.prop`, `props/box.prop`) are unrecoverable
|
||||
(every coordinate was zeroed at save time) and must be re-authored.
|
||||
|
||||
## Changes
|
||||
|
||||
| Target | Change |
|
||||
| --- | --- |
|
||||
| `scripts/prop_file.gd` | `_serialize_shape` uses `_coerce_points` (bug fix). |
|
||||
| `scripts/prop_editor.gd` | `SETTINGS_PATH := "user://prop_editor_settings.json"`, `RECENT_MAX := 5`, `_recent_props`; `update_recent_list(list, path, max)` static (dedupe + front-insert + trim); 🕑 Recent submenu in the File menu (`RecentProps` child popup, updated on `about_to_popup`, missing files pruned, "(No recent props)" placeholder); `_load_settings`/`_save_settings`; `_push_recent(path)` on every successful load; missing recent → error dialog + removal. |
|
||||
| `tests/test_prop_editor_file.gd` | New value-preservation assertions (serialized `x == 64`, round-trip `(64,0)`/`(64,64)`). |
|
||||
| `tests/test_prop_editor_round3.gd` | New `_test_recent_list` (dedupe, front-insert, trim). |
|
||||
|
||||
## Verification
|
||||
|
||||
- `--check-only` clean; all five suites green: file 66, theme 53, canvas 87, thumb 10, round3 21.
|
||||
- Manual F6: save a prop with shapes at distinct coordinates, reload — geometry must match;
|
||||
File → 🕑 Recent lists the last 5 opens and opens them; missing recent file shows the error
|
||||
and prunes itself.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Phase 9 Prop Editor — Round 6 (Recent-on-save, Reshaped Flag, Selection Contrast)
|
||||
|
||||
Status: **implemented** (2026-09-19; direct round — no open design choices).
|
||||
|
||||
Source: three user feedback points against the shipped Round 5 Prop Editor.
|
||||
|
||||
## Feedback summary
|
||||
|
||||
1. Saving a file should add it to File → Recent when it isn't there yet.
|
||||
2. A circle's inspector Radius field becomes meaningless once the circle's vertices are
|
||||
reshaped; every primitive needs a `reshaped` flag so primitive-specific inspector fields
|
||||
hide/disable after reshaping.
|
||||
3. A white shape in front of a white shape makes the white selection outline/box invisible;
|
||||
the selection color should contrast with the shape's fill.
|
||||
|
||||
## Design decisions
|
||||
|
||||
- **Recent-on-save:** `_save_to()` calls `_push_recent(path)` on success (dedupe +
|
||||
front-insert + trim, same helper as load — a save is recent activity).
|
||||
- **`reshaped` flag:** new boolean on every shape dict (internal + export + `.prop` schema,
|
||||
default `false`; additive and backward compatible — old files load as `false`). Set to
|
||||
`true` by: `insert_vertex`, `remove_vertex`, vertex-drag motion, `create_point_on_edge`
|
||||
(via insert), and **non-uniform scaling of a circle** (both the gizmo drag and
|
||||
`scale_selected`); cleared by `set_shape_radius` (which regenerates points from the radius).
|
||||
Translate/rotate/mirror/uniform-scale do **not** set it — they preserve primitive-ness.
|
||||
The inspector hides the Radius row and shows a muted "✏️ This shape was reshaped" note when
|
||||
the selected shape's flag is set (the flag exists on every primitive so future
|
||||
primitive-specific fields can gate on it the same way).
|
||||
- **Selection contrast:** new `PropCanvas.contrast_color_for(fill_hex) -> Color` — dark
|
||||
(`#1F1F1F`) for light fills (luminance > 0.5), white otherwise. Applied to the selection
|
||||
polyline and all gizmo drawing (bbox, rotation dot, scale crosses), which were previously
|
||||
hard white and vanished on white shapes. Vertex handles stay fill-colored (stickman-editor
|
||||
parity, Round 2 decision); the Create-Point flash stays yellow.
|
||||
|
||||
## Changes
|
||||
|
||||
| Target | Change |
|
||||
| --- | --- |
|
||||
| `scripts/prop_editor.gd` | `_save_to` pushes the path to recents on success; `_reshaped_note` label; `_refresh_inspector_from_selection` gates `_radius_row.visible` on `not reshaped` and shows the note when reshaped. |
|
||||
| `scripts/prop_canvas.gd` | `"reshaped"` in `_make_shape_dict`/`_coerce_shape`/`_export_shape`; mutation sites set/clear it; `contrast_color_for` static; selection polyline + gizmos use the contrast color. |
|
||||
| `scripts/prop_file.gd` | `"reshaped"` serialized/parsed (default `false`). |
|
||||
| `tests/test_prop_editor_canvas.gd` | New `_test_reshaped_flag` (insert/remove/radius-reset/scale/mirror/translate/rotate matrix) and `_test_selection_contrast`. |
|
||||
| `tests/test_prop_editor_file.gd` | Round-trip `reshaped` preservation + default-false assertions. |
|
||||
|
||||
## Verification
|
||||
|
||||
- `--check-only` clean; all five suites green (file 68, theme 53, canvas 100, thumb 10,
|
||||
round3 21).
|
||||
- Manual F6: save a new prop → appears in File → 🕑 Recent; reshape a circle (drag a vertex,
|
||||
create a point, or stretch non-uniformly) → Radius row disappears + muted note; select a
|
||||
white shape → dark outline/box/dot/crosses.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Phase 9 Prop Editor — Round 7 (Two-Tone Halo Selection)
|
||||
|
||||
Status: **implemented** (2026-09-19; direct follow-up on the Round 6 selection-contrast fix).
|
||||
|
||||
Source: one user feedback point — the Round 6 contrast approach did not fix the reported
|
||||
case.
|
||||
|
||||
## Diagnosis
|
||||
|
||||
Round 6 made the selection color adaptive to the *selected shape's fill* (dark on light
|
||||
fills, white on dark fills). That solves "white shape selected on a dark canvas" but not the
|
||||
reported case: a shape sitting **in front of a white shape** — the selection outline, bounding
|
||||
box, rotation dot, and scale crosses extend beyond the selected shape onto the white shape
|
||||
behind it, so a white (or fill-mismatched) selection still blends into the white background.
|
||||
Fill-based contrast fundamentally cannot know what is behind the shape.
|
||||
|
||||
## Fix
|
||||
|
||||
Every selection element now uses a **two-tone halo**: a dark backing pass (`SELECTION_HALO_COLOR`,
|
||||
black at 0.85 alpha, ~2 px wider) drawn first, then a bright core pass (`SELECTION_CORE_COLOR`,
|
||||
white, the original width) on top. The pair is visible on any background — the dark halo
|
||||
separates the bright core from light/white surroundings, and the bright core separates it from
|
||||
dark surroundings. Applied to:
|
||||
|
||||
- the selection polyline over the shape outline,
|
||||
- the bounding box (dark 3.5/zoom pass + white 1.5/zoom pass),
|
||||
- the rotation dot (dark ring at radius + 2, white dot at radius),
|
||||
- the four scale crosses (dark 3.5 pass + white 1.5 pass),
|
||||
- vertex handles in Reshape mode (a 1.5 px dark ring behind each handle).
|
||||
|
||||
The Round 6 `contrast_color_for` helper was removed. `SELECTION_COLOR`/`GIZMO_COLOR` were
|
||||
replaced by `SELECTION_CORE_COLOR`/`SELECTION_HALO_COLOR`.
|
||||
|
||||
## Changes
|
||||
|
||||
| Target | Change |
|
||||
| --- | --- |
|
||||
| `scripts/prop_canvas.gd` | Halo constants; two-pass drawing for selection polyline, bbox, rotation dot, scale crosses, vertex handles; `contrast_color_for` removed. |
|
||||
| `tests/test_prop_editor_canvas.gd` | `_test_selection_contrast` replaced by `_test_selection_halo` (halo is dark, core is bright). |
|
||||
|
||||
## Verification
|
||||
|
||||
- `--check-only` clean; all five suites green (file 68, theme 53, canvas 100, thumb 10,
|
||||
round3 21).
|
||||
- Manual F6: select a dark shape in front of a white shape, and a white shape in front of a
|
||||
white shape — the outline/box/dot/crosses must be visible in both; repeat in Reshape mode
|
||||
for the vertex handles.
|
||||
Reference in New Issue
Block a user