Skip to content

Commit 098c8af

Browse files
author
Shane Wall
committed
feat: add context toolbar and hotkey palette workflows
Introduce the floating context toolbar and hotkey palette UI, including editor integration, state updates, shortcut handling, and regression coverage for both surfaces. Fix several material and selection workflow issues while the new UI lands: - defer hotkey palette population until its controls exist and harden focus handling - wire favorite materials into the face-selection toolbar state - correct command-palette paint labels to match the routed tools - allow browser assign and double-click actions to fall back to whole-brush material assignment - suppress spurious empty editor-selection signals triggered by prototype texture reimport while preserving intentional clears via dock signalling - extract pure decision helpers so the new selection and material-assignment behavior can be tested headlessly Refresh the user and developer docs, smoke checklist, changelog, and roadmap to describe the new workflows and validations. Verification: - Godot 4.6.1 headless GUT suite: 737/737 passing
1 parent 0615edf commit 098c8af

28 files changed

Lines changed: 2084 additions & 43 deletions

CHANGELOG.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,62 @@ All notable changes to this project will be documented in this file.
44
The format is based on Keep a Changelog, and this project follows semantic versioning.
55

66
## [Unreleased]
7+
### Fixed
8+
- **Material assignment no longer requires face selection (Mar 2026):**
9+
- Double-clicking a texture in the material browser, or clicking the Assign button, now
10+
falls back to **whole-brush assignment** when no individual faces are selected but brushes
11+
are selected in the viewport. Previously this showed "No faces selected — select faces
12+
first" even with brushes highlighted.
13+
- New `resolve_material_assign_action()` pure-decision helper on `dock.gd` encapsulates
14+
the face-vs-brush fallback logic, shared by `_on_material_assign()`,
15+
`_on_browser_material_double_clicked()`, and available for future callers.
16+
- **Texture reimport no longer clears brush selection (Mar 2026):**
17+
- Loading prototype SVG textures in the material browser could trigger Godot's texture
18+
reimport pipeline, which emitted spurious empty `selection_changed` signals that cleared
19+
the dock's brush selection cache.
20+
- Added `should_suppress_empty_selection()` static guard in `plugin.gd`: ignores empty
21+
editor selection events when `hf_selection` is still populated. Intentional deselects
22+
(Escape key, delete, dock Clear Selection button, Commit Cuts) clear `hf_selection`
23+
first so the guard lets them through.
24+
- New `selection_clear_requested` signal on `dock.gd` lets the dock tell the plugin to
25+
clear its cache before calling `editor_selection.clear()`.
26+
- Reordered `hf_selection.clear()` before `selection.clear()` in three plugin deselect
27+
paths (Escape, delete brushes, duplicate brushes) for consistency with the guard.
28+
729
### Added
30+
- **Smart Contextual Toolbar + Command Palette (Mar 2026):**
31+
- **Floating context toolbar** (`ui/hf_context_toolbar.gd`): appears in the 3D viewport overlay with
32+
context-sensitive actions based on current selection and tool state. Automatically shows/hides as
33+
context changes — no manual tab switching needed.
34+
- **Brush selected** → Extrude Up/Down, Hollow, Clip, Carve, Duplicate, Delete buttons. Label shows
35+
"N brush(es)" count.
36+
- **Face selected** → Material thumbnail strip (5 favorites), UV Justify buttons (Fit/Center/L/R/T/B),
37+
"Apply to Whole Brush" button. Label shows "N face(s)" count.
38+
- **Entity selected** → I/O connect and Properties quick-edit buttons (jump to Entities tab),
39+
Duplicate, Delete.
40+
- **Draw idle** → Quick shape selector (Box/Cyl/Sph/Cone), Add/Subtract toggle with color-coded label
41+
(green Add / red Sub) and one-click switch.
42+
- **Dragging** → Live dimension display, Axis Lock buttons (X/Y/Z), Cancel button.
43+
- **Vertex edit** → Vertex/Edge sub-mode toggle, Merge, Split, Exit buttons.
44+
- **Auto-mode hint bar**: during brush drawing, a blue overlay bar appears with the current operation
45+
mode ("Drawing in Add mode — press Subtract to toggle") and a one-click "Switch to Subtract/Add"
46+
button. Fades in smoothly, auto-hides when not drawing.
47+
- **Command palette** (`ui/hf_hotkey_palette.gd`): searchable action palette toggled with `Shift+?`
48+
or `F1`. Lists all HammerForge actions grouped by category (Tools, Editing, Paint, Axis Lock) with
49+
key bindings. Live search filters by action name or binding. **Live gray-out**: actions that cannot
50+
run in the current state are visually disabled (e.g. Hollow grayed out with no brush selection,
51+
paint tools grayed out outside paint mode, vertex tools grayed outside vertex mode). Press Enter to
52+
execute the first visible+enabled match. Esc to close.
53+
- **Dock integration**: `dock.gd` gains `_apply_material_to_whole_brush()` and
54+
`_on_face_assign_material()` convenience methods for toolbar-initiated material assignment.
55+
- **Plugin integration** (`plugin.gd`): context toolbar and palette added to
56+
`CONTAINER_SPATIAL_EDITOR_MENU` alongside existing HUD. State updates every frame via
57+
`_update_context_toolbar_state()` which computes brush/entity/face counts, input mode, operation,
58+
and vertex state. Action dispatch routes to existing dock/plugin methods (hollow, clip, carve,
59+
justify, axis lock, tool switch, etc.) with full undo/redo support.
60+
- **32 new GUT tests** (`test_context_toolbar.gd` 20 tests, `test_hotkey_palette.gd` 12 tests):
61+
context determination, label content, action signals, material thumbnails, search filtering,
62+
gray-out logic, toggle visibility. **Total: 726 tests across 43 files.**
863
- **Player Spawn System + Quick Play Overhaul (Mar 2026):**
964
- **New subsystem** (`systems/hf_spawn_system.gd`): `HFSpawnSystem` manages spawn lookup, physics-
1065
based validation, auto-fix, default spawn creation, and debug visualisation. Follows the

