Skip to content

Preview 3d ratatui - #12

Merged
sorcerer86pt merged 9 commits into
mainfrom
preview-3d-ratatui
Jun 22, 2026
Merged

sorcerer86pt merged 9 commits into
mainfrom
preview-3d-ratatui

Conversation

@sorcerer86pt

Copy link
Copy Markdown
Owner

No description provided.

Two new ways to inspect a scene's geometry, plus a shared-loader
refactor of the existing PNG/PPM path.

Cargo: add optional ratatui 0.30 dep behind a new `tui` feature
(default off, so the engine lib / Python wheel / CUDA builds never
compile it). ratatui re-exports crossterm, so no separate dep.

Refactor: extract load_preview (scene + materials + palette + names)
and auto_bounds (viewport + z-slice) from render_ppm so the new
viewers reuse identical colours / framing. The PNG/PPM path behaviour
is unchanged (verified by re-render).

Terminal viewer (--tui, feature tui): half-block truecolor cross-
section (2 vertical px per char cell), keyboard + mouse pan/zoom,
scroll zoom-at-cursor, [ ] z-slice stepping, legend panel, crosshair
material probe, status bar. Works over SSH / headless. By default
re-execs itself in a NEW console window (CREATE_NEW_CONSOLE on
Windows; TERMINAL/x-terminal-emulator elsewhere) so the shell stays
free; --inline keeps it in the current terminal.

3D view (--3d window needs preview; --render3d-out <png> is headless
and feature-free): perspective ray-cast of the actual CSG, reusing
the MC geometry walk (trace_step_recursive + find_cell_recursive).
One ray/pixel, steps through cells to the first opaque material
(air/void/vacuum skipped), shades Lambert with analytic surface
normals from Surface::normal_at (no occupancy-gradient banding;
gradient kept only as fallback). Orbit camera: left-drag rotate,
scroll zoom, right-drag pan, r reset, Esc quit. rayon-parallel;
CPU-only by design so it needs no GPU or cuda feature.

Tests: 8 new unit tests in preview_scene (tui viewport math +
TestBackend render; render3d ray-AABB, camera, opaque mask, and a
full two-sphere CSG render).
Adds a GPU path for preview_scene's 3D view, reusing the device-side
recursive CSG the transport kernels already use — no GPU geometry
code duplicated.

New kernel gpu/cuda/geom_recursive_raycast.cu (raycast_preview):
one camera ray per pixel, generated on-device from the camera basis;
ray-AABB entry; marches via gr_find_cell / gr_trace_step to the first
opaque material (air/void skipped); analytic surface normal from the
hit cell's nearest bounding surface (gr_surf_normal added; translation-
only descent ⇒ local normal == world normal); Lambert key + head
light tinted by an uploaded per-material palette; dark-slate background
gradient. Writes a 0x00RRGGBB framebuffer. Concatenated last in
assemble_kernel_source so it only depends on the gr_* helpers already
in scope — its successful NVRTC compile also proves the shared compile
of every existing recursive kernel is intact.

gpu_recursive.rs: load raycast_preview into GpuRecursiveContext
(k_raycast) and add raycast_image(camera, w, h, aabb, palette,
opaque) -> Vec<u32>. Geometry tables already live on the device; only
palette / opaque mask / per-thread evals scratch / output are sized
per call.

preview_scene.rs: --gpu flag. Headless --render3d-out and the
interactive --3d window both build the context ONCE (NVRTC compile is
slow) and reuse it per frame; any GPU failure logs and falls back to
the CPU ray-caster, so behaviour degrades gracefully without a device
or the cuda feature.

Test: gpu_recursive::raycast_tests renders a two-sphere CSG on the
device and asserts the object covers the frame centre + a meaningful
fraction; skips cleanly when no CUDA device is present. Verified on an
RTX A1000 (sm_86, CUDA 12.9) — GPU output matches the CPU render.
The interactive --3d --gpu drag was laggy because raycast_image
re-allocated the per-thread `evals` scratch (n_surfaces × W×H × 8 B —
tens of MB) AND zeroed it, plus reallocated the output and re-uploaded
the palette, on EVERY frame. Each orbit mouse-move event paid a fresh
multi-MB cudaMalloc + memset.

