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

13 KiB
Raw Blame History

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)

# 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")

{
  "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:

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:

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

  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.