DEVELOPMENT.md

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Development Guide
1+
# Development Guide
22

33
Last updated: March 29, 2026
44

@@ -65,6 +65,8 @@ addons/hammerforge/
6565
hf_shortcut_dialog.gd Searchable shortcut reference dialog (filterable Tree with categories)
6666
hf_material_browser.gd Visual material browser (thumbnail grid, search, filters, favorites, drag-drop)
6767
hf_prefab_library.gd Prefab library dock section (ItemList + drag-and-drop)
68+
hf_context_toolbar.gd Floating contextual mini-toolbar (context-sensitive actions in 3D viewport)
69+
hf_hotkey_palette.gd Searchable command palette with live gray-out (Shift+? or F1)
6870
paint_tab_builder.gd Builds Paint tab sections + signal connections
6971
entity_tab_builder.gd Builds Entity Properties + Entity I/O sections
7072
manage_tab_builder.gd Builds Manage tab sections (Bake, File, Settings, etc.)
@@ -112,6 +114,8 @@ addons/hammerforge/
112114
- **Input state machine.** `HFDragSystem` owns the `HFInputState` instance. Drag state transitions are explicit (`begin_drag` -> `advance_to_height` -> `end_drag`). Extrude uses `begin_extrude` -> `end_extrude`.
113115
- **Direct typed calls.** `plugin.gd` and `dock.gd` use typed references (`LevelRoot`, `DockType`) with direct method calls instead of `has_method`/`call`.
114116
- **Sticky LevelRoot discovery.** `plugin.gd` keeps `active_root` sticky: `_edit()` does not null it when non-LevelRoot nodes are selected. `_handles()` returns true for any node when a LevelRoot exists (deep recursive search). `dock.gd` mirrors this pattern.
117+
- **Sticky brush selection.** `plugin.gd` suppresses spurious empty `selection_changed` signals (e.g. from texture reimport) via `should_suppress_empty_selection()`. When the editor selection goes empty but `hf_selection` is still populated, the event is ignored. Intentional deselects must clear `hf_selection` *before* calling `editor_selection.clear()`. Dock paths that clear editor selection emit `selection_clear_requested` first so the plugin can clear its cache.
118+
- **Material assignment fallback.** `dock.resolve_material_assign_action(mat_index)` is a pure helper returning `{action, method, args, toast}`. Both `_on_material_assign()` and `_on_browser_material_double_clicked()` delegate to it. When faces are selected → face assignment. When no faces but brushes are selected → whole-brush fallback. When nothing is selected → error toast. Context menu options (Apply to Faces, Apply to Whole Brush) remain explicit and do not use the fallback.
115119
- **Collapsible sections.** Use `HFCollapsibleSection.create("Name", start_expanded)` from `ui/collapsible_section.gd` for dock sections. Each section has an HSeparator, indented content, and persisted collapsed state via user prefs. Tab contents are built programmatically in `_build_paint_tab()`, `_build_manage_tab()`, `_build_selection_tools_section()`, and `_build_entity_io_section()`. All 18 sections tracked in `_all_sections: Dictionary`.
116120
- **Signal-driven dock sync.** Setting controls push values to LevelRoot via `toggled`/`value_changed` signal connections. Paint layers, materials, and surface paint sync instantly via `paint_layer_changed`, `material_list_changed`, and `selection_changed` signals. Perf panel updates every 30 frames; disabled hints are flag-driven. Form label widths standardized to 70px.
117121
- **Input decomposition.** `_forward_3d_gui_input()` in `plugin.gd` is a ~50-line dispatcher that routes to focused handlers: `_handle_paint_input()`, `_handle_keyboard_input()`, `_handle_rmb_cancel()`, `_handle_select_mouse()`, `_handle_extrude_mouse()`, `_handle_draw_mouse()`, `_handle_mouse_motion()`. Shared `_get_nudge_direction()` is used by both `_forward_3d_gui_input()` and `_shortcut_input()`.
@@ -148,13 +152,15 @@ addons/hammerforge/
148152
- **Geometry-aware snapping.** `_snap_point()` delegates to `HFSnapSystem`. Three modes (Grid=1, Vertex=2, Center=4) as a bitmask. Vertex mode collects 8 box corners from all brushes; Center mode collects brush centers. Closest candidate within `snap_threshold` beats grid snap. Pass `exclude_ids` to skip the brush being dragged.
149153
- **Reference cleanup.** `delete_brush()` calls `_cleanup_brush_references()` which strips group_id meta (+ cleans empty groups via `visgroup_system._cleanup_empty_group()`), clears visgroup membership, and calls `entity_system.cleanup_dangling_connections()` to remove I/O connections targeting the deleted node. Always fires before the node is removed from the tree.
150154
- **Live dimensions.** `input_state.get_drag_dimensions()` returns `Vector3(W, H, D)` during DRAG_BASE/DRAG_HEIGHT; `Vector3.ZERO` otherwise. `format_dimensions()` renders as `"64 x 32 x 48"` (whole numbers omit decimals). The mode indicator banner appends dimensions to the stage hint during drag gestures.
155+
- **Context toolbar.** `ui/hf_context_toolbar.gd` is a `PanelContainer` added to `CONTAINER_SPATIAL_EDITOR_MENU` via `plugin.gd`. It determines context via `_determine_context(state)` using a priority chain: vertex_mode > dragging > face_selected > entity_selected > brush_selected > draw_idle > NONE. Each context maps to a pre-built `HBoxContainer` section with tool buttons. The toolbar emits `action_requested(action, args)` which `plugin.gd` dispatches to existing dock/plugin methods. Auto-hint bar uses a separate `PanelContainer` child with fade-in tween. State is pushed every frame from `_update_hud_context()` via `_update_context_toolbar_state()`.
156+
- **Command palette.** `ui/hf_hotkey_palette.gd` extends `PanelContainer`. Populated once via `populate(keymap)`. Live gray-out uses `_is_action_available(action)` which checks brush_count, entity_count, paint_mode, vertex_mode, and tool_id from the state dict. Toggle with Shift+? or F1. Emits `action_invoked(action)` which `plugin.gd` handles identically to keyboard shortcuts.
151157

