Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
# Threaded Rendering
Parse + evaluate runs in a background `QThread`. Two helper classes in `main_window.py`:
- **`_RenderWorker(QObject)`** — moved to the worker thread via `moveToThread`; does the parse/evaluate work; emits `logged`, `parse_errored`, `finished`, `done`
- **`_RenderCallback(QObject)`** — stays in the main thread; `@Slot` methods receive worker signals; Qt auto-detects the cross-thread boundary (`QueuedConnection`), so callbacks run on the main thread
## Camera framing
Only a tab newly loaded from a file re-fits the camera to the result
(`_render(reframe=True)`, from `open_file_by_path` and from opening an
example). Every other render leaves the camera exactly where the user put
it. Re-fitting after each one fought them: an edit-and-re-render threw away
the angle and zoom they had just set up, and on a model that grows or
shrinks as a parameter changes, the view jumped every time.
The request is recorded as the render's **id**, not a plain flag, because
renders overlap — an animation frame can start before the previous one has
landed — and only the render that asked for it should move the camera.
`frame_scene` is still called on every render, with `reframe=False`: it
caches the bounds even when it does not move the camera, and **View All**
frames from that cache. Without the call the command would have nothing to
work from after the first render.
**Do not connect worker signals to Python lambdas** — lambdas have no thread affinity, so Qt can't determine which event loop to post to. Always route through a `QObject` slot with known thread affinity.
**Source input**: `_render()` reads the editor's current text (`toPlainText()`), not the saved file. The worker writes this to a temp file in the same directory as the original (so relative `include`/`use` paths resolve) and passes that to the parser.
Every temp `.scad` in the app goes through `belfryscad/scad_temp.py` — the render worker, the debug session, the AI expression probe, and the CLI's `-D` prelude. Because they land in the *user's* project directory rather than `/tmp`, a leaked one is visible clutter in their work, so that module does two things a bare `tempfile` call does not:
- Names them `belfryscad-*.scad`. A default `tmp*.scad` is indistinguishable from a file the user wrote, which makes it unsafe to ever clean up automatically.
- Sweeps its own stale leftovers from that directory on each new write. `try/finally` covers every failure the process survives; it cannot cover SIGKILL or a segfault in the C++ evaluator, and those are what strand a temp. The sweep only touches the app's own prefix, only in the one directory it was already writing to, and only files older than an hour — so a second BelfrySCAD window rendering out of the same folder never loses its in-flight temp.
`mkstemp` is used rather than `NamedTemporaryFile(delete=False)` because it returns the path at the moment the file starts existing, leaving no window where the file is on disk but the caller has nothing to clean up yet.
**Cancellation**: `_render()` passes a `threading.Event` to the worker, which checks `cancel.is_set()` between major steps. A `render_id` counter increments per render; the callback discards results whose `render_id` no longer matches.
**Progress indicator**: a QLabel overlay centered in the viewport shows elapsed seconds and cycling dots (`.` → `..` → `...` → blank) during rendering, updated every 100ms via QTimer. A `WaitCursor` override is set/restored at the same time. The viewport geometry is cleared at render start so only the overlay is visible.
**Cancellation by user**: pressing Escape while a render is in progress sets the cancel event, hides the render overlay, and logs "Render cancelled." to the console.
**Elapsed time**: every render outcome logs its elapsed wall-clock time to the console via `_fmt_elapsed()` — formatted as `(Nms)` under 1000ms or `(N.NNNs)` at 1000ms and above. This applies to successful renders (alongside the bounding-box summary), no-geometry renders, eval errors, recursion-limit errors, and uncaught runtime errors.
**Job lifetime**: each `_render()` call appends `(worker, callback, thread)` to `self._render_jobs`, kept alive until `thread.finished` fires `_cleanup_job` (which removes the entry). Without this, Python could GC the worker/callback before `thread.started` fires, raising `AttributeError: Slot '_RenderWorker::run()' not found.`
**`_RenderCallback`**: constructor takes `(main_window, file_tab, render_id)`. `on_logged` routes to `main_window._console.append_output()`; `on_ast_ready` sets `file_tab.root_scope` and calls `file_tab.editor.update_user_names()`; `on_finished` calls `main_window._on_render_done(file_tab, ...)` which stores results in window-level `self._rendered_tab`, `self.id_to_node`, `self._bodies` and loads geometry into `self._viewport`. The `tab` arg in `_on_render_done` et al. identifies which `FileTab` produced the render, used for source write-back by gizmos.
**Profiling**: `_RenderWorker.__init__` takes an opt-in `profile: bool = False` param (default `False` — normal F6 renders are unaffected), passed straight through to `Evaluator(..., profile=self._profile)`. `finished`'s payload is `Signal(object, object, float, object, object, object)` — `(bodies, id_to_node, elapsed_ms, final_vp, csg_tree, profile_result)`, the last element being the evaluator's `ProfileResult` (`None` unless `profile=True`; see openscad_cpp_evaluator's `CLAUDE.md` on profiling). `_on_render_done` stashes it as `self._last_profile_result` and, if it's not `None`, immediately opens the report (`_show_profile_report()`) rather than waiting for a separate Design → "Show Profile Report…" click — that action still works afterward to reopen the same (`ProfileViewer`) result on demand. `MainWindow._render(self, tab=None, profile: bool = False)` threads the flag through from Design → "Render with Profiling" (no keyboard shortcut — F6/Shift+F6 are already Render/Debug) — a separate, explicitly opt-in menu action, not part of the automatic render-trigger set (gizmo drag, open, animation frame advance, and a save while Automatic Reload and Render is on -- see #395 -- all call `_render()` with `profile` left at its default `False`).
**Shutdown and interpreter exit**: `MainWindow.closeEvent()` pauses the window-level `AnimatePane` (no new renders get queued), sets the cancel event, and waits (with a 5s deadline, pumping `QApplication.processEvents()`) for any `_render_jobs` threads to finish — Qt aborts if a `QThread` is destroyed while still running. It then saves settings (with an explicit `QSettings.sync()`) and clears `self._bodies` / `self._viewport.load_geometry([])` to drop references to Manifold geometry via normal refcounting.
## Stereo modes
**View ▸ Stereo** is three modes -- Off, Cross-eye, Red-Blue Anaglyph -- picked from that radio submenu, or stepped through in that order by the Tools toolbar's glasses button and Ctrl+Cmd+3 (`MainWindow._set_stereo_mode` / `_cycle_stereo`; the button shows pressed in either stereo mode). Saved as `stereoMode`; an old boolean `stereo` setting reads as cross-eye.
### Cross-eye
**Cross-eye** renders two side-by-side perspective views in a single `QOpenGLWidget`. When enabled, `Camera.stereo = True` and `SceneRenderer.paint()` renders two passes:
1. Calls `Camera.stereo_view_matrices(half_vp_w, vp_h)` (device pixels), which shifts each camera ±(`distance × stereo_fraction / 2`) along the camera's right vector (row 0 of the view matrix), pointing both eye cameras at the same target (toe-in). Cross-eye arrangement: left panel = right eye, right panel = left eye.
`stereo_fraction` is computed from physical viewer measurements stored in `QSettings` and applied to `Camera` fields at preference-apply time:
```
rendered_half_fov_h = atan( tan(fov/2) × half_vp_w / vp_h )
physical_half_fov_h = atan( (half_vp_w × 25.4 / screen_dpi) / (2 × viewer_screen_dist) )
stereo_fraction = (viewer_ipd / viewer_screen_dist)
× (physical_half_fov_h / rendered_half_fov_h)
× stereo_depth_scale
```
The first factor (`IPD / screen_dist`) matches the angular separation of the cameras to the angular separation of the viewer's eyes. The second factor corrects for the viewer not sitting at the "natural" viewing distance for the rendered FOV (i.e., the physical visual angle of the viewport differs from the rendered FOV). `stereo_depth_scale` (default 0.75) is a comfort trim because the geometrically exact value can exceed comfortable disparity limits for objects near the camera.
`screen_dpi` is read from `QScreen.physicalDotsPerInch()` when preferences are applied. For a 100 DPI monitor, 90 mm IPD, 770 mm screen distance, and a ~900 px tall window, this yields roughly 3–4 % of camera distance.
2. Each pass sets `ctx.viewport` to its half of the framebuffer, temporarily overrides `self._viewport` to `(half_w, h)` so axes, labels, and other screen-size-dependent calculations use the half-width, and calls `_paint_scene(view, proj, L_world)` where `proj` uses the half-width aspect ratio.
3. `_paint_scene()` computes eye position from the view matrix (`eye = -R^T · t`) for correct per-eye specular highlights. Axes, labels, and gizmo all render in both eyes.
### Red-Blue Anaglyph
`Camera.anaglyph = True` (never together with `stereo`). `SceneRenderer._paint_anaglyph()` places the eyes with the same `stereo_view_matrices()` maths at FULL width, and not swapped: its second matrix is the left eye, behind the red lens. Each eye is drawn in full colour into its own multisampled framebuffer (so edges stay as smooth as the normal view), resolved to a texture, and one full-screen pass (`_ANAGLYPH_FRAG`) writes the left eye's **brightness** to red and the right eye's to green and blue. Brightness, not colour, is the trade: a pure red part would be invisible to the cyan eye and a pure blue one to the red eye, and images that different cannot be fused; the background comes out grey the same way. Green and blue both, so red/cyan glasses work as well as red/blue. Colour-coded cues (selection, gizmo axes, magenta back faces, the RGB axes) are lost while it is on -- it is a mode for looking, not editing. QPainter overlays (orientation cube, measurement labels, busy spinner) are drawn once, flat, after the composite. The per-eye targets are cached and rebuilt only on resize. `tests/test_renderer.py::test_anaglyph_eyes_give_the_right_depth_sign` pins the eye order: swapped eyes turn depth inside out with nothing else noticing.
Stereo and Perspective are independently togglable. Both states are saved to `QSettings` and restored on launch. Keyboard shortcut: **Ctrl+Cmd+3**.
In orthographic mode, stereo still works: the same toe-in view matrices are used (both cameras shifted laterally and pointed at the target), but `projection_matrix()` produces a parallel projection for each eye instead of a frustum. The eye separation formula is unchanged. Toe-in with orthographic projection introduces a small amount of keystone distortion, but at the typical 3–5 % separation values it is negligible.
## Spin
**View menu → "Spin"** (Ctrl+Cmd+1) continuously rotates the camera azimuth at 6 RPM (1.2°/tick at 30 FPS, driven by a `QTimer` with a 33 ms interval). Spin state is **not** saved between sessions — the app always starts with Spin off.
## Modifier characters
| | model | viewport | export |
|---|---|---|---|
| `#` highlight | unchanged | drawn see-through in red, never solid | included — `#` does not change the model |
| `%` background | excluded | drawn as a translucent grey ghost | **excluded** |
| `!` show only | becomes the whole model | its subtree only | its subtree only |
| `*` disable | removed | nothing | nothing |
`%` is scenery: something to line other work up against. It is kept out of
the booleans upstream, and `exporters.exportable()` keeps it out of files
too — `%cube(10);` on its own exports nothing at all, matching the
reference.
Its ghost is a fixed neutral grey (`_BACKGROUND_COLOR`), not the body's own
colour and not an explicit `color()`: tinting scenery with the live theme
made it read as part of the model. The alpha is measured against the empty
viewport — at 0.2 the ghost sat 23 levels off the background and barely
registered; at 0.35 it sits 35 off and still shows what is behind it.
`#` bodies are drawn only in the see-through pass (`_HIGHLIGHT_COLOR`),
never opaquely: `#` marks something to look at, not something to look at
the outside of, and a solid draw underneath leaves nothing to see through
to. The alpha is measured — below about 0.4 the wash lands within a shade
of the untouched surface colour and reads as no highlight at all.
### Known divergence
A `#` on an operand of a boolean is exported twice over. The evaluator
returns the boolean's result *and* a copy of the highlighted operand for
the viewport to ghost, and nothing in a `ColoredBody` distinguishes that
display copy from a highlighted object at top level, which is real
geometry and must be exported. Measured against the reference:
`difference() { cylinder(h=10,d=10); #cube(10); }` writes 64 triangles
where it should write 52. Fixing it means marking the copy in the
evaluator, not guessing in the exporter.
## `$export_name`
The Export dialog's default filename. `belfryscad/export_name.py` holds the whole thing — pure and Qt-free, so the GUI, the debugger and the headless CLI all seed it identically; a script reading `$export_name` should not find it defined in one and undefined in another.
**No evaluator change was needed.** `viewport_params` seeds arbitrary `$`-names (the name is historical, not a restriction — `evaluateImpl` does a plain `for (k, v) : viewportParams` into `ctx.dyn`), and `Evaluator.dyn` hands every `$`-variable back after `evaluate()`. So the feature is a seed on the way in and a read on the way out.
`_RenderWorker` seeds from `self._file_path`, the **original** path — never `parse_path`, which for an unsaved buffer is a temp file whose name would leak into the dialog. The final value rides back on `finished` as an eighth element and is stashed per `FileTab`, so switching tabs offers the right name.
Read back **unconditionally**, unlike the `$vp*` values immediately above it, which are filtered through `dyn_explicit`. Those are pre-seeded from the live camera and go stale if the user orbits mid-render; `$export_name` is seeded from a file path that cannot change under it, and the seeded value *is* the wanted default when the script leaves it alone.
Sanitising keeps `[A-Za-z0-9_+.-]` and replaces every other character with one underscore — per character, not per run, so length stays recognisable. Path separators are in the replaced set, so a script cannot steer the dialog out of its directory.
## Export
**Export lives in openscad_cpp_evaluator, not here.** Every writer -- STL
(binary/ASCII), OBJ, OFF, 3MF, PLY, VRML, X3D, SVG and PDF -- and the whole
colour pipeline behind them now live in that package's `export.cpp`. This used to
be ~400 lines of Python in `exporters.py` that the evaluator's own CLI never
saw, and the two disagreed: the CLI wrote `cube(100); cube(100,
center=true);` as two overlapping objects of 24 triangles where this side
wrote one of 36. One implementation, one answer.
`exporters.py` is now the interface plus the mesh helpers the rest of the
GUI shares (`body_to_manifold`, `body_mesh_arrays`, `merge_bodies_to_mesh`
are used by the validation panel, and `exportable` is the `%`-background
rule). The rules themselves -- the implicit top-level union, one object per
colour with the later shape winning an overlap, the connected-component
split, per-triangle colour, and the sliver-strip/mesh-check repair policy --
are documented in openscad_cpp_evaluator's own `CLAUDE.md`.
**SVG and PDF are the 2D formats, and the ones that can refuse.** It writes a
model that is all 2D at 1:1 in millimetres -- so a print of it measures
what the script says, which is what the format was asked for (BelfrySCAD
#367: a printed paper scale for calibrating a laser printer). None of the
mesh pipeline applies to it: no merge, no sliver strip, no manifoldness
check, and so no warnings. A model with any 3D in it raises
`Current top level object is not a 2D object` rather than writing a
silhouette, exactly as OpenSCAD refuses -- so the GUI's Export and the
CLI's `-o` both catch more than `OSError` now, and show it the way they
show any other export failure.
**PDF differs from SVG in what a page is.** SVG cuts the page to fit the
model; PDF centres the model on a fixed sheet and draws OpenSCAD's ruler
around it -- two axes at a 30pt margin, ticks every 10mm of *model* space,
labels on every second tick, and a caption saying what to measure. That
ruler is the point of the format: measuring between the 0 tick and the last
one on a printed page is how you find out that your printer is scaling by
99.2%.
Its page setup is asked **after** the save dialog, by
`window/export_options.py`: the format is only known once the name has been
typed and the filter picked, and the native macOS dialog cannot host extra
controls of its own. `export_kwargs()` turns the answers into the dict
`export_model(pdf_options=...)` wants, keyed as OpenSCAD names its own
`-O export-pdf/...` settings, and the CLI has `--pdf-paper-size`,
`--pdf-orientation`, `--pdf-no-scale` and `--pdf-grid`. An unknown key
raises rather than being quietly dropped -- a misspelt `papersize` would
otherwise print the wrong page size with nothing to explain it.
### The export options dialog
These options lived in Preferences > Export first. That worked, but put
PDF's paper size three menus away from the Export command that uses it,
with nothing to hint the setting existed. They are now asked where they
apply:
```
File > Export -> save dialog (name + format) -> options for THAT format -> write
```
`export_fields(ext)` returns the controls for a format as plain data --
no Qt -- so what each format offers is testable without a widget, and the
dialog only renders it. An empty list means no dialog at all (`.off`),
which is why an export of a format with nothing to choose is still one
click. Cancelling the options cancels the export: the file has been named
but not written, and writing it with settings the user just backed out of
would be worse.
The `export/*` preference keys did not go away -- they are the defaults the
dialog opens on, and every export writes its answers back, so the second
export of a session opens on what the first one used.
Two things became reachable in the process that never had a home in the
GUI before: **ASCII STL** (previously `--export-format asciistl`, CLI only)
and **SVG's stroke width**, which also decides the document's size.
### The geometry handle
`bodyToDict` flattens every Manifold into numpy arrays on the way to
Python, which is all the *renderer* needs. Export has to do real CSG, so
rebuilding Manifolds from those arrays would cost ~146ms on a
224k-triangle model and throw away Manifold's provenance. Instead the
evaluator stashes an opaque handle on itself:
```python
bodies, id_to_node = evaluator.evaluate(path) # unchanged
geometry = evaluator.geometry # opaque, C++-side
...
for problem in exporters.export_model(path, geometry):
self.log(f"WARNING: export: {problem}")
```
`_RenderWorker` carries the handle in its `finished` payload and
`_on_render_done` stores it as `self._geometry` -- **only past the
`render_id` guard**, so a superseded render can never leave its geometry
behind for Export to write. The handle has to stay in step with
`self._bodies`, which is what the viewport is showing.
Measured honestly, the handle is **not** faster today: like-for-like on
Dalek it is 1045ms against the old Python path's 976ms. The rebuild saving
is real in isolation but swamped by the mesh checks and the split, which
dominate and which both sides hand to the same Manifold library. It is kept
for the architecture and the provenance, not for a speedup it does not
deliver.
Warnings are returned rather than logged, so the GUI and the CLI can
present them their own way. Nothing refuses to write: a deliberately open
surface is a legitimate export, and blocking a save the user asked for
would be worse than saying so.
## The X-ray flashlight
View ▸ X-ray Flashlight (Ctrl+Shift+X) shines a beam wherever the pointer is,
showing what the model hides there. Everything outside the beam is drawn
exactly as it would be with the torch off.
**The model is never cut.** The opaque pass draws it in full, writing depth,
and a second pass draws only what that pass HID -- a reversed depth test
(`depth_func = ">"`) selects fragments behind the surface already on screen --
compositing them over the intact model at `torch_alpha`.
That is the whole design, and it was arrived at the hard way. The obvious
approach is to cut a hole in the model inside the beam and refill it with
blended geometry, and it works until you look at the rim: a hole has an edge,
and every attempt to disguise that edge failed in a different way. Fading the
hole's alpha did not help, because the geometry revealed THROUGH the hole was
still drawn at full strength right up to the boundary and then simply stopped.
Compositing over an uncut model has no boundary to hide: the only thing that
varies across the rim is how strongly hidden geometry shows, and that fades to
zero. `torch_plateau` (0.8) is the fraction of the radius held flat before
that fade begins.
**Shells.** A body's surface can be several disjoint shells -- the outside, a
sealed cavity within it, something solid inside that cavity. They are found
on first torch use (`_ensure_shells`): weld the triangle soup by position,
union-find the connected components, then classify each by signed volume
(positive encloses material, negative is a cavity) and by nesting depth (ray
parity from a point ON the shell -- its CENTROID sits inside everything nested
within it and counts the containment backwards). Shells are drawn in nesting
order so nested cavities composite correctly, and deeper ones are darkened
(`torch_depth_dim`) so the stack reads as a stack. Single-shell bodies skip
all of it and cost nothing.
**Nothing additive.** Specular is suppressed on everything but the nearest
face of the outermost shell, and the depth cue multiplies rather than adds.
The beam stacks many surfaces, so any additive term accumulates: a specular
highlight, then a Fresnel rim light, each whited out the beam centre before
being removed. Fresnel also raised alpha at grazing angles, which drew a dense
ring around every sphere and made a hollow one look like a torus.
**Known limit: perforated geometry.** A Menger sponge is one connected shell
that a view ray crosses a dozen times, and a dozen translucent layers are
unreadable however they are ordered -- order-independent transparency would
composite them correctly and still give a wash. Depth fog, a rim light, a
front-face cutaway and a Gaussian-faded spherical bite were all tried and all
failed for that reason. The mode is for solids with cavities; lattices are
out of scope.
## Viewport visuals
**Clip planes**: `Camera.clip_planes()` returns `(near, far)`, scaled to `camera.distance` rather than fixed constants (floors at the original `0.1`/`10000.0`, so typical/small scenes are unaffected). `frame_bounds()` sets `distance` proportional to the framed object's radius (same distance-scaling heuristic `_render_axes`/`_axis_extent` use for tick/axis extent) — a fixed `far=10000` clipped large or elongated models once that pushed the camera far enough away to fit them at the default FOV: `cylinder(h=3500, d=1000)` needs `distance≈10438` to fit at `fov=22.5`, already past a fixed `far=10000`, silently clipping whichever end of the cylinder was farther from the eye (and varying with zoom, since that changes `distance`). `far = max(10000.0, distance * 3.0)`; `near = max(0.1, far / 100000.0)` — grown proportionally rather than held fixed, preserving the original `far/near` ratio (holding `near` fixed while `far` grows would only worsen depth-buffer precision for large scenes on top of the clipping bug). `projection_matrix()` uses these for both perspective (`_perspective`'s own near/far) and orthographic (`_ortho`'s symmetric `±far` depth range) modes. Pure math, unit-tested in `test_renderer.py::TestCameraClipPlanes` — no GL/Qt dependency.
**Object colors**: default geometry is yellow `(0.9, 0.85, 0.1)`. Selection applies `_highlight_color`, which tints toward green `(r*0.35, g*0.35+0.65, b*0.35)`.
**Cut faces**: a face `difference()` exposes takes the cutting object's colour, or the cut green (#9DCB51, what OpenSCAD's preview and colour-preserving render show) when the cutter has no colour — so a cut through an uncoloured part still reads as a cut. Preferences ▸ Viewport ▸ **Cut faces** flips that to the colour of the object that was cut (`viewport/keepMinuendColor` → `Evaluator(keep_minuend_color=True)`); the evaluator differences each minuend part on its own so a multi-colour model's cross-section keeps every part's colour. See openscad_cpp_evaluator's `CLAUDE.md` on `keepMinuendColor`.
**Multi-color CSG merges**: a body produced by a real boolean merge (`union()`/`difference()`/`intersection()` — see openscad_cpp_evaluator's `CLAUDE.md` on multi-color merges and `triColors`) can carry `ColoredBody.tri_colors`, a per-triangle RGBA override recovering each part's own original color/alpha, which the single-color `.color` field can no longer represent once the parts are merged into one `Manifold`. `_upload_body` uploads this as a per-vertex `in_vcolor` attribute (white `(1,1,1,1)`, a shader no-op, for the overwhelming majority of ordinary single-color bodies) multiplied against the `object_color` uniform in the fragment shader — `_highlight_color`'s selection tint and per-frame color-theme resolution keep working unchanged on top, since they operate on the uniform, not `in_vcolor`. If a merged body's triangles span both an opaque and a translucent alpha, `_upload_body` splits it into two `MeshBuffer`s (one per alpha bucket) so each still routes into the correct opaque/translucent pass below; both buffers share the *same* (full, unsplit) `original_ids` set, so selecting/dragging/rotating either half moves the whole original body together rather than leaving one half behind (`_selected_buffer_bbox` also aggregates across every buffer matching the selected ID, not just the first found). A split buffer's dummy `MeshBuffer.color` alpha (`1.0` or `<1.0`) exists only to route it into the right pass — `uses_vertex_color=True` tells `_paint_scene` to force the *uniform's* alpha to `1.0` before drawing, so it doesn't also multiply into the real per-triangle alpha already carried by `in_vcolor.a`.
**Modifier render passes** — `_paint_scene()` runs the following sequence per eye:
1. **Opaque pass**: bodies with `role` not in `{"background", "highlight_ghost"}` whose resolved color has alpha `>= 1.0` (the common case), rendered normally with full depth test and depth write.
2. **Translucent pass** (`color()`'s alpha `< 1.0`, any of `normal`/`show_only`/`highlight` role — e.g. `color([1,0,0,0.5])`): deferred out of the opaque pass (drawing it there would depth-write it fully opaque, discarding the alpha the fragment shader computed) and drawn afterward with `SRC_ALPHA/ONE_MINUS_SRC_ALPHA` blending, depth write disabled, and back-face culling on. Sorted back-to-front by each buffer's object-space centroid (`cpu_v0.mean(axis=0)`) transformed to world space and measured against the eye position — an approximation (not true per-triangle sorting), sufficient to avoid the worst near/far swaps between multiple overlapping translucent bodies. Culling matters even for a single body: with depth write off, a thin/coplanar body's own hidden far face (e.g. the underside of a top-level 2D shape's near-zero-height extrusion) is drawn in raw mesh-triangle order rather than depth-sorted, and can land after the visible face and wrongly overwrite it — culling removes the hidden face outright.
**`depth_mask` lives on `Framebuffer`, not `Context`** — `moderngl.Context` has no such attribute, so `self._ctx.depth_mask = False/True` silently no-ops (sets a stray, unused Python attribute) rather than raising or disabling GL depth writes. This affected every blended pass below too (ghost, highlight), not just this one — depth writes were never actually being disabled during blending, so overlapping translucent geometry got real (order-dependent-looking but actually just wrong) opaque z-buffer occlusion instead of blending. Fixed by resolving the real framebuffer once in `paint()` (`self._active_fbo = fbo`, the same object returned by `self._ctx.detect_framebuffer(qt_fbo_id)`) and toggling `self._active_fbo.depth_mask` in `_paint_scene()` instead of `self._ctx.depth_mask`.
3. **Axes and labels**: rendered after the opaque+translucent passes with full depth write still active, so they are composited correctly under the subsequent transparent ghost pass — ghost geometry (at 0.2 alpha) composites over the axes, making the axes visible through ghost objects.
4. **Ghost pass** (`role="background"`, OpenSCAD `%`): `SRC_ALPHA/ONE_MINUS_SRC_ALPHA` blending, depth test LESS, depth write disabled, back-face culling on — the ghost appears only where no opaque geometry or axes occlude it. Color uses the body's own color at a fixed 0.2 alpha (its own `color()` alpha, if any, is not honored here). Background bodies are skipped by ray-cast picking.
5. **Highlight overlay pass** (OpenSCAD `#`): covers two sub-cases:
- `role="highlight"` (top-level `#`): body is real geometry already rendered in the opaque pass. Re-rendered with polygon offset `(-1.0, -1.0)` shifting toward the camera so the overlay passes the LESS depth test, pink `(1.0, 0.08, 0.45, 0.35)`, blending on, depth write off.
- `role="highlight_ghost"` (`#` used inside a CSG op like `difference()`): the body was consumed by the CSG kernel and is NOT in the opaque pass. Rendered as a pink ghost with back-face culling on, using the depth buffer written by the opaque pass, so it is occluded by surrounding solid geometry. No polygon offset needed.
`MeshBuffer.role` stores the role string so the renderer can classify each buffer. Background buffers are excluded from ray-cast picking in `ray_cast()`.
**Lighting**: Blinn-Phong shading with a key light, fill light, ambient term, and specular highlights (exponent 64, intensity 0.5). The key light direction is defined in view space as `[0.6, 0.8, 1.0]` and transformed to world space, so it follows the camera by default. Option+left-drag adjusts the light direction via azimuth (around viewport vertical Y axis) and elevation (around viewport horizontal X axis) offsets applied in view space before the world transform — the adjusted light stays fixed relative to the user's POV when orbiting.
**Axis ticks and labels**: each axis has perpendicular tick marks (X/Z ticks extend along Y; Y ticks extend along X). Ticks are one-sided, extending only in the positive perpendicular direction; minor ticks are ~24 px, major ticks ~48 px. The world-space half-length `L` driving both the drawn axis-line extent and `_nice_spacings(L)`'s tick/label spacing target (`raw = L / 15`) comes from `_axis_extent(camera)` (free function, `renderer.py`, pure math, unit-tested in `test_renderer.py::TestAxisExtent`): `distance * tan(fov/2) / tan(DEFAULT_FOV/2) * 2.5` — scaled by `fov` as well as `distance` so that changing `fov` alone (Shift+wheel, no change in `distance`) keeps tick density visually consistent on screen; previously this was a flat `distance * 2.5`, so narrowing `fov` (optical zoom-in) left tick spacing unchanged in world-space while far fewer ticks fit on screen, and widening `fov` left the axes looking sparse. Dividing by `tan(DEFAULT_FOV/2)` makes this identical to the old flat formula at the default fov, so views that never touch `$vpf` are unaffected. When only one minor tick would fall between majors (`major_steps <= 2`), spacing is promoted so the minor interval becomes the new major and the old major becomes the label interval, keeping the axis visually dense. That promotion lives in `_drawn_spacings(L)`, not in `_render_axes`, because the label walker needs the *promoted* numbers too: **every major tick carries a label**, so a label placed from `_nice_spacings`' un-promoted tier would land where no major tick was drawn. Tick labels are depth-aware, camera-facing textured billboards rendered in the 3D scene (not a QPainter screen-space overlay) — each `QImage` is rasterized once into a ModernGL texture and drawn as a unit quad oriented via `right`/`up` vectors from the view matrix. Labels are positioned on the negative perpendicular side (opposite the ticks). Labels and minor ticks on axes within 5° of end-on to the camera are suppressed (major ticks still draw). Before that hard cutoff, `_axis_density(camera)` (free function, `renderer.py`, pure math — no GL/Camera-instance coupling beyond taking a `Camera` argument, unit-tested in `test_renderer.py::TestAxisDensity`) thins labels and minor ticks per axis to avoid the alternative: evenly-world-spaced labels crowding into an ever-smaller on-screen span as the axis foreshortens, overlapping illegibly well before 5°. For each axis, `view_dir[ai]` (cosine of the angle between the camera's view direction and that world axis) gives foreshortening `sqrt(1 - view_dir[ai]**2)` — 1 when the axis is broadside (full density), shrinking toward 0 as it swings end-on. Half the reciprocal (doubling density vs. a plain 1/foreshorten stride), snapped to the nearest power of two, is the "show every Nth tick" stride — powers of two keep the visible subset nested as the camera rotates (every 4th-tick set ⊂ every-2nd ⊂ every tick) rather than jumping to an unrelated set each frame. `_render_axes` applies this stride via `_tick_is_drawn(k, major_steps, end_on, stride)` (pure, unit-tested in `test_renderer.py::TestTickIsDrawn`): at the hard end-on cutoff, only major ticks draw (unchanged, long-standing behavior — majors always show even dead-on end-on). Below that cutoff, stride applies uniformly to major *and* minor ticks alike, rather than exempting majors the way the hard cutoff does — `major_steps` (how often a major tick falls) and `stride` aren't related, so exempting majors there let an every-major-step tick land arbitrarily close to a kept minor tick while a stride-sized gap opened up elsewhere, a visibly inconsistent spacing bug. Requiring just `k % stride == 0` (majors included) keeps the drawn set a strict, evenly-spaced arithmetic subsequence regardless of where `major_steps` falls. `_axis_tick_world_points` applies the same per-axis stride to labels independently, since labels are keyed to the label-spacing tier (which may differ from the tick marks' major/minor tiers, not to the tick-mark loop's own index) — that loop only ever had the one tier, so it was never susceptible to this bug. Positive axis lines are colored (red/green/blue); negative axes are gray.
**XY grid**: one line per minor tick across the XY plane, spanning the same `L` as the axes, in `grid_color` — a derived property, `GRID_AXES_MIX = 0.25` of the way from `bg_color` to `axes_color`. Derived rather than stored so a colour-scheme change carries it along; there is no fixed colour that stays legible against one theme's background without vanishing into another's axes. Toggled by **View ▸ Show Grid** (Ctrl+G, `SceneRenderer.show_grid`), independently of Show Axes — it is the one overlay dense enough to want gone while keeping the axes and their labels, and for that same reason it is **off by default**.
⚠️ Two defaults have to stay in step: `SceneRenderer.show_grid` and the "Show Grid" menu item's initial checked state. `_add_checkable` sets its checkbox *before* connecting the toggle slot, so a menu default never reaches the renderer — a mismatch shows an unchecked menu item over a visible grid, with the first Ctrl+G appearing to do nothing. `tests/test_export_name.py::TestGridDefaultsOff` pins that they agree. Data-viewer dialogs embed the same renderer and so inherit the same default.
Built in `_render_axes` but drawn as its **own VAO, before the axes, with `_active_fbo.depth_mask = False`**, and this is load-bearing rather than tidiness. Grid lines lie in the same `z=0` plane as the axes and their tick marks, and a tick runs *along* a grid line, so the two are exactly coincident. Ordering within a single draw call does not settle that: a grid line spans `-L..+L` while a tick spans a few pixels, and interpolating depth across such different primitive lengths leaves the shared pixels differing in the last bits either way — z-fighting, seen as ticks dissolving into the grid. Drawing the grid with depth writes off keeps it out of the depth buffer entirely, so the ticks drawn afterwards always win, while the grid is still depth-*tested* and stays correctly hidden behind solid geometry. No grid line is emitted at 0, since that is the axis itself.
**Mouse-wheel zoom**: fixed ±1% step (`factor = 1.01` or `0.99`) with a 5-unit deadspot on `angleDelta` to avoid jitter on near-zero deltas.
`main.py` then exits via `os._exit(code)` rather than `sys.exit(code)`/falling off the end of `main()`. This **skips Python's normal interpreter finalization**, which performs a final `gc.collect()` pass — and collecting nanobind-wrapped `m3d.Manifold`/`m3d.CrossSection` objects shortly after a background render `QThread` has been active can SIGSEGV (nanobind's object collection isn't thread-safe across a recently-active worker thread). `gc.disable()` does not prevent this, since CPython's finalizer forces a collection regardless. Because `os._exit()` skips `atexit`/destructors entirely, `closeEvent`'s explicit `QSettings.sync()` is required so window geometry/state are flushed to disk before exit.
62c9a9e10This file is pinned to the selected release. Download the original to open it in your design tool.