Files
stickman/docs/phase2_spec.md
T
2026-08-08 00:00:50 -04:00

242 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 2 — Architectural Specification
## Overview
Phase 2 adds **interactive vertex editing** (custom shapes), **per-panel zoom**, and an **expanded .stk save format** to the Stickman Studio editor. Phase 1 (shape creation, save/load, whole-stickman assembly) is complete and functional; Phase 2 builds on it without breaking existing behavior.
---
## 1. Data Model Changes
### 1a. Shape Data Dictionary (updated)
```gdscript
# body_part_panel.gd — internal shape_data extends to:
{
"shape_type": String, # "line", "rectangle", "circle", "" (unchanged)
"points": PackedVector2Array, # vertex positions (unchanged)
"color": String, # hex color (unchanged)
"closed": bool, # NEW — true for polygon fill, false for open line
"vertex_flags": PackedInt32Array # NEW — 0=original, 1=user_created; same length as points
}
```
- `closed` replaces the implicit closed-ness that was derived from `shape_type`. A line has `closed=false`; a rectangle and circle have `closed=true`. The rendering logic switches on `closed` instead of `shape_type`.
- `vertex_flags` tracks which vertices are "original" (filled circle handle) vs "user-created via Create Point" (hollow rectangle handle). Default all-zero for starter shapes.
- `shape_type` is retained as a descriptive tag but no longer drives rendering behavior.
### 1b. .stk File Format (version "1.1")
```json
{
"version": "1.1",
"stickman_name": "Bob",
"body_parts": {
"head": {
"shape_type": "circle",
"closed": true,
"points": [{"x": 120, "y": 40}, ...],
"color": "#000000",
"position": {"x": 150, "y": 40},
"vertex_flags": [0, 0, 0, 1, 0, ...]
}
},
"metadata": {
"created_at": "...",
"modified_at": "..."
}
}
```
- **Version bumped to `"1.1"`.** Loading: if version is `"1.0"`, auto-migrate (set `closed` based on `shape_type`, set `vertex_flags` as all-zeros). Save always writes `"1.1"`.
- New per-shape keys: `"closed"` (bool) and `"vertex_flags"` (array of ints, length matching `points`).
- Backward compatibility: loading a 1.0 file infers `closed` from `shape_type` (`"line"`→false, `"rectangle"`/`"circle"`→true, `""`→false). vertex_flags defaults to all zeros for any shape loaded from 1.0.
### 1c. Circle Refactor
- `_create_circle()` generates **12 vertices** (down from 32). The shape_type remains `"circle"`, `closed=true`, `vertex_flags` all zeros.
- Existing 32-vertex circles in saved `.stk` files still load correctly — `set_shape_data()` accepts arbitrary point arrays.
---
## 2. BodyPartPanel — Vertex Editing
### 2a. Selection State
New internal state variables in `body_part_panel.gd`:
```gdscript
var _selected: bool = false # is this panel's shape currently selected?
var _dragging_vertex: int = -1 # index of vertex being dragged, -1 if none
var _drag_offset: Vector2 # offset from vertex to mouse during drag
var _zoom: float = 1.0 # zoom factor clamped to [0.3, 3.0]
```
### 2b. Interaction Model
| Action | Trigger | Behavior |
|---|---|---|
| **Select shape** | Left-click inside shape bounds | Sets `_selected=true`, emits `shape_selected` signal. Deselects all other panels (coordinated by `stickman_editor.gd`). |
| **Deselect** | Left-click outside shape OR clicking another panel | `_selected=false`. Any in-progress drag is cancelled. |
| **Drag vertex** | Left-click press on a vertex handle, then drag | Sets `_dragging_vertex` to that index. On mouse motion, updates vertex position. On release, clears drag state. Emits `shape_changed`. |
| **Open context menu** | Right-click on the outline path of a **selected** shape | Shows a `PopupMenu` with one item: "Create Point". The click position is stored for vertex insertion. |
| **Create initial shape** | Right-click on an **empty** drawing area (no shape OR shape not selected) | Shows the existing 3-item context menu (Line, Rectangle, Circle). *Unchanged from Phase 1.* |
### 2c. Hit Testing
- **Shape selection hit-test**: Point-in-polygon check against the full shape polygon. For open shapes (lines), check if the click is within a threshold distance (12px) of any edge.
- **Vertex hit-test**: For each vertex, check if the mouse position is within `HANDLE_RADIUS_SELECTION` (8px) of the vertex position (in screen space, accounting for zoom).
- **Edge hit-test for Create Point**: Find the nearest edge to the right-click position (in screen space). The new vertex is inserted at the midpoint of that edge.
### 2d. Rendering Changes (in `_on_drawing_area_draw()`)
Rendering order (back to front):
1. **Fill** (if `closed`): `draw_colored_polygon(pts, color)` — same as Phase 1.
2. **Outline**: `_draw_polyline(pts, color, ...)` — same as Phase 1.
3. **Selection highlight** (if `_selected`): Redraw the polyline in white (`Color.WHITE`), 2px wide, on top of the color outline. This makes the selected shape stand out.
4. **Vertex handles**: For each vertex:
- If `vertex_flags[i] == 0` (original): filled circle, radius `HANDLE_RADIUS / zoom` (so it stays constant screen size).
- If `vertex_flags[i] == 1` (user-created): hollow rectangle, size `(6/zoom)×(6/zoom)`, centered on the vertex.
- If `i == _dragging_vertex`: draw in yellow highlight.
### 2e. Context Menu Refactor
The existing `ContextMenu` (ids 0, 1, 2 — Line, Rectangle, Circle) is repurposed for initial shape creation only.
A **second** `PopupMenu` is needed for vertex editing. Two options:
- **Option A (chosen):** Single PopupMenu, dynamically repopulated based on context. When right-clicking on empty area → show "Line", "Rectangle", "Circle". When right-clicking on outline of selected shape → show "Create Point" only. This requires clearing/rebuilding the menu items programmatically.
- **Option B:** Two separate PopupMenu nodes.
**Decision: Option A** — less scene overhead, single menu node reused.
Menu id: 0=Line, 1=Rectangle, 2=Circle, 3=Create Point (only shown in vertex context).
### 2f. Vertex Insertion (Create Point)
1. Right-click on the outline of a selected shape.
2. Find the nearest edge (line segment) to the click position.
3. Insert a new vertex at the **midpoint** of that edge (not at the click position — this matches the user story where the vertex "appear[s] in the outline where the user clicked" at the nearest edge midpoint, not an arbitrary point).
4. Append `1` to `vertex_flags` at the corresponding position.
5. `queue_redraw()` and `emit shape_changed`.
*Note: PROJECT.md says "a small hollow rectangle will appear in the outline where the user clicked — this is not a point(vertex)". I interpret this to mean: the vertex is placed on the outline at the nearest edge midpoint. The "not a point" language means it's a vertex, just drawn differently (hollow rectangle).*
### 2g. Vertex Dragging
1. Left-click press on a vertex handle (within `HANDLE_RADIUS_SELECTION`).
2. Set `_dragging_vertex = index`, `_drag_offset = position - vertex_position`.
3. On mouse motion (`InputEventMouseMotion`):
- Compute new world-space position: `new_pos = mouse_position / zoom` (undo zoom) or keep everything in world space. **Design decision:** Store points in world space (unscaled). Apply `draw_set_transform(Vector2.ZERO, 0, Vector2(zoom, zoom))` in `_draw()`. Mouse positions are divided by zoom to get world coordinates.
- Update `shape_data.points[_dragging_vertex] = world_pos`.
- `queue_redraw()`.
4. On mouse release: clear `_dragging_vertex`, emit `shape_changed`.
---
## 3. Window Zoom
### 3a. BodyPartPanel Zoom
- Add `_zoom: float = 1.0` (range `[0.3, 3.0]`).
- Mouse wheel handling in `_on_drawing_area_gui_input()`:
- `MOUSE_BUTTON_WHEEL_UP`: `_zoom = clamp(_zoom * 1.10, 0.3, 3.0)`
- `MOUSE_BUTTON_WHEEL_DOWN`: `_zoom = clamp(_zoom / 1.10, 0.3, 3.0)`
- In `_on_drawing_area_draw()`, apply: `drawing_area.draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom))` before all draw calls.
- Mouse-to-world conversion: All input event positions must be divided by `_zoom` before using as world coordinates (hit-testing, vertex creation, dragging).
- Vertex handles drawn with sizes divided by `_zoom` (e.g., `HANDLE_RADIUS / _zoom`), so they appear the same screen size regardless of zoom level.
### 3b. WholeStickmanPreview Zoom
- Add `_zoom: float = 1.0` (same range).
- Same wheel handling, same `draw_set_transform` application.
- No vertex handles drawn here, so the handle-size concern doesn't apply.
- Drag hit-testing (`_try_start_drag`): Divide `at_position` by `_zoom` to convert to world coordinates. The drag offset and part positions remain in world space.
---
## 4. StickmanEditor — Coordination Changes
### 4a. Selection Coordination
When a `BodyPartPanel` is selected, all other panels must be deselected. Add:
```gdscript
signal shape_selected(part_name: String)
# In _on_body_part_shape_selected(part_name):
# for each panel in _body_part_panels where key != part_name:
# panel.deselect()
```
Add a `deselect()` method to `BodyPartPanel` that sets `_selected=false` and `queue_redraw()`.
### 4b. Save Format Updates
- `FILE_VERSION``"1.1"`.
- `_build_json_data()` now includes `"closed"` and `"vertex_flags"` in each shape.
- `_collect_all_shape_data()` updated accordingly — `BodyPartPanel.get_shape_data()` returns the updated dictionary with `closed` and `vertex_flags`.
- `_apply_json_data()` handles both `"1.0"` and `"1.1"`:
- 1.0: auto-migrate (infer `closed` from `shape_type`, set `vertex_flags` to all-zeros).
- 1.1: read `closed` and `vertex_flags` directly.
### 4c. New Signal Wiring
- `BodyPartPanel.shape_changed` — existing, now also emitted on vertex drag end and Create Point.
- `BodyPartPanel.shape_selected` — new, for cross-panel deselection coordination.
- Each panel's `shape_selected` connects to `stickman_editor._on_body_part_shape_selected`.
---
## 5. WholeStickmanPreview — Changes
### 5a. Zoom Support
- Add `_zoom` with same wheel handling and range.
- Apply `draw_set_transform` in `_on_preview_draw()`.
- Convert mouse positions in `_try_start_drag()` and `_on_preview_gui_input()`.
### 5b. Closed Shape Rendering
- The preview currently switches on `shape_type` to decide fill+close. Update to check `closed: bool` from the shape data instead.
- Add `_draw_polyline_preview()` helper for clean line drawing (consistent with BodyPartPanel).
---
## 6. Files Modified
| File | Changes |
|---|---|
| `scripts/body_part_panel.gd` | Major: vertex editing state, selection, zoom, context menu refactor, rendering with closed flag & vertex_flags, circle→12 vertices, mouse handling expanded |
| `scripts/stickman_editor.gd` | Version bump to 1.1, save format includes closed/vertex_flags, load handles 1.0+1.1, selection coordination signal |
| `scripts/whole_stickman_preview.gd` | Zoom support, closed-based rendering instead of shape_type-based, input coordinate transform |
| `scenes/body_part_panel.tscn` | Minimal — ContextMenu items may be reduced or left as-is (dynamic repopulation in script). *Consider adding a 4th static item "Create Point" hidden by default.* |
| `README.md` | Document Phase 2 features, updated .stk format, vertex editing, zoom |
| `docs/phase2_spec.md` | This file |
---
## 7. Implementation Order (Recommended)
1. **Data model + circle refactor** (shape_data adds `closed` + `vertex_flags`, circle→12 vertices, rendering uses `closed`)
2. **Zoom** (mouse wheel, draw_set_transform, coordinate conversion, handle-size scaling)
3. **Shape selection** (left-click hit-test, white outline, cross-panel deselection)
4. **Vertex hit-testing & dragging** (left-click on handles, drag motion, release)
5. **Create Point** (right-click context menu on selected outline, edge finding, vertex insertion)
6. **Save/load update** (version 1.1, closed/vertex_flags in JSON, backward compatible loading)
7. **WholeStickmanPreview zoom + closed rendering**
8. **README update**
---
## 8. Edge Cases & Constraints
- **Empty panel**: Right-click shows shape creation menu (unchanged). Left-click does nothing. Zoom still works.
- **Single-vertex shapes**: If a shape somehow has only 1 vertex, skip rendering (guard already exists at `count < 2`).
- **Min zoom (30%)**: Vertex handles drawn at `HANDLE_RADIUS / 0.3 ≈ 10px` — still visible.
- **Max zoom (300%)**: Vertex handles at `HANDLE_RADIUS / 3.0 ≈ 1px` — still visible but small.
- **Zoom state is NOT persisted** in the .stk file. Each panel resets to 1.0 on load/clear (editor-only transient state).
- **Vertex drag performance**: Emit `shape_changed` on drag end (not every frame) to avoid excessive WholeStickmanPreview rebuilds. Redraws during drag only touch the local panel.
- **Multiple selected shapes**: Only one shape can be selected at a time across the entire editor.
- **Right-click on empty area when shape exists but not selected**: Shows shape creation menu (existing behavior), which replaces the current shape.