Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
# Editor & UI Reference
Implementation details for the code editor, layout, preferences, and UI conventions. See `CLAUDE.md` for core architecture.
## Error Display
Parse errors get a squiggly underline at the error location via `QTextCharFormat` with `SpellCheckUnderline` style, applied as an extra selection on the `QPlainTextEdit`. Also reported in the console.
## Find / Replace
`CodeEditor.show_find(replace=False)` opens a `FindBar` overlay parented to the editor, top-right corner. `show_find(replace=True)` also shows the replace row. Find is `QKeySequence.StandardKey.Find` (Cmd+F). Find & Replace carries two sequences, `StandardKey.Replace` plus an explicit `Ctrl+Shift+F`, with empty ones filtered out: Qt has no macOS default for `Replace`, so the menu item was unbound there, and `Ctrl+Shift+F` renders as ⇧⌘F on macOS while riding alongside Ctrl+H elsewhere. Not `Ctrl+Alt+F` — ⌥F produces `ƒ`, so the native menu bar's key equivalent never matches and the shortcut displays but is silently dead.
`FindBar` features:
- Plain-text and regex search (`.*` toggle), case-sensitive toggle (`Aa`), whole-word toggle (`ab` with a rule beneath it) which wraps the term in `\b` anchors inside a non-capturing group, so the anchors bind to the whole term rather than to its first and last alternatives
- All matches highlighted pale amber; current match orange. Both formats set their **foreground explicitly** (`ui_colors.find_match_colors()`), which makes them theme-INDEPENDENT rather than merely dark-corrected: these backgrounds are pale in either theme, so leaving the text to the palette turned it white-on-amber the moment the app went dark. The bar's own background comes from `ui_colors.find_bar_bg()` and the no-match tint from `find_no_match_colors()`, both light/dark pairs following the `gutter_colors()`/`header_colors()` convention. `FindBar._apply_theme` is registered with `on_appearance_change`, so a live light/dark switch repaints the bar and re-runs the highlights (which may already be on screen) rather than only painting once at construction
- Match count label ("N of M"); prev/next navigation (◀ ▶ or Shift+Enter / Enter)
- **Typing refreshes the highlights and moves nothing.** `_on_search_changed()` recomputes the match list, marks the one Enter would take you to, and neither moves the editor's cursor nor scrolls. That covers typing in the DOCUMENT — the jump used to select the match, so the next keystroke replaced it, and typing a word with the bar open silently overwrote a different occurrence per letter (#376) — and typing in the SEARCH field, where the view jumped around mid-word and, because each jump parked the cursor at the end of the hit, every further letter searched on from there: three letters into `Example` you were three occurrences down the file (#417). The toggle/textChanged connections are wrapped in lambdas only because the method takes no argument and their signals pass one.
- **Next/Prev search from the cursor, never from the last match found** (`_step_from`, #417). The caret says which of two things an arrow means. Still where `_go_to_current` parked it, and the arrow steps on from the current match by index, so the last match wraps round to the first. Anywhere else, and the user put it there, so the search runs from it (`_match_at_or_after` / `_match_before`; `_match_before` uses a strict `<` on the match end so stepping back from the caret Next just left reaches the match before it rather than that one). Stepping by index in the first case is what guarantees an arrow always moves: searching by position finds a match the caret is parked at the start of all over again, which reads as a dead button and reads worst on the last match, where the step is meant to wrap.
- **Enter is handled in exactly one place** (`FindBar.eventFilter` on the search field, which consumes the key). `QLineEdit` emits `returnPressed` *and* leaves the key event unaccepted so a parent can act on it too, so connecting `returnPressed` while `keyPressEvent` also handled Return stepped twice per Enter — with three matches that reads as stepping backwards, since two forward is one back — and cancelled Shift+Enter out entirely, one step each way.
- **The count reads `2/5`, and sits after the arrows** rather than against the field, so it reads as the result of the button you just pressed. `No results` still replaces it outright, and it is empty when the field is.
- **Stepping leaves a caret at the end of the match, not the match selected** (`_go_to_current`, #417). A selection is invisible as a cursor, so the arrow keys looked dead and the next thing typed replaced the word that had been found; the current match is already obvious from its highlight. The caret sitting *after* the match is also what makes a second Next move on instead of finding the same one again. Replace is unaffected — it edits through the stored match cursor, not the editor's.
- **A match near an edge is centred, one comfortably on screen is left alone** (`CodeEditor.center_unless_comfortable`, #505). `ensureCursorVisible()` scrolls the MINIMUM, so a match one line past the edge landed flush against it with nothing readable around it. The caret now keeps `SCROLL_MARGIN_LINES` (3) of context above and below, or the view centres. Same rule the undo jump has used since #389, except that "on screen" means far enough in to see context rather than inside the viewport by a pixel. Nothing extra is needed near the first or last line: `centerCursor()` cannot scroll past the document, so it stops as near the middle as the document allows.
- Replace one (current match) and Replace All (works backwards through matches to preserve positions, wrapped in one `beginEditBlock`/`endEditBlock` for one undo step)
- A single-word selection present when Find opens pre-populates the search field
- Escape closes the bar and returns focus to the editor
- `_find_selections` is a separate extra-selection list on `CodeEditor`, inserted between `_selection_extra` and `_exec_selection` in `_refresh_extra_selections`
- Document changes while open auto-rerun the search via `document().contentsChanged`
## Minimum width
`CodeEditor` never shrinks below `_MIN_COLUMNS` (40) characters of code.
`_apply_min_width()` computes the floor as 40 character widths plus the
chrome that takes real width regardless — the gutter (breakpoint column and
line numbers) and the scrollbar extent, which macOS overlay scrollbars still
reserve — and calls `setMinimumWidth`.
A real `setMinimumWidth`, not a `minimumSizeHint()` override: the hint only
guides layouts, and a direct `resize()` walks straight past it. That was
measured, not assumed — with the hint alone, squeezing the widget to 40px
left it at 40px, and the first version of the test was passing on a bogus
measurement until it was pointed at `minimumWidth()` instead.
Recomputed rather than baked in, from `changeEvent`'s `FontChange` (a
preferences font change or a Cmd+[/Cmd+] zoom both move the floor) and from
`_update_line_number_area_width` (the gutter widens by a digit as the file
passes 10/100/1000 lines). `test_editor_min_width.py` squeezes a real
`CodeEditor` at three font sizes and checks both that the floor holds and
that it tracks the font.
## Indent Guides
Faint vertical lines drawn inside each indented line's leading whitespace, every `_indent_size` columns, except at the column of the first non-whitespace character. The pen comes from `ui_colors.guide_colors()` (indent, column) and both overlays register `on_appearance_change` so a live light/dark switch repaints them. The values were hardcoded near-white, which measured 1.3:1 against a white page but **13.6:1** against the dark editor background (`Base` is `#171717`) — ten times louder than the same lines are in light mode, which is what "too bright in dark mode" was. The dark values are chosen to land at the same contrast ratio as the light ones rather than picked by eye; `tests/test_editor_indent.py::TestGuideColors` pins the ratios. Implemented as `_IndentGuides(QWidget)`, a transparent overlay on `CodeEditor.viewport()`, created before `_ColumnGuide` so the column guide renders on top. Repainted on `document().contentsChanged` and `set_indent_size()`.
Both this and the Column Guide overlay are viewport-child widgets whose own geometry has to stay pinned to `(0, 0, viewport.width(), viewport.height())` — `CodeEditor._reposition_scroll_overlays()`, connected to both `horizontalScrollBar().valueChanged` and `verticalScrollBar().valueChanged` (not just `resizeEvent`, which was the only place `update_geometry()` was called before this was added), re-asserts that on every scroll. Without it, `QPlainTextEdit`'s internal scroll optimization moves the viewport's own child widgets by the scroll delta as a side effect (confirmed empirically: scrolling to hbar value 100 left `_column_guide.geometry()` at `x=-100`) — since each overlay's `paintEvent` already recomputes its line's position fresh from `cursorRect()` (itself already scroll-adjusted), a widget origin that *also* drifted by the same delta doubles the effective on-screen movement per scroll step, so the guide moved twice as fast as the actual text and never stayed aligned with its column.
Paint logic: for each visible block, count leading spaces `n`; draw guides at `indent_size, 2*indent_size, …` while `col < n` (strictly less, so the column at `n` is never drawn). Empty/unindented lines skipped. Uses `QFontMetricsF` for sub-pixel accuracy.
Both this and the Column Guide draw their line(s) via the module-level `_draw_vline_avoiding_cursor(painter, x, y_top, y_bottom, cursor_rect)` rather than a plain `painter.drawLine()`: since both overlays are raised viewport-child widgets, their solid guide pixels paint *over* the text edit's own cursor (a child can't be told to render behind its own parent's content, only reordered among siblings) — a guide line landing on the caret's column would otherwise hide it completely. The helper splits the line into up to two segments stopping exactly at `editor.cursorRect()`'s top/bottom edges when `x` falls within the cursor rect's horizontal span, leaving its full interior undrawn; when `x` doesn't overlap the cursor at all, it's a single unmodified `drawLine()`.
## Column Guide
Faint vertical lines at **one or more columns** (default 80), implemented as `_ColumnGuide(QWidget)`, a transparent overlay on `CodeEditor.viewport()`:
- **The preference is a list.** `editor/columnGuide` holds free text — `67, 100` — parsed by `preferences.parse_guide_columns()`, which is Qt-free and deliberately tolerant: the field applies on every keystroke, so a half-typed `67, ` has to keep drawing the 67 rather than blanking the guide while you reach for the next digit. Junk, duplicates and anything outside 1..300 are dropped, and a **bare int is accepted**, which is what a settings file written before #467 still holds — so there is no migration step. `set_columns(list)` replaced `set_column(int)`; `paintEvent` culls to the exposed rect and then draws one line per surviving column.
- `WA_TransparentForMouseEvents` + `WA_TranslucentBackground` so only the line pixel shows and mouse events pass through
- `update_geometry()` keeps the overlay sized to the full viewport rect; called from `CodeEditor.resizeEvent()` and `_reposition_scroll_overlays()` (see Indent Guides above for why the latter is needed)
- x position = `cursorRect(cursor_at_pos_0).x() + QFontMetricsF(font).horizontalAdvance('0' * 80)`. `QFontMetricsF` (not `QFontMetrics`) is required — the integer version rounds character width up by ~0.2px, accumulating to ~2 columns of error over 80 characters.
## Code Folding
Fold markers (▼ unfolded, ▶ folded) appear in the right section of the line-number gutter; clicking calls `toggle_fold(block_number)`.
`_compute_fold_regions(doc)` returns `{open_block: close_block}` via two passes:
1. **Delimiter matching** — `{…}`, `(…)`, `[…]` pairs; a region only forms when opener and closer are on different lines
2. **Indentation continuation** — any non-empty line followed by at least one more-indented non-empty line; covers function bodies, ternary chains, nested list comprehensions, etc.; `setdefault` lets pass-1 delimiter regions take precedence
`_fold_regions` recomputes lazily on first paint after `_fold_dirty` is set by `_on_doc_changed`. `_fold_busy` guards against re-entrant recomputation and against `_on_doc_changed` resetting `_fold_dirty` mid-toggle.
`_set_range_visible(start_bn, end_bn, visible)` sets `QTextBlock.setVisible()` on each hidden block, then a no-op `cursor.beginEditBlock(); cursor.endEditBlock()` forces `QPlainTextDocumentLayout` to recalc block heights (required for visibility changes to take effect).
Fold indicators are drawn with `painter.drawPolygon(QPoint[])` — `QPainterPath.drawPath` was invisible at small sizes on macOS; `drawPolygon` is reliable.
## Editor Context Menu
Right-clicking in the editor builds a standard Qt context menu, then appends identifier-aware and debug-aware actions.
**Every item acts on the position that was clicked**, taken once as
`click = self.cursorForPosition(event.pos())`, not on `self.textCursor()`.
macOS moves the caret to a right-click and Windows does not, so reading the
caret was the clicked line on one platform and an unrelated line on the other
-- which is how Reflow Comment came to be missing entirely on Windows (#467)
while looking correct here. The literal menus already did this; the newer
items did not, and the inconsistency was the bug.
**A read-only buffer says so**, as a disabled `Read Only Buffer` item where
the missing entries would have been. Read-only loses Use Library…, Reflow
Comment…, Reformat Selection and Edit as… all at once, and nothing said why —
a file in the libraries folder opens read-only, so that is the menu a library
developer sees all day.
A selection counts only when the click falls **inside** it
(`in_selection`). Otherwise the menu treats the click as a plain caret
position, so right-clicking elsewhere no longer offers to reformat a span
you cannot see.
**Debug variable inspection** (when debugger is paused and the word under the cursor is a known variable — locals, globals, constants, or `$`-specials):
- **`Variable: x`** / **`Value: <value>`** — two disabled (grayed-out) header items: the variable name and its value formatted by `_fmt()` and truncated to 30 characters with `…` if longer. Appear before the standard cut/copy/paste items, followed by a separator.
- **Print 'x' to Console** — emits `CodeEditor.print_value_to_console(name, value)`, connected to `MainWindow._on_debug_print_value`, which calls `self._console.append_value(name, value, _pretty_assignment(name, value))`. The original Python value is stored for the console right-click viewer menu.
- **View 'x'…** submenu — populated by `build_viewer_menu()` from `data_viewers.py`; only appears when the value type supports a viewer (list, VNF, path, grid, matrix, affine transform).
The available variables come from the innermost debug frame: `{**outer_scope, **local_scope}` (local overrides outer on collision), which covers locals, globals, constants, and `$`-specials. `Qt.WordUnderCursor` excludes `$`, so `contextMenuEvent` manually checks whether the character immediately before the selection is `$` and prepends it — allowing `$fn`, `$t`, etc. to match.
`MainWindow._on_debug_paused` and `_on_debug_error_break` call `_set_debug_locals_on_visible(locals_dict)`, which first clears locals from all editors via `_clear_all_debug_locals()`, then sets them on `_current_tab().editor` — the editor that is actually visible after `_show_debug_line` may have switched to an included file's tab. All resume/step/stop/finish/restart handlers call `_clear_all_debug_locals()` to sweep every open editor clean.
**Reformat Selection ▸ …**: pretty-prints the selection with the profile
picked. One submenu and no plain item beside it: the plain item reformatted
with whatever profile an `editor/formatProfile` preference named, so the menu
offered the same three reformats twice under two names, and which one the
unnamed item would do was invisible from the menu. Submenu and preference
both went (#502).
`scad_format.FormatProfile` is four booleans, and `PROFILES` the three
built-ins (#466):
| | Compact | Default | Expanded |
|---|---|---|---|
| `break_chained_child` — `let(a=1) cube(a);` across lines | ✗ | ✓ | ✓ |
| `wrap_every_list` — wrap whatever the length | ✗ | ✗ | ✓ |
| `wrap_one_per_line` — vs a greedy fill | ✗ | ✗ | ✓ |
| `collapse_blank_lines` | ✓ | ✓ | ✗ |
Three things worth knowing about the shape of that table:
- **Brace style is not a setting.** K&R everywhere, so no profile disagrees
with another about code all three lay out the same way — a parameter with
one value in every column is just the behaviour.
- **`wrap_every_list` fires on `_ARGUMENT_LISTS`, not every separated list.**
`ListComprehension` is excluded: `translate([1, 0, 0])` is *one* argument
that happens to be a vector, and the same greedy-fill comment that has
always been there explains why — a 60-point path one number per line is
three screens of scrolling.
- **Default is byte-identical to the old behaviour** except for the wrap
column, which moved from a hard-coded `WRAP_WIDTH = 80` to
`CodeEditor.guide_width()` — the rightmost column guide. The formatter and
the line you are eyeing now agree; before, someone with a guide at 67 was
reformatted to 80 regardless. `WRAP_WIDTH` survives as the fallback for a
caller with no guide to offer.
`guide_width()` is shared with the comment reflow below, which is why it is
named for the guide rather than for either feature.
**Neither reformatting nor reflowing re-renders.** Both write back through
`replace_span` but, unlike "Edit as..." and the AI proposals, deliberately do
*not* emit `source_edited_externally`: they move whitespace and comment text,
so the geometry cannot have changed, and a render mid-edit reports syntax
errors for lines the user has not finished writing — commenting out the first
of two paragraphs and reflowing it flagged the second one (#537).
**A profile has to be reachable from wherever the text currently is**, or the
submenu is a one-way door. It is not enough for each profile to be right on
freshly written code: reformatting Expanded output as Compact left it
Expanded, because the tokenizer copies whitespace inside parens through
verbatim and `_wrap_one_long_list` then skips a list that already contains
newlines as "wrapped by hand". So the tokenizer tracks `bracket_depth`
separately from `paren_depth` and **collapses a newline inside a `(...)` to a
single space**, letting the wrap pass decide afresh — while a newline inside a
`[...]` is left alone, since the rows of a matrix or a path a point per line
are the author's meaning rather than argument layout. `tests/test_format_profiles.py`
walks all nine profile pairs across four shapes.
**A list is wrapped only when the list itself overflows**, measured from where
its last item ends rather than where its node does. A `FunctionDeclaration`'s
span covers the body, so `function foo(a, b, c) = <87-char body>;` looked
over-long and split `a, b, c` across three lines — to fix a line that was
still 66 wide afterwards. Skipping such a list lets the round fall through to
the one that really does overrun. It is also the rule as a person states it:
keep the arguments inline unless there are a lot of them. `wrap_every_list`
bypasses it, since Expanded's whole point is wrapping a list that fits.
**The collapse stops at a comment**, in both directions: a newline inside a
running `//` comment is kept (collapsing it ran the rest of the arguments into
the comment — `max(a, // why` + `b);` became `max(a, // why b);`, which still
parses and computes `max(a)`), and so is the newline *before* a comment, since
pulling one up onto the previous line re-attaches it to a different AST node
and the rewrite stops being purely whitespace. Both are caught by `_same_shape`
in `tests/test_format_profiles.py`.
**A function whose body does not fit breaks at the body's own structure**
(`_break_function_bodies`), which is what Default is meant to look like —
BOSL2/strings.scad is the reference. The signature keeps its line, the body
goes on the next one indented a level, each leading `assert()`/`echo()` clause
takes a line, and a ternary chain breaks after each depth-0 `:`. Depth-0
matters: the `:` in `[0:1:n]` is a range, not an arm.
It runs **before** `_wrap_long_lists`, so wrapping an argument list is the
fallback rather than the first thing tried. Before this the whole body was
joined onto the signature line and then rescued by wrapping whatever argument
list happened to be there, which read worse than the input it replaced:
```openscad
function substr_match(str, start, pattern) = assert(
_is_liststr(str), "str must be a string or list"
) assert(...) len(str)-start <len(pattern)? false : _substr_match_recurse(
str, start, pattern, len(pattern)
);
```
`tests/test_bosl2_house_style.py` reformats **every definition in
strings.scad** and requires each to be shape-preserving and idempotent.
Two smaller faults fell out of the same change. With `break_chained_child`
off nothing flushed at the `)`, and a newline at depth 0 added no space, so
`translate([1, 0, 0])cuboid(` came out glued; and collapsing the newline
before a closer produced `anchor=TOP )`, so the collapse looks ahead for
`)`, `]`, `,` or `;`.
**Adjust Value...** (writable tabs only): arms the number or expression under
the click for Up/Down stepping in place, Escape or a click to end it. The item
is shown only when there is a value under the click, so the label need not
name it. While armed the status bar carries the value, the step sizes and the
Escape; `focusOutEvent` disarms, so it never outlives the editor's focus. The span
comes from `scad_format.find_value_span` and the step size from
`is_angle_value`; the window applies each step as one merged undo entry and
renders on a 400ms debounce. Full rules in `docs/wysiwyg.md` under
"Nudging a number in the editor". Read-only tabs get no item at all, matching
the gizmo and viewport-nudge gating.
**Reflow Comment…** (writable tabs, **selected** whole-line `//` comments, or
the text of a `/* ... */` comment):
rewraps the selection to a width you are asked for, repeating each line's own
prefix — vim's `gq`, which is what editing BOSL2 documentation otherwise means
doing by hand (#467). The two pieces live in `scad_format.py`, Qt-free:
- `comment_prefix(line)` captures the `indent + // + spacing` verbatim. A
*trailing* comment (`cube(1); // why`) returns None — the code before it is
not prose to be rewrapped. `CodeEditor._reflow_target` requires every
selected line to have one, so a selection reaching into code offers nothing.
**The selection is the whole input.** Reflow used to work from the caret too,
via a `comment_block_at` that expanded to the run of lines sharing the caret's
**exact** prefix — which kept `// Description:` out of the `// ` body under
it, but could say nothing about what was *inside* that body. A docsgen `// .`
spacer came back as a stray full stop mid-sentence, a markdown table was folded
into the paragraph, and a `// ` line lost its trailing spaces. What a line
means is not recoverable from its prefix, so the author says so by selecting it
(#467 follow-up); `comment_block_at` went with the caret path.
- `reflow_comment(lines, width)` wraps via `textwrap`, re-applying the prefix
as `initial_indent`/`subsequent_indent`. `break_long_words` and
`break_on_hyphens` are both off — a URL or a `some_function()` split across
lines stops being either. A bare `//` is a paragraph break, kept as-is with
the paragraphs either side wrapped separately; each line's *own* prefix is
stripped to detect that, since `//` does not start with `// `.
- `reflow_block_comment(lines, width, starts_inside)` does the same for a
`/* ... */` comment (#567). A line that is only `/*`, `/**`, `*/`, blank, or
the bare ` *` of a Javadoc-style block is kept exactly as written and ends
the paragraph before it; the text keeps the first text line's prefix (its
indent and a leading `* ` if the block uses one), and text sharing a line
with `/*` or `*/` stays on it. It returns None for anything that is not
comment text, which is how `_reflow_target` refuses a selection reaching
into code. Whether the first selected line starts *inside* a comment opened
above it comes from the highlighter's block state (bit 0 of the previous
line's `userState()`), the same state that colours those lines.
**The width is asked, not assumed** (`ask_reflow_width`): it is the whole point
of the operation and differs between a code comment and a paragraph of prose.
The dialog opens on `guide_width()` — the rightmost column guide, or
80 with guides off — and the answer is remembered for the session, so
reflowing a run of blocks is one keystroke each after the first. Cancelling
changes nothing.
**Go to Definition** (for any identifier, always shown):
Right-click an identifier shows "Go to Definition of 'name'", only for words matching `\$?[A-Za-z_][A-Za-z0-9_]*` (plain identifiers and `$`-prefixed specials).
`CodeEditor.go_to_definition_requested` (emits the word) connects to `MainWindow._go_to_definition(tab, word)` per tab.
`_go_to_definition` requires a cached `root_scope` on the tab (set after every successful `build_scopes()`, both in the render worker and `_start_debug()`); if absent, logs a message asking the user to render first.
Lookup order: `scope.lookup_variable(word)` → `scope.lookup_function(word)` → `scope.lookup_module(word)`, first non-None wins. Built-in modules return `None` from `lookup_module` and are skipped.
The definition node's `.position.origin` gives the source file path, `.position.line` the 1-indexed line. Navigation uses `scroll_to_line(line)` which ensures at least 5 lines of context above and below:
- Same file (or origin `None` / untitled tab): scroll current editor to the line
- Different file: switch to a matching open tab by `file_path`, or open via `_create_and_add_tab()` followed by `_render(tab)`
`_create_and_add_tab(path, text) -> FileTab` creates a `FileTab` (editor + file metadata) and adds it to `_tabs` in the editor dock. Also registers the editor with `DocumentManager` for cross-window sync. If the only existing tab is an empty, unmodified Untitled tab, it is replaced rather than kept alongside. Used by `_open_file`, `_open_recent`, `_go_to_definition`, `_find_or_open_tab`; not by `_new_document` (different setup path for blank tabs).
**Read-only library files**: `_create_and_add_tab` also sets `tab.editor.setReadOnly(True)` when the opened path resolves under the OpenSCAD library directory (`library_manager._library_dir()` — e.g. installed BOSL2 files), so files opened via "Use Library" or "Go to Definition" into a library aren't edited by accident. `FileTab.display_name()` appends `" (ro)"` to the tab label whenever `editor.isReadOnly()` is true (alongside the `"*"` modified marker). The Edit ▸ Read Only checkable action reflects and toggles the current tab's state (synced in `_tab_changed`), letting library developers make any tab editable again.
**BOSL2 Documentation** (for any identifier, in a script whose text matches `^\s*(?:include|use)\s*<\s*BOSL2/` — a plain OpenSCAD script would otherwise get a menu item on every identifier that leads nowhere):
"BOSL2 Documentation for 'name'…" calls `CodeEditor._open_bosl2_docs(word)` directly rather than going up through a signal the way Go to Definition and Use Library do: opening a URL needs nothing from `MainWindow` (no tabs, no scopes, no customizer), so there is nothing to route.
`window/bosl2_docs.py` backs both this and the AI's `bosl2_docs` tool. A GitHub wiki is a git repo of markdown, so every page is readable raw at `raw.githubusercontent.com/wiki/BelfrySCAD/BOSL2/<Page>.md` — no HTML scraping. `AlphaIndex.md` is the whole name→location map (`- [`cuboid()`](shapes3d.scad#module-cuboid) Mod – synopsis`, one line per entry), parsed into `{lowercased name: Entry(name, kind, page, anchor, summary)}`; `Entry.url` composes `https://github.com/BelfrySCAD/BOSL2/wiki/<page>#<anchor>`, which is what `QDesktopServices.openUrl` gets. The wiki pages carry rendered example images, which is most of what makes them worth reading, so this leaves for the browser rather than rendering the markdown in-app.
Pages are cached under `~/.cache/BelfrySCAD/bosl2-wiki/` for a week (`_MAX_AGE`); a fetch failure falls back to a stale cached copy rather than failing outright, and only errors when there is no copy at all. The index fetch is synchronous under a wait cursor — one ~150 KB GET, once a week — with a `ponytail:` note marking the worker-thread upgrade if that freeze ever bites on a slow link. A miss shows a message box; an unreachable wiki shows the error.
`_section(markdown, matches)` extracts one heading's chunk (from the first heading the predicate accepts to the next heading at that level or above) and serves all three lookups: an entry's write-up out of its file page, a topic's entry list out of `Topics.md`, and — via `Tutorials.md`'s `[title](Tutorial-Page)` links — a tutorial page whole. Headings escape underscores (`### Function/Module: regular\_prism()`), so both the index and the heading matcher strip backslashes before comparing.
**Lexical "View as..."/"Edit as..."** (for a plain numeric literal under the cursor, independent of debug session state — unlike the debug-gated "View 'x'…" submenu above, which is keyed by identifier name and requires a paused debug session):
`contextMenuEvent` locates the enclosing `[...]` literal(s) at the click offset via `data_viewers.find_viewable_literals`/`find_editable_literals` (both plural), each backed by the shared `_iter_enclosing_literals` generator: it bracket-matches outward from the cursor, `ast.literal_eval`s each candidate span, and skips (but keeps walking past) any level that fails to parse — i.e. contains identifiers, calls, or OpenSCAD-only syntax like ranges or `true`/`false`/`undef`. This also means "numeric-only, no expressions" falls out of `ast.literal_eval` for free, with no extra grammar checking needed.
- **View as...** — `find_viewable_literals` (plural) matches each of List/VNF/Grid/Path/Region *independently* — a separate walk-outward search per shape, not one shared "first match wins" walk — returning `{shape: (start, end, value)}` for whichever match. This independence matters because `_is_list` is trivially true for any list: a shared walk would almost always resolve at the innermost bracket (e.g. clicking anywhere inside an outer path `[[0,0],[1,0]]` usually lands inside one inner point's own brackets), permanently starving "View as Path..." of a reachable click position — a real bug this shape caused, not just a cosmetic quirk. Each shape now finds its own appropriate level: "list" still resolves to the inner point, while "path" keeps walking out to the enclosing path, so both options show together. `build_lexical_view_menu` builds one action per matched shape (each from its own span/value), reusing the existing `_open_list_viewer`/`_open_vnf_viewer`/`_open_grid_viewer`/`_open_path_viewer` unchanged, non-modal. Works on read-only tabs too, since viewing doesn't mutate anything.
- **Edit as...** — `find_editable_literals` (plural, same independent-per-shape search as `find_viewable_literals`) matches each of Path/Grid/Matrix/Affine/VNF/Region independently, as `{shape: (start, end, value)}`. This independence matters here too: a grid's own row is itself a valid Path (a list of numeric points), so a single shared "first match wins" walk would resolve "path" (the row) before ever reaching "grid" (the whole structure) for *any* click inside a row — a real bug caught while writing a grid test file, not just a theoretical edge case. `build_editor_menu` builds one action per matched shape (each from its own span/value — its `on_commit(new_text, start, end)` closure is bound per-shape so Save writes back to the *right* span), opening the same viewer classes in a new **editable** mode (`editable=True`): cell edits (`itemChanged`) only update the dialog's own in-memory value and live preview (via the existing `load_path`/`load_grid`/`load_matrix` refresh hooks) — no source writeback, no render. An explicit **Save** button serializes the whole edited value via `data_viewers._format_value` — plain recursive formatting, always inline, with numbers rendered `f"{v:g}"` to match `debugger._fmt`'s convention. There is no line-wrapping: the value is emitted as a single `[...]` however long it gets, which stays readable at the dimensions these editable Matrix/Affine/Path/Grid values actually run to. (It previously built a synthetic `Assignment` AST and rendered it through the Python parser's width-aware `to_openscad` pretty-printer; the C++ backend exposes no pretty-printer, so that route is gone.) `_format_value` emits the result through the dialog's `committed` signal exactly once; **Cancel** discards everything. The dialog is **non-modal** (`.show()`, same as the read-only viewers) — not modal at all. Modal was tried first (both `setModal(True)`/`ApplicationModal` and `setWindowModality(Qt.WindowModality.WindowModal)`) to block editing the parent editor's source while the dialog is open, but *any* Qt-modal dialog turned out to suppress the main window's View-menu shortcuts for the dialog's own embedded viewport, regardless of modality flavor (Qt suppresses non-active-window `WindowShortcut`/`ApplicationShortcut`-context actions whenever any modal widget is active — see `docs/editor.md`'s "Keyboard shortcuts" note below). So instead, `data_viewers._lock_parent_editor_while_open(dlg, parent)` sets `parent.setReadOnly(True)` when the dialog opens and restores it (`False`) on the dialog's `finished` signal — achieving the same "don't let the source change out from under the tracked span" goal without touching Qt modality at all. Skipped entirely on read-only tabs.
- **Add data literal...** — the same editors, but *creating* a value rather than editing one. Offered only when the line under the cursor is an assignment with no value yet (`foo =`, optionally with a trailing `;`), which `find_empty_assignment` matches with a plain regex — `foo =` is a syntax error, so there is no AST to ask, and this has to work on exactly the half-typed line the parser rejects. It is the one branch "View as..."/"Edit as..." can never cover, since neither finds anything when there is no literal there. `build_new_literal_menu` adds one action per editable shape (Object/Path/Region/Grid/Heightfield/Matrix/Affine Transform/VNF) from `_NEW_LITERAL_SEEDS`, opening that shape's normal `editable=True` dialog on a **seed** value. Each seed is the smallest value its own `_is_*` predicate already accepts, deliberately at the acceptance threshold rather than something prettier: that is what guarantees the literal the editor writes back is immediately re-openable through "Edit as...", which a degenerate seed (an empty path, a 1x1 heightfield, an `object()` with no entries) would not be — `test_data_viewers.py` asserts every seed against its own predicate, and that every editable shape is offered so no type is silently uncreatable. Save replaces the **whole line**, not just the empty right-hand side, so the indent, the `=` spacing and the terminating `;` come out consistent however the half-typed line was left; the write-back goes through the same `replace_span` + `source_edited_externally` path as "Edit as...", so it is one undo step and triggers a re-render.
`CodeEditor.replace_span(start, end, new_text)` performs the actual source-text write-back when Save is clicked, then emits `source_edited_externally`, connected per-tab (in both `_create_and_add_tab` and `_new_document`) to `self._render(tab)` — the same "commit → render" pattern as a gizmo drag's mouse-up commit. `replace_span` doesn't need any custom undo handling: native Qt undo is disabled on `CodeEditor` (`setUndoRedoEnabled(False)`), and any document change already flows through `MainWindow._on_editor_changed` (connected to `document().contentsChanged`), which auto-captures a `_TextEditCmd` onto the app's own `QUndoStack` — exactly as it does for ordinary typing.
**Dialog title for lexically-launched viewers/editors**: the `title` string these dialogs are constructed with used to be a 30-char raw-text preview of the literal's own source (e.g. `text[start:end][:30]`). For a multi-line literal this was nearly useless — a window/menu title can't render an embedded newline, so a title like `"[\n [0, 0], ..."` visually truncates to just `"["` (the reported symptom: a dialog titled `Grid Editor: [`). Instead, `_literal_display_name(text, start)` looks backward (up to 200 chars) via `_ASSIGNMENT_NAME_RE` for a simple `name = ` immediately preceding the literal's start, and returns `name` if found. `build_lexical_view_menu`/`build_editor_menu`'s `_preview` helpers call this instead of slicing raw source text. Each viewer's `setWindowTitle` then omits the `": {title}"` suffix entirely when the name lookup comes back empty (e.g. an inline literal like `translate([10,20,30])`'s argument, which isn't a `name = ` assignment) — the title is just `"Grid Editor"` rather than a colon dangling with nothing useful after it.
**Closure bug caught by manual testing on `test26.scad`**: `build_lexical_view_menu`/`build_editor_menu` each build several `QAction`s in a row from the same local `start`/`end`/`value` variables (reassigned once per matched shape, e.g. once for `"path"`, once for `"grid"`). The outer per-action lambdas originally closed over those variable *names* rather than their values at the time each action was built (Python closures capture by reference, not by value) — since all the `if` blocks run immediately while the menu is being constructed, every action ended up reading whichever shape's `start`/`end`/`value` was assigned *last*, regardless of which menu item was actually clicked. Concretely: right-clicking inside a grid row in `test26.scad` (where both "Edit as Path..." and "Edit as Grid..." match, from independent spans) and choosing "Edit as Path..." opened `PathViewer` with the *grid's* nested-list value instead of the row's flat point list, crashing in `_make_vert_table` (`f"{p[j]:g}"` — `p[j]` was itself a point-list, not a number). Fixed by binding `start=start, end=end, value=value` as default arguments on each outer lambda, forcing early (per-action) capture — the same fix already applied to the inner `on_commit` lambdas' `s=start, e=end` binding, just missing from the outer ones.
## Code Completion
`QCompleter` with a `QStringListModel` provides prefix-based autocomplete. The popup appears after 2+ identifier characters are typed and hides when there are no matches or an exact match.
**Word list**: 96 built-in names (keywords, modules, functions, constants, and `$`-variables like `$fn`/`$fa`/`$fs`/`$t`/`$children`/`$vpt`/`$vpr`/`$vpd` from `_BUILTIN_WORDS`) plus user-defined names (including user `$`-variables) from the last successful `build_scopes()`. `update_user_names(scope)` extracts names from `scope.variables`, `scope.functions`, and `scope.modules` dicts; called from `_RenderCallback.on_ast_ready` after each successful parse.
**Key handling in `keyPressEvent`**: when the popup is visible, Enter/Tab accept the selected completion and Escape dismisses. Tab for indent only fires when the popup is not visible. Other keys pass through to the editor and then `_update_completer_popup()` refreshes the prefix/popup state.
`_text_under_cursor()` walks backward from the cursor to find the current identifier prefix (alphanumeric + underscore + leading `$`). `_insert_completion()` replaces the prefix with the selected completion.
## Colour swatch tooltips
Hovering a colour literal shows a tooltip with a swatch of it, the colour's
name, its `#rrggbb`, and the 0..1 components `color()` takes — the spellings
the literal itself does not show. `color_literals.py` is the whole of the
matching (Qt-free of widgets, so `tests/test_color_literals.py` tests it
directly); `CodeEditor._show_color_tooltip` is the wiring.
**Two shapes count**, both matched lexically on the hovered line, with no
AST behind them — this has to answer on half-typed lines the parser rejects:
- a double-quoted **name or hex** Qt resolves: `"red"`, `"SteelBlue"`,
`"#fff"`, `"#ffffff"`. Restricted to those two spellings on purpose — Qt
would also take `#aarrggbb` and `#rrrgggbbb`, which no OpenSCAD build
renders.
- a **3- or 4-element vector of literal numbers, every one in 0..1**, which
is OpenSCAD's own range. Components that are expressions or variables
never match: there is nothing to resolve without evaluating.
**And it has to sit somewhere that means a colour** (`in_color_context`),
which is the other half of the test — a colour and a point are the same
three numbers, and without this every `translate([0.5, 0, 0.2])` got a
swatch. Three positions qualify:
| | |
|---|---|
| An argument of `color()`, `recolor()` or `color_this()` | However deeply nested inside it, so `color(mix([1, 0, 0], t))` counts |
| The value of an assignment whose **name** says colour | `thecolor = [0.5, 0.75, 1.0];` yes, `p1 = [0.5, 0.75, 1.0];` no. Matched on `colou?r` anywhere in the name, either spelling, any case |
| A `color=`, `colour=` or `c=` **argument** of any call | `c=` is BOSL2's short spelling, and is an argument name only — a bare `c = [0.5, 0.5, 0.5];` says nothing about what it holds |
The assignment rule wants the literal to **be** the value: a palette's
members (`thecolors = [[1, 0, 0], [0, 1, 0]];`) are not each the value of
the assignment, so they get nothing. Inside `color(...)` nesting is fine,
since there the whole call is about colour however deep it goes.
`_blanked()` replaces string bodies and `//` comments with spaces (keeping
offsets) before that scan, so a `color(` written inside a string or a
comment is not read as structure.
**One line only.** A call split across lines (`color(\n "red")`) is not
matched — the price of answering with no AST, which is what lets this work
on lines the parser rejects.
**`QColor` is the name table deliberately.** The evaluator's own
`css_colors.cpp` was generated from a live PySide6 install, so asking Qt
keeps the tooltip and the renderer agreeing by construction — including on
`rebeccapurple`, which CSS3 has and neither of them does, and on
`transparent`, which both accept.
**On the viewport, not on `event()`** (`CodeEditor.viewportEvent`): a
`QAbstractScrollArea` delivers tooltip events to its viewport, so an
`event()` override never sees one. A miss hides the tooltip rather than
showing "not a colour", the same rule the argument hint follows.
The swatch is a table cell with `bgcolor`. That is the one way Qt's
rich-text subset will paint a block of colour: a `data:` image URI is not
loaded, and a styled `<span>` colours only as far as its text goes.
Widget-level behaviour — that the event reaches the viewport at all, and
that the position maps back to the right character — is covered by
`scripts/verify_color_tooltip.py`, since Qt widgets abort pytest here.
### Choose Color… (editing a colour literal)
`window/color_picker.py`. Right-clicking any literal the swatch recognises
(same finder, same one-line rule) offers **Choose Color…**, a top-level item
beside **Choose Font…**: a swatch, then the Color List's own table
(`color_list.ColorTable`, shared by both) of every name `color()` takes -- the
CSS names plus `rebeccapurple` and the 949 `xkcd:<name>`s, each with its
swatch, hex and rgb -- with its filter, **Sort by** menu (Name, Hue,
Saturation, Chroma, Lightness, Value, Luminance) and **Hide xkcd colors** box.
Selecting a row picks that colour, double-clicking one picks it and saves, and
the current colour's row stays selected (a hex typed or a native-panel colour
selects its name if it has one). Then a
**Hex** field taking `#rgb` or `#rrggbb`, and **Other Colors…** for the
platform's native colour panel (`QColorDialog.getColor`, with an alpha slider
only when the literal has an alpha). Save writes back through `replace_span` +
`source_edited_externally`, which renders; saving an unchanged colour writes
nothing.
**The literal keeps its shape** (`literal_for`, tested in
`tests/test_color_picker.py`):
- **A string** is written the way the colour was chosen: the name picked, or
the hex exactly as typed (`#fff` stays short). A colour from the native
panel becomes a name if the literal was a name and the colour has one
(`name_for`, first alphabetically where Qt has two), otherwise `#rrggbb`.
- **A vector** stays a vector of 0..1 components to three decimals, keeping
its alpha if it had four. Choosing `"red"` does not turn `[1, 0, 0]` into a
string: a `thecolor = [...]` may be indexed or `concat()`ed elsewhere.
`transparent` is left out of the name list: its swatch would be black. A
name literal shows its own name while the colour is unchanged, so
`"xkcd:dark mint"` and `"cyan"` (which shares `aqua`'s rgb) open as themselves.
Which string literals count as colours at all -- for the swatch, the picker and
**Choose Color…** -- is `color_names.parse_color`, the GUI's copy of OpenSCAD's
`parse_color` that openscad_cpp_evaluator >= 1.29.0 follows: CSS names,
`transparent` (alpha 0), `xkcd:<name>`, and `#rgb` / `#rgba` / `#rrggbb` /
`#rrggbbaa`, alpha last. Qt's own `#aarrggbb` reads alpha first, so hex is not
left to `QColor.fromString`.
## Undo/Redo
Code edits and gizmo drags are undo/redo-able via Qt's `QUndoStack`. Each operation is a `QUndoCommand` subclass:
- **Code edits**: `TextEditCommand` stores before/after document state and calls `QPlainTextEdit.setPlainText()` on undo/redo
- **Gizmo ops**: `GizmoCommand` stores before/after source text and re-triggers a render on redo
All Cmd+Z / Cmd+Shift+Z route through `QUndoStack`, which disables `QPlainTextEdit`'s built-in undo (`setUndoRedoEnabled(False)`).
**One stack per tab, in a window-wide `QUndoGroup`** (#540). `FileTab` owns
its `undo_stack`; `MainWindow._adopt_undo_stack` puts a new tab's stack in
the group, `_tab_changed` makes the visible tab's stack the active one, and
`_close_tab` takes it out again. The Undo/Redo actions come from the
**group** (`createUndoAction`), so they follow whatever tab you are looking
at, and every push goes through `_stack_for(tab)` — the tab the command is
about, which `_TextEditCmd` and `_GizmoCmd` already carried.
Before this there was a single window-wide stack, so undoing past the start
of one tab's history carried on undoing *another* tab's text, with nothing
on screen to say so. `addStack` does not make a stack active, which is why
`_adopt_undo_stack` activates the first one itself: a window's first tab can
be added before anything is listening for `currentChanged`. A tab never
moves between windows — `_tear_off_tab` closes it and opens a fresh one in
the new window — so a stack belongs to one group for life.
**Cursor positions are captured through `_cursor_position(editor)`, which never returns a negative offset.** `setPlainText()` replaces the whole document, and a `contentsChanged` handler running across that replacement can read a cursor still attached to the old one — a null cursor, whose `position()` is `-1`. Stored in the command, that `-1` came back on the matching redo as `QTextCursor::setPosition: Position '-1' out of range` (Qt refuses it and leaves the cursor where it was), and it also reached `_TextEditCmd.mergeWith`, which compares stored positions to decide whether a burst of edits is one undo step. `_set_cursor` clamps both ends as the last line of defence. `scripts/verify_undo_cursor_clamp.py` covers it.
**Where the cursor lands after undo** is where it stood when the undone edit began. `_TextEditCmd` takes that from the tab's `_last_cursor`, which `_on_editor_changed` writes at the end of every edit and, since #389, `_on_editor_cursor_moved` also writes on every cursor move that is *not* part of an edit. Qt emits `cursorPositionChanged` before `contentsChanged` for a keystroke, so that handler ignores a move whose document revision no longer matches the last recorded one — that move belongs to the edit about to be captured. Consecutive keystrokes merge into one undo step only while each begins where the previous one ended; move the cursor between them and the next edit is its own step, so one undo no longer throws away both. An undo whose target is off-screen centres it (`centerCursor()`); one already in view leaves the scroll alone. The parse-error squiggle (`set_error_location`, an ExtraSelection anchored in the document) is cleared on the first edit after it appears, because an anchored selection otherwise grows under everything typed inside it (#388).
**Both redo conventions are bound**, via `main_window.redo_shortcuts()`. Qt's `StandardKey.Redo` is a *list* that differs per platform — Ctrl+Y first on Windows, Ctrl+Shift+Z on macOS — and `setShortcut()` binds only the first of them, so Ctrl+Shift+Z did nothing on Windows (issue #377). `setShortcuts()` with the platform list plus an explicit Ctrl+Shift+Z and Ctrl+Y gives OpenSCAD/VS Code/Adobe/macOS habits and the Office one everywhere. Qt maps Ctrl to Command on macOS, so no special case is needed there.
**Editable literal-viewer dialogs** (`MatrixViewer`, `AffineMatrixViewer`, `VNFViewer`, `PathViewer`, `GridViewer`, `RegionViewer`, `ObjectViewer` — see Data Viewers below) each get their own separate, dialog-scoped `QUndoStack`/`QUndoCommand` via the shared `_UndoableViewerMixin` (`window/data_viewer_common.py`) — distinct from the main editor's document-level stack above, and only relevant up until the dialog's own Save/Cancel. Every mutation funnels through `_commit_value(new_value, label)`, a generic snapshot `_ViewerEditCmd` (before/after full-value copies — these values are tiny, so snapshotting is simpler than inverse-operation commands). A continuous gesture (a Cmd+drag on a vertex marker, or one keyboard nudge) is bracketed by `_begin_live_edit()`/`_end_live_edit()` around the viewport's `vertex_drag_started`/`vertex_drag_finished` signals, so live per-frame updates during a drag don't each push their own undo step — the whole gesture collapses into exactly one. Each dialog also offers a **Reset to Original** button (`_commit_value(self._original_value, ...)`, itself undoable); `MatrixViewer`/`AffineMatrixViewer` additionally offer **Reset to Identity**, since those are the only two where "identity" is a meaningful concept. No dedicated Undo/Redo buttons — Cmd+Z/Cmd+Shift+Z (`_undo_action`/`_redo_action`, added to the dialog via `self.addAction(...)` in `_setup_undo`) are always expected to be available regardless of any visible button, so `_make_reset_button_row` only builds the Reset button.
## Console Output
The console is `ConsoleWidget` (`window/console.py`), a `QTextBrowser` subclass. One instance exists per `MainWindow` (window-level singleton `self._console`) — there is no per-tab console. Plain log output goes through `MainWindow.log(text)` or `MainWindow.log_to_tab(tab, text)` (tab argument is ignored) → `self._console.append_output(text)`. Value-bearing output (from "Print to Console" or debugger return values) goes through `MainWindow.log_value_to_tab(tab, name, value)` or directly in `_on_debug_print_value` → `self._console.append_value(name, value, _pretty_assignment(name, value))`.
`QTextBrowser` is used (not `QPlainTextEdit`) because it handles cursor shapes natively: `PointingHandCursor` over anchor links, `IBeamCursor` over selectable text. `setOpenLinks(False)` prevents navigation; `anchorClicked` handles fold toggles.
`append_output(text)` and `append_value(name, value, text)` both route:
- **Single-line text** → `_append_plain(text)` — inserts a plain text paragraph via `QTextCursor`.
- **Multi-line text** → `_append_foldable(lines[0], '\n'.join(lines[1:]))` — inserts the first line as an HTML anchor `<a href="fold:N">▼ summary</a>` and the remaining lines as plain paragraphs. Clicking the anchor collapses or expands the block; `QTextBrowser` shows a hand cursor over it automatically.
`append_value` additionally stores `(name, value)` in `_fold_values: dict[int, tuple[str, object]]` keyed by fold_id, so right-clicking on any line of that block can launch a viewer.
Fold state is tracked in `_fold_headers: dict[int, tuple[int, int, int]]` (fold_id → (header_bn, first_body_bn, last_body_bn)) and `_folded: set[int]` (fold_ids currently collapsed). `clear()` resets all three dicts. Block visibility is toggled with `QTextBlock.setVisible()`; layout recalculation is triggered by updating the arrow character via `QTextCursor.insertText` (done after setting visibility so the `documentChanged` signal sees the correct state). The fold_id is a sequential integer assigned at append time and embedded in the anchor href.
**Console right-click context menu** (`MainWindow._console_context_menu`): `ConsoleWidget.value_at(pos)` checks whether the right-clicked position falls inside a fold header or body block that has a stored value (by scanning `_fold_headers` for block number, then looking up `_fold_values`). If found, a **View 'name'…** submenu is added via `build_viewer_menu()` before the **Clear Console** action.
The console displays:
- Parse errors (file/line/col from AST metadata)
- On each render: bounding box of the resulting mesh and current camera position
## Animation
The **Animate** dock (`AnimatePane` in `window/animate.py`, one per `MainWindow`) implements OpenSCAD's [`$t` animation](https://en.wikibooks.org/wiki/OpenSCAD_User_Manual/Animation):
- **Time / FPS / Steps** fields: Time shows the current `$t` (read-only display, but editable — typing a value jumps to the nearest step, clamped to `[0, 1 - 1/steps)`); FPS (1-1000) sets the playback rate; Steps (1-1,000,000) sets the number of frames in one cycle. `$t = step / steps` for `step` in `0..steps-1`. Tab/Shift+Tab move between these three fields, and Enter confirms an edit (`QLineEdit.editingFinished`, which fires on Return as well as focus-out). `AnimatePane` installs an event filter on each field to accept the `ShortcutOverride` event for Tab/Backtab — otherwise the main window's Indent/Undent actions (bound to Tab/Shift+Tab as window-wide shortcuts for the code editor) would consume the key before normal focus-navigation gets it.
- **Big play/pause button** and the **transport row** (First / Previous / Play / Pause / Next / Last) drive playback. Any non-playback transport action pauses playback first.
- **Dump Pictures** checkbox: when checked and Play is pressed, BelfrySCAD prompts (once per tab) for a destination folder via a folder picker, then saves each frame of one full animation cycle as `frameNNNN.png` (via `Viewport.grabFramebuffer()`), pausing automatically after frame `steps - 1` rather than looping.
Each frame change re-renders the current file with `$t` set accordingly — `MainWindow._viewport_params()` includes `"$t": self._animate_pane.current_t()`, merged into the evaluator's dynamic context alongside `$vpt`/`$vpr`/`$vpd` (see openscad_cpp_evaluator's `CLAUDE.md`). During playback the viewport camera is **not** auto-fit to the model's bounding box on each frame (unlike a normal Render), so the camera stays put across frames. Animation re-renders whichever file tab is currently visible in the editor.
## AI Chat
The **AI Chat** dock (`AIChatPane` in `window/ai_chat.py`, tabbed with Customizer/Animate, hidden by default, toggled from View → Show AI Chat) is a chat assistant that can read the user's installed OpenSCAD libraries and open scripts, and propose edits or new scripts.
**Providers** (`window/ai_providers.py`): either an OpenAI-protocol endpoint or Anthropic's native Messages API. Because the Base URL is configurable and the `Authorization` header is **omitted entirely** when no API key is set, any local OpenAI-protocol server works with no key — for **Ollama**, set Base URL to `http://localhost:11434` (a URL with no path gets the conventional `/v1` appended — typing the bare host otherwise fails with Ollama's unhelpful router-level `404 page not found`), leave the API key blank, and set Model to one with the **`tools` capability**. Verified end-to-end against a real Ollama server, including multi-round tool calling and the propose/review flow.
The tool capability matters: a model without it fails the whole request, since the pane always sends tools. Ollama reports that two different ways — a clear `does not support tools`, or an opaque Jinja traceback from its tool-parser generation (`Unable to generate parser for this template…System message must be at the beginning`) that names nothing actionable. `_http_error_message` detects both and leads with a plain-language explanation instead; it likewise names the attempted URL on a 404 and points at the API key on 401/403. Note that `tools` capability is necessary but not sufficient in practice — some small tool-capable models still answer in prose instead of emitting a tool call. Both are plain generator functions sharing `ChatMessage`/`ToolCall`/`StreamEvent` dataclasses and yielding the same event stream, so nothing downstream branches on provider. HTTP is stdlib `urllib` (no `requests` dependency); responses stream token-by-token over SSE. Each protocol's SSE decoding is a pure `_parse_*_sse_lines` generator over already-decoded lines, so tool-call argument accumulation — which arrives as JSON fragments across many chunks, keyed by array index (OpenAI) or content-block index (Anthropic) — is unit-testable without a network.
**Reaching Claude** (`resolve_anthropic_transport`) tries three things in order, so a key is often unnecessary:
1. **`ANTHROPIC_API_KEY`** in the environment → direct HTTP with that key.
2. **A `claude` CLI on `PATH`** → run it as a persistent coprocess (`window/ai_cli.py`). It carries its own subscription login, so this path needs **no API key at all**.
3. **A key entered in Preferences** (OS keychain) → direct HTTP.
The CLI path is a `claude -p --input-format stream-json --output-format stream-json --verbose --include-partial-messages --replay-user-messages` process kept alive for the whole conversation: state lives in the CLI, so only the new message is sent each turn (never the accumulated history), and `Clear` ends the session. Its `stream_event.event` payloads are ordinary Anthropic streaming events, so text deltas are read by the same logic as the direct-API path.
Tools reach it through **`window/ai_mcp.py`**, a minimal MCP server the app hosts in-process on `127.0.0.1` (ephemeral port), wired up with `--mcp-config` + `--strict-mcp-config` and an `--allowedTools` list naming exactly our six `mcp__belfryscad__*` tools. The CLI's *own* `Read`/`Write`/`Edit`/`Bash` are explicitly disallowed: they can't see unsaved editor buffers, and `Write`/`Edit` would apply changes straight to disk, bypassing the review dialog. `--system-prompt` *replaces* the CLI's default prompt, which also keeps any personal `CLAUDE.md` persona out of the CAD assistant. Only the slice of MCP that Claude Code exercises is implemented (`initialize`, `notifications/initialized`, `tools/list`, `tools/call`); tool-level failures come back as ordinary content with `isError`, not JSON-RPC errors, so the model can read and correct them. On this path the CLI runs the tool loop itself, so `tool_use` blocks are translated to report-only `tool_running` events that drive the activity line and are never re-executed locally. Cancelling drops the whole process, since the CLI has no mid-turn cancel over stdin.
**Settings** live in Preferences → AI as a **Service** dropdown of known providers (Claude, ChatGPT, Gemma/Gemini, Kimi, Ollama, Custom — `ai_providers.PRESETS`, each with a verified base URL). Base URL, model and API key are all stored *per preset* (`ai/baseUrl/<id>`, `ai/model/<id>`, and the keychain under the preset id), so several services can be configured at once and switching between them keeps each one's key.
The Model field is an editable combo with a **Fetch** button that lists the server's `/models`. Where capabilities are known — Ollama reports them via its native `/api/tags`; the OpenAI-protocol `/models` endpoint does not — models **without tool support are omitted from the list entirely**, since the pane always sends tools and such a model fails the whole request rather than merely working less well. Remaining entries are marked `(vision)` when they can also use `view_viewport`. The marker is display-only: the real id is kept in the combo's item data (`_current_model_id`) so it never gets saved as part of the model name. The field stays free-text for servers that implement neither endpoint. Non-secret settings (base URL, model name) go through the normal live-save `QSettings` path; **API keys go to the OS keychain** via `window/ai_secrets.py` (a thin `keyring` wrapper), never `QSettings`. Every keyring call swallows `KeyringError`, so a machine with no working backend degrades to "no key set" rather than breaking the dialog.
**Tools** (`window/ai_tools.py`): `list_library_files`, `read_library_file`, `list_open_scripts`, `read_open_script`, `propose_script_edit`, `propose_new_script`, `view_viewport`, `describe_geometry`, `read_console`, `schedule_followup`. Tool specs carry plain JSON Schema, which is what both OpenAI's `function.parameters` and Anthropic's `input_schema` want, so each tool is defined once and each provider's request builder wraps it differently.
Every path-accepting tool enforces two independent guards, each with its own message so the model can tell them apart: **`.scad` files only** (the model can neither read nor write any other file type), and — for library reads — **containment** inside the library directory, via `is_path_within`, which generalizes the same resolve/`is_relative_to` check `main_window.py` already uses to mark opened library files read-only.
**Threading**: one `_AIWorker` per user turn, moved to its own `QThread` and reporting back only through signals — the same pattern `library_manager.py`'s download/update workers use, including the `thread.quit()`/`wait(3000)`/`deleteLater()` teardown on the `done` signal. The worker runs the whole agentic loop (stream → run any tool calls → feed results back → repeat, capped at `_MAX_TOOL_ROUNDS`) without ever touching a Qt widget: `MainWindow._on_ai_send` builds a frozen `AIToolContext` of plain `TabSnapshot` data **on the GUI thread** first, exactly as `_RenderWorker` receives plain source text rather than reaching into the editor. A snapshot is therefore per-turn — an edit made mid-turn isn't visible until the next one.
**Markdown transcript**: the reply pane is a `QTextBrowser` rendered with `setMarkdown`, so lists, tables, emphasis and fenced code blocks come out formatted. Markdown can't be rendered a token at a time, so a reply *streams in as plain text* and is re-rendered as Markdown once complete (`_render_transcript`, which also folds in the in-progress reply whenever anything else forces a rebuild mid-stream). A full Markdown pass per token would be far too slow. Our own status lines are Markdown-escaped (`escape_md`) since file names can contain `_` and `*`; model output deliberately is not, as rendering it is the point.
**Activity line**: a greyed status line under the transcript shows what the pane is waiting on (`Thinking…`, `Looking through your libraries…`, `Preparing an edit…`) with a live elapsed-seconds count, cleared the moment the first reply token arrives and hidden when idle. This matters most when the model is slow to first token, which a modest machine running a local model can be: on one 16 GB M1, a library-listing turn against Ollama on a 9B Q4 model spent **66s** before the tool call appeared and a further **38s** of silence before the reply started streaming. (That figure is one data point on one machine, not a general cost of local inference — better hardware is far quicker.) With no indicator the pane simply looks hung, and the elapsed count is the only thing distinguishing "slow" from "stuck". Raw tool names are deliberately kept out of the transcript (they're an implementation detail); `_TOOL_ACTIVITY` maps each to a plain-language phrase for this line instead.
**Reading the console** (`read_console`): the render's own errors, warnings and `echo()` output — where a failed render explains itself, usually with a line number. Captured on the GUI thread with the rest of the snapshot and capped to the last 200 lines / 20 KB (`MainWindow._console_tail`), since a long session's console runs to megabytes and only the latest render matters; a `(earlier output omitted)` marker is prepended when clipped. Pairs with a `when="render"` follow-up: apply an edit, wait for the render, then read why it failed.
**Measuring the model** (`describe_geometry`): volume, surface area, bounding box, triangle count and **genus** (through-holes) per part and overall, straight from Manifold. This answers what neither the source nor a picture can — "is it one solid or three", "does it actually have a hole", "how big is it really" — and needs no vision support. Computed on the GUI thread in `MainWindow._geometry_summary` and carried as plain text, since Manifold objects are never handed to the worker thread.
**Seeing the model** (`view_viewport`): hands the model a PNG of the rendered 3D view, so it can judge shape, proportion and orientation rather than only reading source. `grabFramebuffer()` needs the GL context, so the grab happens on the GUI thread in `MainWindow._capture_viewport_png` alongside the text snapshot, and is scaled to 1024px on the long edge (`ai_tools.image_to_png`) — a retina viewport is far larger than any model needs and costs prompt tokens for nothing. Needs a vision-capable model; verified end-to-end against a local one, which correctly reported a test shape's colour and proportions.
The tool takes a `view` argument (`current`, `front`/`back`/`left`/`right`/`top`/`bottom`/`iso`), plus `azimuth`/`elevation` to aim the camera anywhere (an explicit angle wins over a named view; elevation is clamped to ±90), and `projection` (`perspective`/`orthographic`), `axes` and `edges` to override display settings for that one image — orthographic in particular is far better for judging true proportions and alignment. Every setting touched is restored in a `finally` block, including when the capture raises, so an AI-requested image never disturbs the user's own camera or display; the caption states what was used, since a model can't tell an orthographic view from a perspective one by eye — one image from one arbitrary angle is a poor way to read a 3D shape, which is why engineering drawings use several. Anything but `current` is rendered on demand: the worker thread calls `MainWindow._capture_view_threadsafe`, which posts the request to the GUI thread (only it may touch GL) and blocks on an `Event` until it's serviced. No deadlock is possible because the GUI thread never waits on the worker. The camera is moved, grabbed and restored inside a single GUI-thread callback, and `grabFramebuffer()` renders offscreen — so the visible viewport never flickers.
How the image is delivered differs by transport, because **neither chat protocol allows an image inside a tool result**: the OpenAI and Anthropic paths answer the tool call with a short text acknowledgement and attach the picture as the *following user message* (`ChatMessage.images`, rendered as `image_url`/data-URI or a base64 `image` block respectively). MCP *does* allow image content in a tool result, so the `claude` CLI path returns it directly with no follow-up message.
Note `QBuffer(QByteArray())` segfaults here — the temporary is freed immediately and the buffer is left pointing at it, which crashed on every capture until `QBuffer()` (which owns its byte array) was used instead.
**Scheduled follow-ups** (`schedule_followup`): the model can ask to be prompted again either after a delay (5s–1h) or — with `when="render"` — **as soon as the next render finishes**. The render trigger is the one that pairs with an applied edit: change it, render, then look, since by then the viewport grab and `describe_geometry` both reflect the new model. It's released from `MainWindow._on_render_thread_done` (not `_on_render_done`, which still runs while the worker tears down), then held a further `_RENDER_SETTLE_DELAY` (5s) before firing — enough for the viewport to settle, and a visible window in which the user can still cancel rather than the next turn starting the instant a render lands (a second render during that window doesn't restart the countdown), and backed by a `_RENDER_WAIT_TIMEOUT` so a render that never comes drops the follow-up instead of leaving a zombie in the bar. Delay-based follow-ups are for continuing a task in steps or checking on something slow. Because a model that can schedule itself can also loop forever against a paid API, three guardrails apply: the pending follow-up is **always visible and cancellable** in its own countdown bar, at most `_MAX_CHAINED_FOLLOWUPS` (5) turns may chain without the user saying anything (any typed message resets the counter), and a due follow-up **waits rather than interrupting** a turn already in flight. Passing `delay_seconds: 0` cancels. Clearing the conversation drops it too.
**Permission modes** (a dropdown beside the provider, persisted as `ai/mode`), mirroring Claude Code's. Only proposals are gated — reads, renders and measurements can't damage anything:
| Mode | Changes | Follow-ups |
|---|---|---|
| **Plan** | refused; the model describes them in prose instead | capped |
| **Manual** (default) | every change reviewed as a diff | capped |
| **Accept Edits** | applied immediately | capped |
| **Auto** | applied immediately | uncapped — may loop unattended |
Plan mode is enforced in `propose_script_edit`/`propose_new_script` themselves, not in the UI, because the claude-CLI transport calls those same handlers over MCP with no Qt code in the loop to gate anything. The refusal is checked before other validation, since the mode is why nothing will happen either way. Applied-immediately changes still go through `replace_span`, so Undo takes them back like any other edit. "Capped" is `_MAX_CHAINED_FOLLOWUPS`; Auto is the only mode that lifts it, which is what makes it genuinely autonomous rather than just quieter.
**Review before apply**: `propose_*` tools never mutate anything. They compute a `difflib` unified diff, hand a `Proposal` to the pane (queuing it in the review bar and logging the diff to the transcript as a collapsible block), and return a synthetic result telling the model the change is queued for the user, so it can finish its turn normally without the stream having to block on a UI click. The actual mutation happens out-of-band when the user clicks Accept: `MainWindow._on_ai_proposal_accepted` applies edits through `replace_span` + `source_edited_externally` — the same path "Edit as..." uses, so it lands as one clean undo step and triggers a re-render (Reformat Selection shares `replace_span` but not the render — see above). An accepted *new* script opens as an unsaved tab (`file_path = None`); nothing is ever written to disk unprompted.
Each `FileTab` carries a `chat_id` from a monotonic counter (not `id(tab)`, whose reuse after a tab closes could let a stale tool-call reference silently hit a different tab). Ids are in-memory only — chat sessions don't outlive the app.
## Customizer
`CustomizerPane` (`window/customizer.py`) mirrors OpenSCAD's Customizer: it scans the active source for top-level variable assignments with simple literal values and generates form widgets. Widget changes write back to the source immediately; a render then follows automatically 2 seconds after the user stops editing fields (`MainWindow._customizer_render_timer`/`_CUSTOMIZER_RENDER_DELAY_MS`, a single-shot `QTimer` restarted — not merely extended — on every `source_changed` emission, so it only fires once editing has genuinely paused; see CLAUDE.md's "Render Triggers"). If a render is still in flight when another field changes, it's cancelled immediately (same `_render_cancel`/`_set_render_busy(False)` idiom as pressing Escape) rather than left to run to completion on source the new edit has already superseded — the debounced render still fires 2s later as usual. Pressing F6 still renders immediately at any time, same as always. The pane's **Automatic update** checkbox (issue #397, OpenSCAD's "Automatic Preview") gates only the debounced render: unticked, a field change still writes the value back to the source and cancels any stale in-flight render, but nothing new starts until Render/F6 — what you want while tuning a model that takes minutes per render. Persisted as `customizer/autoUpdate` (default on).
**Parameter scanning** (`scan_parameters(source)`): reads lines top-to-bottom, tracking brace depth to skip nested scopes. A line comment (`// text`) immediately above an assignment becomes that parameter's description. Tab-group block comments (`/* [TabName] */`) group subsequent parameters; `/* [Hidden] */` suppresses all following parameters until the next group. Qualifying assignments have a name that is not a keyword, and a value that parses as one of: `int`, `float`, `bool` (`true`/`false`), `"string"`, or a vector literal `[a, b, c, d]` of 1–4 numeric elements.
`$`-prefixed names qualify too, except `$vp*` — a top-level `$fn = 32;` is tuning the model exactly as `wall = 2;` is, and every path from the widget map through `write_back_value`/`insert_parameter`/`replace_parameter`/`delete_parameter` is keyed by name string (`re.escape(name)` where a regex is involved), so nothing downstream needed changing for the `$`. OpenSCAD's own Customizer hides them; this is a deliberate divergence. `ParameterEditorDialog` still refuses to *create* one — its create-path check is `_valid_param_name(name) and name.isidentifier()`, and `str.isidentifier()` is False for `$fn` — so only `$vars` the script already declares ever appear. The name check is skipped entirely when editing (renaming is out of scope there), so Edit/Delete work on them unchanged.
`$vpr`/`$vpt`/`$vpd`/`$vpf` are excluded by a `name.startswith('$vp')` test (prefix, not a fixed set, so any future `$vp*` is covered): they describe the viewport, not the model, and the camera is already both ends of that loop — `_viewport_params()` injects the live camera into every render as `$vp*`, and `_apply_vp_params(final_vp)` applies the evaluated result back to it. A control for one would be editing a value the next render overrides.
**The `Global` tab group** (`/* [Global] */`) follows real OpenSCAD Customizer convention: `_rebuild_ui` pops it out of the per-tab grouping and prepends its parameters to every other tab instead of giving it a tab of its own; if Global is the only group present, its parameters fall back to the default single tab. Because a Global parameter ends up with one widget instance per tab it's mirrored into, `_widgets` maps each parameter name to a *list* of widget instances rather than a single one — `set_source`'s value-sync fast path and `_on_widget_changed` (editing any one copy) both update every instance in that list, so all visible copies of a Global parameter always agree.
**Constraint syntax** (trailing `// comment` on the assignment line):
| Constraint | Widget | Notes |
|---|---|---|
| `[max]` | Slider | range 0…max |
| `[min:max]` | Slider | range min…max |
| `[min:step:max]` | Slider | explicit step |
| `[a, b, c]` | Dropdown | numeric or string values |
| `[a:Label, b:Label]` | Dropdown | values with display labels |
| `N` (integer) | Text field | max length N (strings only) |
| (none, bool) | Checkbox | |
| (none, number) | Spin box | step auto-derived from decimal places |
| (none, vector) | Row of spin boxes | one per element |
**Write-back** (`write_back_value(source, name, new_value)`): re-scans to find the parameter's line number, then does a regex substitution on that line to replace the value literal, preserving any trailing `// constraint` comment.
**Structural equality optimisation**: when the source changes but the set of parameters (names, tabs, descriptions, constraints) is unchanged, only widget values are updated — the full UI rebuild is skipped.
**Parameter sets ("presets")**: a combo box + Save As.../Update/Delete row above the tab widget lets the user snapshot every current parameter's value under a name, persisted to a `<file>.json` sidecar next to the `.scad` file (`preset_path_for`). Selecting a preset re-applies its values through the same widget-set/write-back path as manual edits, so it participates in the normal debounced auto-render. Save As... prompts for a name via `QInputDialog`; Update overwrites the selected preset with current values; Delete removes it after a `QMessageBox` confirmation. All three are disabled when the active tab has no saved file path (the sidecar path has nowhere to point) or, for Save As, no parameters.
Values are stored as OpenSCAD literal-syntax strings (e.g. `"width": "10"`, `"name": "\"foo\""`) using the same `parse_literal`/`format_value` round-trip the rest of the Customizer uses — plain-text-diffable, and readable by real OpenSCAD's own Customizer/`-p`/`-P`. The literal-syntax helpers and the preset file I/O (`preset_path_for`/`load_presets`/`save_presets`) live in `belfryscad/scad_literals.py`, not `window/customizer.py` itself, specifically so the CLI's `-p`/`-P` handling (`main.py`) can reuse them without pulling PySide6 into the Qt-free headless export path (`belfryscad.headless`'s own module doc comment covers why that matters).
**CLI parity**: `belfryscad -o out.stl -p params.json -P Big model.scad` loads `Big` from `params.json` and applies it the same way the GUI would, before any `-D` overrides are appended — `-D` wins over a same-named preset value, matching real OpenSCAD's "-D overrides Customizer" precedence, because both are appended-prelude top-level assignments and `-D` is appended after the preset values (see `belfryscad.headless._prepare_source` for the append-based `-D` mechanism this reuses). `-p` and `-P` must be given together; an unknown preset name lists the presets that *do* exist in that file.
**Integration** (`main_window.py`): `_customizer_pane.set_source(text)` is called on tab switch and on every editor keystroke for the active tab; `set_file_path(tab.file_path)` is called alongside it on tab switch and after Save/Save As, so the preset sidecar always tracks the active tab's actual saved path. `source_changed` signal updates the editor text and sets the modified flag (preserving cursor position) without triggering a render.
## Keyboard Shortcuts
Standard platform conventions apply throughout. Custom shortcuts:
| Key | Action |
|---|---|
| Cmd+1 | Toggle Show Edges |
| Cmd+2 | Toggle Show Axes |
| Cmd+3 | Toggle Show Crosshairs |
| Cmd+4 | Top view |
| Cmd+5 | Bottom view |
| Cmd+6 | Left view |
| Cmd+7 | Right view |
| Cmd+8 | Front view |
| Cmd+9 | Back view |
| Cmd+0 | Isometric view |
| Ctrl+Cmd+1 | Toggle Spin |
| Ctrl+Cmd+2 | Toggle Perspective |
| Ctrl+Cmd+3 | Cycle Stereo: Off → Cross-eye → Red-Blue Anaglyph |
| Shift+Cmd+V | View All |
| Shift+Cmd+0 | Center (origin back to the middle) |
| Cmd++ | Increase editor font size |
| Cmd+- | Decrease editor font size |
| Ctrl+Tab / Ctrl+Shift+Tab (also Cmd+Shift+] / [ on macOS) | Show next / previous tab |
| Cmd+Shift+C | Copy the viewport image to the clipboard |
| Option+wheel (Alt+wheel on Windows/Linux) | Step the number at the text cursor: the digit just left of the cursor moves, so the step is 1, 0.1, 10… by its place; decimals, leading zeros and a unary sign kept, one undo step per notch (`number_scroll.step_number`, OpenSCAD's number scroll; a binary minus is not taken as the number's sign, unlike OpenSCAD). No render follows -- edits never render |
| Cmd+wheel (Ctrl+wheel on Windows/Linux) | Zoom editor text, one point per wheel notch; a trackpad's small deltas add up to whole notches (`CodeEditor.wheelEvent` / `step_font_size`, shared with Cmd++/Cmd+-; floor 6 pt; this editor only, not saved) |
| Cmd+[ | Zoom Out |
| Cmd+] | Zoom In |
| Tab | Indent line/selection |
| Shift+Tab | Unindent line/selection |
| Option+Up / Option+Down | Move line/selected lines up or down (Edit ▸ Move Line Up / Move Line Down) |
| Cmd+F | Find |
| Shift+Cmd+F | Find & Replace (Ctrl+H also, off macOS) |
| Cmd+E | Export… |
| F5 | Debug: Continue / Pause |
| Shift+F5 | Debug: Stop |
| Shift+Cmd+F5 | Debug: Restart |
| F6 | Render |
| Shift+F6 | Debug |
| F7 | Animate |
| F10 | Debug: Step Over |
| F11 | Debug: Step Into |
| Shift+F11 | Debug: Step Out |
| Escape | Cancel in-progress render |
## Application Preferences
Preferences live under the `editor/`/`viewport/` key groups in `QSettings("BelfrySCAD", "BelfrySCAD")`, accessed via `load_preference(key, type)` / `save_preferences(dict)` in `preferences.py`. `PreferencesDialog` is organized into **Editor** and **Viewport** tabs (`QTabWidget`) and has no OK/Cancel — only a **Close** button. The Viewport tab's **Cut faces** box (`viewport/keepMinuendColor`, off by default) makes `difference()` paint the faces it exposes with the colour of the object they were cut from instead of the cutting object's colour (or the cut green when it has none, which is OpenSCAD's rule); it is the option OpenCSG cannot offer and upstream declined (openscad/openscad#4798), reached here through `Evaluator(keep_minuend_color=True)`. The rule is baked into the geometry, so toggling it re-renders the current design (`_apply_preferences`). Every control saves its value and calls the `on_change` callback (`MainWindow._apply_preferences`, passed in by `_open_preferences()`) immediately on change (`currentTextChanged`/`valueChanged`/`toggled` → `PreferencesDialog._emit(key, value)`), so edits are reflected live in every open tab/viewport rather than waiting for the dialog to close.
`PreferencesDialog` and `ColorThemeManagerDialog` (opened from its Color Theme dropdown's "Manage Color Themes..." entry) are both **non-modal** (`.show()`, never `.exec()`), opened via the same reused-singleton-window convention as `_open_library_manager`/`LibraryManagerWindow` (`if not hasattr(self, '_preferences_dialog') or ... is None: create it, connect destroyed to reset the attr; then .show()/.raise_()/.activateWindow()` — repeat opens re-raise the existing window rather than creating a duplicate). This isn't just a style choice: any Qt-modal state (`QDialog.exec()`) suppresses the main window's cross-window View-menu shortcuts (Cmd+0–9 view presets etc., even with `ApplicationShortcut` context already set on those actions) for any `Viewport` nested inside the modal dialog — confirmed by this project's own prior fix for the same issue in the data-viewer "Edit as..." editors (`feedback_qt_modal_vs_menubar_shortcuts` session memory: two different modality/shortcut-context workarounds both failed there too; dropping modality was the only fix that worked). `ColorThemeManagerDialog`'s live preview (`_vp`) hit the identical bug once it started embedding a real `Viewport` — plus a separate, related symptom (the main viewport visibly blacking out after closing the modal chain on macOS) that also went away once modality was dropped. `ColorThemeManagerDialog` takes an `on_close` callback (invoked from its `closeEvent` override) since there's no more `dialog.exec()` return to hang "refresh the dropdown, re-apply preferences" logic off of, plus an `on_selection_changed(name)` callback fired on every `QListWidget.currentItemChanged` in the Manager (not just at close) — `PreferencesDialog` wires both to the same shared `_sync_active_theme(select=None)` (repopulate the dropdown, keeping `select` — or, if `None`, whatever's already showing — selected; then `_emit` to save+apply it), so picking a different theme in the Manager's list updates the Preferences dropdown, and the whole app's live theme, immediately while browsing rather than only once the Manager closes. The close-time call (`_on_theme_manager_closed`, `select=None`) is a defensive final sync on top of that live path, catching anything that wouldn't otherwise produce a selection-change signal. `ColorThemeEditorDialog` (the small Name+4-swatches New/Copy/Edit popup, no embedded `Viewport`) stays modal (`.exec()`) — it's a simple blocking form with nothing to lose by staying modal, and no viewport shortcuts to suppress.
| Setting | Key | Default | Tab |
|---|---|---|---|
| Font family | `editor/fontFamily` | `"Menlo"` | Editor — every installed monospaced family (`font_list.fixed_pitch_families`), plus the saved value if this machine lacks it |
| Font size | `editor/fontSize` | `13` | Editor |
| Indent size | `editor/indentSize` | `4` | Editor |
| Show column guide | `editor/showColumnGuide` | `True` | Editor |
| Column guide column(s) | `editor/columnGuide` | `"80"` | Editor — comma-separated, e.g. `67, 100` |
| Eye separation (IPD) | `viewport/viewerIPD` | `65.0` | Viewport |
| Screen distance | `viewport/viewerScreenDist` | `600.0` | Viewport |
| Stereo depth scale | `viewport/stereoDepthScale` | `0.75` | Viewport |
| Color theme | `viewport/colorTheme` | `"Cornfield"` | Viewport |
`MainWindow._apply_preferences()` reads all settings and pushes them to every open tab via `_apply_preferences_to_tab(tab, font, indent, show_guide, guide_col)`, and to every open `Viewport` (color theme, IPD, screen distance, stereo depth scale). Called on startup (end of `_restore_settings()`), on every live preference change, and by `_new_document()`/`_create_and_add_tab()` so new tabs inherit current settings immediately.
**Color theme**: the Viewport section's "Color theme" dropdown (`preferences.py`) lists every theme in `color_themes.all_themes()` — the 13 built-in themes in `color_themes.COLOR_THEMES` merged with any user-created custom themes. Each theme is a 4-key dict of RGBA tuples: `background`/`object`/`axes` lifted from OpenSCAD's built-in render color schemes (`background`, `cgal-face-front`, and `axes-color`; "Cornfield" is a manual entry — OpenSCAD's classic default scheme — with `axes` hardcoded to black rather than taken from a JSON file), plus `unselected_vertex` (labeled just "Vertex" in the theme editor UI) — the color of a data-viewer dialog's not-currently-selected vertex markers (`PathViewer`/`GridViewer`/`VNFViewer`/`RegionViewer`'s `_build_point_markers`), a field with no OpenSCAD equivalent — each built-in theme picks its own hand-chosen value (mostly cyan `(0.0, 0.9, 0.9, 1.0)`, with a few themes using a golden-orange `(1.0, 0.75, 0.0, 1.0)` or ClearSky's orange `(1.0, 0.5, 0.0, 1.0)` instead, for contrast against that theme's own background/object colors). `_apply_preferences()` looks up the selected theme via `all_themes()` and sets `renderer.bg_color`, `renderer._default_color`, `renderer.axes_color`, and `renderer.unselected_vertex_color` on every open `Viewport`, including data-viewer dialogs, then calls `vp.update()` to repaint immediately. The first three are resolved fresh every `paint()`/`_render_axes()`/`_get_label_texture()` call rather than baked into uploaded geometry: `MeshBuffer.color` stores the `ColoredBody`'s explicit `color()` override or `None`, and the draw loop substitutes `renderer._default_color` at draw time when it's `None`; `axes_color` only recolors the negative-axis lines/ticks and tick labels (the "gray" elements) — the positive X/Y/Z axis lines and ticks keep their fixed red/green/blue. `unselected_vertex_color` is read once per marker-rebuild (zoom, selection change), not per-frame, matching how marker geometry itself is only rebuilt on those triggers. Axis-label textures are cached by `(text, axes_color)` so a theme change invalidates stale-colored labels. All theme fields take effect on the next repaint without needing a full re-render.
`_apply_preferences()`'s sync loop only ever reaches *already-open* data-viewer dialogs (`hasattr(w, '_vp')`) — a dialog opened later, after the last preference change, would otherwise be stuck on `SceneRenderer`/`Camera`'s bare constructor defaults (`bg_color`/`_default_color`/`axes_color`/`unselected_vertex_color` a fixed yellow-on-gray/cyan, and `fov` hardcoded to `45.0` in every data-viewer viewport's own `__init__`, vs. the main window `Camera`'s own default of `22.5`) rather than the user's actual current theme/FOV — same-looking object size (`Camera.frame_bounds` compensates distance for FOV so the shape still fills the view), but a visibly different tick-label density and spacing, since `_nice_spacings`/`_axis_density` key off the raw `camera.distance` that a different FOV produces for the same framed shape. `data_viewer_common._sync_viewport_to_main_window(vp)`, called once from each of the five data-viewer dialogs' `__init__` right after constructing `self._vp`, closes this gap: color theme is read directly from the persisted preference via `all_themes()` (works even with no `MainWindow` open), FOV is copied from whichever `MainWindow` viewport is currently open (a no-op fallback to the viewport's own default if none is).
**Custom color themes**: user-created themes are stored as one JSON blob under the `colorThemes/custom` preference key (`color_themes.load_custom_themes()`/`save_custom_themes()`) — the first *structured* (non-flat) value `preferences.save_preferences()` has ever stored, though the function itself is unaware of that; it's just a JSON string like any other preference value. `color_themes.is_builtin(name)` distinguishes the 13 immutable built-ins (Python literals, never editable/deletable) from custom ones; `unique_theme_name(base, existing)` appends an incrementing ` 2`, ` 3`, ... suffix to avoid name collisions (used for New/Copy default names and Import). A trailing "Manage Color Themes..." entry in the Color theme dropdown itself (after a separator, added by `_reload_theme_items`) opens `ColorThemeManagerDialog` — a `QListWidget` + button row (New/Copy/Edit/Delete/Import/Export), broadly the same list-plus-buttons shape as `LibraryManagerWindow` (`library_manager.py`) rather than a new UI pattern; built-in rows are suffixed `" (built-in)"` and their Edit/Delete buttons stay disabled. Picking "Manage Color Themes..." is intercepted in `_on_theme_combo_changed` before it ever reaches `_emit` (so the sentinel text itself never gets saved as `viewport/colorTheme`); once the manager closes, `_reload_theme_items` repopulates the dropdown and re-selects the actually-active theme (falling back to the persisted preference if the just-closed sentinel selection is what it would otherwise land on). New/Copy/Edit all open `ColorThemeEditorDialog` (name field + 4 `_ColorSwatchButton` rows — a thin-black-outlined color rectangle plus its `#RRGGBB` hex text, click opens `QColorDialog`); Save rejects an empty or colliding name. Import/Export work on one theme at a time as a `.json` file (`{"name": ..., "background": [...], "object": [...], "axes": [...], "unselected_vertex": [...]}`), via `QFileDialog`. Closing the Manager dialog always reloads the Preferences dropdown and re-applies preferences, in case the active theme was edited, renamed, or deleted while it was open.
A `QSplitter` puts the list on the left and a live preview, `_ColorThemePreview(Viewport)`, on the right, each the splitter's only direct child so both get exactly the same height — the New/Copy/Edit/Delete/Import/Export buttons and the Close button all live together in one full-width row *below* the splitter instead (so Close lines up vertically with the CRUD buttons rather than sitting in a separate row underneath them; an earlier version stacked the CRUD row under the list *inside* the splitter's left half, which left the list shorter than the preview, since the preview — alone in its half — filled the whole splitter height while the list's own button row ate into its half only). The preview shows a real rendered `Viewport`, not a static swatch mockup — a simple octahedron (`_diamond_faces`, the same octahedron already used for `_PathViewport`'s "same_angle" bezier marker) with dodecahedron vertex markers at its 6 corners, updated to whichever theme the list currently has selected (`_update_preview`, wired to `QListWidget.currentItemChanged`). The octahedron mesh is uploaded once, at first GL-ready, with `color=None` — it already tracks the live `renderer._default_color` fresh every draw (same as any other data-viewer mesh), so switching themes needs no re-upload for the mesh; only `bg_color`/`axes_color`/`unselected_vertex_color` are reassigned directly. Vertex markers bake their color into the uploaded triangle data like every other marker in the app, so `apply_theme` does rebuild those (`_rebuild_markers`, via `_apply_colors_now`) on every selection change. Following the same `makeCurrent()`/`doneCurrent()` convention as every other data-viewer GL-mutating method (see below): `apply_theme`'s own `_ctx is not None` branch brackets its call into `_apply_colors_now`, since *that* path runs from a plain `QListWidget.currentItemChanged` handler, not this widget's own paintGL cycle — without the bracket there, the GL calls would mutate whatever OTHER GL widget's context happened to be currently bound (typically the main window's viewport) instead of its own. `_load_mesh`'s own call into the identical `_apply_colors_now`/`_rebuild_markers` chain does **not** get that bracket, deliberately — it runs inside `initializeGL()`, where Qt has already made this widget's context current and keeps relying on that after `initializeGL()` returns; a stray `doneCurrent()` there was a real, previously-shipped bug that corrupted native Qt/window bookkeeping without crashing immediately, only surfacing later as a `SIGBUS` alignment fault deep inside Qt's own key-event handling on a subsequent, seemingly-unrelated keypress. `apply_theme` is called synchronously during `ColorThemeManagerDialog.__init__` (list population selects a row immediately), well before the widget's GL context exists — since `Viewport.schedule_load` only remembers one pending callback, `apply_theme` doesn't call it directly (that would silently clobber the mesh-upload callback already registered in `_ColorThemePreview.__init__`, and the octahedron would never appear); it just records the requested colors in `self._pending_colors`, and the mesh-upload callback applies them itself once it finally runs. Markers are sized smaller than the app's real ~12px-on-screen convention (`_MARKER_RADIUS = 0.08`, a fixed world-space value rather than `_marker_radius_for_point`'s screen-space-constant sizing, since this preview's camera framing never changes) and set `renderer.depth_test_points = True` — off by default on `SceneRenderer`, and without it markers on the far side of the octahedron would draw right through the solid mesh instead of being occluded by it, the same fix `GridViewer`/`VNFViewer` apply for their own vertex markers.
`CodeEditor.set_indent_size(n)` stores `_indent_size` and updates `tabStopDistance`. Indent/unindent logic lives in `CodeEditor._indent_lines()`/`_unindent_lines()`, called from `keyPressEvent` and the Edit menu actions. Both handle single-line and multi-line selections. **Shift+Tab always unindents; Tab only indents when there is a selection** -- with none it inserts spaces to the next stop from the cursor's own column, since indenting the line the cursor happens to sit on is not what Tab means in an editor (#433).
`CodeEditor._move_lines(delta)` (Option+Up/Down, handled in `keyPressEvent` ahead of the Key_Down end-of-document handling so Option+Down on the last line moves it rather than appending a blank one) moves the current line, or every line the selection touches, by one. It rewrites the affected lines in a single `replace_span()` so the app's undo stack sees one change rather than a delete plus an insert, and re-seats the cursor/selection on the moved text. Lines move verbatim -- nothing is re-indented, so the opposite move is always an exact inverse.
All three share `CodeEditor._selected_block_range(cursor)`, which returns the block numbers a cursor covers and applies the rule that a selection ending exactly at a block start does not include that block.
**Auto-indent on Return**: the new line inherits the current line's indentation, plus **one** level (one, however many are open) when `unmatched_open_brackets()` reports anything still open in the text *before the cursor*. Counting before the cursor rather than over the whole line is deliberate: text after it moves down too, so it is the depth at the split that decides where the new line sits.
That replaced a heuristic asking whether the line *ended* with `{`/`[`/`(` or *started* with `function`/`module`, which missed a line broken mid-call (`foo(a,`) and fired on complete statements (`function f(x) = x + 1;`).
`unmatched_open_brackets()` is a module-level scanner, not a regex: brackets inside strings and comments must not count, and a closer with nothing to match *on that line* is ignored rather than counted negative — that last part is what keeps `} else {` indenting, where a net count would give zero. `ponytail:` it is line-local, so a `/*` opened on an earlier line is not known about; tracking that needs the highlighter's block state and is not worth it until someone hits it.
**Indentation moves to a stop, never by a fixed amount.** `_next_indent_stop()`/`_prev_indent_stop()` back Tab, Shift+Tab, Backspace-in-leading-whitespace and the `}`/`]`/`)` auto-unindent. A line whose indent is not a multiple of `_indent_size` has extra spaces past the last stop; those come off one at a time, and a whole level only ever moves stop to stop. Previously all four added or removed exactly `_indent_size` regardless of column, so an off-grid line stayed off-grid whichever key was pressed — reported as backspace on an extra space unindenting a whole level.
**`BELFRYSCAD_DEBUG_MODIFIED=1`** (issue #394, a Windows-only report of a read-only library file opening already marked modified, via file association only): with that variable set, the first time a tab flips to modified the console and stderr get who did it — a Python stack — and where the text first differs from what was loaded (`MainWindow._log_modified_diagnostic`). A diagnostic, not a feature: it exists because nothing in the load path is platform-specific and the bug does not reproduce elsewhere.
**Open in the running instance** (`app/openInRunningInstance`, Preferences ▸ Editor, on by default, issue #393): a second launch with a file on its command line — a double-click in a file manager — hands the path to the BelfrySCAD already running over a per-user `QLocalServer` (`single_instance.py`: one absolute path per line, then disconnect) and exits before building a window; the running window opens it as a tab and raises itself. If nothing is listening, the new process becomes the listener. macOS already routes a Finder double-click to the running app as a `FileOpen` event, so this mostly matters on Windows and Linux. Never under `--testing`, which must not reach into a real session. A second launch with *no* file still opens a new window. **File ▸ Open** accepts several files at once, one tab each (#392).
**Current line highlight**: a faint full-width band behind the cursor's line (`CodeEditor._update_current_line`, an `ExtraSelection` with `FullWidthSelection`, first in `_refresh_extra_selections` so coverage, errors, find hits, the debugger's line and bracket matches all paint over it). Its colour is derived from the editor's own background -- `darker(106)` on a light one, `lighter(135)` on a dark one -- so it suits any theme and is re-derived on a light/dark switch. **On by default**, as in OpenSCAD (`editor/highlightCurrentLine`, Preferences ▸ Editor ▸ "Highlight the current line").
**Down arrow on last line**: pressing Down on the last line appends an empty line and moves the cursor to it, so the user doesn't need to move to the end and press Return to extend the file. **Off by default** (`editor/appendLineOnDownArrow`, Preferences ▸ Editor): every other editor stops at the end of the file, and a document that grows while you navigate it is a surprise, not a convenience, unless you asked for it (issue #378). Kept rather than removed because it was deliberate. **It never fires while a modifier is held** whatever the preference says — Shift+Down at the last line has to stop there like dragging and Shift+PageDown already do, and inserting a line instead both edited the document the user was only selecting and dropped the selection they had built.
The Preferences action uses `QAction.MenuRole.PreferencesRole` so Qt places it in the macOS application menu (Cmd+,).
**Word Wrap** is a checkable Edit menu item stored at the top level in `QSettings` under `"wordWrap"` (default `False`), not under `editor/`. Persisted/restored in `closeEvent`/`_restore_settings` using the same `blockSignals` pattern as `perspective`, and applied via `_apply_word_wrap_to_tab(tab)` (also called for new tabs in `_new_document` and `_create_and_add_tab`).
## Syntax Highlighting
`OpenSCADHighlighter(QSyntaxHighlighter)` applies keyword/type/number/string formatting via regex rules processed per block in `highlightBlock()`.
Comments and brackets are *not* regex-driven. They share one left-to-right pass, `_scan_line(text, in_comment)`, which returns the line's comment spans, its bracket positions, and whether a block comment is still open at end of line. They have to share it: a bracket inside a comment or string is text, not nesting, and a regex pass over a single line cannot know that — a `{` inside a `/* ... */` that opened three lines earlier must not shift the depth colours.
Multi-line `/* ... */` block comments use `QSyntaxHighlighter` block state tracking (`previousBlockState`/`setCurrentBlockState`) so highlighting persists across lines. The state packs two things: nesting depth in the high bits, "inside a block comment" in bit 0. Both `//` line comments and `/* */` block comments render in the same green (`#6A9955`).
### Bracket depth colours
`()`, `[]` and `{}` take one of five colours by nesting depth, so a pair shares a colour. The cycle steps two places at a time round the hues sorted by angle, so adjacent depths land on opposite sides of the wheel rather than walking along it.
| depth | colour | | hue |
|---|---|---|---|
| 0 | `#C4921C` | dark goldenrod | 42° |
| 1 | `#59D798` | spring green | 150° |
| 2 | `#C3ACFA` | violet (pastel) | 258° |
| 3 | `#8BCD5C` | green | 95° |
| 4 | `#54A5DE` | blue | 205° |
The magenta/pink band is deliberately empty, because a saturated pink reads as red-adjacent next to the unmatched-bracket red below however far round the wheel it technically sits — hue angle alone passed a rose at 330° and then an orchid at 310°, and both had to be replaced. Every colour but the goldenrod is at least 95° from red; the goldenrod is dark and yellow-side and has never been the one confused.
Vacating a third of the wheel is what stops the ring being an even 72° apart: an even five-way split always lands some hue within 36° of red, whichever way it is rotated. So adjacent depths are 108°–163° apart rather than a uniform 144°.
A stray closer clamps at depth 0 rather than driving it negative, so one typo cannot recolour the rest of the file.
### Unmatched brackets
Any bracket without a partner is drawn in bold red (`#FF2D2D`) instead of a depth colour — an opener the document never closes, **and equally a closer that closes nothing**.
This cannot be decided per line — the closer may be thousands of lines below — so `_rescan_unmatched()` walks the whole document on `contentsChanged` and records `{block number: {columns}}` of openers left on the stack at end of document. `highlightBlock()` reads that back for its own line. Only the lines whose verdict changed are repainted, and a `_rescanning` guard stops those repaints re-entering the scan.
The scan runs on every edit rather than debounced: it measures 15ms on BOSL2's `skin.scad` (5400 lines), and a delayed scan would leave the red on a stale line number, so typing above an unclosed bracket would drag the mark down the screen a beat behind the cursor. If a file ever makes that felt, the upgrade path is caching per-block bracket lists and rescanning only edited blocks.
An unmatched opener still counts towards the depth, so the brackets that *do* pair below it keep the colours they had.
Pairing lives in the module-level `pair_brackets()`, split out of the document walk so it can be pytest-covered without building a `QSyntaxHighlighter`. It returns every unmatched `(key, pos)`, where the document scan passes a block number as the key. A closer fails three ways, and all three go red: nothing open to close, a **crossing**, or an opener still open at the end.
**Crossings.** Bracket kind used to be deliberately *not* checked when popping, so any closer popped any opener and `( [ { ] ) }` looked entirely fine. It is checked now. When a closer's own partner is open but something else was opened after it, the two spans interleave rather than nest, and there is no honest answer to which open bracket it should have closed — so everything still open is flagged with it and the stack is cleared. Without clearing, `{`/`}` in that example would pair up around the wreckage and only four of the six would show. Clearing also lets a well-formed section *after* the damage match normally instead of reddening the rest of the file.
⚠️ **`highlightBlock()` has to consult the unmatched set in BOTH branches.** The closer branch did not, so a stray `}` was correctly flagged by the scan and then painted the ordinary depth colour anyway — the bug survived a test suite that stopped at the scan, and was only caught by the user looking at the screen. `tests/test_editor_brackets.py::TestScanAndPaintAgree` pins the invariant; the painted colour itself is verified by a throwaway script reading back `block.layout().formats()`, since widget instantiation crashes the pytest runner.
## Appearance (Light / Dark Mode)
Most widgets follow the system palette on their own. The ones that paint or
style *themselves* do not, and every one of those had its colours picked
against a light appearance — so on a Mac in Dark Mode they came out
illegible. `window/ui_colors.py` is where all of them now get their
colours.
| what | was | dark mode |
|---|---|---|
| console text | black, whatever the palette said | palette foreground |
| editor gutter | `#CCCCCC` bg / `#000000` text | `#3A3A3A` / `#FFFFFF` |
| gutter fold arrows | `#606060` | `#B0B0B0` |
| debugger execution line | `#FFFF88` | `#4A3D00` |
| table headers (`QHeaderView`) | `#e8e8e8`, no text colour | `#3A3A3A` bg, `#E0E0E0` text |
| toolbar / debug / anim icons | SVG assets inked `#444` | re-inked `#D0D0D0` |
Two of those need explaining.
**Console text was black because QTextDocument makes it black.** A
document's default character format is black *regardless of the palette* —
measured, with the palette reporting `#e0e0e0` text on a `#252526` base
while every block still resolved to `#000000`. Anything inserting into a
document has to set the colour itself, which `_plain_fmt()` now does per
append. The fold headers are inserted as HTML and set it inline, because
`a { color: inherit }` in a document stylesheet inherits that same black.
**Icons are re-inked in the SVG source, not the rendered pixmap.** Every
asset under `resources/icons` is drawn in `#444`/`#444444` (checked across
the set; the `#ffffff`/`#d1d1d1`/`#111111` that also appear are inside
Inkscape's `<sodipodi:namedview>` and never rendered), so a flat string
substitution before `QSvgRenderer` recolours them all and keeps the shapes
vector-clean. Recolouring the rendered pixmap instead — what
`viewport.py`'s `_recolored_icon_pixmap` does for its always-white overlay
icons — fringes antialiased edges with the old ink.
Colours were chosen by measurement, not eye: `#D0D0D0` ink scores 8.93
contrast on a `#2D2D2D` toolbar against the 8.24 the assets' own `#444444`
scores on `#ECECEC`, i.e. the same perceived weight rather than a harsh
white. The execution-line shade is a trade-off pinned from both ends —
darker reads the code better but sinks the band into the editor
background — and `tests/test_ui_colors.py` asserts both bounds so neither
can be tuned away by accident. **Light mode is deliberately byte-for-byte
unchanged**; it was never the bug.
### Switching appearance while running
`ui_colors.on_appearance_change(owner, callback)` re-themes live. Which Qt
signal to hang this on was measured, because the obvious approach silently
never fires:
- A widget's `changeEvent()` receives **`PaletteChange`**, *not*
`ApplicationPaletteChange`.
- An event filter installed on the `QApplication` **does** receive
`ApplicationPaletteChange` addressed to the application object — one
central hook for the whole app.
- `QStyleHints.colorSchemeChanged` is connected as well, since that is what
a real macOS appearance switch emits; `setPalette()` alone does not
trigger it, so it cannot be the only hook.
Both paths run the same idempotent callbacks. Registration lifetime is tied
to `owner`, so nothing unregisters explicitly; entries are pruned when the
owner dies, covering both Python garbage collection and C++-side widget
destruction (which raise differently).
Anything that paints — the gutter, the syntax view — just asks at paint
time and needs no registration. Registration is for state that outlives the
call: a stylesheet, a `QIcon` already handed to an action, or text already
sitting in a document. `_style_table_headers()` registers the table itself,
so every table in the app follows a switch just by calling it as before.
Two subtleties in the implementation:
- **Console back-text.** New appends colour themselves; text written before
the switch does not. A document-wide `mergeCharFormat` touches only the
foreground, leaving the fold anchors' hrefs — and so the fold toggles —
intact.
- **Buttons that swap icons.** Continue/pause and play/pause change icon at
runtime. `apply_themed_icon()` stores the path on the target and
registers exactly one callback that re-reads it; registering per call
would pile up stale callbacks, each restoring whichever icon it captured.
## Startup Behavior
Opens with a single blank untitled document. When opening a file while the only tab is an empty, unmodified "Untitled" tab, that tab is replaced by the new file tab (in `_create_and_add_tab`).
## File Opening
Files can be opened in several ways:
- **File > Open** — standard file dialog
- **File > Recent Files** — reopens previously opened files; stale entries are auto-pruned
- **Drag and drop** — `.scad` files dropped onto the window open as new tabs
- **macOS file association** — `.scad` files opened from Finder (double-click or Open With) send a `QFileOpenEvent` to `BelfrySCADApp`, which forwards to `MainWindow.open_file_by_path()`; the Info.plist declares `CFBundleDocumentTypes` for `.scad` via briefcase config in `pyproject.toml`
- **Command-line arguments** — `BelfrySCAD foo.scad bar.scad` opens each file at launch
All paths converge on `open_file_by_path(path)`, which resolves the path and checks for an already-open tab before creating a new one.
## Quit Behavior
When the user quits the app and there are modified editors open, a Save/Discard/Cancel dialog is shown for each unsaved tab. Cancel aborts the quit.
## GUI Layout
```
┌────────────────────────────────────────────────────────────────────────┐
│ [New][Open][Export] | [Undo][Redo] | [Render][Debug][Animate] | [T][R][S] │ ← toolbar
├──────────────────────────────────────────────────────────────────────────┤
│ [file1 ×][file2 ×][file3 ×] │ ← tab bar (full width)
├──────────────────────┬────────────────────────────────┬───────────────┤
│ │ │ Debugger │
│ │ 3D Viewport [cube] ├───────────────┤
│ Code Editor │ (central widget) │ Animate│Cust │
│ (left dock) │ │ (tabbed) │
│ ├────────────────────────────────┘ │
│ │ Console │
├──────────────────────┴─────────────────────────────────────────────────┤
│ Viewport: translate=[0,…] rotate=[55,…] dist=50.0 FoV=22.5 (1024 × 768) 0 FPS │ ← status bar
└────────────────────────────────────────────────────────────────────────┘
```
- **Toolbars**: three, sharing the top row, each hidden on its own from the View menu (the first two as OpenSCAD's editor and 3D view toolbars are). **Editor** (`MainToolBar`, the old single toolbar's name, so a saved layout that hid it still does): New, Open, Save, Export | Undo, Redo | Render, Debug. **3D View** (`ViewToolBar`): Top, Bottom, Left, Right, Front, Back, Isometric, View All, Center. **Tools** (`ToolsToolBar`): Perspective, Spin, Stereo, Animate | Measure Distance, Measure Angle -- Perspective, Spin and Stereo are the View menu's own checkable actions, so button and menu tick always agree. Icons are 28 px. All are movable and floatable -- drag a handle to any edge of the window or off it -- and the saved window layout remembers where they went. The menu toggles are the toolbars' own `toggleViewAction()`s, so they stay ticked correctly after a saved layout is restored
- **Tab bar**: its own full-width `QToolBar` ("TabBarToolBar"), by default directly under the toolbars and movable along the top or bottom edge only (a `QTabBar` in a vertical toolbar stays horizontal and becomes a sliver; it is not floatable, since a floating tab strip is detached from the pages it switches), spanning the entire window regardless of how the Editor dock is sized/positioned — not confined to the Editor dock's own width, so it stays usable with many tabs open. Toggle via View ▸ Show Tab Bar (hides the whole strip, not just the tab widgets). See "Detached tab bar" below for how this is implemented without an editor-dock content widget.
- **3D Viewport** (central widget): single 3D viewport shared across all editor tabs; always shows the last render result; contains a cube gizmo for view angle control
- **Editor dock** (left, full height): holds only the editor *pages* (no visible tab bar of its own — see below); toggle via View ▸ Show Editor
- **Debugger** (right dock, top): visible by default; right dock owns all four corner pixels so it spans the full window height
- **Animate / Customizer** (right dock, bottom — tabbed together): both hidden by default; Animate opens via toolbar (F7) or View ▸ Show Animate; Customizer opens via View ▸ Show Customizer
- **Console** (bottom dock): single running log per window; not per-tab
- **Status bar**: bottom strip; four camera labels displayed as `Viewport: translate=[…] rotate=[…] dist=… FoV=…` (translate at 2 dp, others at 1 dp), each with a tooltip ("Viewport Translate ($vpt)", "Viewport Rotation ($vpr)", "Viewport Distance ($vpd)", "Viewport FOV ($vpf)") and a right-click context menu with **Copy $vp\*** (copies the single variable, e.g. `$vpt = [0.00, 0.00, 0.00]`) and **Copy all $vp\* values** (copies all four as semicolon-terminated OpenSCAD assignments, one per line); a viewport size label `(W × H)` in logical pixels (updated via `size_changed` signal from `resizeGL`); and an FPS counter. All four `$vp*` values are passed to the evaluator as OpenSCAD special variables via `_viewport_params()`. Default FOV is 22.5°. If the script assigns any `$vp*` variable, `_apply_vp_params()` updates the camera after render and skips the normal auto-fit; the same update fires on every debugger pause so the viewport tracks the script's camera state while stepping.
The editor, console, debugger, animate, and customizer panes are `QDockWidget` instances — dockable to any side or floatable, with position/visibility persisted via `QSettings("BelfrySCAD", "BelfrySCAD")` (`saveState()`/`restoreState()`). Object names: "EditorDock", "ConsoleDock", "DebuggerDock", "AnimateDock", "CustomizerDock". All four corners are owned by the side docks (left owns top-left/bottom-left, right owns top-right/bottom-right), so the bottom dock is constrained between them. `setDockNestingEnabled(True)` allows docks to be split within an area; `setAnimated(False)` works around a Qt crash in `QVariantAnimation` during drag-to-tab operations. On first launch (no saved `windowState` matching `_LAYOUT_VERSION`), `showEvent` fires a deferred `_set_default_layout` call: editor dock ≈ 30% width, right dock ≈ 25% width, bottom dock ≈ 17% height, right dock split 60/40 between debugger and customizer/animate. The Debugger pane is a single shared widget on `MainWindow` (not per-tab).
**Detached tab bar**: `_DetachedTabBar(QWidget)` (in `main_window.py`) is a `QTabWidget`-compatible facade splitting a plain `QTabWidget` into two independently-placed pieces: a `QTabBar` (`self.tab_bar`) and a `QStackedWidget` (`self._stack`, which *is* `_DetachedTabBar`'s own layout content). `MainWindow` puts `_stack` inside the Editor dock exactly where a real `QTabWidget` used to sit (`self._editor_dock.setWidget(self._tabs)` is unchanged), but places `tab_bar` in a separate `QToolBar` ("TabBarToolBar", movable along the top or bottom edge) added via `addToolBarBreak()` right after the main toolbar — toolbar areas always span the full window width above the dock/central-widget region, unlike the Editor dock, which is only as wide as its own dock area. `_DetachedTabBar` implements only the `QTabWidget` methods `MainWindow` actually calls (`addTab`, `removeTab`, `widget`, `count`, `currentIndex`/`setCurrentIndex`, `currentWidget`/`setCurrentWidget`, `indexOf`, `setTabText`, `setMovable`, `setTabsClosable`, `setDocumentMode`, `tabBar`) plus `currentChanged`/`tabCloseRequested` signals forwarded from the internal `QTabBar` — so none of `MainWindow`'s ~60 other call sites needed to change. Dragging a tab to reorder it (`setMovable(True)`) fires `QTabBar.tabMoved`, which `_DetachedTabBar` mirrors into the stack via `removeWidget`/`insertWidget` so page order stays in sync with tab order. View ▸ Show Tab Bar toggles `_tab_bar_toolbar.setVisible` directly (not `tabBar().setVisible()`) so the whole strip collapses rather than leaving an empty toolbar row.
Scale markers are tick marks along the viewport axes showing distance units (Show Scale Markers), each labeled with its distance value. Labels are rendered in 3D as camera-facing textured billboards: each tick's number is rasterized to an RGBA texture (cached by string) and drawn on a small transparent quad positioned just past the tick, so labels respect depth (occluded by geometry in front of them) and scale with zoom like the tick marks themselves. An axis whose line is nearly end-on to the camera has its tick labels suppressed (its ticks would otherwise overlap near the origin); as an axis approaches that cutoff, labels (and minor ticks) thin out first — see `_axis_density` in `docs/rendering.md`. Show Edges renders the full triangulation wireframe via `GL_POLYGON_OFFSET_FILL` on the solid pass (pushes fill surfaces away from camera), then draws edges at true depth in a second pass — avoids z-fighting on coplanar faces while keeping hidden edges correctly occluded. Show Crosshairs draws four diagonal lines (the four space diagonals of a unit cube) crossing at the camera target, each extending `camera.distance * 2.5 / 12`, colored with the current theme's `axes_color` (same as the negative-axis lines/ticks) rather than a fixed color, so it reads correctly against every background theme. Perspective/orthographic toggle uses `camera.orthographic`, persisted in QSettings. Also directly clickable in the viewport itself: `Viewport._persp_btn`, a small icon button fixed in the upper-left corner (`view-perspective.svg`/`view-orthographic.svg`, `resources/icons/`, recolored white via `_recolored_icon_pixmap(..., prefix="view")` for contrast against the dark translucent button background — same treatment as the debug-pause overlay), showing the *current* mode and toggling to the other on click. `Viewport.perspective_toggled` (new/changed state) and the public `refresh_perspective_icon()` keep the button and the View menu's "Perspective" checkbox in sync in both directions — clicking the button updates the menu (`MainWindow._on_viewport_perspective_toggled`, signal-blocked to avoid re-entering `_toggle_perspective`) and toggling the menu/shortcut/restored-preference path updates the button (`_toggle_perspective` calls `vp.refresh_perspective_icon()` after flipping `camera.orthographic`).
**Restoring settings to checkable actions**: wrap `setChecked()` in `blockSignals(True/False)`, then call the handler explicitly — avoids double-invocation (signal + explicit call) and ensures the handler fires even if the stored value matches the default. Pattern used in `_restore_settings`:
```python
self._act_perspective.blockSignals(True)
self._act_perspective.setChecked(perspective)
self._act_perspective.blockSignals(False)
self._toggle_perspective(perspective)
```
**New tabs only need editor settings**: `FileTab` contains only the `CodeEditor`. There is no per-tab viewport or camera. `_new_document()` and `_create_and_add_tab()` call `_apply_preferences_to_tab(tab, font, indent, ...)` to inherit editor font/indent/guide settings and `_apply_word_wrap_to_tab(tab)` for word wrap. The `hasattr(self, '_act_word_wrap')` guard in `_new_document()` covers the case where it's called during `__init__` before `_setup_menus()` finishes.
**`FileTab` vs `DocumentTab`**: the old `DocumentTab` owned a `Viewport`, `ConsoleWidget`, `AnimatePane`, and tools strip. These are now window-level singletons on `MainWindow`. `FileTab` keeps only: `editor`, `file_path`, `is_modified`, `root_scope`, `_last_text`, `_last_cursor`, `_suppress_text_undo`.
**Cross-window sync**: `DocumentManager` (singleton in `window/document_manager.py`) tracks all open editors keyed by resolved file path. `_create_and_add_tab` calls `register(path, editor)`; `_close_tab` / `_write_file` call `unregister`. `_on_editor_changed` calls `broadcast_change(path, text, source_editor)` after each text change — other editors for the same file have `setPlainText(text)` called with signals blocked to avoid re-entrancy. `open_file_by_path` queries `get_current_text(path)` to seed a newly opened editor with in-memory (potentially unsaved) text from another window.
**Unsaved-changes prompt**: `_confirm_unsaved(tab)` is the single place that shows the "Save changes to X?" `QMessageBox` (Save/Discard/Cancel) for a modified tab, shared by `_close_tab` (closing one tab) and `closeEvent` (closing the window, looping over every tab). Returns `True` if it's fine to proceed (no unsaved changes, discarded, or saved successfully) or `False` if the caller should abort (Cancel, or a failed save) — `_close_tab` just returns early on `False`; `closeEvent` additionally calls `event.ignore()` so the window stays open. `MainWindow.skip_unsaved_prompts` (default `False`) is a testing-only escape hatch: when `True`, `_confirm_unsaved` returns `True` immediately without ever showing the modal, so a script that constructs a `MainWindow` directly can close tabs/the window without blocking on a dialog it has no way to click through programmatically.
**`--testing` mode**: `belfryscad --testing` bundles the two things a test run needs. It implies `--no-save-prompts` (above), and it makes every settings change evaporate when the process exits — preferences, recent files, window layout, AI config. Current settings are still *read*, so the app behaves like the real install; only the writes are thrown away. One line goes to stderr at startup naming the scratch file, so a run that mysteriously forgets a preference is self-explaining.
The mechanism is `belfryscad/settings.py`: `app_settings()` is the single constructor every settings read/write in the GUI goes through, and `use_scratch_settings(tmpdir)` copies the real settings into a temp `.ini` and points `app_settings()` at it (`main._isolate_settings` makes the temp dir and registers an `atexit` cleanup). Qt's own `QSettings.setDefaultFormat()` looked like the zero-refactor hook — flip the default format, `setPath` it at a temp dir, and every existing two-argument `QSettings("BelfrySCAD", "BelfrySCAD")` follows — but it **does not work on macOS**: measured, `defaultFormat()` duly reports `IniFormat` while `fileName()` still ends in `Library/Preferences/com.belfryscad.BelfrySCAD.plist`. The two-argument constructor keeps resolving to the native store, so there is no global switch to flip and the 26 call sites had to route through something this project owns instead. `test_settings_isolation.py` greps the source for a stray `QSettings("BelfrySCAD", ...)`, since one would silently opt that single setting out of testing mode with nothing else to catch it.
## Data Viewers
One module per viewer, `src/belfryscad/window/data_viewer_<name>.py` (`_list`, `_profile`, `_object`, `_matrix`, `_affine`, `_vnf`, `_vnf_tile`, `_path`, `_grid`, `_heightfield`, `_region`), with helpers used by more than one of them in `data_viewer_common.py`. `data_viewers.py` holds what opens them: literal detection (`find_viewable_literals`/`find_editable_literals`), the `_open_*` factories and the menu builders. `ListViewer` and `ObjectViewer` open nested viewers from their own context menus, so they import from `data_viewers` inside that method -- it imports them, and a top-level import both ways would be a cycle. Viewer dialogs for inspecting evaluated data, opened from the debugger's variable context menu via `build_viewer_menu()`, or lexically from any numeric literal in the editor via `build_lexical_view_menu()`/`build_editor_menu()` (see "Lexical 'View as...'/'Edit as...'" above) — `PathViewer`/`GridViewer`/`MatrixViewer`/`AffineMatrixViewer`/`VNFViewer`/`RegionViewer`/`ObjectViewer`/`HeightfieldViewer` additionally support an `editable=True` constructor mode for the latter.
### `ObjectViewer` and geometry objects
`ObjectViewer` is the odd one out in two ways.
**An object is a CALL, not a bracket literal**, so it cannot ride on `_iter_enclosing_literals`/`ast.literal_eval` like every other editable shape. It has its own span finder (`_find_object_call`), argument parser (`_parse_object_call_args`) and text renderer (`_render_object_call`). Two details there are easy to get wrong, and both are pytest-covered:
- Translating OpenSCAD's `true`/`false`/`undef` to Python **cannot be a `str.replace`** — that turns the string `"undefined"` into `"Noneined"`. `_scad_literal_to_python` walks the text and only rewrites whole words found outside quotes. It returns `(ok, value)` so a legitimately-parsed `None` (OpenSCAD `undef`) is distinguishable from a parse failure, which also yields `None`.
- A duplicate key **overwrites in place, keeping its position** — `object(a=42, b=1, a=99)` is `{ a = 99; b = 1; }`, checked against the reference binary. Remove-and-append would be *delete* semantics, and the difference is observable because `ValueObject` is insertion-ordered and `oscEqual` is order-sensitive.
**Editing is offered only for all-literal calls.** `_parse_object_call_args` returns `None` for anything else, and both `find_editable_literals` and `find_viewable_literals` gate on that. `object(other, [["b"]])` depends on `other`, whose contents are unknowable from source text, so rewriting the call would silently destroy the reference. Offering the *lexical* object view for such a call was a shipped bug: the viewer opened with no rows at all. `_open_object_viewer` now also refuses to open with nothing to show, so an empty window cannot appear by another route. Such objects remain fully inspectable at runtime, where the debugger supplies resolved values.
**`HeightfieldViewer` edits a rectangular 2D array of scalar heights** —
BOSL2's `heightfield()` data argument — as a table of numbers beside a 3D
surface of the same values. It fills a real gap between its neighbours:
`_is_grid` wants rows of *points*, one nesting level deeper, and `_is_matrix`
wants a *square* 2x2..5x5, so a 30x40 array of heights matched neither and
had no editor at all.
The surface reuses `_GridViewport` rather than growing another viewport: a
heightfield *is* a grid of points once the coordinates the array only implies
are filled in (x = column, z = the height, and y counting *down* from the
top), so the mesh, the markers, the framing and vertex picking all come for
free.
Row 0 is the **top** of the field, matching the texture convention and the
row order of every raster format — so an imported picture reads the same way
up as its source. Mapping the row index straight onto y instead put row 0 at
the near edge and drew every field upside-down. Only the y *value* is
flipped, not the order of the nested lists, so which vertex belongs to which
cell is unchanged.
Editing is constrained to height. A heightfield stores one number per cell
and x/y *are* that cell's position in the array, so `_GridViewport` gains a
`z_only` flag rather than being denied `editable=True`: a Cmd+drag moves the
vertex up and down and leaves its place alone, and the arrow keys nudge with
Up/Down only — Left/Right fall through to the viewport's own key handling,
since sideways is not a height change.
A nudge steps the **stored** height by 0.5, 0.05 or 0.005 (Shift, nothing,
Cmd), whatever exaggeration the preview is showing — sized for a 0..1
field, so the coarse step crosses it in a couple of presses and the fine
one still shows in the table's three decimals. The shared
`_key_nudge_magnitude` returns 10/1/0.1, sized for geometry, where a
1-unit step leaps past a 0..1 field entirely; `_HEIGHT_NUDGE_STEPS` maps
those onto heightfield-sized ones and `z_nudge_scale` cancels the
exaggeration the writeback divides back out. `_emit_moved` also skips its
usual three-decimal rounding in `z_only` mode: that rounds a *world*
coordinate, so a small step was quantised at 25% and vanished outright at
smaller scales. The dialog rounds the stored value instead, which is the number
that actually needs to be tidy. A drag never locks the Z axis either
(`_view_locked_axis` would, looking from above), or the drag plane would be
horizontal and height could not move at all; it locks whichever *horizontal*
axis faces the camera most squarely, leaving a plane that always contains Z.
Heights are equally editable in the table; clicking a vertex selects its
cell, and selecting a cell highlights its vertex.
Heights show to **three decimals**, since a heightfield is normally a 0..1
field. Three is what the finest nudge steps by, so a cell always shows the
change a keypress made — at two it looked like nothing had happened — and
it matches the writeback, so what the table shows is what the file gets.
Display only: the stored value keeps every digit it had while the dialog is
open, and only a cell actually typed into changes.
The table takes an **extended selection**, and the viewport nudges whatever
is selected, so a group of points raises and lowers together. A live move
(drag or nudge) mutates the value **in place** rather than going through
`_commit_value`: a group nudge calls the handler once per point, and
`_commit_value` would push an undo step for each — the
`vertex_drag_started`/`vertex_drag_finished` bracket pushes exactly one for
the whole gesture. Selection syncs both ways, guarded by a flag so the
table→viewport→table round trip cannot recurse; a viewport click honours
the same replace/add/toggle modifiers the table does.
**Right-clicking a row or column header** offers Duplicate and Delete for
that line. Delete asks first, naming the line and how many heights go with
it: it throws away a whole row or column of hand-placed values, and its menu
item sits one pixel from Duplicate. Undo is there, but not before the
surface has already changed under the user. Duplicate asks nothing — it is
additive. The question lives in `_confirm_delete_line` rather than
`_delete_line`, so the operation itself stays callable without a dialog in
the way. Delete is disabled at two rows or two columns: below that the
value stops being a heightfield at all (see `_is_heightfield`), and the
dialog would be editing something it could not save back. Because these
change the array's shape, `_apply_value` rebuilds the table when the
dimensions differ rather than re-labelling cells — undoing a Duplicate Row
leaves fewer rows than the table has, and writing into cells that no longer
exist is how that crashes.
Even with tiling off, one **wrapped row and column** are drawn past the
end. An N x M field tiles into N x M cells of surface, but N x M points only
span (N-1) x (M-1) of them — so an untiled tile was drawn a row and a column
short of what it actually covers, and the edge where it meets its own next
copy was precisely the part not shown. Those extra points take their heights
from the far side, and get no markers: they are copies, not cells.
**Show tiling** draws the field 3x3 with the editable copy in the middle,
so the seam between one tile and the next is visible — the thing a texture
tile actually has to get right, and the thing an isolated tile cannot show.
The period is the array's own size, so column 0 of the next tile sits
immediately after the last column of this one. Only the middle copy gets
grab handles: `_GridViewport.marker_indices` restricts markers to a subset,
because a marker on a point that cannot be grabbed is an invitation to try.
`_cell_of_flat` returns None for a vertex in a neighbouring copy, and every
path that acts on a vertex — click, drag, nudge — goes through it.
**Import Image…** builds the field from an image's brightness, through
`HeightfieldImportDialog`. Qt reads some two dozen formats and does both
hard parts itself: `Format_Grayscale8` applies a proper luma weighting
rather than averaging the channels (which would read a saturated blue as
bright as a saturated green), and a smooth scale **area-averages** on the
way down — point-sampling a 3000px photo at 50 columns would take one pixel
in sixty and alias badly.
The dialog offers **sampling** (an image is normally far larger than a
useful field), **input levels**, each with an
**eyedropper** that reads its value straight off the image (a photo rarely
spans the full range, so without levels its detail is squeezed into the
middle — and guessing its black and white points by typing numbers is far
harder than pointing at them), an **output height
range**, and **invert**. `black >= white` would divide by zero; the span
collapses to a step at that value instead, which is the sensible reading of
"everything below is low, everything above is high".
It shows two panels: the source on the left to **crop** on, and the result
on the right. Cropping needs the picture; judging levels and sampling needs
the field. Drag on the source to select a region, click to go back to the
whole image — a drag under five pixels is a click, since without a floor a
shaky click selects a 2px region and the preview goes blank with no obvious
cause. The rectangle is held in *image* coordinates, so it means the same
thing however the panel is scaled, and everything outside it is dimmed
rather than merely outlined, which on a busy picture is easy to lose.
The result panel is scaled up without smoothing so each height reads as one
visible cell — the sampling grid is one of the things being chosen. It
shows the result rather than the original because the result is what is
being decided.
The eyedroppers are **checkable**, because picking is a mode: the button
stays lit while the next click on the source will sample rather than crop,
which is the only thing telling the user why their click is not cropping.
Arming one disarms the other — with both lit there would be no telling
which a click was meant for — and picking disarms itself afterwards, since
staying armed would make the next click silently overwrite the level just
set. A sample averages a 3x3 neighbourhood: one pixel is a poor sample of a
photograph, where sensor noise and JPEG artefacts move it far more than the
tone being pointed at. While a picker is armed the cursor becomes the eyedropper itself,
anchored at its tip: it is the only thing showing *where* the sample will
be taken from, and a pointing hand points at nothing in particular. That
cursor is always dark-on-white rather than palette-coloured, with an
outline stroked behind it — it sits over the user's image, not over the
dialog, so the theme says nothing useful about what it needs to contrast
with, and a single-colour shape disappears against half of all photographs.
Both the button icon and the cursor come from
`resources/icons/eyedropper.svg` — one piece of artwork, so editing it in
Inkscape changes both. The button loads it through `apply_themed_icon` like
every other icon, so it recolours on a light/dark switch rather than
keeping whatever ink it was given when the dialog opened. The cursor
renders the same file at 2x for Retina and needs no high-contrast variant:
the artwork is a light fill inside a darker outline, which reads over a
light or a dark image alike.
Its hotspot is **measured from the rendered artwork** — the lowest-left
opaque pixel, which for an eyedropper is the point that does the picking —
rather than written down. A hard-coded offset would keep pointing at where
the tip used to be the first time the icon was redrawn.
**Uniform** holds the sample grid to the crop's proportions, so a cell is
square and the field is not stretched. Either box can lead — editing rows
recomputes columns and vice versa — with a flag stopping the two from
correcting each other forever, and changing the crop re-derives from its
new aspect.
Both boxes go through one handler that matches the aspect **before**
rebuilding. Refreshing first and matching after left the preview built from
the pair as typed while the boxes showed the matched pair: the flag that
stops the round trip also swallowed the refresh that would have caught up.
Committing with Return fixed it by accident, which is what made it look
like typing did not work while the arrows did. An import lands as a single undoable edit, so one that
turns out wrong is one Cmd+Z away.
**No skeleton lines over the surface.** `_GridViewport` draws a
row/column/diagonal skeleton, which for a control-point grid *is* the
subject — GridViewer keeps it. A heightfield is intrinsically different:
its subject is the surface, the skeleton only repeats what Show Edges
draws from the real triangulation, and lying coplanar with the surface it
has no depth bias to lift it clear, so it speckled through as a coloured
grid over what should be a clean shape. `show_skeleton = False` suppresses
it at upload, so nothing is drawn rather than merely hidden.
**Marker geometry is built in bulk.** Markers rebuild on every zoom, to
stay a constant size on screen, and building them one triangle at a time in
Python cost **6.5 seconds for a 100x100 field** — on every zoom.
`_lit_marker_field` does the same work as array operations and takes 37ms
for the same 10,000 points, a 175x saving measured in the real viewport
rather than in isolation. The saving comes from what every marker has in
common: they are the same unit shape, so the offsets are shared, and a
normal is unchanged by uniform positive scaling, so each triangle's normal
is computed once for the shape rather than once per marker. Only the
positions differ, and those are one broadcast add.
`_marker_radii_for_points` vectorises the per-point radius the same way.
`_lit_marker_triangles` stays for the handful of *selected* markers, where
a loop costs nothing and the per-marker shape can vary (`_PathViewport`
draws a different solid per bezier node type). The bulk path is checked
against it row for row, since `upload_points` reads the rows positionally
and a reordering would be a silently wrong picture rather than an error.
**Show points** turns off the marker on every unselected point. An
image-derived field is routinely 50x50 or more, where a marker per point
buries the surface it sits on; selected points keep their markers either
way, so it never loses track of what is being edited.
**Normalize…** rescales every height into a given range (0..1 by default),
linearly, so the field keeps its shape — a heightfield is usually consumed
as 0..1 and one built by hand rarely arrives that way. `lo > hi` is allowed
and flips the field. A field that is already flat has no range to map from,
so every cell takes the low end rather than dividing by zero.
**Saving writes multi-line source**, one row per line with the columns
right-aligned to a common width. `_format_value` — what every other
editable viewer uses — puts everything on one line, which is fine for a 4x4
matrix and unreadable for a 30x40 field: the whole point of the array is
that its shape on the page matches the surface, and a single line throws
that away. Heights are written at three decimals, the same as the table
shows: a heightfield is a field of proportions rather than measurements, a
column of them stays readable at a common width, and three is what the
finest nudge steps by. This is the one writeback that does not use
`_format_value`'s `%g`, and unlike the table's *display* it rounds the
value that reaches the file.
The table's columns share the width evenly (`QHeaderView.Stretch`) rather
than each sizing to its own contents — every cell holds one number of about
the same length, so content-sizing only produced a ragged right edge. With a
floor, though: `setMinimumSectionSize` keeps a column wide enough for a
number, because Stretch alone divides whatever width there is by the column
count and a wide field in a narrow pane shrank every value to `4.…` — evenly
sized and unreadable. Below that floor the table scrolls. The **Style** and
**Z scale** controls sit on their own row beneath the size/min/max label,
which side by side had left the label no room to say anything.
A **Style** dropdown picks how each quad splits into triangles, offering
BOSL2's own documented `vnf_vertex_array` set — `default`, `alt`, `flip1`,
`flip2`, `min_edge`, `min_area`, `quincunx`, `convex`, `concave` — which is
what `heightfield()` and the texture machinery build with. `_quad_triangles`
ports the rules from `vnf.scad`; BOSL2's corners map to this grid's as
i1=p00, i2=p10, i3=p11, i4=p01, so its two splits are the p00–p11 diagonal
(`default`) and the p01–p10 one (`alt`), and every data-dependent style is a
rule for choosing between exactly those two. `quincunx` is the one that adds
a vertex: the quad's centre, joined to all four corners. `random` is
deliberately not offered — a preview that re-triangulates on every redraw is
not a preview — and `max_edge`/`quad` exist in BOSL2's source but not in its
documented list.
A **Z scale** box exaggerates the preview only. A height map's values are
often a small fraction of its width, which renders as a flat sheet; the
stored heights never change, which the tooltip says outright. It reads as a
percentage (15% … 400% as presets) and is editable, because the useful
exaggeration for a given field is rarely one of the presets. Typed input is
lenient about the `%` and surrounding space — an entry is as likely to be
`150` as `150%` — but anything that is not a positive number puts the last
good value back rather than being guessed at, since zero or negative would
flatten or mirror the preview. It reads on `activated` and
`editingFinished` rather than `currentTextChanged`, which fires per
keystroke and would rebuild the surface at 2% and 20% on the way to 200%.
Shapes are offered independently (see "Lexical View as.../Edit as..." above),
so a 3x3 array of numbers legitimately offers Heightfield, Matrix *and* Path
together — each is a true reading of the same literal.
**Geometry objects reach the mesh viewers.** A `render()` expression yields an object carrying the mesh as separate `vertices`/`faces` keys, so `_is_vnf` — which only matches a bare 2-list — fires for `obj.vnf` but never for `obj`. `_geometry_object_vnf` unwraps it; `_geometry_object_region` handles the 2D case, which is *not* a straight unwrap since `paths` holds indices into `vertices` and must be resolved to real points first. Dispatch is on `dim`: 3 → `VNFViewer`, 2 → `RegionViewer` (plus `PathViewer` when there is exactly one contour). Deliberately not folded into `_is_vnf`, which also spots VNF literals in source text where an object is not a candidate. An eighth, `ProfileViewer`, follows the same `QDialog`/`QTableWidget` skeleton but is launched differently — see below.
### Shared viewport: `Viewport` (`window/viewport.py`) + `SceneRenderer` (`engine/renderer.py`)
`VNFViewer`/`PathViewer`/`GridViewer` each embed the **same** `Viewport(QOpenGLWidget)` class the main window uses (as a thin per-dialog subclass — `_VNFViewport`, `_PathViewport`, `_GridViewport` — adding only picking and blinking-selection-marker overlays), rather than a separate simplified reimplementation. This means every main-window viewport feature — perspective/orthographic, stereo (cross-eye), spin (auto-rotate), show axes, show edges, show scale markers, show crosshairs, view presets, mouse-centered zoom — works identically in a data-viewer dialog, and `MainWindow`'s View-menu shortcuts apply to whichever one currently has focus (see "Keyboard shortcuts" below). There used to be a second, independent `_SimpleViewport` implementation duplicating most of this; it was deleted once all three dialogs were migrated (confirmed no remaining references before removal).
Each dialog constructs its own `Viewport(parent, selectable=False, pan_speed=2.0)` — two behavioral differences from the main window's viewport (which uses the defaults, `selectable=True`/`pan_speed=1.0`): `selectable=False` disables the Ctrl+click "select an AST/CSG original-id" gesture (meaningless for raw display data, and would otherwise stop Ctrl+drag from orbiting); `pan_speed=2.0` doubles right-drag pan's `mouseMoveEvent` scale factor (`cam.distance * 0.001 * self._pan_speed`) — data-viewer dialogs are typically zoomed in tighter on a single small shape than the main viewport is on a whole model, so the same 1x pan speed felt sluggish there. Everything else about mouse handling (plain-drag orbit, wheel zoom, Alt-drag light) works the same. Gizmo dragging and the debug-session busy-spinner overlay are main-window-only *features* of `Viewport` (opt-in via `set_active_tool`/`set_render_busy`/`set_debug_busy`), not main-window-only *code paths* — data viewers simply never call those methods, so they stay dormant.
`SceneRenderer` gained a **generic raw-buffer upload API**, additive alongside its existing `ColoredBody`/CSG-mesh path (`load_geometry`), for exactly this use case — geometry with no CSG/original-id/gizmo concept:
- `upload_mesh(positions, normals, color=None, backface_color=None, edge_positions=None, edge_colors=None, tri_ids=None) -> MeshBuffer` — `positions`/`normals` are `(3T, 3)` arrays, one row per triangle corner. Appends to the same `_buffers` list CSG bodies use (distinguished by `MeshBuffer.program is self._prog` vs. the separate `_mesh_prog`, branched in `_paint_scene`'s opaque pass), so camera-framing and `ray_cast` picking work generically across both. Passing `tri_ids` (one id per triangle) lets the mesh participate in `ray_cast`. `color=None` (the default) tracks the live color theme's object color, resolved fresh every draw (`buf.color if buf.color is not None else self._default_color`, the same substitution the CSG-body path already used — this generic/data-viewer path just didn't apply it until a real bug: every caller previously passed an explicit hardcoded `(0.9, 0.85, 0.1, 1.0)`, masking that the substitution was missing here, since `buf.color` was never actually `None` in practice).
- `upload_lines(data) -> LineBuffer` / `upload_points(data) -> PointBuffer` — rows are `[x, y, z, r, g, b]`; lines drawn `GL_LINES`, points drawn `GL_TRIANGLES` (pre-tessellated markers, e.g. octahedra). Both reuse the existing `_gizmo_prog` (its shader is byte-identical to what a standalone line/point shader would be). `clear_lines()`/`clear_points()`/`clear_simple_buffers()` release them independent of `_clear_buffers()` (CSG-only).
- `pick_nearest_point(points, px, py, w, h) -> int` — screen-space nearest-point picking, wrapping the pure module-level `nearest_point_index()` (pytest-covered in `tests/test_renderer.py`, no GL/Qt dependency). Replaces three independently-duplicated nearest-point search loops (Path/Grid vertex picking, VNF vertex hover tooltip).
- `pick_nearest_segment(points, segments, px, py, w, h) -> tuple[int, np.ndarray] | None` — the line-segment analog, behind `PathViewer`/`RegionViewer`'s right-click-a-line "Add Vertex". `segments` is a list of `(i, j)` index pairs into `points`. Wraps two pure module-level helpers (also pytest-covered, no GL/Qt dependency): `_closest_point_on_segment_to_ray(a, b, ray_origin, ray_dir)` finds the closest point on segment `[a, b]` to the pick ray (standard closest-points-between-two-lines, segment parameter clamped to `[0, 1]`, ray parameter left unclamped), and `nearest_segment_index(...)` projects each segment's closest point to screen space and returns whichever is nearest the click, or `None` if none is within the pick threshold. Doing the closest-point search in 3D world space first (rather than, say, projecting the whole segment to screen space and doing 2D point-to-line distance there) means it works identically for 2D and 3D paths without any locked-plane branching — the resulting `world_point` already lies exactly on the segment in 3D, ready to insert as-is.
- `ray_cast()`/`camera_ray()` (pre-existing, used for the main window's Ctrl+click AST selection) are reused as-is for `VNFViewer`'s face picking, once its mesh buffer's `tri_ids` is populated with its own face-index array — this deleted ~60 lines of independently-duplicated vectorized Möller–Trumbore that `_VNFViewport._pick_face` used to carry.
- `Viewport` itself gained `schedule_load(fn)` (defers a geometry-load call until `initializeGL()` has run — needed since a dialog constructs its viewport and loads geometry before the widget is ever shown), a `_paint_extra(mvp)` no-op hook (subclasses override it for overlay geometry, called every frame right after the generic lines/points pass), `scroll_to_visible(pt)` (pans the camera minimally to keep a point on screen), and cached-bbox reframing (`frame_scene()` caches its bounds so "View All" works even when a dialog's geometry lives only in line/point buffers with no per-vertex CPU arrays to scan).
**`makeCurrent()`/`doneCurrent()` around post-initial-load GL mutation**: unlike `Viewport.load_geometry`/`closeEvent` (main window), the data-viewer subclasses' own GL-mutating methods — `_AffineViewport.load_matrix`, `_PathViewport.load_path`/`set_selected`, `_GridViewport.load_grid`/`set_selected`, `_VNFViewport.highlight_face`/`highlight_vertices` — must each bracket their buffer work in `self.makeCurrent()`/`self.doneCurrent()` when called from anywhere other than the initial `schedule_load` (which Qt guarantees runs with the right context current via `initializeGL()`). Without it, a GL call issued from an arbitrary Qt event handler (a table `itemChanged` commit, a row-selection change) can land on whatever *other* widget's context happened to be current at that moment instead of this one's — corrupting that other widget's rendering (observed: editing a `PathViewer` cell and pressing Tab blacked out the main window's viewport, because the commit fired while the main viewport's context was current).
Keyboard shortcuts (Cmd+0–9 views, Cmd+1–3 toggles, Ctrl+Cmd+1–3 toggles) are set directly on the `QAction` instances in the View menu, so they appear in the menu and work application-wide — meaning they fire regardless of which *window* currently has OS focus, main window or a data-viewer dialog. Each handler (`_toggle_perspective`, `_toggle_stereo`, `_toggle_spin`, `_toggle_axes`, `_toggle_edges`, `_toggle_scale_markers`, `_toggle_crosshairs`, `_set_view`) resolves its target via `MainWindow._target_viewport()`, which returns `_active_viewer_viewport()` (the active window's `_vp`, if it's a data-viewer dialog) or falls back to `self._viewport` (the main window's own). Since every viewport now has the identical `vp._renderer.<attr>` shape, every handler is a uniform two-liner.
**Every one of these actions explicitly sets `Qt.ShortcutContext.ApplicationShortcut`** (same as `_act_undo`/`_act_redo`/`_act_animate_tb` elsewhere) rather than relying on the default `WindowShortcut`. This isn't cosmetic: Qt's shortcut dispatch suppresses `WindowShortcut`-context actions owned by a widget outside the currently-modal-active top-level window whenever *any* modal widget is active (`QApplication.activeModalWidget()` is set) — independent of whether that dialog is `ApplicationModal` or `WindowModal`. This was discovered while the "Edit as..." editable data-viewer dialogs were still modal (before they were changed to non-modal, see above) — with a modal editable dialog focused, these shortcuts silently stopped firing even after switching modality flavors, which is what led to finding this general Qt behavior and adding the explicit context here as a defensive fix for any genuinely-modal dialog in the app (e.g. Preferences, Library Manager).
### ListViewer (QDialog)
`QTableWidget` with key/value columns. Recursive drill-down via context menu for nested lists/dicts. No 3D viewport.
### VNFViewer (QDialog)
`QSplitter`: `_VNFViewport` on left, `QTabWidget` (Vertices / Faces tabs) on right. Starts in the main window's current perspective mode. Read-only by default; pass `editable=True` for a Save/Cancel editing mode (see `MatrixViewer` for the shared editing convention) — vertex positions, plus full face topology editing (add/duplicate/delete vertices, add/delete/reverse faces).
- **Vertices tab**: `QTableWidget`, 0-indexed rows, X/Y/Z columns sized for 6 digits, wrapped in a container with a **"+"/"–"** button row below it (`editable=True` only). Multi-select (extended selection). Selected vertices shown as blinking (red↔white, 250ms) dodecahedron markers in the viewport, ~12px screen size regardless of zoom or camera distance in both orthographic and perspective modes (`_marker_radius_for_point`, rebuilt on wheel). Hovering a highlighted vertex shows a tooltip with index and coordinates. When `editable=True`, cells are directly editable (`itemChanged`, same parse/validate/revert convention as `PathViewer`/`GridViewer`), updating `self._vnf[0][i][j]` in place and calling `_rebuild()`.
- **"Show Vertices" checkbox** (main button row, both modes, unchecked by default): draws every non-selected vertex as a static theme-colored (`renderer.unselected_vertex_color`) dodecahedron marker — `_VNFViewport.set_show_unselected`/`_build_unselected_markers` mirror `_PathViewport`/`_GridViewport._build_point_markers` exactly (same color, same shared `SceneRenderer.upload_points`/`clear_points` "simple points" pipeline, auto-rendered by `_render_simple_points` with no `_paint_extra` change needed) — but opt-in here rather than always on, since a mesh can have far more vertices than a hand-edited path/grid. Rebuilt alongside the selected markers on every selection change, mesh reload/edit (`frame_scene`), and zoom (`wheelEvent`), so it always reflects the current vertex list and excludes whichever vertices are currently selected.
- **Faces tab**: `QTableWidget`, 0-indexed rows, single "Vertex Indices" column. Selecting a face highlights it green in the viewport (polygon offset overlay), deselects all vertices, and selects the face's referenced vertices. Cells are never directly editable (typing a raw index list isn't supported), but rows can be added/deleted/reversed — see "Face topology editing" below.
- **Viewport**: `_VNFViewport(Viewport)`. Backfaces rendered magenta (`upload_mesh`'s `backface_color`). Face picking via `SceneRenderer.camera_ray()` + `ray_cast()` — the mesh is uploaded with `tri_ids` set to its own OpenSCAD face-index array (`VNFViewer._load_mesh`), so a ray hit resolves straight back to the face index. Plain left-click resolves against `_pick_vertex` first (nearest vertex, same screen-space threshold used by Cmd+drag/right-click — works on any vertex, not just ones currently shown as markers) and only falls back to face picking if no vertex is under the cursor; a vertex hit emits `vertex_clicked(vi, mode)` (mode from `_vertex_click_mode(event.modifiers())`, or hardcoded `"toggle"` on a Cmd-press that never actually dragged — see `PathViewer`'s "Standardized vertex-click selection" above) and a face hit emits `face_clicked` (switches to Faces tab, selects the row). `_on_viewport_vertex_clicked` switches to the Vertices tab and calls `_apply_click_selection(self._vert_table, vi, mode)`, then lets the resulting `itemSelectionChanged` → `_on_vert_table_selection` naturally highlight the *whole* selection and update the Add Face button's enabled state — a click never touches any current face selection/highlight, matching how selecting vertices directly in the table already leaves face selection alone. Clicking blank space (neither a vertex nor a face hit) still deselects everything, via `face_clicked(-1)`.
- **Vertex dragging and arrow-key nudging** (`editable=True` only): reuses `_PathViewport`/`_GridViewport`'s exact mechanism — `_pick_vertex` searches *all* mesh vertices (not just the currently highlighted set) so Cmd+dragging can grab any vertex regardless of table/face selection; the lock axis is always chosen via `_view_locked_axis(camera)` (VNF data has no 2D case — vertices are always 3D). Arrow keys nudge every vertex in `self._vert_indices` (the currently highlighted set, from a table/face selection) the same single-axis-snapped way `_PathViewport`/`_GridViewport.keyPressEvent` do.
- **Face topology editing** (`editable=True` only — added after the "vertex positions only" limitation noted above was lifted):
- **Add Vertex**: the "+" button below the Vertices tab, or right-click an existing vertex (in the table or the viewport) → **Duplicate Vertex**. Both append a copy of a source vertex (the last vertex for "+", the right-clicked one for Duplicate) to the *end* of the vertex list, offset from the source via `VNFViewer._offset_position` — which tries successively larger offsets along a fixed step direction until it finds a position that doesn't exactly coincide with any existing vertex (guards against a duplicate-of-a-duplicate walking back onto an earlier point, or a symmetric mesh where the naive offset spot happens to already be occupied). The newly added vertex is left selected. "+" on an empty vertex list appends `[0, 0, 0]` instead (no source to offset from).
- **Delete Vertex**: the "–" button (selected rows), Delete/Backspace key (selected rows), or right-click → Delete Vertex is *not* offered per-vertex (only Duplicate is) — deletion is selection-driven. `VNFViewer._delete_vertices(indices)` cascade-deletes: any face referencing a deleted vertex is dropped too, and every surviving face's vertex indices are renumbered via an `old_idx -> new_idx` map built from the survivors in list order — the same technique `PathViewer._delete_vertex`'s `index_map` uses, applied to faces instead of `_node_types`.
- **Add Face**: the "Add Face" button below the Vertices tab, enabled only when exactly 3 vertex rows are selected (tracked in `_on_vert_table_selection`, which already fires on every selection change for the viewport-highlight sync). Appends `[i, j, k]` in ascending selected-row order — winding isn't inferred from geometry; use Reverse Face afterward if it comes out backwards.
- **Delete Face** / **Reverse Face**: right-click a face — in the Faces table (`rowAt`-based, same convention as `_show_vert_table_context_menu`) or in the viewport (`_VNFViewport.contextMenuEvent`, using the same `_pick_face`/`_pick_vertex` helpers `_pick_face` already served for click-to-select). Delete just removes that one face (no vertex/renumbering changes); Reverse replaces its vertex-index list with the reverse of itself, flipping the face's winding/normal direction.
- All six operations funnel through `_commit_value`/the shared undo stack like every other edit in this dialog. `_apply_value` (undo/redo's entry point) now fully repopulates both tables via `_populate_vert_table`/`_populate_face_table` (row-count-safe rebuilds, mirroring `PathViewer._populate_vert_table`) rather than only patching existing cells in place, since these operations can change either table's row count.
- **`_rebuild()`** (editable only) re-triangulates and re-uploads the whole mesh via `_load_mesh` against the current `self._vnf` (vertices and faces both, now that faces can change too). `_load_mesh` needed a `SceneRenderer._clear_buffers()` call added at its start (previously safe to omit, since it was only ever called once via the initial `schedule_load`) to avoid leaking/duplicating GPU buffers across repeated live-edit rebuilds. `_rebuild()` also needs its own `makeCurrent()`/`doneCurrent()` bracket (a live edit isn't guaranteed to run inside `initializeGL`, unlike the original single-call path) and explicitly re-invokes `_rebuild_highlight()` when a face is currently highlighted — `frame_scene` (which `_load_mesh` calls) already rebuilds vertex markers for a live edit, but not the separate highlight VAO, which would otherwise keep showing the *old* vertex positions for whatever face is highlighted.
### VNFTileViewer (QDialog)
A `VNFViewer` subclass for a BOSL2 **VNF tile texture**: a VNF whose X and Y lie in the unit square, repeated edge to edge by `texture=` on `linear_sweep()`, `cyl()` and the rest. Offered as "View/Edit as VNF Tile..." alongside "VNF" whenever a VNF fits the unit square (`_is_vnf_tile`), and as "VNF Tile" under "Add data literal..." (a flat unit square, wound clockwise seen from +Z like every one of BOSL2's own VNF textures). Everything `VNFViewer` does still works; three things are added:
- **Show tiling** draws the tile's eight neighbours in a fixed light grey and frames the whole 3x3 block, so the seams between copies are visible. They are opaque, not the translucent `%` ghost pass, which turned the seams unreadable. They are uploaded with an empty `tri_ids`, which `ray_cast` skips, so a click on a neighbour never picks the tile's own face.
- **Seam check** (the label beside it) re-runs on every rebuild: `vnf_tile.tile_problems` is a port of BOSL2's `_validate_texture` (skin.scad), so "Tiles cleanly" means BOSL2 will accept the texture. It reports vertices outside [0,1], and vertices on an *open* boundary edge at X=0/1 (or Y=0/1) with no twin at `[1-x, y, z]` (or `[x, 1-y, z]`). It checks open edges only, as BOSL2 does: a closed-off side face touching the tile's edge is not a seam. Edge membership is exact (`== 0`/`== 1`) and twins match to BOSL2's `approx()` tolerance, 1e-9. All 12 of BOSL2's own VNF textures come back clean.
- **Linked edge moves** (`vnf_tile.linked_moves`): moving a vertex on X=0/1 carries its twin on the opposite X edge along in Y and Z; the same for Y; a corner carries the other three corners in Z. A drag or arrow-key nudge also keeps the vertex on its edge and inside the unit square, because dragging freely would break the seam it exists to keep. A value typed into the table is taken as given (its twins still follow in the other coordinates), and the seam label then says what it broke. The twins move inside the same undo step as the vertex.
### PathViewer (QDialog)
`QSplitter`: `_PathViewport` on left, vertex table on right. "Close Path" checkbox, mode dropdown and (NURBS modes only) Degree spinbox (20px left padding) and Dismiss button (20px right padding) below; in `editable=True` mode, Dismiss is replaced with Cancel/Save.
- **Vertex table**: `QTableWidget`, 0-indexed rows, X/Y/Z columns. Extended multi-select. Selecting rows highlights the corresponding vertices in the viewport. When `editable=True`, cells are directly editable (`itemChanged` parses/validates each edit, reverting non-numeric input, updates `self._path` in place, and calls `_rebuild()` to refresh the viewport preview) — no source writeback until Save, which emits `self._path` re-serialized via `committed`.
- **Viewport**: `_PathViewport(Viewport)`. Black lines at 2× width (`SceneRenderer.line_width`). Dodecahedron vertex markers (~12px screen size regardless of zoom, camera distance, or projection mode — `_marker_radius_for_point`, rebuilt on zoom): theme-colored (`renderer.unselected_vertex_color`) for unselected, red↔white blink (250ms) for selected. Hovering a marker shows a tooltip with index and coordinates.
- **Standardized vertex-click selection** (shared by every editable viewport with a selectable vertex list — `PathViewer`, `GridViewer`, `VNFViewer`, `RegionViewer` — via two module-level helpers, `_vertex_click_mode(modifiers)` and `_apply_click_selection(table, row, mode)`): clicking a marker with **no modifier** replaces the selection with just that vertex; **Shift** adds it to the current selection; **Cmd** (`Qt.KeyboardModifier.ControlModifier`) toggles its membership. `mouseReleaseEvent`'s plain-click branch resolves the mode via `_vertex_click_mode(event.modifiers())` and emits `vertex_clicked(vi, mode)`; the dialog-side `_on_viewport_vertex_clicked(vi, mode)` calls `_apply_click_selection(self._vert_table, vi, mode)`, which uses `QItemSelectionModel.SelectionFlag.Select`/`Toggle` (plus `Rows`) for add/toggle, or a plain `clearSelection()` + `selectRow()` for replace. Clicking blank space (`vi < 0`) only has an effect for replace (clears everything) — add/toggle on blank space is a no-op, since there's nothing to add or toggle. `GridViewer`/`RegionViewer` layer one extra rule on top (see their own sections) since their vertex table only ever shows one row/path at a time.
- **Close Path**: checkbox toggles whether the last vertex connects back to the first.
- **Mode** (dropdown, replacing the old "Bezier" checkbox): how the point list is read. View-only, like Close Path -- the literal is the same list of points in every mode, so the mode is never written back. Only Bezier Path changes editing behaviour (node types, De Casteljau Add Vertex, shape-keeping delete); the NURBS modes edit exactly like Path.
- **Path**: straight segments.
- **NURBS Path**: the points are the control points of a uniform B-spline -- BOSL2's `nurbs_curve(points, degree, type=closed ? "closed" : "clamped")`. The control polygon is drawn in grey under the curve.
- **NURBS Interpolated Path**: the curve passes *through* the points -- `nurbs_curve(nurbs_interp(points, degree, closed=closed))`, centripetal, unconstrained.
- The maths is `belfryscad/nurbs.py`, a Qt-free numpy port of BOSL2's `nurbs.scad` (including the closed-interpolation seam rotation and `_fix_tiny_spans`), checked against BOSL2's own echoed output to its 6 printed digits (`tests/test_nurbs.py`). Only what the viewer can express is ported: no weights, knots, multiplicities, derivative/curvature/corner constraints, `extra_pts`, or other parameterisation methods; and a singular closed fit gives up rather than taking BOSL2's regularised fallback. Wherever BOSL2 would refuse the points (a duplicate neighbour, a singular fit), `_PathViewport._nurbs_curve` returns None and the plain polyline is drawn, so the points never vanish.
- **Degree** is capped by `PathViewer._sync_degree_spin` at `len(points) - 1` (BOSL2 needs degree+1 points), and floored at 2 for interpolation (BOSL2's default `smooth=3` asserts degree >= 2 even with nothing to smooth). Re-capped on every rebuild, so deleting points lowers it rather than breaking the curve.
- **Bezier Path**: cubic Bezier curves. Every 4 points form one cubic segment (P0, C1, C2, P3) with shared endpoints between consecutive segments. Open paths require 3k+1 points; closed paths require 3k points (last segment wraps to first). Each curve is tessellated into 32 line segments.
- 2D paths (all Z=0) start in top-down orthographic; 3D paths start in perspective orbit. **Orbit-dragging is disabled entirely for 2D data** (`Viewport._orbit_enabled = not is_2d`, checked in the base class's `mouseMoveEvent`) — a locked top-down 2D view has no meaningful "other side" to orbit to, and dragging away from it only gimbal-locks/disorients (right-drag pan and wheel-zoom are unaffected).
- **Vertex dragging** (`editable=True` only, requires **Cmd held down**): Cmd+pressing directly on a vertex marker (`_pick_vertex`) starts a drag instead of arming orbit/pan — a plain drag (no Cmd) is always just camera orbit, in both 2D and 3D. `mouseMoveEvent` reprojects the cursor to world space via `SceneRenderer.camera_ray` intersected with an axis-locked plane (`_ray_plane_axis_locked(ray_o, ray_d, plane_point, lock_axis)` — generic plane-through-a-point with an axis-aligned normal) and emits `vertex_moved(index, x, y, z)` on every move — coordinates rounded to 3 decimal places (`round(..., 3)`) before emitting, so dragged coordinates don't carry the raw reprojection's long floating-point tail into the table/source.
- **2D data**: the locked axis is always Z (`plane_point = [0,0,0]`) — 2D data is always embedded at Z=0 by `load_path`/`load_grid`, and the camera is locked top-down, so this is the only plane ever needed.
- **3D data**: the locked axis is chosen at drag-start by `_view_locked_axis(camera)` — whichever world axis (X/Y/Z) is most nearly *parallel* to the camera's current view direction (`argmax(abs(target - eye_position))`), since a 2D mouse drag can't usefully control movement along an axis pointing into/out of the screen. The vertex is then only ever moved within the plane spanned by the other two (most-perpendicular-to-view) axes, through the vertex's own starting position (`plane_point = self._path_pts[vi]`/`self._all_pts[vi]` at drag-start) — the locked axis's original value is explicitly reasserted every move (`hit[lock_axis] = plane_point[lock_axis]`) to avoid float drift.
- The dialog's `_on_viewport_vertex_moved` mirrors `_on_item_changed`'s update pattern — writes into `self._path`/`self._grid` (skipping the Z write for 2D points, which have no third coordinate), updates the matching table cell(s) (`blockSignals` around the `setText`, avoiding a recursive `itemChanged`), and calls `_rebuild(reframe=False)` for a live preview — so table and viewport stay in sync in both directions, same as before. `reframe=False` threads down through `_rebuild`/`load_path`/`load_grid`/`load_region`/VNFViewer's `_load_mesh` to `Viewport.frame_scene(bb_min, bb_max, reframe)`, skipping the actual `Camera.frame_bounds` re-fit (a full "View All" — recenter *and* rezoom) while still updating the cached bounds `frame_scene` keeps for the real "View All" action, and still rebuilding vertex markers at their new positions (each subclass's `frame_scene` override always does that part, reframe or not). Without this, every single drag/nudge frame — including a keyboard nudge, which shares the exact same `vertex_moved` signal/handler path as a drag — re-fit the camera to the whole shape's growing/shrinking bounding box, causing the view to visibly zoom/recenter on every move instead of just tracking the edited vertex. Immediately after the rebuild, `_on_viewport_vertex_moved` calls the pre-existing `Viewport.scroll_to_visible(pt)` (already used elsewhere to keep a newly table-selected vertex on-screen) with the vertex's new position — pans (never zooms) the camera the minimum amount needed if the moved point has strayed within 15% of the viewport edge (NDC `threshold = 0.85`), so a vertex being dragged/nudged toward the edge scrolls into view instead of disappearing, without the jarring reframe a full "View All" would cause on every move.
- **Arrow-key nudging** (`editable=True` only): `_PathViewport`/`_GridViewport.keyPressEvent` picks the same locked axis Cmd+drag would use for the current view (Z for 2D data; `_view_locked_axis(camera)` for 3D) and passes it to `_key_nudge_delta(camera, lock_axis, key, magnitude)`, which reads the camera's actual screen-right/up directions off `view_matrix()` rows 0/1. Unlike a drag (which can move freely to anywhere in the locked plane), each key press snaps to exactly one of the plane's two free axes rather than blending both — "Right"/"Left" moves along whichever free axis has the larger-magnitude component in the camera's screen-right direction (sign-matched to that component), "Up"/"Down" always uses the *other* free axis against screen-up, so a press always reads as a single changed coordinate in the table regardless of how the viewport has been orbited (for the 2D top-down camera this reduces to the original fixed ∓X/±Y mapping, verified unchanged). **Step size**: `_key_nudge_magnitude(event.modifiers())` — shared by every editable viewport's `keyPressEvent` (`_PathViewport`, `_GridViewport`, `_VNFViewport`, `_RegionViewport`) — returns `0.1` with Cmd held (`Qt.KeyboardModifier.ControlModifier`, Cmd on macOS — distinct from Cmd's other meaning of starting a vertex *drag* on mouse-press), `10.0` with Shift held, or the default `1.0` otherwise. For every currently-selected vertex, reads its live position out of `_path_pts`/`_all_pts`, adds the delta (rounded to 3 decimal places, same as drag), and re-emits `vertex_moved` — reusing the exact same signal/handler path as Cmd+dragging, so table sync and rebuild work identically. No-op (falls through to the base `keyPressEvent`) when not editable or when nothing is selected.
- A press-then-release with no net movement (the same <3px `_drag_started` threshold used to distinguish a click from a camera-orbit drag) emits `vertex_clicked(vi, "toggle")` instead of a move — Cmd is only ever intercepted at `mousePressEvent` to arm this drag, so by the time the release handler knows nothing moved, it already knows Cmd was held and hardcodes "toggle" directly rather than re-deriving the mode from `event.modifiers()`.
- Read-only viewers (`editable=False`) never enter this code path regardless of Cmd — pressing on a vertex behaves exactly as before (selects on release, no drag).
- **Plane indicator**: while a drag is in progress, `_show_delta(f"Plane: {_unlocked_plane_name(lock_axis)}")` shows a bottom-center overlay label (`Viewport._delta_label` — the same mechanism the gizmo drag uses for its angle/scale readout, e.g. "Plane: XY"/"Plane: XZ"/"Plane: YZ") naming the two axes the drag is currently constrained to. `_unlocked_plane_name(lock_axis)` joins whichever two of "XYZ" aren't the locked one. Shown on press (once a vertex is picked) and hidden on release, regardless of whether the drag actually moved anything.
- **Adding/deleting vertices** (`editable=True` only) — no buttons; all via context menus and the keyboard:
- **Right-click a line to Add Vertex, right-click a vertex to Delete it** (viewport, and not immediately after a right-button pan drag — `_context_menu_suppressed`, captured in `mouseReleaseEvent` before the drag-tracking state resets): `_PathViewport.contextMenuEvent` first checks `_pick_vertex` — a hit offers **"Delete Vertex"** for that specific point (`delete_vertex_requested(vertex_idx)` → `PathViewer._delete_vertex(vertex_idx)`, regardless of the table's current selection). Otherwise it builds the segment list (`self._path_pts` consecutive pairs, plus the wrap-around edge when `self._closed`) and calls `SceneRenderer.pick_nearest_segment` — for each segment, `_closest_point_on_segment_to_ray` finds the closest point on that 3D segment to the pick ray (a generic closest-point-between-two-lines computation, clamped to the segment), which is then screen-projected and compared to the click position, so this works uniformly for 2D and 3D paths without needing a locked-plane branch the way dragging does. On a hit, the context menu offers "Add Vertex", which inserts a point **exactly at the clicked location on that segment** via `add_vertex_requested(insert_after, x, y, z)` → `PathViewer._on_viewport_add_vertex_requested`. The wrap-around segment's "insert after" index is always `len(path) - 1`, so clicking the closing edge correctly appends at the end of the list rather than inserting before index 0.
- **Right-drag panning vs. context menus**: on macOS, `contextMenuEvent` fires on the right mouse-button **press** (matching the platform convention of showing menus without waiting for release), not after `mouseReleaseEvent` the way it's easy to assume. Two consequences, both fixed here: (1) the base `Viewport`'s orbit/pan tracking (`_last_mouse`/`_mouse_button`, armed by `mousePressEvent`) must only be reset right before an actual `QMenu.exec()` call — resetting unconditionally at the top of `contextMenuEvent` (an earlier version of this fix) fired on *every* right-click, including blank-space ones that show no menu at all, which broke ordinary right-drag panning outright by clearing the drag state before any `mouseMoveEvent` could use it. (2) `_context_menu_suppressed` (set by the *previous* gesture's `mouseReleaseEvent`) is stale at the moment a *new* press's `contextMenuEvent` runs, since that press's own release hasn't happened yet — so `mousePressEvent` now also resets `_context_menu_suppressed = False` at the start of every new gesture, or a single right-drag-pan would permanently block every later right-click's context menu. Same fixes in `_GridViewport`/`_RegionViewport`'s `contextMenuEvent`/`mousePressEvent` below. `_RegionViewport` is a partial exception: it always shows a menu on any right-click (either "Add Vertex" or "Add Path" — see below), so unlike Path/Grid, right-drag panning can't coexist with it there at all when a menu fires on press.
- **Right-click a table row for Delete Vertex**: `_vert_table.customContextMenuRequested` → `PathViewer._show_vert_table_context_menu` offers "Delete Vertex" for whichever row is under the cursor (`self._vert_table.rowAt(pos.y())`; no menu at all if the click lands on empty space below the last row) — mirrors `GridViewer`/`RegionViewer`'s table context menu convention.
- **Delete/Backspace key deletes the current selection**: `PathViewer.keyPressEvent` catches `Qt.Key.Key_Delete`/`Qt.Key.Key_Backspace` at the dialog level (neither the table nor the viewport consumes those keys themselves, so the event otherwise just bubbles up here unhandled) and calls `self._delete_vertex()`. Works regardless of whether the selection was made by clicking table rows or viewport markers, since the two stay in sync (`_on_vert_table_selection` ↔ `_on_viewport_vertex_clicked`).
- `_delete_vertex(vertex_idx: int | None = None)` is the single shared deletion path for all three of the above (an explicit index for a targeted right-click, or `None` to fall back to the table's current selection for the Delete key), fully repopulating the vertex table (`_populate_vert_table`, mirroring `GridViewer._populate_table`'s same row-count-change situation) and refusing (silently, no popup, matching the invalid-cell-edit convention) if that would leave fewer than the 2 points `_is_path` requires.
- **Bezier node types** (`editable=True`, Bezier Path mode only) — each on-curve vertex ("v0", `idx % 3 == 0` in the flat point list) carries a persistent, viewer-only **node type** controlling how dragging/nudging one of its two control-point handles affects the other:
- **Disjointed**: handles move independently (a corner/cusp — this is how every vertex behaved before node types existed).
- **Same Angle**: the opposite handle mirrors the dragged handle's *direction* through v0 but keeps its own prior distance from v0 — a continuous tangent, discontinuous curvature.
- **Symmetric**: the opposite handle mirrors both direction *and* the dragged handle's new distance from v0 — a fully smooth node.
Types are transient (`_PathViewport._node_types: dict[int v0_idx -> str]`), never serialized into the saved Path literal (OpenSCAD paths are plain point lists with no room for per-point metadata — same as `_selected_indices`). **Auto-detected** from existing handle geometry (`_classify_node_type`: opposite-direction-and-equidistant → symmetric; opposite-direction-only → same_angle; anything else, or a missing handle at an open path's start/end → disjointed) whenever the mode switches into Bezier Path (`load_path`, comparing `mode == "bezier"` against the viewport's own previous `self._bezier`) — never on an ordinary drag/nudge-triggered rebuild, so it can't clobber an explicit override or the type a user is actively dragging toward. **Set explicitly** via a "Bezier Node Type" submenu (checkmark shows the current type) added to the existing right-click-a-vertex menu when the clicked point is a v0 in Bezier mode, right above "Delete Vertex".
- **Setting a type immediately snaps both handles into line with it**, rather than leaving them wherever they were until the next drag — `_snap_handles_to_node_type(v0, handle_a, handle_b, node_type)` (pure, alongside `_classify_node_type`): `same_angle` averages the two handles' *direction* through v0 only (each keeps its own existing distance — the shared axis is the normalized average of `handle_a`'s own direction and `handle_b`'s *opposing* direction, `da/len_a - db/len_b`, which is exactly `2*da/len_a` and thus idempotent when the pair is already perfectly opposite); `symmetric` also averages their *distance*, landing both handles equidistant from v0. Returns `None` (no snap — same for `disjointed`, a missing handle at an open-path boundary, a handle coincident with v0, or the degenerate case where the two handles already point in the exact *same* direction from v0, making "opposing average" ill-defined) or the pair. `_v0_handle_indices(v0_idx, n, closed)` (pure, shared with `classify_single_node`) resolves the same preceding/following handle indices used everywhere else node types are computed. `_set_node_type` emits the snapped positions as ordinary `vertex_moved` signals (one per handle) rather than mutating anything directly, so the write-back into `self._path`, the table, and the live rebuild all go through the exact same path a real drag would use; only when nothing needs to move does it fall back to a direct `refresh_markers()` (a `makeCurrent()`/`_build_point_markers()`/`doneCurrent()`/`update()` wrapper, needed here since — unlike `load_path`/`set_selected` — this can fire from an arbitrary context-menu action, not the initial `schedule_load`).
- **Marker shapes** make a v0's type visible at a glance: Disjointed keeps the plain dodecahedron (`_dodecahedron_faces` — a regular 12-pentagon solid in 3D, a flat 12-gon in 2D); Same Angle is a diamond (`_diamond_faces` — rotated square in 2D, octahedron in 3D); Symmetric is a triangle (`_triangle_faces` — equilateral triangle in 2D, tetrahedron in 3D). All three share `_dodecahedron_faces`'s existing `(r, is_2d)` offset-vector convention. Handle (v1/v2) markers are always the plain dodecahedron regardless of type — type belongs to the v0, not its handles. The mapping (`_NODE_TYPE_SHAPE_FN`) is a **module-level** dict, not a class attribute — referencing it from inside `_PathViewport`'s class body would have resolved the bare names to the class's own same-named instance methods (defined earlier in the same class body) rather than the free functions. All point markers (this shape family, `_diamond_faces`, `_triangle_faces` alike) are phong-lit: `_lit_marker_triangles` computes a flat per-triangle normal for each one, and `SceneRenderer.upload_points`/the per-viewport selected-marker VAOs render them through `_marker_prog` (same lighting formula as `_mesh_prog`, but per-vertex color so a single batched draw can mix marker colors) rather than the old flat-color `_gizmo_prog`.
- **Linked drag/nudge**: one pure helper, `_bezier_linked_moves(path_pts, closed, dragged_idx, new_pos, node_type)`, drives both `mouseMoveEvent`'s drag and `keyPressEvent`'s arrow-key nudge (via a shared `_resolve_bezier_moves` wrapper that looks up the dragged point's owning v0's type) — replacing each one's single `vertex_moved.emit(...)` call with a loop emitting once per point the helper says must move. Dragging/nudging a **v0** always rigid-translates both adjacent handles by the same delta (type is irrelevant) unless a handle doesn't exist (an open path's start/end has only one). Dragging/nudging a **v1/v2** consults the owning v0's type: `disjointed` (or no partner handle exists — the open-path-boundary case) moves only the dragged point; `symmetric`/`same_angle` also move the opposite-side handle per the formulas above.
- **`_owning_v0_index(idx, n, closed)`** (pure, shared by `_bezier_linked_moves` and `_resolve_bezier_moves`) resolves which v0 a handle belongs to: v0 owns itself; **v1** (`idx % 3 == 1`, this v0's own forward handle) is owned by the v0 right before it (`idx - 1`); **v2** (`idx % 3 == 2`) is owned by the v0 right *after* it (`idx + 1`), *not* the v0 its own segment starts from — v2 is the incoming tangent handle for the *next* v0, symmetric to how v1 is the outgoing tangent handle for its own v0. Getting this backwards was a real, shipped bug: `_resolve_bezier_moves` originally computed the owning v0 via a uniform `dragged_idx - dragged_idx % 3`, correct for v0/v1 but silently wrong for v2 (it resolved to the v0 the segment *starts* from instead of the one v2 is actually attached to) — dragging the control point *before* an on-curve vertex always read that wrong v0's type (frequently `disjointed`, if the wrong v0 had no override), making it look permanently disjointed regardless of its real owning v0's type, while dragging the one *after* an on-curve vertex worked correctly. `partner_idx` (the *other* handle attached to the same owning v0 — `idx - 2` for a v1, `idx + 2` for a v2) was already correct throughout; only the type-lookup's own v0 resolution was wrong.
- **Structural changes reindex, never reclassify**: deleting vertices (`PathViewer._delete_vertex`) and a plain (non-Bezier-mode) Add Vertex both build an `old_index -> new_index` map and call the new `_PathViewport.remap_node_types(index_map)` (wrapping the pure `_remap_node_types`) *before* mutating `self._path`, so an explicit override on a v0 elsewhere in the path survives the point list shifting under it — a deleted index's old node-type entry (if any) is simply dropped.
- **Bezier-mode Add Vertex is a De Casteljau split**, not a plain point insert — right-clicking *on the curve* (not a vertex) picks against the **tessellated** curve (`_pick_bezier_segment_t`, reusing `_tessellate_bezier`'s own `steps=32` sampling so picking and rendering can never drift apart) instead of the straight v0-to-v0 line, recovering which cubic segment and curve parameter `t` the click landed nearest (coarse micro-segment from the pick result, refined by projecting the click onto that micro-segment). `_decasteljau_split(p0, c1, c2, p3, t)` (pure, standard cubic De Casteljau subdivision) returns 5 points `(a, d, f, e, c)` that splice in place of the segment's 2 old interior control points (`path[i0+1:i0+3] = [a,d,f,e,c]`, net **+3** points) — `f` is the new on-curve vertex, landing exactly on the original curve, and both resulting half-segments retrace the original exactly (the curve's visible shape doesn't change at all). `PathViewer._on_viewport_bezier_vertex_added` builds the reindex map (old `i0+1`/`i0+2` — the replaced control points — are dropped; everything from old `i0+3` onward shifts by `+3`) *before* splicing, then classifies just the new vertex `f` (`_PathViewport.classify_single_node`, the single-vertex counterpart of the off→on transition's full `_classify_all_node_types` — reused by both) without touching any other v0's already-recorded type.
- **Deleting a single on-curve vertex (v0) in Bezier mode does its best to keep the curve's shape close to before**, rather than leaving a straight-line gap — `PathViewer._delete_vertex` special-cases `self._mode() == "bezier" and len(rows) == 1 and rows[0] % 3 == 0` (any other selection — a handle, or multiple points at once — falls through to the ordinary plain-removal path) and delegates to `_delete_bezier_v0(i0)`:
- **Interior v0** (both a preceding and following segment exist — for a closed path with more than one segment, always true): the two adjacent segments merge into one new segment, least-squares fit via `_fit_merged_segment(p0, c1, c2, v0, c3, c4, p3, samples=16)` (pure) to approximate the shape of both — `p0`/`p3` (the surviving neighboring v0s) are fixed, only their two facing control points (`NEW_C1`/`NEW_C2`) are solved for, via the closed-form linear least-squares solution to `minimize sum_k |Q_k - B(t_k; p0,C1,C2,p3)|^2` (`Q_k` sampled evenly across both original segments, `B` the standard cubic Bernstein blend — this reduces to an ordinary 2-unknown-per-coordinate normal-equations solve since the two unknowns' basis functions `3(1-t)^2t`/`3(1-t)t^2` don't depend on the point's coordinate). When the two original segments genuinely came from one undivided cubic (e.g. the reverse of a De Casteljau split), the fit recovers the original control points exactly; otherwise it's a good approximation (verified ~27x lower sum-squared-residual than the naive fallback of just keeping the two *outer*, unrelated handles unchanged, which `_fit_merged_segment` itself only falls back to if the least-squares system is singular, e.g. degenerate/coincident samples). Net **-3** points (the deleted v0 and its two innermost handles; the outer two handles' *values* are replaced by `NEW_C1`/`NEW_C2` rather than removed).
- **Endpoint v0** (open path start/end, only one adjacent segment): no merge is meaningful — removing an entire terminal segment necessarily just shortens the curve — so the v0 and its own 2 handles on the one side that exists are dropped outright, leaving the neighboring v0 as the new path start/end (also net -3).
- **Closed-path v0 alignment**: `_tessellate_bezier`'s closed-path indexing assumes every v0 sits at `idx % 3 == 0` starting from index 0 — if the deleted v0 happened to be (at or before) whatever old index would otherwise land at new index 0, every point after it would end up misaligned by 1 or 2. `_delete_bezier_v0` sidesteps this by walking survivors starting from `next_v0` (itself a v0, guaranteed to survive) rather than from old index 0, for the closed interior-deletion case specifically — verified against the case that actually exposed this (a 2-segment closed path, deleting the v0 originally *at* index 0).
- Reindexing (`remap_node_types`) happens first, as with every other structural change, so an explicit override elsewhere in the path survives; then whichever of `p0`/`p3` (or the single surviving neighbor, for an endpoint deletion) still exist are *reclassified* (`classify_single_node`) from their new handle geometry — their own adjacent handle just changed value, so a previously-recorded type may no longer match.
### GridViewer (QDialog)
`QSplitter`: `_GridViewport` on left, Row dropdown + vertex table on right. Dismiss button (20px right padding) below; in `editable=True` mode, Dismiss is replaced with Cancel/Save. Detects lists of lists of points (`_is_grid`); rows need **not** be the same length — a ragged/non-rectangular grid (e.g. a cone's single-point apex row next to a wider base row) is supported, not just a rectangular one. When editable, cell edits (`itemChanged`) apply to whichever row the dropdown currently shows (`self._grid[row_idx][col_idx][j]`), refresh the viewport preview via `_rebuild()`, and — on Save — emit the whole grid re-serialized via `committed`; `_populate_table` blocks table signals during row-switch repopulation so switching rows doesn't misfire `itemChanged` for the newly-shown row's cells.
- **Row dropdown**: `QComboBox` selects which grid row to display in the vertex table. Changing the row highlights that row's vertices in the viewport.
- **"Row Points (N)" label**: shows the point count for the selected row (rows can have different counts).
- **Vertex table**: `QTableWidget`, 0-indexed rows, X/Y/Z columns. Extended multi-select. Selecting rows highlights individual vertices in the viewport.
- **Flat indexing for ragged rows**: since rows can differ in length, point index ↔ `(row, col)` conversion isn't simple `divmod` arithmetic. `_grid_row_offsets(grid)` computes cumulative per-row flat-index offsets once (`offsets[r]` = flat index of row `r`'s first point); `_grid_flat_to_rc(vi, offsets)` (a `bisect` lookup) converts a flat index back to `(row, col)`. Both `GridViewer` and `_GridViewport` share this scheme instead of assuming a uniform column count.
- **Orbit-dragging and vertex-dragging**: same rules as `PathViewer` above — `_orbit_enabled = not is_2d` disables camera orbit for 2D grids; Cmd+dragging a vertex marker (`editable=True`) reprojects to the Z-locked plane (2D) or the drag-start axis-locked plane from `_view_locked_axis` (3D), and emits `vertex_moved(flat_index, x, y, z)`. `_on_viewport_vertex_moved` converts the flat index via `_grid_flat_to_rc`, writes into `self._grid[row][col]` (including `z` for 3D grids), switches the row dropdown if the dragged vertex belongs to a different row than currently shown (reusing `_on_row_changed`'s existing repopulation, which already reflects the just-written coordinates) or otherwise updates the visible table cell(s) directly, then calls `_rebuild()`.
- **Add/Delete Row/Column context menu** (`editable=True` only, replacing the earlier button-based UI): right-clicking the vertex table (`setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)` + `customContextMenuRequested` → `_show_vert_table_context_menu`) opens "Add Row Before", "Add Row After", "Add Column Before", "Add Column After", "Delete this Row", "Delete this Column". "This row" is always whichever row `_row_combo` currently shows; "this column" is the specific table row (a grid column/point index) under the cursor (`self._vert_table.rowAt(pos.y())`) — the two column-specific actions are omitted entirely when the click lands on empty space below the last point, since there's no column to act on there. No menu at all in Bezier Patch mode -- see below.
- **Right-click a vertex in the viewport for Delete Row/Delete Column** (`editable=True` only, and not immediately after a right-button pan drag — same `_context_menu_suppressed` convention as `_PathViewport`): `_GridViewport.contextMenuEvent` offers "Delete Row" and "Delete Column" for whichever vertex `_pick_vertex` hits (no menu at all if the click misses every vertex — unlike Path/Region, a grid has no "click a line to add" gesture here). Both emit the flat vertex index (`delete_row_requested`/`delete_column_requested`) → `GridViewer._on_viewport_delete_row_requested`/`_on_viewport_delete_column_requested`, which convert it to `(row_idx, col_idx)` via `_grid_flat_to_rc`, switch `_row_combo` to that row (so the table reflects what's about to be deleted, matching `_on_viewport_vertex_clicked`), and delegate to the existing `_delete_row`/`_delete_column` (same methods the vertex table's own context menu uses, including their silent refuse-below-minimum behavior). `contextMenuEvent` also clears the base `Viewport`'s orbit/pan tracking right before `menu.exec()` (not unconditionally at the top), and `mousePressEvent` resets `_context_menu_suppressed` at the start of every new gesture — see the "Right-drag panning vs. context menus" note on `_PathViewport.contextMenuEvent` above for why. No menu at all in Bezier Patch mode -- see below.
- **Add/Delete Row/Column disabled in Bezier Patch mode**: a Bezier Patch's control net requires an exact 4x4 grid, so `GridViewer._is_bezier_mode()` (`self._face_mode_combo.currentText() == "Bezier Patch"`) gates every entry point into `_add_row`/`_delete_row`/`_add_column`/`_delete_column` -- guarded at the mutation methods themselves (the single shared root, refusing silently, same no-popup convention as the existing row/point-count minimums), and additionally at both context-menu builders (`_show_vert_table_context_menu` shows no menu at all; `_GridViewport.contextMenuEvent` checks a `_bezier_patch_mode` flag it caches from `load_grid`'s `bezier_patch` argument) so the menus don't even appear rather than silently no-opping when clicked.
- `_add_row(before)`/`_add_column(before, col_idx)` share the same before/after logic: the new row/point is the midpoint with whichever neighbor sits in that direction (wrapping around if Row Wrap/Col Wrap is on), or — if there's no such neighbor (row 0's "before", the last row's "after", with no wrap; same for columns) — this row/point's own values nudged along the last coordinate so the new one isn't exactly coincident. For Add Column specifically, each row computes its own neighbor/insert position against its *own* length (not the clicked row's), so a ragged grid's shorter/longer rows each still get a sensible placement, and rows too short to reach `col_idx` at all are simply left unchanged.
- `_delete_row()` refuses (silently) below the 2 rows `_is_grid` requires; `_delete_column(col_idx)` refuses entirely if removing it would empty any affected row (a grid row needs >= 1 point).
- All four call `_sync_face_mode_combo()` first (adds/removes the "Bezier Patch" option to match whether the grid is still exactly 4x4 after the change, resetting the selection to "Grid Faces" if it no longer qualifies) and `_GridViewport.sync_row_bookkeeping(grid)` before `_rebuild()` — a small dedicated method (not folded into `load_grid`) so `_grid_rows`/`_row_offsets` (needed by `set_selected_row`, called from `_on_row_changed` right after) are correct even if `_rebuild()`'s GL-side work is a no-op because the widget hasn't been shown yet (no GL context = no-op `load_grid`, but the bookkeeping still needs to be right before `_on_row_changed` runs).
- `_on_row_changed(row_idx, select_all=True)`'s `select_all` flag distinguishes ordinary row-switching (select-all, the default) from `_delete_row`/`_delete_column`, which pass `select_all=False` so the user ends up with *nothing* selected after a delete rather than the whole (possibly renumbered) row auto-selected as a side effect of switching to it — `Add Row`/`Add Column` still select-all as before, only Delete changes. `select_all=False` clears both the table selection and `_vp`'s viewport selection (`_vp.set_selected([])`) explicitly, rather than relying on `clearSelection()`'s `itemSelectionChanged` signal alone — if `_populate_table` happened to leave the freshly-repopulated table with no rows selected already, Qt won't fire that signal (no change to signal), which would otherwise leave the viewport's own still-blinking selection stale.
- **Viewport**: `_GridViewport(Viewport)`. Always draws row lines (red, within one row) and column lines (blue, between adjacent rows) via `SceneRenderer.upload_lines`. Row lines use each row's own length independently. Column lines/quad faces between a pair of rows use `min(len(row_r), len(row_{r+1}))` — the columns the two rows actually share. **Triangular grids** — any grid where two adjacent rows differ in length (`_grid_is_triangular`), e.g. a cone's apex-to-base taper or a triangular-number row progression (1, 2, 3, ...) — additionally get a third, diagonal line direction (green) and, in faces mode, matching mesh faces. Two pieces combine to cover every quad *and* every leftover point: (1) one diagonal per shared-column quad cell (`(row_r[c], row_{r+1}[c+1])` for every `c` in `[0, shared - 1)`, the same edge the mesh splits each quad on — e.g. a row growing from 3 points to 4 shares 3 columns, giving 2 quad cells, each with its own diagonal), plus (2) `_grid_fan_spec(len_a, len_b, col_wrap)`, which anchors at the shorter row's last shared-index point and fans out to any points beyond the shared range (e.g. all 7 edges of an 8-point base row fanning from a single apex point). A row pair sharing fewer than 2 columns (e.g. an apex row next to any other row) is *only* fan, with no quads or quad-diagonals. Fan triangle winding is chosen to match the adjoining quad triangles' orientation, so mesh shading doesn't flip at the quad/fan seam. A plain rectangular grid (all rows the same length) has no mismatch to trigger any of this, so it gets only row and column lines/quads. In faces mode, also draws triangulated quad mesh faces (yellow both sides) via `upload_mesh`. `show_edges = True` enables `GL_POLYGON_OFFSET_FILL` when a mesh is present, pushing face triangles slightly back so the skeleton lines always render in front. Vertex markers are depth-tested (occluded by mesh, `SceneRenderer.depth_test_points = True`), which — since the row/column skeleton lines sit exactly on top of the marker positions — meant markers and lines shared identical depth at every vertex, so the tie could resolve either way (markers flickering/z-fighting against the grid's own wireframe). Fixed the same way as the face/edge polygon-offset trick just above: `_render_simple_points` (in `renderer.py`) now applies its own small `GL_POLYGON_OFFSET_FILL` (`(-1.0, -1.0)`, pulling toward the camera) only while `depth_test_points` is on, so vertex markers win that tie and always render over grid/path lines while still being correctly occluded by mesh faces meaningfully in front of them. Theme-colored (`renderer.unselected_vertex_color`) dodecahedra for unselected, red↔white blink (250ms) for selected. The blinking-selection overlay is a *separate* draw call (`_paint_extra`, right after the main lines/points pass — see below), so it needed the identical `polygon_offset` bracket applied independently; `_GridViewport`/`_VNFViewport` (both `depth_test_points = True`) apply it there too, rather than fully disabling depth test the way `_PathViewport`'s `_paint_extra` does (Path never has occluding mesh faces, so a blanket disable is correct there — but doing that for Grid/VNF would let a selected vertex on the far side of the mesh incorrectly show through it). Hovering shows `[row,col]: (x, y, z)` tooltip. Clicking a vertex applies the standard replace/add/toggle selection mode (see `PathViewer`'s "Standardized vertex-click selection" above) — with one grid-specific rule: the vertex table only ever shows one row's own vertices at a time (via the Row dropdown), so if the clicked vertex belongs to a *different* row than currently shown, `_on_viewport_vertex_clicked` switches the dropdown to it first and forces the mode to "replace" regardless of what was held — the previous row's selection no longer exists once the table repopulates, so "add"/"toggle" can't mean anything across that boundary. Clicking blank space (`vi < 0`) clears the table selection (replace mode's usual meaning); matches `PathViewer`/`RegionViewer`/`VNFViewer`.
- **Face mode**: dropdown, replacing the old "Faces" checkbox — "Grid Only" (skeleton lines only), "Grid Faces" (default; triangulated quad mesh behind the skeleton lines, as before), and "Bezier Patch" (a smooth bicubic Bezier surface instead of flat quads). "Bezier Patch" only appears in the dropdown at all for a grid that's exactly 4 rows of 4 points each (`GridViewer._sync_face_mode_combo` recomputes the offered modes from the grid's shape on every rebuild, so adding or deleting a row or column adds or drops it, and simply omits the item otherwise, rather than adding it disabled — a 4x4 grid of points *is* a Bezier patch's control net, so any other shape has nothing meaningful to render). `_bezier_patch_mesh(cp, steps=16)` (module-level, `data_viewer_grid.py`) treats the grid's row and column directions as the patch's two parametric directions `u`/`v` and evaluates `S(u,v) = sum_i sum_j B_i(u) * B_j(v) * cp[i][j]` over a `steps x steps` sample lattice, using the same cubic Bernstein basis (`(1-t)^3, 3t(1-t)^2, 3t^2(1-t), t^3`) as `_PathViewport._tessellate_bezier`'s per-segment curve — just applied in both directions at once (`np.einsum('ia,jb,abk->ijk', basis, basis, cp)`) rather than along one curve. Each sampled quad is split into 2 triangles with a cross-product face normal, exactly like the flat "Grid Faces" quad-splitting logic, and uploaded via the same `upload_mesh(..., backface_color=(0.9, 0.85, 0.1, 1.0))` call — so a Bezier patch looks and behaves identically to a quad-faced grid (same color, same polygon-offset-vs-skeleton-lines behavior) except the surface actually curves through the interior instead of being flat per quad. The patch always passes exactly through its 4 corner control points (the Bernstein basis is 1 at `t=0`/`t=1` and 0 for the other three), so corner-dragging behaves identically to quad-faces mode; only the interior curvature differs. Row/column wrap and the ragged-grid triangular-fan logic are irrelevant to Bezier mode (a rectangular non-ragged 4x4 grid can't be ragged, and wrapping a Bezier patch isn't implemented) — `bezier_patch` is a separate boolean passed alongside `draw_faces` into `_GridViewport.load_grid`, and the two are mutually exclusive (the dropdown only ever selects one).
- **NURBS Surface / NURBS Interpolated Surface** (face modes, offered for any rectangular grid -- BOSL2 wants a rectangular patch, so a ragged row rules them out): the grid is the control net of a uniform B-spline surface, `nurbs_patch_points(grid, [du, dv], type=...)`, or the surface passes *through* every point, `nurbs_patch_points(nurbs_interp_surface(grid, [du, dv], row_wrap=, col_wrap=))`, centripetal and unconstrained. Two **Degree u/v** spinboxes, shown only in these modes, set the degree in each direction in BOSL2's terms: u runs down the rows (the first index), v along each row. Each is capped at that direction's point count minus 1, re-capped on every rebuild, so deleting a row or column lowers it rather than breaking the surface. The existing wrap dropdown sets each direction clamped or closed: "Wrap Rows" closes u, "Wrap Columns" closes v. The control net stays drawn, as the skeleton lines it always is.
- The dialog samples the surface (`GridViewer._nurbs_surface`, 16 steps per knot span as BOSL2 defaults) and hands `_GridViewport.load_grid` the sampled grid as `surface=`, which `_surface_mesh` triangulates -- the same two-triangles-per-cell tessellation the Bezier patch uses, now shared, with the last row/column joined back to the first in a closed direction. Points BOSL2 would refuse (duplicate neighbours, a singular fit) give no surface rather than an error.
- The maths is `belfryscad/nurbs.py`: `patch()` is the tensor product BOSL2 uses -- a curve down every column, then along every row of the result -- built on the curve port, since `curve()` is linear in its control points and a whole row of 3D points can be fitted as one long point. `interp_surface()` averages each grid line's centripetal parameters across the grid, then solves every row in v and every column of that in u. Its closed direction is BOSL2's `_build_closed_system`, which unlike the closed *curve* fit searches no seam rotation and nudges a parameter that lands on a knot. Checked against BOSL2's echoed output for every clamped/closed pairing of both, with mixed degrees (`tests/test_nurbs.py`). Same limits as the curves: no weights, knots, multiplicities, `type="open"` or `nurbs_interp_surface`'s derivative/normal/edge constraints, since the grid literal carries none of them.
- **Wrap mode**: dropdown, replacing the old separate "Col Wrap"/"Row Wrap" checkboxes — "No Wrap", "Wrap Columns", "Wrap Rows", "Wrap Both". `GridViewer._wrap_flags()` maps the selected text to the `(col_wrap, row_wrap)` booleans `load_grid` already took. "Wrap Columns" connects each row's last point back to its own first point, closing that row's line loop (and, in faces mode, the mesh) horizontally — applied per row, so it works even when rows have different lengths. "Wrap Rows" connects the last row back to the first, closing the grid vertically (limited to the columns shared between the last and first rows, same as any other adjacent row pair). "Wrap Both" combines both, forming a torus for a rectangular grid.
- 2D grids start in top-down orthographic; 3D grids start in perspective orbit.
### RegionViewer (QDialog)
`QSplitter`: `_RegionViewport` on left, Path dropdown + vertex table on right — modeled on `GridViewer`'s "index dropdown selects one sub-list" UI, combined with `PathViewer`'s per-path vertex editing (add/delete vertex, always-closed loop, 2D-only drag/nudge), since each path in a region *is* an independently-closed polygon, unlike a grid's rows, which connect to their neighbors. Read-only by default; pass `editable=True` for a Save/Cancel editing mode. **Regions are always 2D** — `_RegionViewport`'s camera is permanently locked top-down orthographic with orbit disabled (`_orbit_enabled = False` unconditionally), there is no 3D mode to switch into, unlike `PathViewer`/`GridViewer`.
- **`_is_region(v)`**: a list of `>= 1` closed 2D polygon paths, each with `>= 3` points, representing non-overlapping perimeters under **even-odd fill semantics** — a path nested inside another alternates solid/hole (e.g. three concentric circles = a central disc surrounded by a ring). Structurally identical to `_is_grid` whenever there are `>= 2` paths (a 2D-only grid *is* a list of point-lists too) — the one case that doesn't also read as a grid is a single-path region (no holes), since `_is_grid` requires `>= 2` rows. For the overlapping case, both "Edit as Grid..." and "Edit as Region..." are offered and the user picks the intended interpretation — the same precedent as a grid row also being a valid path, or a square matrix also being a valid affine transform.
- **Path dropdown**: `QComboBox` selects which path to display in the vertex table (labeled "Path:", analogous to `GridViewer`'s "Row:"). **"Path Points (N)" label** shows the point count for the selected path.
- **Vertex table**: `QTableWidget`, 0-indexed rows, X/Y columns only (regions are 2D-only, no Z column). Extended multi-select. When `editable=True`, cells are directly editable, same `itemChanged` parse/validate/revert convention as the other editors.
- **Delete Path / Add-Delete Vertex context menu** (`editable=True` only): right-clicking the vertex table opens "Add Vertex Before"/"Add Vertex After" (only when the click lands on a specific point — `rowAt(pos.y())`), "Delete this Path", and "Delete this Vertex" (also only on a specific point). "This path" is always whichever path `_path_combo` currently shows.
- `_add_vertex(before, vertex_idx)` inserts the midpoint of `vertex_idx` and its neighbor in that direction. Unlike `GridViewer`'s Add Row/Column, every region path is an implicitly closed polygon (no "Close Path" checkbox), so the neighbor is always found by wrapping (`% len(path)`) — there's no "no neighbor" case to fall back on. `_delete_vertex(vertex_idx)` refuses below the 3 points `_is_region` requires (a polygon minimum, not `PathViewer`'s 2). `_delete_path()` refuses (silently) if that would leave 0 paths (`_is_region`'s minimum — unlike `GridViewer`'s 2-row minimum, a region may have just 1 path).
- **No "Add Path" here, deliberately**: unlike Add Row/Column, a brand new path has no meaningful "before"/"after" position relative to an existing one — there's nothing sensible to place it next to. Instead, right-clicking the viewport (`_RegionViewport.contextMenuEvent`, gated on `editable`, not over an existing vertex, and not immediately after a right-button pan drag — `_context_menu_suppressed`) offers one of two actions, checked in this order:
- **On a path's line**: "Add Vertex", inserting a point **exactly at the clicked location** on that segment (not a midpoint, unlike the table's Add Vertex Before/After). Segments span every path's own points, built fresh each time from `_path_offsets` (`(start+k, start+(k+1)%n)` per path, so each path's own wrap-around edge is always included — a region path never has an "open" case the way `PathViewer`'s does), and picked via the same `SceneRenderer.pick_nearest_segment`/`_closest_point_on_segment_to_ray` machinery as `PathViewer`. The hit segment's flat start index is converted back to `(path_idx, col_idx)` via `_grid_flat_to_rc`, so `add_vertex_requested(path_idx, col_idx, x, y)` → `RegionViewer._on_viewport_add_vertex_requested` can switch the Path dropdown first if the clicked line belongs to a different path than the one currently displayed (same as clicking a vertex does), then insert at `col_idx + 1` — which, for the wrap segment, is always exactly `len(path)`, correctly appending rather than inserting before index 0.
- **Otherwise, blank space**: "Add Path", which appends a small rectangle centered at the clicked position (reprojected to world space via `_ray_plane_axis_locked` with `lock_axis=2`, then sized via `_marker_radius_for_point(self._vp, [x, y, 0.0]) * 8` so it looks reasonable at the current zoom) — a concrete starting shape the user then builds out with Add/Delete Vertex, rather than a duplicate of whatever path happens to be selected.
- **Vertex-click selection**: clicking a vertex applies the standard replace/add/toggle mode (see `PathViewer`'s "Standardized vertex-click selection"), with the same one-group-at-a-time rule `GridViewer` has: the vertex table only ever shows one path's own vertices at a time (via the Path dropdown), so a click on a vertex belonging to a *different* path switches the dropdown first and forces "replace" — "add"/"toggle" can't span that boundary once the table repopulates. Left-click on blank viewport space (`vi < 0`) still deselects everything (replace mode's usual meaning) — `_apply_click_selection`'s `clearSelection()` call flows via the existing `itemSelectionChanged` → `_on_vert_table_selection` → `_vp.set_selected([])` chain, which stops the blink timer and rebuilds markers back to the theme's unselected-vertex color, the same as any other selection change. This is a general convention across every viewer with vertex selection (`PathViewer`, `GridViewer`, `RegionViewer`, and `VNFViewer`'s face-click-driven equivalent).
- **Vertex dragging and arrow-key nudging** (`editable=True` only): identical mechanism to `PathViewer`'s 2D case (always locked to the Z=0 plane, no `_view_locked_axis` branch needed since regions have no 3D mode at all) — Cmd+drag reprojects via `_ray_plane_axis_locked` with a fixed `lock_axis=2`, arrow keys use `_key_nudge_delta(camera, 2, key, magnitude)` with the same `_key_nudge_magnitude` step sizing (1 unit, 0.1 with Cmd, 10 with Shift) as `PathViewer`/`GridViewer`.
- **Fill mode**: dropdown — "Region Only" (outlines only) or "Region Filled" (default). Filled mode tessellates via `_region_fill_mesh(paths)`, which builds a `manifold3d.CrossSection(paths, FillRule.EvenOdd)` and extrudes a hairline (`1e-3`) sliver, reading the resulting `Manifold`'s mesh back as flat (`Z` forced to `0`) triangles — reusing `manifold3d`'s own fill-rule/triangulation rather than hand-rolling an even-odd polygon tessellator, the same trick `openscad_cpp_evaluator.to_renderable_bodies` already uses to render a lone 2D shape in the main viewport. The extruded prism's side-wall triangles collapse to degenerate zero-area slivers once flattened to `Z=0` (harmless — a few extra invisible triangles, not worth filtering out by face normal).
- **Viewport**: `_RegionViewport(Viewport)`. Each path drawn as its own closed line loop, cycling through `_REGION_PATH_COLORS` (6 colors) by path index so overlapping/nested paths stay visually distinguishable while editing. `show_edges = True` (same polygon-offset trick as `GridViewer`) pushes the fill mesh backward so the outlines reliably win the depth tie against it — both are coplanar at `Z=0`, so without this the outline could randomly z-fight against the fill depending on triangle rasterization order. Vertex markers use `depth_test_points` at its **default `False`** (unlike Grid/VNF) — since the camera is always locked top-down with no 3D orbit, there's no real occlusion scenario to get right, so the simpler "always draw markers regardless of depth" approach (same as `PathViewer`'s 2D case) suffices; `_paint_extra`'s blink overlay likewise just disables depth test outright rather than needing Grid's polygon-offset tie-break.
### MatrixViewer (QDialog)
`QTableWidget` only, no 3D viewport — displays a square 2x2 through 5x5 list of lists of numbers (`_is_matrix`, e.g. an affine/rotation matrix) as an NxN grid of cells. Row/column headers are 0-indexed (`_style_table_headers` for the header look, shared with `GridViewer`'s vertex table). Read-only by default (`NoEditTriggers`, non-editable `QTableWidgetItem`s) with a single Dismiss button; pass `editable=True` for `DoubleClicked`/`EditKeyPressed` edit triggers, Save/Cancel buttons, and an `itemChanged` handler that parses/validates each edit (reverting non-numeric input) and updates `self._value` in place — no source writeback happens until Save is clicked, which emits the whole re-serialized matrix (`data_viewer_common._format_value`) via the `committed` signal. Dialog size is computed from the table's content size (`resizeColumnsToContents` + summed column/row extents) rather than a fixed constant, since 2x2 and 5x5 need noticeably different window sizes. No overlap with `_is_grid`: a grid row is a list of *points* (2/3-number lists) — one nesting level deeper than a matrix row, which is a list of plain numbers. There *is* overlap with `_is_path`, though: a 2x2 or 3x3 matrix's rows are themselves valid 2D/3D points, so both "View as Path..." and "View as Matrix..." can appear for the same value; 4x4/5x5 rows are too long to match `_is_path`'s point-length check (2 or 3), so only "View as Matrix..." appears there.
### AffineMatrixViewer (QDialog)
`QSplitter`: `_AffineViewport` on left, matrix table (same look and, when `editable=True`, the same Save/Cancel editing convention as `MatrixViewer`'s) on right — an edit additionally calls `_AffineViewport.load_matrix()` to refresh the reference/transformed-shape preview live. Detects specifically a homogeneous **affine transform** matrix — `_is_affine_matrix`: a 3x3 (2D) or 4x4 (3D) `_is_matrix` whose bottom row is exactly the homogeneous identity row `[0, ..., 0, 1]` (within float tolerance) — as opposed to an arbitrary numeric matrix, which just gets `MatrixViewer`'s plain table. Every affine matrix also satisfies `_is_matrix` (3x3/4x4 are in its 2-5 range), so "View as Matrix..." and "View as Affine Transform..." both appear in the context menu for the same value.
- **Reference shape**: `_affine_reference_shape(n)` — a unit square centered at the origin for `n == 3` (2D), a unit cube for `n == 4` (3D); `_affine_shape_edges(n)` gives the wireframe edge index pairs (4 for the square, the standard 12-edge cube wireframe for the cube).
- **Transform math**: `_apply_affine(matrix, points)` — promotes each `(N-1)`-dim point to homogeneous coordinates (`[x, y, ..., 1]`), multiplies by the matrix, and drops the trailing `1` back off. Pure/`numpy`-only, pytest-covered independent of any Qt/GL setup.
- **Translate/Rotate/Scale/Skew tools** (editable mode only): a `QTabWidget` between the splitter and the button row lets the user *compose* a new operation onto the current matrix rather than hand-editing numbers. Each Apply computes `M_new = N · M` (the new op `N` left-multiplied onto the existing matrix `M` — "applied after" the existing transform, not decomposed from it: a composed matrix has no memory of the operations that built it, so recovering an original authored order is fundamentally impossible; composing forward sidesteps that entirely). Rotate/Scale take an explicit **Center** field (`M_new = T(c)·N·T(-c)·M` via `_pivot_about`), pre-filled once at dialog-open from the matrix's current translation column (`M[i][n-1]`) so the common case — rotate/scale in place — needs no typing; Translate needs no center (translation commutes with any pivot wrap). Skew (`_shear_matrix`) has two axis dropdowns — the second excludes whichever axis the first has selected, which naturally collapses to a single valid pair for the 2D (n=3) case with no special-case code — and is applied about the origin only (no centerpoint), matching OpenSCAD's own convention. Pure matrix builders (`_identity_matrix`, `_translation_matrix`, `_axis_rotation_matrix`, `_scale_matrix`, `_shear_matrix`, `_pivot_about`, `_compose_after`) live beside `_apply_affine` and are pytest-covered the same way. **Reset to Identity** and **Reset to Original** buttons, plus keyboard-only Cmd+Z/Cmd+Shift+Z, come from the shared `_UndoableViewerMixin` — see "Undo/Redo" above.
- **Viewport**: `_AffineViewport(Viewport)`. Draws the reference shape as a gray wireframe and its transformed image as an orange wireframe (`SceneRenderer.upload_lines`), both via the shared `_dodecahedron_faces`/`_marker_radius_for_point` module-level helpers (extracted from what used to be near-identical duplicated methods on `_GridViewport` and `_PathViewport`, now used by every viewer with vertex markers: `_AffineViewport`, `_VNFViewport`, `_PathViewport`, `_GridViewport`, `_RegionViewport`). The transformed shape's corners get dodecahedron point markers too, with the **first corner marked red** rather than orange — since a plain shape has no other visual landmark, this is what makes reflections/orientation flips (e.g. a negative-determinant matrix) visible at a glance instead of looking identical to a non-mirrored rotation. Camera framing (`frame_scene`) covers the union of both shapes' bounding boxes. 2D matrices start in top-down orthographic; 3D matrices start in perspective orbit — same convention as `GridViewer`/`PathViewer`.
- **`_marker_radius_for_point(vp, world_point)`**: computes each marker's world-space radius from the *specific point's* actual distance from the camera eye, not a single shared value for the whole batch — replacing the earlier `_viewport_marker_radius(vp)`, which used only `cam.distance` (the camera's target distance) for every marker regardless of where that marker actually was. That single-value approach happened to be exactly correct in **orthographic** mode (apparent size never depends on a point's eye-distance there at all — the defining property of orthographic projection — and `Camera.projection_matrix`'s ortho half-height is itself defined as `distance * tan(fov/2)`, deliberately matching perspective's apparent size at the target depth for a seamless mode toggle) and for any viewer permanently locked top-down (`PathViewer`/`GridViewer`'s 2D case, `RegionViewer` always — every vertex is coplanar with, and thus equidistant from, a camera looking straight down at that plane). It was **wrong in perspective mode** for any 3D scene where vertices aren't all sitting at exactly the target's depth (routine for an orbited/zoomed 3D `PathViewer`/`GridViewer`/`VNFViewer`, or `AffineMatrixViewer`'s 3D case): apparent size genuinely scales with each point's own eye-distance in perspective, so a marker nearer the eye than the target rendered larger than intended, and one farther rendered smaller — most noticeable as "zooming in close makes markers look bigger" when orbiting/zooming toward one end of a 3D shape rather than its center. Every marker-building loop (`_build_point_markers`/`_build_sel_markers`/`_build_vert_markers` across all five viewports) now builds one set of **unit**-scale offset triangles via `_dodecahedron_faces(1.0, is_2d)` (cheap, geometry only, computed once per rebuild) and multiplies by `_marker_radius_for_point(self, pt)` per vertex inside the loop, rather than computing one fixed radius for the whole batch up front.
### ProfileViewer (QDialog)
`QTableWidget` only, no 3D viewport — one row per `CallSiteProfile` from a `ProfileResult` (see openscad_cpp_evaluator's `CLAUDE.md` on profiling). Unlike every other viewer here, it's launched directly from the Design menu ("Show Profile Report…", `MainWindow._show_profile_report`), not from `build_viewer_menu()`/a value's context menu — a profiling result isn't an OpenSCAD value with a console representation. `setSortingEnabled(True)` with a small `_NumericTableWidgetItem(QTableWidgetItem)` (`__lt__` override sorting by a stored numeric value) so numeric columns sort correctly rather than lexically — the first sortable table in this codebase; every other viewer's `QTableWidget` is unsorted. Default-sorted by "Self (ms)" descending. A search row above the table filters to rows whose `Name`/`Caller`/`Caller File` match the typed text (case-insensitive, `setRowHidden` per keystroke — the same pattern `DebuggerPane`'s variables-panel search uses), via a `QComboBox` (`Contains`/`Starts With`/`Exact Match`, default `Contains`) next to the `QLineEdit`. Row → `CallSiteProfile` lookup (for navigation and the context menu) is stored directly on each row's own item via `Qt.ItemDataRole.UserRole`, not a side list indexed by row — `setSortingEnabled` reorders the table's visual rows without reordering a separate Python list, so a row-indexed side list silently goes stale after any sort (a real, shipped bug here: double-clicking a call under the default sort could navigate to an unrelated row). `navigate_requested = Signal(str, int)` (double-click a row, or right-click for "Go to Call Site"/"Go to Declaration") is connected by `MainWindow` to `_on_debug_frame_selected` — the exact same open-tab-and-highlight-line handler the debugger's call stack table already uses, reused as-is since it has no debug-session dependency. The context menu's last two actions, "Filter by Name"/"Filter by Caller", switch the mode combo to `Exact Match` and set the search box to that row's `name`/`caller_name` — a quick way to see every call site for a given function/module, or everything a given call site invoked, without a same-named substring elsewhere in the file path pulling in unrelated rows.
## Menu Structure
**File**: New / Open… / Open Recent ▶ / Examples ▶ / Close / Save / Save As… / Reload (Ctrl+R, as OpenSCAD: the current tab's text from disk via `_reload_one(force=True)`, the path Automatic Reload uses; asks before discarding unsaved edits, which stay one Undo away since the swap goes through the undo stack; no render) / — / Export… / Export as Image… (the 3D view as shown, as a PNG at the viewport's device-pixel size, offered as `$export_name.png` beside the source like Export…; the CLI's `-o out.png` renders headlessly with its own camera instead) / — / Quit
**Examples** covers one feature per file, named for it, so the menu is a lookup: Basics holds the shapes and moves (`csg`, `transforms`, `mirror`, `resize`, `color`, `polygon`, `text`, `linear_extrude`, `rotate_extrude`, `modules`), Advanced the rest (`hull`, `minkowski`, `offset`, `projection`, `polyhedron`, `multmatrix`, `intersection_for`, `import`, `surface`, `children`). `import` and `surface` ship their data alongside in `Advanced/data/`, which works because both resolve a relative path against the file naming it.
**Examples** is built on demand from `resources/examples/examples.json`, a category → filenames manifest with the scripts in per-category subdirectories (the same shape OpenSCAD uses). Each category becomes a submenu. They open read-only, on the same rule as an installed library file: they live inside the application, so Save would write into the package — Save As is the way to start from one. Hatchling ships everything under the package directory, so no packaging entry is needed for them.
The editor's right-click menu offers **Use Library…** as well, near the top so it does not shift position with whatever word is under the cursor; a read-only tab (a library file) does not get it, having nothing to insert into. It goes up through `CodeEditor.use_library_requested`, the same signal-to-MainWindow pattern as Go to Definition.
**Design ▸ Use Library…** opens `UseLibraryDialog` (`window/library_manager.py`): a combo of installed libraries, a list of that library's `includes` below it, and the selected file's description in a word-wrapped label beside the list, with the exact statement shown under both. A menu of submenus was tried first and is what this replaces — a library can offer dozens of files (BOSL2 23, dotSCAD 276), which is awkward to aim at and leaves nowhere for the description.
Non-modal, and it stays open after Insert: pulling in two or three files from one library is the normal case, and closing on each would make that several trips. It is created once and re-populated on reopen, so a library installed meanwhile appears. Rows are labelled by file, since the verb and directory repeat on every row; the entry point sorts first and is marked as such.
**Edit**: Undo / Redo / — / Cut / Copy / Paste / Select All / — / Expand Selection / Contract Selection / — / Indent / Undent / Comment / Uncomment / Move Line Up / Move Line Down / Convert Tabs to Spaces (each tab out to the next indent stop, `str.expandtabs` at the indent size -- OpenSCAD's is a fixed 4; one undo step) / — / Increase Font Size (Cmd++) / Decrease Font Size (Cmd+-) / — / Copy Viewport Image (Cmd+Shift+C; the viewport's framebuffer to the clipboard) / — / Find… / Find & Replace… / — / Word Wrap (checkable) / — / Read Only (checkable; per-tab, defaults on for files opened from the library directory)
**Design**: Render / Render with Profiling / Show Profile Report… / — / Run Tests… / — / Check Validity / Dump CSG Tree to Console / — / Flush Caches / — / Insert Primitive ▶ (Cube, Sphere, Cylinder, Cone, …) / Boolean Operation ▶ (Union, Difference, Intersection) *(behavior of Insert Primitive and Boolean Operation deferred)* / — / Use Library ▶ *(lists installed libraries; inserts `use`/`include` statement)* / Manage Libraries…
**Check Validity** (`MainWindow._check_validity`): prints `_geometry_check()` -- the same report the AI's geometry check reads -- to the console: whether each part of the last render is a closed manifold solid, then the same for the merged mesh an export would write, with what is wrong (boundary edges, degenerate faces, ...). More than OpenSCAD's single "Valid: yes/no" line, because the parts and the merged mesh can disagree. A 2D result (`flat_preview`) is refused with "Current top level object is not a 3D object.", as OpenSCAD does: its thin preview slab is always sound and says nothing about the shape. With nothing rendered: "Nothing to validate! Try building first (press F6).", OpenSCAD's wording.
**Dump CSG Tree to Console** (`MainWindow._dump_csg_tree`): prints the resolved `CSGNode` tree from the last successful render (`self._last_csg_tree`, set in `_on_render_done` from `_RenderWorker.finished`'s `csg_tree` payload, threaded through the worker's `finished` signal alongside `bodies`/`id_to_node`/`elapsed_ms`/`final_vp`) via `openscad_cpp_evaluator.format_csg_tree()`. "No CSG tree available — render first." if nothing has rendered yet.
**Wrapped lines** — with Edit ▸ Word Wrap on, a wrapped line's continuation rows are **indented** one indent unit and every row that carries on below gets a **return-arrow marker** at the right edge, so a wrapped line does not read as two statements. The indent is not a block format: `QPlainTextEdit`'s layout ignores `QTextBlockFormat`'s `leftMargin`/`textIndent` outright (measured — rows report x=4.0 for every combination, where a `QTextEdit` gives 0.0/40.0). `CodeEditor._apply_wrap_indent` re-runs each visible block's own `QTextLayout` with its own `setLineWidth`/`setPosition`, from `paintEvent` **before** `super()`. It has to be after Qt's own pass, which is lazy — doing it from a `QPlainTextDocumentLayout` subclass's `documentChanged` is discarded, and a custom `QAbstractTextDocumentLayout` is rejected by the widget entirely (`Document set does not support QPlainTextDocumentLayout`). Only on-screen blocks are touched, and only those not already right, so it measures free: 0.55ms vs 0.57ms per repaint on BOSL2's `skin.scad` (5386 lines), scrolling and typing unchanged. Block heights stay equal to the sum of their lines, so scrollbars and hit-testing follow, and selections and coverage tints paint against the indented rows. **Trailing spaces** get a dot each (`_paint_trailing_spaces`) — only trailing ones, since marking every space turns the file into dot-matrix. Both markers use `whitespace_marker_color()`, **not** `guide_colors()`: the guides are deliberately 1.3:1, a hint of structure you are not meant to look at, which left a marker saying "there is something here you cannot otherwise see" too faint to see. It targets 2:1 (#B8B8B8 on white, #4A4A4A on the dark editor) — half again the guides, enough to read, still clearly quieter than the code it annotates.
**Coverage** — which statements, branch arms and function/module bodies the **test suite** executed, painted over the source. Coverage comes from a test run or not at all: there is no per-render capture, because coverage of one run of one script says very little, while "what does my suite miss" is the question a library author actually has. **Design ▸ Run Tests…** asks for a *directory* — tests are project-wide, not per-file — and opens the **Testing** dock (`window/testing.py`, `TestingPane`), which is a pane rather than a dialog so the editor, console and overlay stay usable while a suite runs. It holds the directory (elided to keep a long path from setting the dock's minimum width; full path in the tooltip), a **Collect coverage** box, a **Run Tests** button that becomes **Cancel** mid-run, and a results tree: one top-level row per `.scadtest` file with passed/total, % passed and the coverage that file's tests reach on their own, expandable to the individual tests (a file with a failure opens itself; the failure messages are the row's tooltip). A totals line sits under it. Suites are listed as soon as a directory is chosen, before any run, so **Add Test…** has something to aim at. **Double-clicking a test opens the test editor** (`window/test_editor.py`, `TestEditDialog`) on every field a `TestCase` carries; Adding and deleting live on the tree's **context menu**, which offers what the row you hit can actually do: a file row gets **Run Tests in this File**, then a separator, then **Add Test**, a test row gets **Add Test Before**, **Add Test After** and **Delete Test** (confirmed). `scadtest.insert_test_block` puts the new block either side of its anchor, with the blank line on the side facing it. The row under the table is **New File…** (which creates a `.scadtest` and goes straight into the dialog, since a suite with no tests is no use), then the totals, then **Report…** and **Run Tests** pushed right; the totals label takes the slack rather than a bare stretch, or it sits at its text width and clips. The row above the table is just the two checkboxes. The **AI chat pane** reaches all of this too: `window/ai_test_tools.py` adds `list_tests`, `read_test`, `run_tests`, `read_coverage_gaps` and `propose_test_change` to `ai_tools.TOOLS` (so they reach the MCP server as well). Reads and runs go straight through `scadtest`, which is Qt-free, so they answer within the call rather than waiting on the GUI thread; every path is confined to the chosen tests directory. Writes do **not** happen in the tool — they come back as a `Proposal` of kind `test_edit` carrying the whole new file, reviewed in the chat pane's diff bar and written by `_on_ai_proposal_accepted` through `_write_test_file`, because "never write to disk unprompted" applies to a `.scadtest` as much as to a script. The menu is built by `context_menu_for` and shown by `_show_context_menu` separately: `QMenu.exec` is a C++ method that cannot be monkeypatched, so a test that shows one hangs. Running a single file still refreshes the tree with *every* suite — listing only the one that ran would drop the rest of the directory. Each suite's tests are listed from the file itself, so editing and deleting work without running anything first. A run updates the existing rows **in place**: `takeTopLevelItem` destroys the row and its children, and if the view's current item is in that subtree its pointer dangles and a later `setCurrentItem` segfaults. `file_done` is connected to a bound method with an explicit `QueuedConnection`, not a lambda — a plain Python callable has no thread affinity, so Qt connects it directly and the slot touches the tree from the worker thread. Double-clicking a *file* row does nothing — the file is TOML scaffolding and the test is the thing worth editing. The dialog is three tabs — **Source** (inline script or a script file, plus the timeout), **Overrides** and **Results** (the success expectation and the echo/warning assertions) — with **Name** outside them, since it says what you are editing and should not depend on which tab is showing. `set_vars` is a **Variable** / **Value** table with **+** / **−** buttons and a draggable divider (both sections `Interactive`, last one stretching so no gap opens on the right); each value is TOML and is parsed with `tomllib`, so it means what it would mean in the file. A row left blank by clicking **+** and changing your mind is ignored rather than rejected, but a name without a value (or the reverse) is refused. Saving splices the one `[[test]]` block in or out (`scadtest.replace_test_block` / `delete_test_block`) rather than round-tripping the file: `tomllib` is read-only, and a rewrite would discard every comment and `[config]`'s own formatting. A rename deletes the old block first, and the script is written as a TOML *literal* (`'''`) string because a basic (`"""`) one would eat OpenSCAD's backslashes. `find_test_files` walks the directory recursively. `TestRunWorker` runs on a `QThread` and emits `file_done` per file, so a long suite fills the tree in rather than landing at once; the tests' own temp snippets are dropped (`docsgen.runner._TEMP_PREFIX`) so the report is about the library. The merged report reaches `_set_coverage`, the same code path `belfryscad --test --coverage` uses. **Origins are normalised** (`CoverageReport.merge_spans` abspaths them): running a whole tree means suites in different subdirectories reach one library by different relative paths (`lib.scad`, `../lib.scad`), and keying on the raw string filed those as two half-covered files. The overlay is `CodeEditor.set_coverage`/`clear_coverage`, a `_coverage_selections` list painted *under* every other extra selection: uncovered spans translucent red, covered ones faint green. Two things stand between a span and a tint. **Byte offsets are not document positions**: the evaluator reports `start`/`end` as byte offsets into the file, OpenSCAD source is UTF-8, and the editor loads it with `read_text(encoding="utf-8")` — so one em dash in a comment puts everything below it two characters right, and a CRLF file adds another character per line. `coverage.document_offset_map` builds the mapping from the bytes the evaluator parsed (`MainWindow._coverage_offset_map`), returning `None` for the ASCII/LF case so the common path costs two C-speed scans. **Spans nest**: there is one per statement, per body and per branch arm, so a module body encloses everything in it, and painting each as its own translucent selection stacked the alpha until deeply nested code came out a muddy brown. `editor._flatten_spans` reduces them to disjoint runs with the *innermost* span deciding each — also the right answer semantically, since an uncovered statement inside a covered body is uncovered — and merges adjacent runs that agree. Each run is then emitted **one selection per line**, from that line's first non-whitespace character to its last (`editor._line_segments`): indentation and blank lines carry no tint, because a bar of colour down the indent column says nothing about what ran. Trimming only a run's two ends is not enough — a fully covered module merges into a single run with every newline and indent *inside* it — while dropping whitespace outright would break each line into one stripe per token, so whitespace **within** a line keeps its tint (Preferences ▸ Editor ▸ Coverage, `coverage/tintCovered`, a display taste, not a mode). The pane's **Highlight coverage in editors** box turns it on and off — it replaced View ▸ Show Coverage, so there is one control rather than two. `_apply_coverage_overlays` runs on every tab, and again on tab switch, matching a span's `origin` to the tab by `file_path` (a library opened in a tab, the interesting case) or `_last_parse_path`. An edit clears the tab's overlay exactly like the error squiggle, since the spans have moved; the next test run restores it. The pane's **Report…** button opens a modeless monospace window (`window/coverage_report.py`, `CoverageReportDialog`) with `format_report`'s per-file lines and every uncovered span, worst file first. Coverage is **session-only, deliberately not persisted**.
**Render with Profiling** / **Show Profile Report…**: a dedicated, explicitly opt-in render pass (`MainWindow._render(profile=True)`, no keyboard shortcut — F6/Shift+F6 are already Render/Debug) that turns on per-call-site timing (`Evaluator(profile=True)`, see openscad_cpp_evaluator's `CLAUDE.md` on profiling, and `docs/rendering.md`). `_on_render_done` opens the report automatically as soon as a profiled render completes (whenever `profile_result` is non-`None`) — no need to separately click "Show Profile Report…" after every run; that action stays available to reopen the last result on demand (e.g. after closing the auto-opened window). It opens `ProfileViewer` (`window/data_viewer_profile.py`) on the result of the last such run — a sortable table (`Name`/`Caller`/`Kind`/`Caller File`/`Line`/`Calls`/`Self (ms)`/`Self %`/`Total (ms)`/`Total %` — `Caller` is the enclosing module/function's name, or `<toplevel>` for a call made outside any module/function body; `Total` is cumulative time (self time plus children), default-sorted by Self time descending) with a summary line of total/resolve/generate/unattributed time and a search box (matches substrings against `Name`/`Caller`/`Caller File` only, case-insensitive, live-filtered via `setRowHidden` as you type — same pattern as the debugger's variables-panel search). Double-click or right-click a row ("Go to Call Site" / "Go to Declaration") to jump to source, reusing the same tab-open-and-highlight handler the debugger's call stack uses (`MainWindow._on_debug_frame_selected`). "No profile available — use Render with Profiling first." if nothing has been profiled yet. A plain F6 "Render" never profiles and pays zero overhead for it.
One line per node — `kind` and a compact params summary. Represents geometry, not the code that produced it: neither `children()` calls nor user-module calls get a node of their own — their resolved subtree is spliced directly into the enclosing node's children (see openscad_cpp_evaluator's `CLAUDE.md`), so e.g. a user module wrapping a `cube` shows up as just `cube(...)`, with no wrapper node named after the module. If that spliced subtree has more than one sibling, they're grouped under a `union()` line instead — display grouping only, no actual boolean merge (see openscad_cpp_evaluator's `CLAUDE.md`), so e.g. a module producing both a `cube` and a `sphere` with no explicit combinator shows `union() { cube(...); sphere(...); }`, not two unrelated-looking top-level siblings. Two things are deliberately never shown: a generated body count (removed — once `ManifoldCache` reuses a cache-hit ancestor's result, the generate pass skips visiting that ancestor's descendants entirely, leaving their `.bodies` at the empty default; a "0" there wouldn't mean "no geometry," just "never visited this pass," which is actively misleading rather than merely uninformative) and several params keys that are pure bookkeeping or already represented elsewhere in the tree (`_DUMP_HIDDEN_PARAM_KEYS`: `op`/`name` duplicate the node's own `kind`; `group_sizes` is `_generate_csg`'s private re-chunking data; `color` is already visible as its own wrapping `color()` `CSGNode` when relevant, same as `translate`/`rotate`, so showing it again on every descendant leaf — needed at generate time, not a display concern — would just be noise, especially the common `color=None` case). `verts`/`tris`/`tri_arr` (`_DUMP_TESSELLATION_KEYS`) are hidden for every kind except `polyhedron`, since elsewhere (sphere, imported meshes, etc.) they're auto-generated tessellation data, not something a user wrote — a polyhedron's own points/faces are its actual authored content and stay visible. A transform's `args` param (`_resolve_transform`'s output, `{"name": ..., "args": {0: [2.0, 2.0, -1.0]}}` for `translate([2,2,-1])`) is special-cased via `_format_call_args`, rendering it as OpenSCAD call syntax directly in the parens — `translate([2.0, 2.0, -1.0])` — rather than as a Python dict literal behind an `args=` key, which hid the actual argument value behind `_summarize_param`'s own collapsing. `_resolve_sphere` includes its resolved `r` and `segs` in `params`, so `sphere(...)` shows its radius and resolved segment count (e.g. `sphere(r=5.0, $fn=16)`) instead of appearing empty. `_DUMP_KEY_RENAMES` renames the `segs` params key — the resolved segment count shared by `sphere`/`cylinder`/`circle`/`offset`/`text`'s resolve functions — to `$fn` for display, since that's what it actually represents; the underlying `params["segs"]` key itself is unchanged (`_generate_cylinder` etc. still read `"segs"`).
`_summarize_param` collapses a value to `<list of N>`/`<dict of N>` purely by item count (`max_items=6` — not "contains a nested container" or "is a numpy array": a small nested list/dict/array, e.g. `translate`'s `args={0: [1.0, 2.0, 3.0]}` or a small polyhedron's own points, is shown in full, each element recursively summarized) or a long string to `max_len=40` characters — needed since e.g. a large resolved array's default `repr()` can span multiple lines, which would otherwise break the one-line-per-node format. Booleans render as OpenSCAD's own lowercase `true`/`false` rather than Python's `repr()`-default `True`/`False`, matching the source syntax the dump otherwise mirrors.
Indentation is `" " * (depth + 1)` for every depth beyond the root (root itself gets no leading padding) rather than the naively-expected `" " * depth`: the console (`ConsoleWidget._append_foldable`) displays multi-line output with a "▼ "/"▶ " toggle prefix (2 display columns) on the first line only and no prefix on the rest, so without this compensating offset a depth-1 child's own indent would land in the same column the root's text starts at (right after the arrow), making it look like a sibling of the root instead of its child.
**Flush Caches** (`MainWindow._flush_caches`) also clears `self._csg_cache` (the `ManifoldCache` backing incremental Manifold rebuilds — see openscad_cpp_evaluator's `CLAUDE.md`), alongside the existing AST scope/node table and parser AST cache resets.
**View**:
- Show Editor Toolbar / Show 3D View Toolbar / Show Tools Toolbar / Show Tab Bar / Show Editor / Show Console / Show Debugger / Show Animate / Show Customizer / Show Status Bar
- —
- Top / Bottom / Left / Right / Front / Back / Isometric (change azimuth/elevation and reset `fov` to `Camera.DEFAULT_FOV`, i.e. `$vpf` back to 22.5°; preserve target and distance) / View All (frame geometry: sets target to bounding-sphere center, distance = radius / tan(fov/2) × 1.1 — FOV-aware so result is consistent regardless of current `$vpf`; does not itself change `$vpf`) / Center (target back to the origin; rotation and distance untouched, as OpenSCAD's). Top/Bottom set elevation to `89.9999`/`-89.9999`, not exactly `90`/`-90` — `Camera.view_matrix()`'s `_look_at` (`engine/renderer.py`) has a gimbal-lock fallback when forward is parallel to world-up (exactly at the poles) that hardcodes an arbitrary `+X` "right" vector, which doesn't match the azimuth-dependent basis orbit-dragging continuously converges to just off the pole. Landing exactly on elevation=90 made the first orbit-drag frame snap (up to 90°) to whatever that fallback implied; landing a hair off the pole is visually identical but keeps the camera on the continuous branch. The three 2D-shape data-viewer default views (`_AffineViewport`/`_PathViewport`/`_GridViewport`, in their `data_viewer_*.py` modules) have the same fix for the same reason. Pytest-covered in `tests/test_renderer.py`'s `TestCameraViewMatrixContinuity` (pure math, no GL/Qt).
- —
- Zoom In (Cmd+]) / Zoom Out (Cmd+[) — `_zoom_viewport(direction)` calls `Viewport.zoom(direction)` (`window/viewport.py`), a uniform ±3% `camera.distance` step centered on the current target, distinct from `wheelEvent`'s cursor-centered zoom. Previously a bare `QShortcut` with no menu presence; moved into the View menu (same `_target_viewport()`/`ApplicationShortcut` treatment as the view presets above) so the shortcut is discoverable and to avoid a duplicate-registration ambiguity risk against any future menu-based binding of the same keys.
- —
- Spin
- Perspective (toggle perspective/orthographic projection) / Stereo ▶ (Off, Cross-eye, Red-Blue Anaglyph)
- —
- Show Edges / Show Axes / Show Scale Markers / Show Crosshairs
**Window**: Minimize / Zoom / — / Move Tab to New Window / Show Next Tab / Show Previous Tab (Qt's NextChild/PreviousChild keys -- Ctrl+Tab / Ctrl+Shift+Tab on Windows and Linux, Cmd+Shift+] / Cmd+Shift+[ on macOS -- plus, on macOS, the physical Ctrl+Tab / Ctrl+Shift+Tab, which Qt's list leaves out and which otherwise reached the editor and indented the line; wraps round) / — / *(open document list)* / Bring All to Front
**Help**: About BelfrySCAD / Documentation
`window/about.py` holds both, and the version facts behind them as plain data (`about_info()` / `about_html()` / `about_text()`) so they test without a widget. The About action carries `MenuRole.AboutRole`, which moves it into the application menu on macOS where the platform expects it — the same treatment Preferences already gets with `PreferencesRole`; on Windows and Linux it stays under Help.
The dialog leads with the version, because "mainly I want to know what version I'm running" is what issue #379 asked for, and lists **openscad_cpp_evaluator** beside it: geometry comes from there, it moves independently of the app, and a bug report needs both. A **Copy Versions** button puts the plain-text form on the clipboard so a version reaches an issue without being retyped. Documentation opens the wiki. **Font List** (`window/font_list.py`) lists every face `text()` can resolve — family, style, and the exact `font=` spec that selects it — with a live filter over family and style, sortable columns, and double-click (or the button) to copy the spec. Regular shows as the bare family, so what you copy is what you type. Families whose name starts with a dot — macOS's private system faces, which Font Book hides too — are left out of both the list and the picker (`is_private_family`, issue #402); `load_fonts()` is the one place both get their fonts, so the filter lives there and `text()` itself is unaffected. A **Sample** column (issue #388) paints a pangram plus the digits in each face, via a `QStyledItemDelegate` that registers the row's font file with Qt the first time that row is painted — nothing is pre-rendered, so opening the list costs nothing extra and only rows scrolled into view pay. The sentence is chosen per family by crc32, so a face keeps it across sorts, filters and sessions. The Qt-side face resolution (`qt_family_for`/`qt_font_for`, `font_picker.py`) is shared with the picker's live preview. **Color List** (`window/color_list.py`) is the same idea for `color()`: a window, not OpenSCAD's dock, listing all 1,097 names `color()` accepts (as OpenSCAD's `parse_color` and openscad_cpp_evaluator >= 1.29.0 read them) with a swatch, hex and `[r, g, b]`: the 147 CSS names of Qt's list (the colour picker's `_PICKABLE_NAMES`, less `transparent`, whose swatch would be a lie) plus `rebeccapurple`, which Qt never adopted, and the 949 xkcd survey names as `xkcd:<name>` (`window/xkcd_colors.py`, generated from OpenSCAD's `XkcdColors.h`, as the evaluator's table is). **Sort by** Name (on the bare name, so `red` and `xkcd:red` sit together), Hue (the picker's 30° hue bands, dark to light; a hueless grey counts as hue 0 -- Saturation is the sort that gathers the neutrals), Saturation (HSV -- HSL's rates near-whites fully saturated), Chroma (max − min of r, g, b), Lightness (HSL, (max + min) / 2), Value (HSV, max) or Luminance (WCAG relative luminance: how light it looks), low to high; ties fall back to hue. Filter by name or hex, and **Hide xkcd colors** to list only the CSS names; double-click or Copy Name puts the quoted name -- `"xkcd:dark mint"` for an xkcd one -- on the clipboard.
It reads `openscad_cpp_evaluator.list_fonts()`, **not** Qt's `QFontDatabase`: the names a system font dialog shows are frequently not the names OpenSCAD takes, and that mismatch is the whole complaint the feature answers (*"All too often I specify an interesting font only to get the default Liberation Sans instead"*). The evaluator reports from the same FreeType index `text()`/`textmetrics()`/`fontmetrics()` match against.
The filter reads each row's own cells rather than indexing the source list by row number — clicking a column header sorts the table, after which row *n* is no longer the *n*th font, and a list-indexed filter hides the wrong lines.
### Choose Font… (the `font=` picker)
`window/font_picker.py`. Right-click a `font="…"` argument in the editor and the context menu offers **Choose Font…** — a family pane, a style pane, and a live preview. Save writes the canonical spec back through `replace_span`, the same path the "Edit as…" viewers use. It is a top-level item rather than an "Edit as…" entry, since that submenu is for shapes.
The **style pane lists what the selected family actually has**, not a fixed Bold/Italic pair: 93 of the 379 families on a plain macOS install have styles outside those four, and Helvetica Neue alone has fourteen — a curated list would make its Thin and UltraLight faces unreachable. The strings shown are literally what goes after `:style=`. Regular leads (it is the default) and saves as the *bare family*, never `:style=Regular`.
**The preview is the editor.** One multiline field, set in the chosen face, that you type into — not an input box plus a sample label, which meant reading your text in one font and the font in another. It is seeded from the enclosing `text()` call's own string literal when there is one, falling back to a pangram.
Rendering needs Qt, which cannot resolve the evaluator's names (that mismatch is the reason the picker exists), so the face's *file* is loaded with `QFontDatabase.addApplicationFont` and Qt's own name for it used for the preview only. **`setStyleName()` is what selects the face**: `QFont(family)` alone says nothing about *which* of a family's faces to use, so every style rendered identically as Regular until it was added. Qt's style names match the ones the evaluator reports, both being read from the font's name table. A bundled face has no file — its bytes live inside the evaluator — and falls back to a Qt substitute, where bold/italic are applied as a hint since there is no "Condensed Black" to name.
`find_font_argument`/`find_preview_text` are lexical, like `find_editable_literals`: no parser, just the source text around the cursor, matching `font` on a word boundary so `subfont=` is not mistaken for it.
**The picker must not carry `WA_DeleteOnClose`.** It is run with `exec()` and the caller reads the choice back *after* that returns — by which point DeleteOnClose has already destroyed the C++ widgets, so reading the selection raised `RuntimeError: Internal C++ object (QListWidget) already deleted` the instant Save was clicked. The selection is now also recorded on `accepted` (connected before `accept`, since connection order is emission order), so nothing depends on the list widgets outliving the dialog either way. Every other editable viewer sets DeleteOnClose safely because they are modeless and commit through a signal rather than being read after `exec()`.
The `font = "…"` line the dialog will write sits on the **button row**, beside Save — it is what the button commits, not a separate piece of information.
The **Customizer** offers the same picker for a font-ish parameter (`font`, `title_font`, `font_face`, …, matched by name because an OpenSCAD string parameter carries no type beyond "string"). The text field stays editable — a script may legitimately name a font this machine does not have.
62c9a9e10This file is pinned to the selected release. Download the original to open it in your design tool.