152158
### CI
153159

154160
The project has a GitHub Actions workflow (`.github/workflows/ci.yml`) that runs on push and PR to `main`:
155161
- `gdformat --check` -- verifies formatting
156162
- `gdlint` -- checks lint rules (configured in `.gdlintrc`)
157-
- **GUT unit + integration tests** -- 622 tests across 38 test files (runs Godot headless)
163+
- **GUT unit + integration tests** -- 737 tests across 43 test files (runs Godot headless)
158164

159165
Run locally before pushing:
160166
```
@@ -205,6 +211,11 @@ Tests live in `tests/` and use the [GUT](https://github.com/bitwes/Gut) framewor
205211
| `test_vertex_edges.gd` | 19 | Edge extraction (12 edges for box), dedup, edge selection (additive, toggle, clear), edge world positions, edge split (vertex count, face vert count), vertex merge, sub-mode toggle, get_single_selected_edge, point-to-segment-dist-2d |
206212
| `test_polygon_tool.gd` | 16 | Convexity validation (square, triangle, L-shape, pentagon, collinear, degenerate), face data construction (square/triangle extrusion, local space, top face normal), empty/two-point, tool metadata, settings schema |
207213
| `test_path_tool.gd` | 15 | Segment brush construction (straight, diagonal, zero-length, center, size), miter joint construction (right angle, straight skipped, acute skipped, group_id), face data validation, face reconstruction, tool metadata |
214+
| `test_material_browser.gd` | 24 | Thumbnail grid, palette view, null material skip, selection signals, double-click, drag data, search, pattern/color filters, favorites, hover preview, context popup |
215+
| `test_material_integration.gd` | 28 | Brush search (_iter_pick_nodes), hover overlay mesh (normals, mutation, lifecycle), whole-brush/per-face assignment via root, face selection counting via dock, resolve_material_assign_action fallback (face→brush→error), selection_clear_requested signal, empty-selection suppression guard (reimport, intentional deselect, new selection, first select, dock clear protocol) |
216+
| `test_context_toolbar.gd` | 20 | Context determination, label content, action signals, material thumbnails, search filtering, gray-out logic, toggle visibility |
217+
| `test_hotkey_palette.gd` | 12 | Search filtering, action availability gray-out, key binding display, action invocation |
218+
| `test_spawn_system.gd` | 21 | Spawn lookup, validation, auto-fix, default creation, debug viz, entity property helpers, severity ordering |
208219

209220
Run all tests:
210221
```