Add RaycastBuffers (persistent evals / output / palette / opaque) and
raycast_reuse(&mut buffers, ...): the evals scratch only grows, the
output is reallocated only when the pixel count changes, and the
palette / opaque mask re-upload only on a length change. A steady
window size now does ZERO per-frame allocation — just the kernel
launch + the device→host copy. raycast_image keeps its one-shot
signature by allocating a throwaway buffer and delegating.

preview_scene: the --3d window builds RaycastBuffers once and reuses
them every redraw (gpu_frame now takes &mut RaycastBuffers); the
headless --render3d-out path allocates one buffer per render.

Output is byte-identical to before (verified on the A1000). Note: the
remaining per-frame cost is the f64 geometry walk — A1000 f64 is a
small fraction of its f32 rate — so a future f32 preview path would
cut it further.
The first half of the "proper 3D scene" path: turn the CSG into
triangle meshes and draw them with a real GPU rasterization pipeline,
instead of ray-casting per pixel.

Deps (gated behind new features, default off):
  mesh3d  = fast-surface-nets        (CSG -> mesh, pure Rust)
  raster3d = mesh3d + wgpu + pollster + winit  (GPU pipeline + window)

Mesh extraction (mesh3d module): sample the deepest opaque material on
a regular grid once (rayon), then run Naive Surface Nets per material
on the binary occupancy indicator. Smooth per-vertex normals are
computed by area-weighted face-normal averaging over the merged
topology — frame-independent (correct for lattices) and free of the
surface-picking ambiguity an analytic per-vertex normal had (which
produced vertical shading streaks). Colour is baked per vertex.
Unit-tested on a two-sphere CSG (valid topology, in-range indices,
unit normals, vertices within bounds).

GPU rasterizer (raster3d module): a wgpu 29 pipeline — depth buffer,
4x MSAA, a Lambert (two directional lights + ambient) WGSL shader,
interleaved pos/normal/colour vertex buffer, hand-rolled column-major
perspective + look-at (no glam dep). `render_to_png` renders headless
(offscreen MSAA texture -> resolve -> readback) so it's verifiable
without a window; the Renderer / camera / shader are factored for
reuse by the upcoming winit orbit window.

CLI: --raster-out <png> (headless), --mesh-grid <N> (grid resolution),
and --raster (interactive window, wired next). Verified on the A1000:
heu-comp-inter-003 renders as a clean shaded reflector cylinder with
the HEU can recess (~686k tris at grid 160). Higher --mesh-grid
reduces the voxel-terracing fluting on curved surfaces.
Wraps the verified offscreen rasterizer in a winit 0.30 window:
ApplicationHandler builds the wgpu surface on `resumed` (sRGB target
format from surface caps, MSAA + depth recreated on resize), and the
render path reuses the exact Renderer / WGSL shader / draw() the
headless --raster-out path already exercises — only the event-loop and
surface-present glue is window-specific.

Orbit camera: left-drag rotate (azimuth/elevation), right-drag pan the
look-at centre across the camera plane, scroll zoom, r reset, Esc quit.
ControlFlow::Wait + request_redraw so an idle view costs nothing. The
App camera math (basis/mvp) mirrors the offscreen `camera_mvp`.

Wired --raster in main. Compiles clean against winit 0.30 + wgpu 29.
Note: the live window needs a real display, so it's verified by
compilation + the shared offscreen render path (the render-to-texture
"framebuffer trick"); the winit/surface glue itself is unrun here.
The --raster flag and run_window existed but main() never dispatched
to them, so --raster fell through to the 2D run_preview path and
errored 'requires the preview feature'. Add the args.raster branch
(feature-gated, with a clear message when raster3d is off), mirroring
the --3d dispatch.
…anic)

