# Phase 3 — Architectural Specification ## Overview Phase 3 adds a **grid system** (configurable, with snap-to-grid for vertices and whole-stickman assembly), **panning** (middle-mouse-button drag), **global settings persistence** (`settings.json`), **color changing** (ColorPicker integration), **shape delete**, and **vertex delete** to the Stickman Studio editor. Phases 1-2 (shape creation, vertex editing, zoom, save/load) are complete and functional; Phase 3 builds on them without breaking existing behavior. --- ## 1. Data Model Changes ### 1a. Color is already in the model The shape data dictionary already includes `"color": "#hex"`. The `.stk` format already serializes/deserializes color. Phase 3 adds a **ColorPicker UI** to change it interactively. ### 1b. Global Settings (`user://settings.json`) New persistent settings file stored in Godot's user data directory: ```json { "grid_size": 5, "snap_to_grid": false, "version": "1.0" } ``` | Key | Type | Default | Description | |---|---|---|---| | `version` | `string` | `"1.0"` | Settings file version (for future extensibility) | | `grid_size` | `int` | `5` | Grid interval in pixels (applied equally to width and height) | | `snap_to_grid` | `bool` | `false` | Whether snap-to-grid is active | - **Load**: On editor startup (`_ready()`), attempt to load `user://settings.json`. If the file doesn't exist or fails to parse, use defaults. - **Save**: Whenever the user changes grid size or toggles snap-to-grid, write the updated settings. - **Scope**: Global — all panels share the same grid size and snap setting. ### 1c. Pan offset (transient, not persisted) Both `BodyPartPanel` and `WholeStickmanPreview` gain a `_pan_offset: Vector2` variable, default `(0,0)`. This is **not persisted** in any file — it resets on load/clear and when the user clicks "Reset Views". --- ## 2. BodyPartPanel — Phase 3 Changes ### 2a. New State Variables ```gdscript var _pan_offset: Vector2 = Vector2.ZERO # camera pan, modified by middle-mouse drag var _grid_size: int = 5 # received from editor var _snap_enabled: bool = false # received from editor var _is_panning: bool = false # true while middle mouse is held var _pan_start: Vector2 = Vector2.ZERO # screen position where pan started var _restore_color: Color # saved color for Cancel support ``` ### 2b. New Public Methods ```gdscript func set_grid_size(size: int) -> void: _grid_size = size drawing_area.queue_redraw() func set_snap_enabled(enabled: bool) -> void: _snap_enabled = enabled func reset_view() -> void: _zoom = 1.0 _pan_offset = Vector2.ZERO drawing_area.queue_redraw() ``` ### 2c. Panning (Middle Mouse Button) In `_on_drawing_area_gui_input()`: 1. `MOUSE_BUTTON_MIDDLE` pressed: Set `_is_panning = true`, record `_pan_start = mb.position`. 2. `MOUSE_BUTTON_MIDDLE` released: `_is_panning = false`. 3. `InputEventMouseMotion` while `_is_panning`: `_pan_offset += (mm.position - _pan_start) / _zoom`. Then `_pan_start = mm.position`. `queue_redraw()`. **Note**: pan offset moves inversely to drag direction (drag right → camera moves right, content appears to move left). The offset is added to the transform, so positive offset shifts drawing right. Z-order input priority: 1. Middle mouse button (if press or panning in progress) 2. Mouse wheel (zoom) 3. Left mouse button (vertex drag / shape selection) 4. Right mouse button (context menu) ### 2d. Grid Drawing In `_on_drawing_area_draw()`, after `draw_set_transform` but before shape rendering, draw the grid: ```gdscript func _draw_grid() -> void: var gs := float(_grid_size) if gs <= 0: return var area_size := drawing_area.size var world_origin := (-_pan_offset - Vector2(area_size) * 0.5) / _zoom var world_size := area_size / _zoom var world_end := world_origin + world_size var start_x := floor(world_origin.x / gs) * gs var start_y := floor(world_origin.y / gs) * gs var grid_color := Color(1.0, 1.0, 1.0, 0.15) var x := start_x while x <= world_end.x: drawing_area.draw_line(Vector2(x, world_origin.y), Vector2(x, world_end.y), grid_color, 1.0 / _zoom) x += gs var y := start_y while y <= world_end.y: drawing_area.draw_line(Vector2(world_origin.x, y), Vector2(world_end.x, y), grid_color, 1.0 / _zoom) y += gs ``` The grid is drawn **before** the shape fill/outline so it appears behind the shapes. Rendering order (updated): 1. Grid lines (thin, low alpha) 2. Shape fill (if closed) 3. Shape outline 4. Selection highlight (if selected) 5. Vertex handles **Important**: The grid must account for both zoom AND pan offset. The transform applied is: ```gdscript drawing_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom)) ``` This replaces the current `draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom))`. ### 2e. Snap to Grid (Vertex Drag) In the `InputEventMouseMotion` handler for vertex dragging, after computing the world position, apply snap: ```gdscript var world_pos := _screen_to_world(mm.position) if _snap_enabled: world_pos = _snap_to_grid(world_pos) shape_data.points[_dragging_vertex] = world_pos - _drag_offset ``` Snap helper: ```gdscript func _snap_to_grid(pos: Vector2) -> Vector2: var gs := float(_grid_size) return Vector2( round(pos.x / gs) * gs, round(pos.y / gs) * gs ) ``` Snap is applied **before** the drag offset is subtracted, so the user sees the vertex jump to the nearest grid intersection while dragging. ### 2f. Context Menu — Color Entry The right-click context menu for a selected shape is updated: ``` [existing: "Create Point"] (id 3) [separator] "Color..." (id 4) [separator] "Delete" (id 5) ``` **"Color..." behavior**: 1. Save the current color: `_restore_color = Color.from_string(shape_data.color, Color.BLACK)`. 2. Create or show a `ColorPicker` dialog. 3. When the user changes the color in the picker (preview): update `shape_data.color` to the hex string and `queue_redraw()`. This gives live preview. 4. On **OK**: commit the color change, emit `shape_changed`. 5. On **Cancel**: restore `shape_data.color = _restore_color.to_html()`, `queue_redraw()`. **Implementation via ColorPickerButton**: - Add a `ColorPickerButton` node to the `BodyPartPanel` scene (hidden by default, `visible = false`). - When "Color..." is clicked: set the picker's color to the current shape color, set `visible = true`, and programmatically trigger `popup()`. - Connect the `color_changed` signal: update `shape_data.color` → `queue_redraw()` (live preview). - Connect the `popup_closed` signal: this is where we distinguish OK vs Cancel. If the user clicked the "OK" or selected a swatch, the color was already committed through `color_changed`. If they cancelled, restore `_restore_color`. Actually, `ColorPickerButton` in Godot 4.4 has a `color_changed` signal for preview but no built-in way to detect Cancel. A cleaner approach: **Implementation via custom dialog**: - Add a `ColorPicker` node inside a `Popup` or `AcceptDialog` with OK/Cancel buttons in the `BodyPartPanel` scene. - On "Color...": set the picker color, show the popup. - On Cancel: restore previous color. - On OK: commit. Since `ColorPickerButton` doesn't provide a clean Cancel detection, use a simple `AcceptDialog`-equivalent with a `ColorPicker`. Actually, the cleanest approach in Godot 4: Use the built-in `ColorPicker` node (not `ColorPickerButton`) in a new scene or directly in `body_part_panel.tscn`. When "Color..." is clicked, we can also just use `PopupPanel` with a ColorPicker inside, or use `AcceptDialog` with a custom child. **Simplest reliable approach**: Use `ColorPicker` node, detect color changes for preview, and use the color_picker's own `Popup` behavior. Actually, let me reconsider. The `ColorPicker` extends `VBoxContainer` - it needs to be in a popup. **Decision**: Add a `PopupPanel` → VBoxContainer → ColorPicker → HBoxContainer (OK, Cancel buttons) to `body_part_panel.tscn`. On "Color...": set the picker's color, show popup. On color_changed: preview. On OK: commit, hide popup, emit shape_changed. On Cancel: restore, hide popup. ### 2g. Context Menu — Delete Entry **"Delete" behavior** (id 5): 1. Call `clear_shape()` (which resets shape_data but does NOT emit shape_changed). 2. Emit `shape_changed` manually so the Whole Stickman preview updates. ### 2h. Context Menu Refactor — Right-Click Logic The current `_on_drawing_area_gui_input()` right-click logic is: ``` if _selected AND near outline → show "Create Point" else → show "Line", "Rectangle", "Circle" ``` Phase 3 changes this to: ``` 1. Check if right-click is on a VERTEX (hit-test vertex handles). If YES: → show "Remove Point" context menu (id 6) return 2. If a shape EXISTS (not empty) AND (_selected OR mouse is over the shape): → show context menu with [Create Point / Color... / Delete] entries return 3. Else (no shape or shape not selected): → show [Line / Rectangle / Circle] shape creation menu ``` **New context menu item IDs**: | ID | Label | Context | |----|-------|---------| | 0 | Line | No shape / shape not selected | | 1 | Rectangle | No shape / shape not selected | | 2 | Circle | No shape / shape not selected | | 3 | Create Point | Shape selected, near outline | | 4 | Color... | Shape selected | | 5 | Delete | Shape selected | | 6 | Remove Point | Right-click on vertex handle | **Rule for "Delete" visibility**: Show Delete only when a shape exists AND (`_selected == true` OR mouse is over the shape). This means: - If the shape is selected but the mouse is not over it, Delete still shows. - If the shape is not selected but the mouse is over it, Delete still shows. ### 2i. Vertex Delete (Remove Point) When the user right-clicks on a vertex handle: 1. Show context menu with only "Remove Point" (id 6). 2. Store the vertex index as metadata on the context menu. 3. On "Remove Point" selected: a. Remove the point at the stored index from `shape_data.points`. b. Remove the corresponding entry from `shape_data.vertex_flags`. c. If `shape_data.points.size() == 2`: - Set `shape_data.closed = false` (becomes a line). d. If `shape_data.points.size() < 2`: - Call `clear_shape()` (removes the shape entirely). e. `queue_redraw()` and emit `shape_changed`. ### 2j. Updated `_on_context_menu_id_pressed()` New match cases: ```gdscript 4: # Color... _show_color_picker() 5: # Delete _delete_shape() 6: # Remove Point _remove_vertex(context_menu.get_meta("vertex_index", -1)) ``` ### 2k. Updated `clear_shape()` `clear_shape()` MUST also reset pan and zoom since we're starting fresh: ```gdscript _pan_offset = Vector2.ZERO _zoom = 1.0 ``` ### 2l. Updated `set_shape_data()` Reset pan offset on data load: ```gdscript _pan_offset = Vector2.ZERO ``` --- ## 3. WholeStickmanPreview — Phase 3 Changes ### 3a. New State Variables ```gdscript var _pan_offset: Vector2 = Vector2.ZERO var _grid_size: int = 5 var _snap_enabled: bool = false var _is_panning: bool = false var _pan_start: Vector2 = Vector2.ZERO ``` ### 3b. New Public Methods ```gdscript func set_grid_size(size: int) -> void: _grid_size = size preview_area.queue_redraw() func set_snap_enabled(enabled: bool) -> void: _snap_enabled = enabled func reset_view() -> void: _zoom = 1.0 _pan_offset = Vector2.ZERO preview_area.queue_redraw() ``` ### 3c. Panning Same pattern as BodyPartPanel: middle-mouse press/release tracks `_is_panning`, mouse motion updates `_pan_offset` inversely. ### 3d. Grid Drawing Same grid drawing code as BodyPartPanel, adapted for `preview_area`. Uses the same `_grid_size`. The grid is drawn behind the parts. The draw transform is updated from: ```gdscript preview_area.draw_set_transform(Vector2.ZERO, 0.0, Vector2(_zoom, _zoom)) ``` to: ```gdscript preview_area.draw_set_transform(_pan_offset, 0.0, Vector2(_zoom, _zoom)) ``` ### 3e. Snap to Grid (Part Drag) In the drag motion handler, after computing the mouse world position, snap it: ```gdscript var world_pos: Vector2 = mm.position / _zoom if _snap_enabled: var gs := float(_grid_size) world_pos.x = round(world_pos.x / gs) * gs world_pos.y = round(world_pos.y / gs) * gs var new_pos := world_pos - _drag_offset _part_positions[_dragging_part] = new_pos ``` This uses the mouse pointer position as the snap reference, as specified: the mouse position snaps to grid, and the part follows. ### 3f. Updated `clear_all()` Reset pan offset on clear: ```gdscript _pan_offset = Vector2.ZERO _zoom = 1.0 ``` ### 3g. Updated `set_body_parts()` Reset pan offset on body part set (full refresh from editor): ```gdscript _pan_offset = Vector2.ZERO ``` --- ## 4. StickmanEditor — Phase 3 Changes ### 4a. New Menu Bar Structure ``` ┌──────────────────────────────────────────┐ │ File │ Edit │ View │ │ │──────────────────────────────────────────│ │ Save │ Configure Grid... │ Reset Views │ │ Load │ Snap to Grid │ │ │ ───── │ │ │ │ Clear │ │ │ └──────────────────────────────────────────┘ ``` ### 4b. Menu Setup in `_setup_menu_bar()` After the existing "File" menu, add: **Edit menu** (PopupMenu): - "Configure Grid..." (id 0) — opens a dialog. - Separator. - "Snap to Grid" (id 1) — **checkable** item; checked = snap enabled. **View menu** (PopupMenu): - "Reset Views" (id 0) — resets zoom + pan on all panels. ### 4c. New State Variables ```gdscript var _grid_size: int = 5 var _snap_enabled: bool = false ``` ### 4d. New On-Ready Nodes Add to the scene or create programmatically: - `%GridConfigDialog` — a `ConfirmationDialog` or `AcceptDialog` with a SpinBox for grid size. - `%ColorPickerPopup` — kept in body_part_panel.tscn, not here. Actually, the grid config dialog should be in the editor scene since it's a top-level dialog. Add it to `stickman_editor.tscn`. The ColorPicker stays in `body_part_panel.tscn` since it's per-panel. ### 4e. Grid Config Dialog **Option A**: Add a `ConfirmationDialog` with a `SpinBox` child to `stickman_editor.tscn`. - Title: "Configure Grid" - Label: "Grid Size (pixels):" - SpinBox: min 1, max 100, step 1, value = current `_grid_size`. - On confirmed: read SpinBox value, set `_grid_size`, broadcast to all panels + WholeStickmanPreview, write `settings.json`. ### 4f. Settings Load/Save ```gdscript func _load_settings() -> void: if not FileAccess.file_exists("user://settings.json"): return # use defaults var file := FileAccess.open("user://settings.json", FileAccess.READ) if file == null: return var json_text := file.get_as_text() file.close() var json: Variant = JSON.parse_string(json_text) if json is Dictionary: var d := json as Dictionary _grid_size = int(d.get("grid_size", 5)) _snap_enabled = bool(d.get("snap_to_grid", false)) # Clamp to valid range _grid_size = clampi(_grid_size, 1, 100) func _save_settings() -> void: var data := { "version": "1.0", "grid_size": _grid_size, "snap_to_grid": _snap_enabled, } var file := FileAccess.open("user://settings.json", FileAccess.WRITE) if file: file.store_string(JSON.stringify(data, "\t", false)) file.close() ``` Called in `_ready()`: load settings, then broadcast to panels. ### 4g. Broadcasting Settings to Panels New helper: ```gdscript func _broadcast_settings() -> void: for panel in _body_part_panels.values(): panel.set_grid_size(_grid_size) panel.set_snap_enabled(_snap_enabled) _whole_preview.set_grid_size(_grid_size) _whole_preview.set_snap_enabled(_snap_enabled) ``` Called after loading settings in `_ready()` and whenever grid/snap changes. ### 4h. Save Flow Update Color is already included in `_build_json_data()` via `panel.get_shape_data()` which returns `color`. No .stk format changes needed for Phase 3. ### 4i. Reset Views Handler ```gdscript func _on_view_menu_id_pressed(id: int) -> void: if id == 0: # Reset Views for panel in _body_part_panels.values(): panel.reset_view() _whole_preview.reset_view() ``` --- ## 5. Scene File Changes ### 5a. `scenes/stickman_editor.tscn` Add new nodes under the root `StickmanEditor`: ``` [node name="GridConfigDialog" type="ConfirmationDialog" parent="."] unique_name_in_owner = true title = "Configure Grid" ok_button_text = "OK" [node name="VBoxContainer" type="VBoxContainer" parent="GridConfigDialog"] layout_mode = 2 [node name="GridSizeLabel" type="Label" parent="GridConfigDialog/VBoxContainer"] layout_mode = 2 text = "Grid Size (pixels):" [node name="GridSizeSpinBox" type="SpinBox" parent="GridConfigDialog/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 min_value = 1.0 max_value = 100.0 value = 5.0 step = 1.0 rounded = true ``` ### 5b. `scenes/body_part_panel.tscn` Add a ColorPicker popup: ``` [node name="ColorPickerPopup" type="PopupPanel" parent="."] unique_name_in_owner = true visible = false [node name="VBoxContainer" type="VBoxContainer" parent="ColorPickerPopup"] layout_mode = 2 [node name="ColorPicker" type="ColorPicker" parent="ColorPickerPopup/VBoxContainer"] unique_name_in_owner = true layout_mode = 2 custom_minimum_size = Vector2(300, 300) [node name="ButtonRow" type="HBoxContainer" parent="ColorPickerPopup/VBoxContainer"] layout_mode = 2 alignment = 2 # END [node name="CancelButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"] layout_mode = 2 text = "Cancel" [node name="OKButton" type="Button" parent="ColorPickerPopup/VBoxContainer/ButtonRow"] layout_mode = 2 text = "OK" ``` --- ## 6. Files Modified | File | Changes | |---|---| | `scripts/body_part_panel.gd` | Major: panning (middle mouse), grid drawing, snap-to-grid in vertex drag, ColorPicker integration, "Delete" shape, "Remove Point" vertex delete, context menu refactor, reset_view(), set_grid_size(), set_snap_enabled() | | `scripts/stickman_editor.gd` | Modular: Edit/View menus, GridConfigDialog, settings.json load/save, broadcast grid/snap settings, Reset Views handler | | `scripts/whole_stickman_preview.gd` | Major: panning, grid drawing, snap-to-grid in part drag, reset_view(), set_grid_size(), set_snap_enabled() | | `scenes/stickman_editor.tscn` | Add GridConfigDialog + children | | `scenes/body_part_panel.tscn` | Add ColorPickerPopup + children | | `docs/phase3_spec.md` | This file | --- ## 7. Implementation Order 1. **Menus + Settings** — Add Edit/View menus, settings.json load/save, broadcast infrastructure 2. **GridConfigDialog** — Dialog + SpinBox in editor scene, wire up to settings 3. **Panning** — Middle-mouse drag in BodyPartPanel + WholeStickmanPreview 4. **Grid drawing** — Background grid in both panels, accounting for zoom + pan offset 5. **Snap to Grid** — Vertex drag snap + Whole Stickman part drag snap 6. **Reset Views** — View menu item → reset zoom + pan on all panels 7. **ColorPicker** — Add ColorPickerPopup, context menu "Color..." entry, preview + commit/cancel 8. **Shape Delete** — Context menu "Delete" entry, conditional visibility 9. **Vertex Delete** — Right-click on vertex → "Remove Point", edge cases (2→line, <2→clear) 10. **Scene files** — Update both .tscn files with new nodes 11. **README update** — Document Phase 3 features --- ## 8. Edge Cases & Constraints - **Grid size = 1**: Grid lines become very dense. Still functional; performance acceptable for ~50-100 lines per panel at typical panel sizes (~230x160px @ 1px grid = massive density, but max panel size is ~500x300 visible → 500 lines). If performance issues arise, skip drawing when `_grid_size < 3` at high zoom. - **Pan + zoom interaction**: Pan offset is in screen space, applied via `draw_set_transform`. The offset is NOT divided by zoom — it's added before the scale, so pan feels natural at any zoom level. At 2x zoom, dragging 100px in screen space moves the view by 100px world units. - **Middle mouse + any other button**: If the user presses middle mouse while dragging a vertex, the vertex drag is cancelled and panning takes over. This prevents conflicts. - **ColorPicker Cancel**: If the user opens the color picker and clicks Cancel, the shape color MUST revert to the pre-dialog value. The preview during color picker interaction updates `shape_data.color` directly; on cancel we restore from `_restore_color`. - **Delete last remaining vertex**: If Remove Point is called on a shape with 2 vertices, it becomes an open line (`closed=false`). If called on a shape with 1 vertex, the shape is cleared entirely. - **Delete shape while selected**: When shape is deleted, clear `_selected`, clear `_dragging_vertex`, emit `shape_changed`. - **Settings file missing**: Gracefully use defaults (grid_size=5, snap_to_grid=false). No error dialog. - **Settings file corrupted**: Gracefully use defaults. No error dialog. - **Snap to Grid toggle**: When toggling snap ON, vertices don't immediately snap — they only snap when dragged. This matches user expectation (existing vertex positions aren't retroactively modified). - **Context menu positioning**: All context menus use `popup_on_parent(Rect2(mb.global_position, Vector2.ONE))` for proper screen-space positioning.