HammerForge_SPEC.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ All signals are defined on `LevelRoot`. Subsystems emit them via `root.<signal>.
3636
| `user_message(text, level)` | Subsystem-to-dock notification routing (0=INFO, 1=WARNING, 2=ERROR) |
3737
| `material_list_changed()` | Material palette updated (add/remove) |
3838
| `face_selection_changed()` | Face selection changed (snapshot comparison) |
39+
| `selection_clear_requested()` | Dock requests plugin clear `hf_selection` before `editor_selection.clear()` (reimport guard) |
3940

4041
### Core Scripts
4142

@@ -380,6 +381,15 @@ The dock uses 4 tabs with collapsible sections for visual hierarchy:
380381
- `_edit()` only nulls `active_root` when the root node is removed from the tree.
381382
- `dock.gd` mirrors the sticky pattern and uses `_find_level_root_in()` for deep tree search.
382383

384+
## Selection Guard (Reimport Resilience)
385+
- `plugin.gd` suppresses spurious empty `selection_changed` signals via `should_suppress_empty_selection()` (static method). Texture reimport can trigger Godot's EditorSelection to emit empty selections; the guard ignores these when `hf_selection` is non-empty.
386+
- **Intentional deselect protocol**: clear `hf_selection` *before* calling `editor_selection.clear()`. Plugin deselect paths (Escape, delete, duplicate) do this directly. Dock deselect paths (`_on_clear_selection_pressed`, `_on_commit_cuts`) emit `selection_clear_requested` signal; plugin's `_on_dock_selection_clear` handler clears `hf_selection` in response.
387+
388+
## Material Assignment Fallback
389+
- `dock.resolve_material_assign_action(mat_index)` returns `{action, method, args, toast}`. Used by `_on_material_assign()` and `_on_browser_material_double_clicked()`.
390+
- Priority: face selection (via `_count_selected_faces()`) > whole-brush (via `_get_selected_brush_ids()`) > error toast.
391+
- Context menu options "Apply to Selected Faces" and "Apply to Whole Brush" remain explicit (no fallback).
392+
383393
## Customizable Keymaps
384394

385395
All keyboard shortcuts are data-driven via `HFKeymap` (`hf_keymap.gd`). Plugin loads bindings from `user://hammerforge_keymap.json` (or built-in defaults). Each binding maps an action name (e.g. `"hollow"`) to `{keycode, ctrl, shift, alt}`. Plugin uses `_keymap.matches(action, event)` instead of hardcoded `KEY_*` checks. Toolbar labels and tooltips pull display strings from the keymap.

README.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
<img src="https://img.shields.io/badge/Godot-4.6%2B-478cbf?logo=godot-engine&logoColor=white" alt="Godot 4.6+">
1414
<img src="https://img.shields.io/badge/License-MIT-green" alt="MIT License">
1515
<img src="https://img.shields.io/badge/Status-Alpha-orange" alt="Alpha">
16-
<img src="https://img.shields.io/badge/Tests-694%20passing-brightgreen" alt="694 tests passing">
16+
<img src="https://img.shields.io/badge/Tests-737%20passing-brightgreen" alt="737 tests passing">
1717
<img src="https://img.shields.io/badge/GDScript-23k%2B%20lines-blueviolet" alt="23k+ lines">
1818
</p>
1919

@@ -36,7 +36,7 @@ HammerForge is a single `addons/` folder. No external tools, no custom builds, n
3636

3737
| | |
3838
|---|---|
39-
| **Modular subsystem architecture** | **694 unit + integration tests** with CI on every push |
39+
| **Modular subsystem architecture** | **726 unit + integration tests** with CI on every push |
4040
| **15 brush shapes** (box through dodecahedron) | **150 built-in prototype textures** for instant greyboxing |
4141
| **Quake `.map`** + **glTF `.glb`** export | **.hflevel** native format with threaded I/O |
4242
| **Customizable keymaps** (JSON) | **Plugin API** for custom tools |
@@ -172,6 +172,9 @@ HammerForge's dock is designed to stay out of your way while keeping everything
172172
- **Interactive tutorial wizard** -- 5-step guided walkthrough (Draw → Subtract → Paint → Entity → Bake) with signal-driven auto-advance, progress bar, and persistent resume across sessions
173173
- **Dynamic contextual hints** -- viewport overlay hints that appear when switching tools (e.g. "Click to place corner → drag to set size → release for height"), auto-dismiss after 4s with per-hint persistence
174174
- **Searchable shortcut dialog** -- "?" button opens a filterable, categorized shortcut reference (replaces static popup)
175+
- **Smart contextual toolbar** -- floating mini-toolbar in the 3D viewport shows context-sensitive actions (brush ops when brushes selected, UV tools when faces selected, shape picker in draw mode, axis locks while dragging)
176+
- **Command palette** (Shift+? or F1) -- searchable action palette with live gray-out for unavailable actions; type to filter, Enter to execute
177+
- **Auto-mode hints** -- "Drawing in Add mode" bar appears during drag with one-click Add/Subtract toggle
175178
- **Tool poll system** -- buttons gray out with inline hints when an action can't run ("Select a brush to use these tools")
176179
- **Contextual selection tools** -- hollow, clip, move, tie, duplicator appear in Brush tab only when brushes are selected
177180
- **Live dimensions** -- real-time W x H x D display during drag gestures
@@ -272,13 +275,13 @@ All shortcuts are rebindable via `user://hammerforge_keymap.json`.
272275
| E | Edge sub-mode (in vertex) | | ; | Path tool |
273276
| Ctrl+E | Split edge | | Ctrl+W | Merge vertices |
274277
| T | Texture Picker | | ? | Shortcuts popup |
275-
| X / Y / Z | Axis lock | | | |
278+
| Shift+? / F1 | Command palette | | X / Y / Z | Axis lock |
276279

277280
---
278281

279282
## Testing
280283

281-
622 tests across 38 files using the [GUT](https://github.com/bitwes/Gut) framework, including unit tests and end-to-end integration tests. All checks run on every push via GitHub Actions.
284+
726 tests across 43 files using the [GUT](https://github.com/bitwes/Gut) framework, including unit tests and end-to-end integration tests. All checks run on every push via GitHub Actions.
282285

283286
```bash
284287
# Run all tests headless

ROADMAP.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,13 @@ Priorities are informed by a Hammer Editor gap analysis — see GAP_ANALYSIS.md
153153
- **Playtest FPS controller** updated with `player_start_position` / `player_start_rotation_y` exports.
154154
- 21 new tests. Total: **685 tests across 41 files**.
155155

156+
## Done (Smart Contextual Toolbar + Command Palette)
157+
- **Floating context toolbar** (`HFContextToolbar`): appears in the 3D viewport with context-sensitive buttons (brush ops, face UV tools, entity quick-edit, shape picker, axis locks, vertex tools). Auto-shows/hides based on selection and tool state.
158+
- **Auto-mode hint bar**: blue overlay during brush drawing shows current Add/Subtract mode with one-click toggle.
159+
- **Command palette** (`HFHotkeyPalette`): searchable action list (Shift+? or F1) with live gray-out for unavailable actions. Filters by name or binding, Enter to execute.
160+
- **Dock convenience methods**: `_apply_material_to_whole_brush()` and `_on_face_assign_material()` for toolbar-initiated material assignment.
161+
- 32 new tests (context_toolbar 20, hotkey_palette 12). Total: **726 tests across 43 files**.
162+
156163
## Next (Wave 2c remaining)
157164
- Displacement sewing (stitch adjacent heightmap edges to share vertices).
158165
- Material atlasing for large scenes.

0 commit comments

Comments
 (0)