The --raster window panicked at creation with 'OleInitialize failed!
RPC_E_CHANGED_MODE'. winit initialises OLE for drag-and-drop, which
needs the thread in an STA COM apartment, but other crates linked here
(wmi via hardware-query, the CUDA/NVML stack) already put the thread in
MTA. We don't use drag-and-drop, so set WindowAttributesExtWindows::
with_drag_and_drop(false) — winit then skips OleInitialize and the
window opens.
Add the ENDF/B-VIII.1 ground-truth doc (LANL Table LIX) used by the
acceptance-target resolver so cases are graded against the VIII.1
prediction, not an experimental k_eff the library biases away from.

- docs/endfb-viii1-lanl-table-lix.md: companion reference dataset notes
- scripts/regrade_sweep_viii1.py: re-grade an ICSBEP sweep CSV against
  the correct VIII.1 acceptance target (kills false FAILs from grading
  k_calc against the experimental handbook)
- scripts/runpod_fetch_endf81.sh: stream-extract VIII.1 HDF5 from the
  OpenMC mirror on RunPod without the intermediate .tar.xz
- results/.gitignore: ignore *.ncu-rep (large Nsight Compute binaries)
…r fonts

CI was red on every job. This makes all four jobs pass.

Rust (fmt + clippy + build):
- Apply `cargo fmt` across the workspace (the fmt gate was failing).
- Replace `unwrap()` with real error handling in binaries and the few
  library sites the `unwrap_used` deny flagged: diagnostic bins
  (`chi_compare`, `debug_lct`, `elastic_kinematics_diag`,
  `icsbep_alloc_bench`, `inspect_mt91`, `metal_stats_diag`) now return
  `Result` and use `?` / `ok_or`; `material_resolve` lock guards use
  `expect`, the natural-element split is restructured to drop an
  unconditional unwrap, and `simulate` documents the post-guard
  invariant. No `.expect()` lint-laundering.
- Test-code unwraps: add `clippy.toml` `allow-unwrap-in-tests = true`
  (a failed unwrap in a test is the desired outcome) and drop the
  scattered per-module `#[allow(clippy::unwrap_used)]`. Integration-test
  helper fns that aren't `#[test]` (`icsbep_runs::rank_sweep`,
  `run_case_e2e_with_counts`) propagate `Result` instead.
- Misc clippy: `LfuEntries::is_empty`, `checked_div`, needless borrows,
  inconsistent digit grouping, `to_vec`, `// SAFETY:` comments on the
  counting allocator, remove dead `arg_openmc` in `metal_stats_diag`.
- Gate `tests/cuda_runs.rs` behind `#![cfg(feature = "cuda")]` — it
  imports CUDA-only symbols and broke `--all-targets` without the
  feature.

Vendored third-party crate (`hardware-query`):
- Treat it as the external dependency it is: CI clippy now runs
  `--no-deps` and the global `RUSTFLAGS: -D warnings` is dropped, so our
  lint policy isn't imposed on vendored code (a registry dep would be
  `--cap-lints allow`'d automatically). No source reformatting.
- Remove the one genuinely dead field (`include_capabilities`,
  declared + initialized, never read) to clear the last build warning.

CI workflow:
- Clippy: `--all-targets --no-deps -- -D warnings`.
- Tests: `cargo test --lib` only. The `tests/` ICSBEP/CUDA regressions
  need the ~6.5 GB ENDF/B data CI can't provision; they run on
  data-equipped machines via the Python sweep harness. Integration
  targets stay compile-checked by clippy `--all-targets`.
- Paper: install `texlive-fonts-extra` (provides `newtxtext.sty`).

Python lint:
- `ruff --fix` the four scripts with unused / multi-line imports.

Repo hygiene:
- Ignore build/profiling binaries (`*.exe`, `*.so`, `*.ncu-rep`, `*.7z`,
  ...) and stop tracking the 2.2 MB `outputs/*.7z` Nsight capture.

Verified locally on rustc 1.96.0 (matches CI's `@stable`):
fmt --check clean; `clippy --all-targets --no-deps -- -D warnings` exit 0;
`build --release` exit 0; `test --lib` 440 passed; `ruff check scripts/` clean.
@sorcerer86pt
sorcerer86pt merged commit 4a4f1eb into main Jun 22, 2026
2 of 4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant