From 1a0b33fcf32d579536bf8b90b851bdaec8a9209a Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Thu, 15 Oct 2026 22:19:26 -0400 Subject: [PATCH] Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555) Thanks @jyviko. --- .github/workflows/ci.yml | 15 +- .github/workflows/pages.yml | 7 + ATTRIBUTION.md | 2 +- Cargo.lock | 7 + README.md | 2 +- apps/solidcraft-cli/Cargo.toml | 5 + apps/solidcraft-cli/src/main.rs | 68 +- apps/solidcraft-cli/tests/run_results.rs | 19 +- apps/solidcraft-cli/tests/snapshot_camera.rs | 239 ++- apps/solidcraft-web/src/main.rs | 2 + apps/solidcraft/src/main.rs | 1 + assets/styles/ui/palettes.rs | 7 +- crates/engine/src/capture.rs | 114 ++ crates/engine/src/cmd/file.rs | 2 + crates/engine/src/cmd/file_thumbnail.rs | 88 +- crates/engine/src/lib.rs | 15 + crates/engine/src/view.rs | 90 +- crates/engine/src/view_palette_tests.rs | 17 +- crates/gpu/Cargo.toml | 7 +- crates/gpu/src/capture.rs | 428 +++++ crates/gpu/src/lib.rs | 38 +- .../src/pbr_gpu.rs => gpu/src/pbr.rs} | 0 crates/gpu/src/select.rs | 261 +++ crates/gpu/src/view.rs | 1590 +++++++++++++++++ crates/gpu/tests/capture.rs | 168 ++ crates/mcp/Cargo.toml | 4 + crates/mcp/src/backend.rs | 24 +- crates/mcp/src/tests.rs | 53 +- crates/mcp/src/tools.rs | 9 +- crates/render/Cargo.toml | 2 +- crates/render/src/analysis.rs | 54 + crates/render/src/lib.rs | 6 +- crates/render/src/raster.rs | 62 +- crates/render/src/scene.rs | 65 + crates/ui-egui/src/access.rs | 74 +- crates/ui-egui/src/agent_chat/mod.rs | 9 +- crates/ui-egui/src/control.rs | 89 +- crates/ui-egui/src/dialogs_assembly_tests.rs | 1 - crates/ui-egui/src/dialogs_sheet_tests.rs | 1 - crates/ui-egui/src/file_load.rs | 28 +- crates/ui-egui/src/file_load_ui_tests.rs | 1 - crates/ui-egui/src/gpu.rs | 1554 +--------------- crates/ui-egui/src/home.rs | 15 +- crates/ui-egui/src/render_workspace.rs | 6 +- crates/ui-egui/src/render_workspace_tests.rs | 1 - crates/ui-egui/src/scenario.rs | 15 +- crates/ui-egui/src/theme.rs | 14 +- crates/ui-egui/src/viewport.rs | 218 +-- docs/book/src/dev/architecture.md | 11 +- docs/book/src/guide/getting-started.md | 2 +- docs/control-protocol.md | 2 +- docs/mcp.md | 4 +- xtask/src/book.rs | 2 +- xtask/src/layers.rs | 5 +- 54 files changed, 3525 insertions(+), 1998 deletions(-) create mode 100644 crates/engine/src/capture.rs create mode 100644 crates/gpu/src/capture.rs rename crates/{ui-egui/src/pbr_gpu.rs => gpu/src/pbr.rs} (100%) create mode 100644 crates/gpu/src/select.rs create mode 100644 crates/gpu/src/view.rs create mode 100644 crates/gpu/tests/capture.rs create mode 100644 crates/render/src/analysis.rs create mode 100644 crates/render/src/scene.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a245aab5..6046eed9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -202,8 +202,18 @@ jobs: fail-fast: false matrix: shard: [1, 2, 3, 4] + env: + # Pictures (snapshots, screenshots, ui.render, the viewport's offscreen tests) are drawn on + # the GPU. The runner has none: Mesa's lavapipe (software Vulkan) draws them, and a test that + # would skip for want of an adapter fails instead. + SOLIDCRAFT_GPU: software + SOLIDCRAFT_REQUIRE_GPU: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install a software Vulkan driver (lavapipe) + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1 - uses: taiki-e/install-action@1168f6810a3cb30a3514bf5756a3b7f0422c6df8 # v2.87.28 with: tool: cargo-nextest @@ -364,7 +374,8 @@ jobs: # D3D12's older shader compiler (FXC, used where dxcompiler.dll is absent) is stricter than # DXC, Vulkan and Metal: a shader it cannot compile once crashed the app at startup. Build the # GPU tessellation pipeline with FXC on the runner's WARP software adapter and compare its - # results with the CPU. + # results with the CPU, and draw headless pictures with the viewport's pipelines there + # (crates/gpu/tests/capture.rs). windows-fxc: name: Windows D3D12 FXC shaders needs: changes @@ -374,6 +385,8 @@ jobs: env: WGPU_BACKEND: dx12 WGPU_DX12_COMPILER: fxc + # WARP is a software (Cpu) adapter. + SOLIDCRAFT_GPU: software SOLIDCRAFT_REQUIRE_GPU: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 401b3947..604c3fef 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -45,7 +45,14 @@ jobs: curl -sSL "https://github.com/rust-lang/mdBook/releases/download/${MDBOOK_VERSION}/mdbook-${MDBOOK_VERSION}-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin" curl -sSL "https://github.com/trunk-rs/trunk/releases/download/${TRUNK_VERSION}/trunk-x86_64-unknown-linux-gnu.tar.gz" | tar -xz -C "$HOME/.local/bin" echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Install a software Vulkan driver (lavapipe) + # The book's pictures are `solidcraft-cli snapshot`s, drawn on the GPU; the runner has none. + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends mesa-vulkan-drivers libvulkan1 - name: Build the book + env: + SOLIDCRAFT_GPU: software run: cargo xtask book - name: Build the web app working-directory: apps/solidcraft-web diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 9c7c0876..d2096256 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -16,7 +16,7 @@ Editable drawing code, native projects and shipped exports are collected under ` |---|---|---|---| | examples/render-studio.json | SolidCraft contributors | original procedural box/cylinder enclosure script for the integrated Render workspace | MIT OR Apache-2.0 | | examples/render-opaque-studio.json | SolidCraft contributors | original procedural sphere study varying opaque metal fraction and roughness independently; no external assets or engineering data | MIT OR Apache-2.0 | -| crates/ui-egui/src/pbr_gpu.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 | +| crates/gpu/src/pbr.rs | SolidCraft contributors | original WGSL translation of SolidCraft's opaque surface and fixed studio reference, embedded as Rust source; no external shader code | MIT OR Apache-2.0 | | assets/archive/icons/macos-v1/exports/icon.svg | SolidCraft contributors | original macOS adaptation of the code-drawn SolidCraft mark, edited and exported with VectorCraft; Apple HIG and production templates consulted as layout references only | MIT OR Apache-2.0 | | assets/icons/app/macos/source/SolidCraft.icon/icon.json | SolidCraft contributors | approved Icon Composer composition and appearance settings for the original SolidCraft artwork | MIT OR Apache-2.0 | | assets/icons/app/macos/source/SolidCraft.icon/Assets/02-grid.svg | SolidCraft contributors | original perspective grid with elliptical radial alpha, exported from VectorCraft | MIT OR Apache-2.0 | diff --git a/Cargo.lock b/Cargo.lock index 6862c0be..d8602e2f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4023,8 +4023,10 @@ dependencies = [ name = "solidcraft-cli" version = "0.13.1" dependencies = [ + "image", "serde_json", "solidcraft-engine", + "solidcraft-gpu", "solidcraft-mcp", ] @@ -4086,8 +4088,12 @@ dependencies = [ "log", "naga", "pollster 0.4.0", + "serde", + "serde_json", + "solidcraft-engine", "solidcraft-geom", "solidcraft-kernel", + "solidcraft-render", "thiserror 2.0.21", "wgpu", ] @@ -4135,6 +4141,7 @@ version = "0.13.1" dependencies = [ "serde_json", "solidcraft-engine", + "solidcraft-gpu", ] [[package]] diff --git a/README.md b/README.md index 3b11f3dd..b5c0c8fc 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ configuration and an example session: [docs/mcp.md](docs/mcp.md). | Layer | Crates | Role | |---|---|---| | L0 | `geom` | vectors, planes, profiles, meshes and their measures | -| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and CPU rasterizer | +| L1 | `sketch`, `kernel`, `render` | constraint solver and profiles; the B-rep kernel boundary (truck); view math and the headless scene description | | L2 | `doc` | parameters, expressions, the feature timeline and its incremental evaluation | | L3 | `io` | design files, STL/OBJ/STEP/3MF export, STEP and 3MF/STL/OBJ/glTF import features | | L4 | `engine` | the session and the command registry (everything is a command) | diff --git a/apps/solidcraft-cli/Cargo.toml b/apps/solidcraft-cli/Cargo.toml index 3e4f01e1..85401bae 100644 --- a/apps/solidcraft-cli/Cargo.toml +++ b/apps/solidcraft-cli/Cargo.toml @@ -21,5 +21,10 @@ path = "src/alias.rs" [dependencies] solidcraft-engine = { workspace = true } +solidcraft-gpu = { workspace = true } solidcraft-mcp = { workspace = true } serde_json = { workspace = true } + +[dev-dependencies] +# Snapshot tests decode the pictures to compare them with a tolerance. +image = { workspace = true } diff --git a/apps/solidcraft-cli/src/main.rs b/apps/solidcraft-cli/src/main.rs index 9d320069..b078297a 100644 --- a/apps/solidcraft-cli/src/main.rs +++ b/apps/solidcraft-cli/src/main.rs @@ -34,7 +34,6 @@ use serde_json::{Value, json}; mod app_icon; use solidcraft_engine::Session; -use solidcraft_engine::render::render_png; use solidcraft_engine::view::Guides; const USAGE: &str = "usage: @@ -63,6 +62,10 @@ const USAGE: &str = "usage: solidcraft-cli mcp [--in design.solidcraft|script.json] [--connect 127.0.0.1:PORT [--token T]] [--allow-path DIR]... [--allow-any-path] solidcraft-cli crashes [list [--json] | show | replay | dir | clear []] solidcraft-cli licences + solidcraft-cli gpu [--policy auto|hardware|software|cpu] + (the GPU adapters a headless render can open, best first, the one the policy picks and the tier it + lands on: gpu, gpu-software, or the CPU rasterizer (auto with no adapter, or cpu); the policy defaults + to SOLIDCRAFT_GPU, else auto; exits with an error when hardware or software finds no such adapter) solidcraft-cli icon --out icon.png [--size 256] | --out icon.ico "; @@ -75,6 +78,7 @@ fn main() -> ExitCode { Some("snapshot") => cmd_snapshot(&args[1..]), Some("render") => render::run(&args[1..]), Some("exec") => cmd_exec(&args[1..]), + Some("gpu") => cmd_gpu(&args[1..]), Some("commands") => { use std::io::Write as _; let mut s = Session::default(); @@ -213,6 +217,7 @@ fn print_json(v: &Value, file: Option<&str>) -> Result<(), String> { } fn cmd_run(args: &[String]) -> Result<(), String> { + install_renderer(); let json_out = json_out(args)?; let base = flag(args, "--in"); let path = positional(args); @@ -334,13 +339,59 @@ fn cmd_script(args: &[String]) -> Result<(), String> { Ok(()) } +/// `gpu`: the headless adapter survey and the policy's pick. +fn cmd_gpu(args: &[String]) -> Result<(), String> { + use solidcraft_gpu::select::{Policy, survey}; + let policy = match args { + [] => Policy::from_env()?, + [flag, v] if flag == "--policy" => Policy::parse(v).ok_or_else(|| format!("--policy must be auto, hardware, software or cpu, not `{v}`"))?, + _ => return Err("usage: solidcraft-cli gpu [--policy auto|hardware|software|cpu]".into()), + }; + let s = survey(policy); + let chosen = s.chosen.and_then(|i| s.adapters.get(i)).cloned(); + // The tier pictures land on: a hardware GPU, a software adapter, or the CPU rasterizer. + let renderer = s.lands_on.map(|l| l.renderer()); + println!( + "{}", + pretty(&json!({ + "policy": policy.name(), + "backends": s.backends, + "adapters": s.adapters, + "chosen": chosen, + "lands_on": s.lands_on, + "renderer": renderer, + })) + ); + match s.lands_on { + Some(solidcraft_gpu::select::Landing::Cpu) => { + eprintln!("warning: {}", solidcraft_engine::capture::CPU_WARNING); + Ok(()) + } + Some(_) => Ok(()), + None => Err(format!("no adapter qualifies under the {} policy", policy.name())), + } +} + +/// Draw this process's pictures (snapshots, MCP screenshots, saved thumbnails) on the GPU with the +/// viewport's pipelines: the headless device the `SOLIDCRAFT_GPU` policy picks, opened on the first +/// picture; under `auto` with no qualifying adapter (or `cpu`), the CPU rasterizer, and the +/// picture says so. Under `hardware` or `software` with no such adapter a picture fails with an +/// error that names the policy. +fn install_renderer() { + solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| { + let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?; + Ok(solidcraft_engine::capture::Picture { rgba, renderer }) + })); +} + fn cmd_snapshot(args: &[String]) -> Result<(), String> { + install_renderer(); known_flags("snapshot", args, &["--in", "--out", "--width", "--height", "--view", "--camera"], &["--no-grid", "--no-axes"])?; if positional(args).is_none() && flag(args, "--in").is_none() { return Err(USAGE.to_string()); } let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?; - let size = |name: &str, default: usize| match flag(args, name) { + let size = |name: &str, default: u32| match flag(args, name) { None => Ok(default), Some(x) => x.parse().ok().filter(|n| (16..=8192).contains(n)).ok_or_else(|| format!("{name} must be 16 to 8192 pixels, not `{x}`")), }; @@ -364,9 +415,14 @@ fn cmd_snapshot(args: &[String]) -> Result<(), String> { let cam = s.camera.unwrap_or_else(|| solidcraft_engine::view::home_camera(&s)); let guides = Guides { grid: !args.iter().any(|a| a == "--no-grid"), axes: !args.iter().any(|a| a == "--no-axes") }; let scene = solidcraft_engine::view::scene_with_guides(&s, &cam, s.section, guides); - let png = render_png(&scene, &cam, w, h).ok_or("render failed")?; - std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?; - println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()}))); + let png = solidcraft_engine::capture::png(&scene, &cam, w, h)?; + std::fs::write(out, &png.bytes).map_err(|e| format!("{out}: {e}"))?; + let mut reply = json!({"path": out, "width": w, "height": h, "bytes": png.bytes.len(), "renderer": png.renderer}); + if let Some(w) = solidcraft_engine::capture::warning(png.renderer) { + eprintln!("warning: {w}"); + reply["warning"] = json!(w); + } + println!("{}", pretty(&reply)); Ok(()) } @@ -412,6 +468,7 @@ fn ico(images: &[(usize, Vec)]) -> Result, String> { } fn cmd_exec(args: &[String]) -> Result<(), String> { + install_renderer(); let id = args.first().ok_or_else(|| USAGE.to_string())?; let p: Value = match args.get(1) { Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?, @@ -427,6 +484,7 @@ fn cmd_exec(args: &[String]) -> Result<(), String> { /// a running app's control port. stdout carries only protocol messages. Files the agent's tool /// calls reach are confined to `solidcraft_mcp::agent_scope` unless `--allow-any-path` is given. fn cmd_mcp(args: &[String]) -> Result<(), String> { + install_renderer(); let scope = (!args.iter().any(|a| a == "--allow-any-path")) .then(|| solidcraft_mcp::agent_scope(flags(args, "--allow-path").into_iter().map(std::path::PathBuf::from))); if let Some(s) = &scope { diff --git a/apps/solidcraft-cli/tests/run_results.rs b/apps/solidcraft-cli/tests/run_results.rs index 5b805ad2..1da815e9 100644 --- a/apps/solidcraft-cli/tests/run_results.rs +++ b/apps/solidcraft-cli/tests/run_results.rs @@ -151,11 +151,20 @@ fn snapshot_runs_its_script_on_the_in_design() { json_output(cli().arg("snapshot").args(args).arg("--out").arg(&png).args(["--width", "64", "--height", "48"]).output().unwrap()); std::fs::read(&png).unwrap() }; - let alone = shot("alone.png", &[shifted.as_os_str()]); - let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]); - let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]); - assert_ne!(alone, on_base, "the script ran on the --in design"); - assert_ne!(base_only, on_base); + // Pictures are drawn on the GPU: without an adapter only the option checks below run. + match solidcraft_gpu::capture::shared() { + Ok(_) => { + let alone = shot("alone.png", &[shifted.as_os_str()]); + let on_base = shot("on_base.png", &[shifted.as_os_str(), "--in".as_ref(), base.as_os_str()]); + let base_only = shot("base.png", &["--in".as_ref(), base.as_os_str()]); + assert_ne!(alone, on_base, "the script ran on the --in design"); + assert_ne!(base_only, on_base); + } + Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => { + eprintln!("pictures left out: {e}") + } + Err(e) => panic!("{e}"), + } for (bad, says) in [(["--frobnicate", "1"], "doesn't take --frobnicate"), (["--width", "wide"], "--width must be 16 to 8192")] { let output = cli().arg("snapshot").arg(&base).arg("--out").arg(dir.0.join("x.png")).args(bad).output().unwrap(); assert_eq!(output.status.code(), Some(1)); diff --git a/apps/solidcraft-cli/tests/snapshot_camera.rs b/apps/solidcraft-cli/tests/snapshot_camera.rs index 8f70b1b5..69e4f89b 100644 --- a/apps/solidcraft-cli/tests/snapshot_camera.rs +++ b/apps/solidcraft-cli/tests/snapshot_camera.rs @@ -1,6 +1,8 @@ //! `snapshot` frames its picture like the MCP `screenshot`: from a camera, a view saved in the //! design or one the script applies itself, and without the ground grid and the origin's axes -//! when asked (the grid lies on Z 0 and cuts through a part that straddles it). +//! when asked (the grid lies on Z 0 and cuts through a part that straddles it). Pictures are drawn +//! on the GPU and compared with a tolerance; without a GPU adapter the tests that draw are skipped +//! with the reason printed (`SOLIDCRAFT_REQUIRE_GPU=1` makes that a failure). use std::path::{Path, PathBuf}; use std::process::{Command, Output}; @@ -12,7 +14,52 @@ use solidcraft_mcp::{Headless, Server}; static NEXT: AtomicUsize = AtomicUsize::new(0); -const SIZE: (usize, usize) = (160, 100); +/// Whether pictures can be drawn here; installs the renderer the CLI installs, so the MCP +/// `screenshot` in this process draws as `solidcraft-cli mcp` does. +fn gpu() -> bool { + match solidcraft_gpu::capture::shared() { + Ok(c) => { + solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| { + c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string()) + })); + true + } + Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => { + eprintln!("skipping: {e}"); + false + } + Err(e) => panic!("{e}"), + } +} + +/// A PNG's size and RGBA pixels. +fn decode(png: &[u8]) -> (u32, u32, Vec) { + let img = image::load_from_memory_with_format(png, image::ImageFormat::Png).unwrap().to_rgba8(); + (img.width(), img.height(), img.into_raw()) +} + +/// Pixels of two same-sized pictures that differ by more than a little in some channel. +fn differing(a: &[u8], b: &[u8]) -> usize { + let ((wa, ha, a), (wb, hb, b)) = (decode(a), decode(b)); + assert_eq!((wa, ha), (wb, hb), "pictures of different sizes"); + a.as_chunks::<4>().0.iter().zip(b.as_chunks::<4>().0).filter(|(p, q)| p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 16)).count() +} + +/// The same picture: GPU output may differ by a few levels or a stray edge pixel between drivers. +#[track_caller] +fn assert_same(a: &[u8], b: &[u8], what: &str) { + let (n, total) = (differing(a, b), (SIZE.0 * SIZE.1) as usize); + assert!(n * 500 <= total, "{what}: {n} of {total} pixels differ"); +} + +/// Different pictures: more than stray pixels differ. +#[track_caller] +fn assert_differs(a: &[u8], b: &[u8], what: &str) { + let n = differing(a, b); + assert!(n >= 20, "{what}: only {n} pixels differ"); +} + +const SIZE: (u32, u32) = (160, 100); fn camera() -> Value { json!({"target": [20, 0, 0], "yaw": 0.6, "pitch": 0.25, "distance": 100}) @@ -88,32 +135,38 @@ impl Drop for Scratch { /// not the home view the option used to be ignored for. #[test] fn a_camera_frames_the_picture_as_the_mcp_screenshot_does() { + if !gpu() { + return; + } let dir = Scratch::new(); let script = dir.script("part.json", &[]); let home = dir.snapshot(&script, "home.png", &[]); let json = camera().to_string(); let shot = dir.snapshot(&script, "camera.png", &["--camera", &json]); - assert_ne!(shot, home, "--camera was ignored"); - assert_eq!(shot, dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()}))); + assert_differs(&shot, &home, "--camera was ignored"); + assert_same(&shot, &dir.mcp(&script, "mcp-camera.png", json!({"camera": camera()})), "the MCP screenshot"); // A perspective camera too, and a different eye gives a different picture. let mut near = camera(); near["fov"] = json!(0.6); let perspective = dir.snapshot(&script, "perspective.png", &["--camera", &near.to_string()]); - assert_ne!(perspective, shot); - assert_eq!(perspective, dir.mcp(&script, "mcp-perspective.png", json!({"camera": near}))); + assert_differs(&perspective, &shot, "perspective"); + assert_same(&perspective, &dir.mcp(&script, "mcp-perspective.png", json!({"camera": near})), "the MCP perspective screenshot"); } /// `--view` also takes a view saved in the design, which is the camera it was saved with; a /// standard view still looks as it did. #[test] fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() { + if !gpu() { + return; + } let dir = Scratch::new(); let script = dir.script("part.json", &[]); let json = camera().to_string(); let by_camera = dir.snapshot(&script, "camera.png", &["--camera", &json]); let saved = dir.snapshot(&script, "saved.png", &["--view", "Detail"]); - assert_eq!(saved, by_camera); - assert_eq!(saved, dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"}))); + assert_same(&saved, &by_camera, "the saved view"); + assert_same(&saved, &dir.mcp(&script, "mcp-saved.png", json!({"view": "Detail"})), "the MCP saved view"); // A standard view frames the model from its direction, as before: the home camera turned. let top = dir.snapshot(&script, "top.png", &["--view", "top"]); let mut s = Session::default(); @@ -121,32 +174,38 @@ fn a_saved_view_is_its_camera_and_a_standard_view_is_unchanged() { let mut cam = solidcraft_engine::view::home_camera(&s); cam.set_view(solidcraft_engine::render::StandardView::Top); let scene = solidcraft_engine::view::scene(&s, &cam); - assert_eq!(top, solidcraft_engine::render::render_png(&scene, &cam, SIZE.0, SIZE.1).unwrap()); - assert_eq!(top, dir.mcp(&script, "mcp-top.png", json!({"view": "top"}))); - assert_ne!(top, saved); + assert_same(&top, &solidcraft_engine::capture::png(&scene, &cam, SIZE.0, SIZE.1).unwrap().bytes, "the home camera turned to the top"); + assert_same(&top, &dir.mcp(&script, "mcp-top.png", json!({"view": "top"})), "the MCP top view"); + assert_differs(&top, &saved, "top and the saved view"); let iso = dir.snapshot(&script, "iso.png", &["--view", "iso"]); - assert_eq!(iso, dir.snapshot(&script, "home.png", &[])); - assert_ne!(iso, top); + assert_same(&iso, &dir.snapshot(&script, "home.png", &[]), "iso is home"); + assert_differs(&iso, &top, "iso and top"); } /// A script that applies a view itself (as an MCP `execute` of `view.apply` does) sets the camera /// of its snapshot; `--view` and `--camera` still decide when given. #[test] fn a_script_can_carry_its_camera() { + if !gpu() { + return; + } let dir = Scratch::new(); let script = dir.script("carried.json", &[json!({"command": "view.apply", "params": {"camera": camera()}})]); let plain = dir.script("plain.json", &[]); let carried = dir.snapshot(&script, "carried.png", &[]); - assert_eq!(carried, dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()])); - assert_ne!(carried, dir.snapshot(&plain, "home.png", &[])); + assert_same(&carried, &dir.snapshot(&plain, "by-camera.png", &["--camera", &camera().to_string()]), "the carried camera"); + assert_differs(&carried, &dir.snapshot(&plain, "home.png", &[]), "the carried camera and home"); let top = dir.snapshot(&script, "top.png", &["--view", "top"]); - assert_eq!(top, dir.snapshot(&plain, "plain-top.png", &["--view", "top"])); + assert_same(&top, &dir.snapshot(&plain, "plain-top.png", &["--view", "top"]), "--view decides"); } /// The grid and the axes each go when asked, and a render without both from any side shows the /// model alone: an empty design is then the same blank picture from every camera. #[test] fn the_grid_and_the_axes_can_be_left_out() { + if !gpu() { + return; + } let dir = Scratch::new(); let script = dir.script("part.json", &[]); let json = camera().to_string(); @@ -161,7 +220,7 @@ fn the_grid_and_the_axes_can_be_left_out() { let different = [&all, &no_grid, &no_axes, &bare]; for (i, a) in different.iter().enumerate() { for b in &different[i + 1..] { - assert_ne!(a, b, "{view:?}: two pictures are the same"); + assert_differs(a, b, &format!("{view:?}: two of the pictures")); } } // The same as the MCP's `grid` and `axes`. @@ -173,15 +232,151 @@ fn the_grid_and_the_axes_can_be_left_out() { a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); dir.mcp(&script, out, a) }; - assert_eq!(no_grid, mcp(json!({"grid": false}), "mcp-no-grid.png")); - assert_eq!(no_axes, mcp(json!({"axes": false}), "mcp-no-axes.png")); - assert_eq!(bare, mcp(json!({"grid": false, "axes": false}), "mcp-bare.png")); + assert_same(&no_grid, &mcp(json!({"grid": false}), "mcp-no-grid.png"), "the MCP's grid: false"); + assert_same(&no_axes, &mcp(json!({"axes": false}), "mcp-no-axes.png"), "the MCP's axes: false"); + assert_same(&bare, &mcp(json!({"grid": false, "axes": false}), "mcp-bare.png"), "the MCP's bare picture"); } let empty = dir.0.join("empty.json"); std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap(); let blank = |view: &str, out: &str| dir.snapshot(&empty, out, &["--view", view, "--no-grid", "--no-axes"]); - assert_eq!(blank("top", "blank-top.png"), blank("front", "blank-front.png")); - assert_ne!(blank("top", "blank-top.png"), dir.snapshot(&empty, "top.png", &["--view", "top"])); + assert_same(&blank("top", "blank-top.png"), &blank("front", "blank-front.png"), "blank from every side"); + assert_differs(&blank("top", "blank-top.png"), &dir.snapshot(&empty, "top.png", &["--view", "top"]), "blank and the grid"); +} + +/// A snapshot of a box shows its silhouette where the camera puts it: the pixels that differ +/// from an empty design's picture (same camera, no grid or axes) span the box's corners projected +/// to pixels, within the edge and silhouette lines' width. +#[test] +fn a_snapshot_shows_the_part_where_the_camera_puts_it() { + if !gpu() { + return; + } + let dir = Scratch::new(); + let part = dir.0.join("box.json"); + let empty = dir.0.join("empty.json"); + let corner = [10.0, -20.0, -15.0]; + let size = [60.0, 40.0, 30.0]; + std::fs::write( + &part, + json!({"commands": [{"command": "solid.box", "params": {"length": size[0], "width": size[1], "height": size[2], "corner": corner}}]}) + .to_string(), + ) + .unwrap(); + std::fs::write(&empty, json!({"commands": []}).to_string()).unwrap(); + for cam in [ + camera(), + json!({"target": [40, 0, 0], "yaw": 0.0, "pitch": 0.0, "distance": 160}), + json!({"target": [40, 0, 0], "yaw": -0.4, "pitch": 0.5, "distance": 150, "fov": 0.6}), + ] { + let args = ["--camera", &cam.to_string(), "--no-grid", "--no-axes"]; + let (_, _, with) = decode(&dir.snapshot(&part, "part.png", &args)); + let (_, _, without) = decode(&dir.snapshot(&empty, "empty.png", &args)); + // The part's pixels: where the picture differs from the empty one. + let (mut lo, mut hi) = ([u32::MAX; 2], [0u32; 2]); + for (i, (p, q)) in with.as_chunks::<4>().0.iter().zip(without.as_chunks::<4>().0).enumerate() { + if p.iter().zip(q.iter()).any(|(x, y)| x.abs_diff(*y) > 24) { + let (x, y) = (i as u32 % SIZE.0, i as u32 / SIZE.0); + lo = [lo[0].min(x), lo[1].min(y)]; + hi = [hi[0].max(x), hi[1].max(y)]; + } + } + assert!(lo[0] <= hi[0], "{cam}: the part is not in the picture"); + // Where the camera puts the corners. + let mut s = solidcraft_engine::Session::default(); + s.execute("view.apply", &json!({"camera": cam})).unwrap(); + let c = s.camera.unwrap(); + let vp = c.view_proj(f64::from(SIZE.0) / f64::from(SIZE.1), 1000.0); + let (mut elo, mut ehi) = ([f64::MAX; 2], [f64::MIN; 2]); + for k in 0..8 { + let p = solidcraft_engine::geom::Vec3::new( + corner[0] + size[0] * f64::from(k & 1), + corner[1] + size[1] * f64::from((k >> 1) & 1), + corner[2] + size[2] * f64::from((k >> 2) & 1), + ); + let q = vp.apply(p); + let (x, y) = ((q[0] / q[3] + 1.0) * 0.5 * f64::from(SIZE.0), (1.0 - q[1] / q[3]) * 0.5 * f64::from(SIZE.1)); + elo = [elo[0].min(x), elo[1].min(y)]; + ehi = [ehi[0].max(x), ehi[1].max(y)]; + } + let near = |got: u32, want: f64| (f64::from(got) - want).abs() <= 2.5; + assert!( + near(lo[0], elo[0]) && near(lo[1], elo[1]) && near(hi[0] + 1, ehi[0]) && near(hi[1] + 1, ehi[1]), + "{cam}: the part spans pixels {lo:?}..={hi:?}, its corners project to {elo:?}..{ehi:?}" + ); + } +} + +/// `snapshot` under a `SOLIDCRAFT_GPU` policy: its reply and stderr. +fn snapshot_under(dir: &Scratch, script: &Path, out: &str, policy: &str) -> Output { + Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")) + .env("SOLIDCRAFT_GPU", policy) + .arg("snapshot") + .arg(script) + .arg("--out") + .arg(dir.0.join(out)) + .args(["--width", &SIZE.0.to_string(), "--height", &SIZE.1.to_string(), "--view", "top"]) + .output() + .unwrap() +} + +/// The last tier: `SOLIDCRAFT_GPU=cpu` draws with the CPU rasterizer whatever adapters there are, +/// says so (`renderer: cpu` and a warning that overlays are not drawn), and the picture is the +/// rasterizer's own. +#[test] +fn a_forced_cpu_snapshot_is_the_cpu_rasterizers_and_says_so() { + let dir = Scratch::new(); + let script = dir.script("part.json", &[]); + let output = snapshot_under(&dir, &script, "cpu.png", "cpu"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(output.status.success(), "{stderr}"); + let reply: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(reply["renderer"], "cpu", "{reply}"); + assert!(reply["warning"].as_str().is_some_and(|w| w.contains("not drawn")), "{reply}"); + assert!(stderr.contains("CPU rasterizer"), "{stderr}"); + let mut s = Session::default(); + s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap(); + let mut cam = solidcraft_engine::view::home_camera(&s); + cam.set_view(solidcraft_engine::render::StandardView::Top); + let scene = solidcraft_engine::view::scene(&s, &cam); + let cpu = solidcraft_engine::render::render_png(&scene, &cam, SIZE.0 as usize, SIZE.1 as usize).unwrap(); + assert_eq!(std::fs::read(dir.0.join("cpu.png")).unwrap(), cpu); +} + +/// `hardware` (and `software`) never fall to the CPU rasterizer: without such an adapter the +/// snapshot fails, naming the policy, and writes nothing; with one it says it was drawn on the GPU. +/// `auto` lands where `solidcraft-cli gpu` says it does. +#[test] +fn a_strict_policy_never_falls_to_the_cpu() { + let dir = Scratch::new(); + let script = dir.script("part.json", &[]); + let survey = |policy: &str| { + let o = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(["gpu", "--policy", policy]).output().unwrap(); + (o.status.success(), serde_json::from_slice::(&o.stdout).unwrap_or(Value::Null)) + }; + for policy in ["hardware", "software"] { + let (found, report) = survey(policy); + let output = snapshot_under(&dir, &script, &format!("{policy}.png"), policy); + let stderr = String::from_utf8_lossy(&output.stderr); + if found { + assert!(output.status.success(), "{policy}: {stderr}"); + let reply: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(reply["renderer"], report["renderer"], "{policy}: {reply}"); + assert_ne!(reply["renderer"], "cpu"); + } else { + assert_eq!(report["lands_on"], Value::Null, "{policy}: {report}"); + assert_eq!(output.status.code(), Some(1), "{policy}: {stderr}"); + assert!(stderr.contains(&format!("`{policy}` policy")) && stderr.contains("solidcraft-cli gpu"), "{policy}: {stderr}"); + assert!(!dir.0.join(format!("{policy}.png")).exists()); + } + } + let (found, report) = survey("auto"); + assert!(found, "auto always lands somewhere: {report}"); + let output = snapshot_under(&dir, &script, "auto.png", "auto"); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + let reply: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(reply["renderer"], report["renderer"], "{reply} vs {report}"); + let (_, cpu) = survey("cpu"); + assert_eq!((cpu["lands_on"].clone(), cpu["renderer"].clone()), (json!("cpu"), json!("cpu")), "{cpu}"); } #[test] diff --git a/apps/solidcraft-web/src/main.rs b/apps/solidcraft-web/src/main.rs index 555fe38c..ad0cd875 100644 --- a/apps/solidcraft-web/src/main.rs +++ b/apps/solidcraft-web/src/main.rs @@ -491,6 +491,8 @@ mod web { install_app: Some(crate::install::service(&cc.egui_ctx)), ..Default::default() }; + // Thumbnails of saves and start-page cards. + solidcraft_ui_egui::gpu::install_cpu_picture_renderer(); let mut app = SolveApp::new(Session::default(), services); if let Some(p) = cc.storage.and_then(|s| solidcraft_ui_egui::prefs::stored(|k| s.get_string(k))) { app.load_prefs(&p); diff --git a/apps/solidcraft/src/main.rs b/apps/solidcraft/src/main.rs index 0a43b95b..bc55e866 100644 --- a/apps/solidcraft/src/main.rs +++ b/apps/solidcraft/src/main.rs @@ -567,6 +567,7 @@ fn main() -> eframe::Result { eprintln!("SolidCraft graphics: {:?}, MSAA {}x (requested {}x)", rs.adapter.get_info().backend, rs.msaa_samples, samples); solidcraft_engine::crash::phase("viewport pipelines (shader compile)"); app.set_wgpu(rs, DEPTH_BITS, STENCIL_BITS, rs.msaa_samples); + solidcraft_ui_egui::gpu::install_picture_renderer(rs); // The graphics driver is loaded now; it may have put its own exception filter // in front of ours. #[cfg(target_os = "windows")] diff --git a/assets/styles/ui/palettes.rs b/assets/styles/ui/palettes.rs index d28cfd69..2236e5ff 100644 --- a/assets/styles/ui/palettes.rs +++ b/assets/styles/ui/palettes.rs @@ -93,12 +93,7 @@ impl Tokens { bend_up: Color32::from_rgb(30, 140, 50), bend_down: Color32::from_rgb(200, 90, 20), interference: Color32::from_rgb(235, 45, 40), - analysis: AnalysisColors { - draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]], - access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]], - curvature: [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]], - hatch: false, - }, + analysis: AnalysisColors::DEFAULT, icons: IconColors { sheet: Color32::from_rgb(170, 200, 240), sheet_2: Color32::from_rgb(150, 185, 235), diff --git a/crates/engine/src/capture.rs b/crates/engine/src/capture.rs new file mode 100644 index 00000000..22d7b29d --- /dev/null +++ b/crates/engine/src/capture.rs @@ -0,0 +1,114 @@ +//! How headless pictures of the model are drawn: a renderer the host installs. The engine +//! describes what a picture shows ([`crate::view::scene`]); drawing it is a GPU job the engine +//! cannot do itself (the GPU crate sits above it), so the application, the CLI and the MCP +//! server install one ([`install`]) that draws with the viewport's own pipelines (or, as a last +//! resort the host chooses, the CPU rasterizer). Every picture names the renderer that drew it. +//! Without a renderer, a picture is an error and a save simply has no thumbnail. + +use std::sync::{Arc, RwLock}; + +use solidcraft_render::{Camera, Scene}; + +/// A picture: straight RGBA, rows top to bottom, and the renderer that drew it (`gpu`, +/// `gpu-software` or `cpu`). +pub struct Picture { + pub rgba: Vec, + pub renderer: &'static str, +} + +/// A picture as PNG bytes, and the renderer that drew it. +pub struct Png { + pub bytes: Vec, + pub renderer: &'static str, +} + +/// What a reply says when the CPU rasterizer drew the picture. +pub const CPU_WARNING: &str = "drawn by the CPU rasterizer (no GPU adapter qualified, or SOLIDCRAFT_GPU=cpu): surface analysis colours, highlights and legends are not drawn; `solidcraft-cli gpu` lists the adapters"; + +/// The warning that goes with a picture drawn by `renderer`, if any. +pub fn warning(renderer: &str) -> Option<&'static str> { + (renderer == "cpu").then_some(CPU_WARNING) +} + +/// Draws `scene` seen by `cam` at `width` × `height` pixels (`width * height * 4` bytes of RGBA), +/// or says why it could not. +pub type Renderer = dyn Fn(&Scene, &Camera, u32, u32) -> Result + Send + Sync; + +static RENDERER: RwLock>> = RwLock::new(None); + +/// Make `renderer` draw this process's headless pictures (replacing any installed before). +pub fn install(renderer: Arc) { + if let Ok(mut r) = RENDERER.write() { + *r = Some(renderer); + } +} + +/// The installed renderer, if any. +pub fn installed() -> Option> { + RENDERER.read().ok().and_then(|r| r.clone()) +} + +/// `scene` drawn by the installed renderer. +pub fn rgba(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result { + rgba_with(installed().as_deref(), scene, cam, width, height) +} + +/// `scene` drawn by the installed renderer, as PNG. +pub fn png(scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result { + png_with(installed().as_deref(), scene, cam, width, height) +} + +/// [`rgba`] with `renderer` instead of the installed one. +pub fn rgba_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result { + let renderer = renderer.ok_or("no renderer is installed to draw pictures in this process")?; + let picture = renderer(scene, cam, width, height)?; + if picture.rgba.len() as u64 != u64::from(width) * u64::from(height) * 4 { + return Err(format!("the renderer returned {} bytes for {width} × {height} pixels", picture.rgba.len())); + } + if let Some(w) = warning(picture.renderer) { + log::warn!("{w}"); + } + Ok(picture) +} + +/// [`png`] with `renderer` instead of the installed one. +pub fn png_with(renderer: Option<&Renderer>, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result { + let Picture { rgba, renderer } = rgba_with(renderer, scene, cam, width, height)?; + let bytes = solidcraft_render::png(width as usize, height as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string())?; + Ok(Png { bytes, renderer }) +} + +/// For tests: a stand-in for the renderer the hosts install, a flat picture whose first byte +/// counts the scene's meshes. +#[cfg(test)] +pub(crate) fn install_test_renderer() { + install(Arc::new(|scene: &Scene, _: &Camera, w: u32, h: u32| { + let mut rgba = vec![200; (w * h * 4) as usize]; + if let Some(first) = rgba.first_mut() { + *first = scene.meshes.len() as u8; + } + Ok(Picture { rgba, renderer: "gpu" }) + })); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn without_a_renderer_a_picture_is_an_error_not_a_crash() { + let e = png_with(None, &Scene::default(), &Camera::default(), 16, 16).err().unwrap(); + assert!(e.contains("no renderer"), "{e}"); + } + + #[test] + fn a_renderer_that_returns_the_wrong_size_is_an_error() { + let short: &Renderer = &|_, _, _, _| Ok(Picture { rgba: vec![0; 3], renderer: "gpu" }); + assert!(rgba_with(Some(short), &Scene::default(), &Camera::default(), 2, 2).is_err()); + let plain: &Renderer = &|_, _, w, h| Ok(Picture { rgba: vec![255; (w * h * 4) as usize], renderer: "cpu" }); + let png = png_with(Some(plain), &Scene::default(), &Camera::default(), 2, 2).unwrap(); + assert!(png.bytes.starts_with(b"\x89PNG")); + assert_eq!(png.renderer, "cpu"); + assert_eq!((warning("cpu"), warning("gpu"), warning("gpu-software")), (Some(CPU_WARNING), None, None)); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 6a05824d..78147187 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -738,6 +738,8 @@ mod tests { #[test] fn save_and_save_as_require_explicit_native_cache_opt_in() { + // Thumbnails are drawn by the renderer the host installs. + crate::capture::install_test_renderer(); let mut session = Session::new(Document::new("Cache opt-in")); session.execute("solid.box", &json!({"length":10,"width":20,"height":30})).unwrap(); let world = session.world_state(); diff --git a/crates/engine/src/cmd/file_thumbnail.rs b/crates/engine/src/cmd/file_thumbnail.rs index b3c6aea5..30c7f09c 100644 --- a/crates/engine/src/cmd/file_thumbnail.rs +++ b/crates/engine/src/cmd/file_thumbnail.rs @@ -4,6 +4,15 @@ use solidcraft_geom::Aabb3; use solidcraft_render::{Camera, Rgb, Scene, SceneMesh, StandardView}; pub(super) fn saved(s: &Session) -> Option> { + let (scene, camera) = scene(s)?; + let [width, height] = solidcraft_io::THUMBNAIL_SIZE; + // Drawn by the renderer the host installed (the GPU); none installed, no thumbnail. + let renderer = crate::capture::installed()?; + crate::capture::png_with(Some(&*renderer), &scene, &camera, u32::try_from(width).ok()?, u32::try_from(height).ok()?).ok().map(|p| p.bytes) +} + +/// What the thumbnail shows: the shown bodies' warm display meshes in their colours, iso, fitted. +fn scene(s: &Session) -> Option<(Scene, Camera)> { if s.active_sketch.is_some() || !s.pending_moves.is_empty() || s.model.results.iter().any(|r| r.error.is_some()) { return None; } @@ -38,76 +47,16 @@ pub(super) fn saved(s: &Session) -> Option> { camera.distance *= 0.7; scene.radius = (bounds.diagonal() * 0.5).max(camera.half_height()).max(10.); scene.background = Some((crate::view::colors::BG_TOP, crate::view::colors::BG_BOTTOM)); - let [width, height] = solidcraft_io::THUMBNAIL_SIZE; - if !within_raster_budget(&scene, &camera, width, height) { - return None; - } - let image = solidcraft_render::render(&scene, &camera, width, height); - solidcraft_render::png(width, height, image.rgba) -} - -// Bound overlapping-triangle work too, not only mesh size. The renderer uses -// 2x supersampling; conservatively count each projected triangle's pixel box. -fn within_raster_budget(scene: &Scene, camera: &Camera, width: usize, height: usize) -> bool { - let (w, h) = ((width * 2) as f64, (height * 2) as f64); - let matrix = camera.view_proj(w / h, scene.radius.max(1.)); - let mut pixels = 0usize; - let mut colors = 0usize; - for entry in &scene.meshes { - colors = colors.saturating_add(entry.mesh.triangles.len().saturating_mul(entry.face_colors.len())); - if colors > 1_000_000 { - return false; - } - let mut projected = Vec::with_capacity(entry.mesh.positions.len()); - for point in &entry.mesh.positions { - let q = matrix.apply(*point); - if q.iter().any(|x| !x.is_finite()) || q[3] <= 1e-9 { - return false; - } - let pixel = [(q[0] / q[3] + 1.) * 0.5 * w, (1. - q[1] / q[3]) * 0.5 * h]; - if pixel.iter().any(|x| !x.is_finite()) { - return false; - } - projected.push(pixel); - } - for triangle in &entry.mesh.triangles { - let [Some(a), Some(b), Some(c)] = triangle.map(|i| projected.get(i as usize).copied()) else { - return false; - }; - let dx = (a[0].max(b[0]).max(c[0]).ceil().clamp(0., w) - a[0].min(b[0]).min(c[0]).floor().clamp(0., w) + 2.) as usize; - let dy = (a[1].max(b[1]).max(c[1]).ceil().clamp(0., h) - a[1].min(b[1]).min(c[1]).floor().clamp(0., h) + 2.) as usize; - pixels = pixels.saturating_add(dx.saturating_mul(dy)); - if pixels > 4_000_000 { - return false; - } - } - } - true + Some((scene, camera)) } #[cfg(test)] mod tests { use super::*; - #[test] - fn portable_thumbnail_refuses_excessive_overdraw() { - let mut session = Session::default(); - session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap(); - let mesh = session.world_state().bodies[0].mesh(); - let bounds = mesh.bounds(); - let mut camera = Camera::default(); - camera.set_view(StandardView::Iso); - camera.fit(&bounds); - let mut scene = Scene { radius: bounds.diagonal(), ..Scene::default() }; - scene.meshes.push(SceneMesh { mesh: mesh.clone(), color: Rgb(100, 100, 100), face_colors: vec![] }); - assert!(within_raster_budget(&scene, &camera, 240, 150)); - let mut repeated = (*mesh).clone(); - repeated.triangles = vec![repeated.triangles[0]; 100_000]; - scene.meshes[0].mesh = std::sync::Arc::new(repeated); - assert!(!within_raster_budget(&scene, &camera, 240, 150)); - } #[test] fn portable_thumbnail_preserves_saved_visibility() { + crate::capture::install_test_renderer(); let mut session = Session::default(); session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30,"body_name":"Visible"})).unwrap(); session.execute("solid.box", &serde_json::json!({"length":100,"width":100,"height":100,"body_name":"Hidden"})).unwrap(); @@ -116,6 +65,7 @@ mod tests { for body in &world.bodies { body.mesh(); } + assert_eq!(scene(&session).unwrap().0.meshes.len(), 1, "the hidden body is not in the picture"); let expected = saved(&session).unwrap(); let bytes = session.design_container(solidcraft_io::DesignCompression::Fast, false, true).unwrap(); let doc = solidcraft_io::read_design(&bytes).unwrap(); @@ -131,6 +81,7 @@ mod tests { #[test] fn portable_thumbnail_never_forces_cold_evaluation_or_meshing() { + crate::capture::install_test_renderer(); let mut session = Session::default(); session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap(); let world = session.world_state(); @@ -147,4 +98,17 @@ mod tests { let disabled = session.design_container(solidcraft_io::DesignCompression::Fast, false, false).unwrap(); assert!(solidcraft_io::read_design_thumbnail(&disabled).is_none()); } + + /// Without a renderer installed (a browser, a tool that draws nothing) a save has no + /// thumbnail and does not fail. + #[test] + fn without_a_renderer_a_save_has_no_thumbnail() { + let mut session = Session::default(); + session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap(); + for body in &session.world_state().bodies { + body.mesh(); + } + let (scene, camera) = scene(&session).unwrap(); + assert!(crate::capture::png_with(None, &scene, &camera, 240, 150).is_err()); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8fc7f217..c75ba999 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -7,6 +7,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +pub mod capture; pub mod cmd; pub mod control_auth; pub mod crash; @@ -290,6 +291,20 @@ pub enum SurfaceAnalysis { Access { dir: Vec3 }, } +impl SurfaceAnalysis { + /// How a picture shades the faces for this analysis. + pub fn mode(self) -> render::AnalysisMode { + use render::AnalysisMode as M; + match self { + SurfaceAnalysis::Zebra { stripes } => M::Zebra { stripes }, + SurfaceAnalysis::Draft { pull, angle } => M::Draft { pull, angle }, + SurfaceAnalysis::Curvature { radius } => M::Curvature { radius }, + SurfaceAnalysis::Environment => M::Environment, + SurfaceAnalysis::Access { dir } => M::Access { dir }, + } + } +} + const MAX_UNDO: usize = 200; /// Checkpoints kept (the oldest go first). const MAX_CHECKPOINTS: usize = 16; diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs index 026ae084..8a889ea1 100644 --- a/crates/engine/src/view.rs +++ b/crates/engine/src/view.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use solidcraft_geom::{Aabb3, Plane, Vec3}; -use solidcraft_render::{Camera, Rgb, Scene, SceneLine, SceneMesh}; +use solidcraft_render::{AnalysisColors, Camera, Rgb, Scene, SceneAnalysis, SceneLine, SceneMesh}; use solidcraft_sketch::Sketch; use crate::{Sel, Session}; @@ -68,6 +68,8 @@ pub struct Palette { pub axes: [Rgb; 3], /// What the section cap is tinted toward. pub cap: Rgb, + /// The surface analysis colours. + pub analysis: AnalysisColors, } impl Palette { @@ -89,6 +91,7 @@ impl Palette { sketch_centerline: colors::SKETCH_CENTERLINE, axes: [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], cap: colors::SECTION_CAP, + analysis: AnalysisColors::DEFAULT, }; } @@ -362,10 +365,17 @@ fn build_scene( ) -> Scene { let st = s.world_state(); let section = section.filter(|(o, n)| o.is_finite() && n.is_finite() && n.len() > 1e-9); - let mut sc = Scene { background: Some(p.background), clip: section.map(|(o, n)| (n, n.dot(o))), cap: Some(p.cap), ..Default::default() }; + let mut sc = Scene { + background: Some(p.background), + clip: section.map(|(o, n)| (n, n.dot(o))), + cap: Some(p.cap), + analysis: s.analysis.map(|a| SceneAnalysis { mode: a.mode(), colors: p.analysis }), + ..Default::default() + }; let b = bounds(s); sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target); - // Grid on the ground plane (XY with Z up, XZ with Y up). + // Grid on the ground plane (XY with Z up, XZ with Y up), drawn under the model as in the + // viewport. if guides.grid { let (minor, major) = grid_step(cam.half_height()); let ext = (cam.half_height() * 3.0 / major).ceil() * major; @@ -375,19 +385,12 @@ fn build_scene( let n = ((ext / minor) as i64).clamp(1, 400); for i in -n..=n { let t = i as f64 * minor; - let col = if (t / major).fract().abs() < 1e-9 { p.grid_major } else { p.grid }; - sc.lines.push(SceneLine { - points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))], - color: col, - width: 0.6, - on_top: false, - }); - sc.lines.push(SceneLine { - points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))], - color: col, - width: 0.6, - on_top: false, - }); + let major_line = (t / major).fract().abs() < 1e-9; + let col = if major_line { p.grid_major } else { p.grid }; + // The viewport's widths (pixels). + let width = if major_line { 1.0 } else { 0.7 }; + sc.grid.push(SceneLine { points: vec![w(c + Vec3::new(t, -ext, 0.0)), w(c + Vec3::new(t, ext, 0.0))], color: col, width, on_top: false }); + sc.grid.push(SceneLine { points: vec![w(c + Vec3::new(-ext, t, 0.0)), w(c + Vec3::new(ext, t, 0.0))], color: col, width, on_top: false }); } } // The origin's axes, each unless hidden one by one (`browser.visibility`), as in the viewport. @@ -583,8 +586,8 @@ mod tests { use super::*; - /// The CPU renderer (snapshots) shows appearances as the viewport does: a body's colour and - /// faces with looks of their own. + /// Headless pictures show appearances as the viewport does: a body's colour and faces with + /// looks of their own (the picture itself: `solidcraft-gpu`'s capture tests). #[test] fn snapshot_scene_carries_appearances() { let mut s = Session::default(); @@ -596,9 +599,6 @@ mod tests { let sc = scene(&s, &cam); assert!(sc.meshes.iter().any(|m| m.color == Rgb(220, 60, 40))); assert!(sc.meshes.iter().any(|m| m.face_colors.iter().any(|(_, c)| *c == Rgb(40, 170, 80)))); - // And the rendered picture has the green top. - let img = solidcraft_render::render(&sc, &cam, 200, 140); - assert!(img.rgba.chunks(4).any(|p| p[1] > 120 && p[0] < 90 && p[2] < 110), "no green pixels"); } /// A render can leave out the ground grid and the origin's axes (a box straddling Z 0 is cut @@ -610,7 +610,7 @@ mod tests { let cam = home_camera(&s); let grid = [colors::GRID, colors::GRID_MAJOR]; let axes = [colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z]; - let count = |sc: &Scene, colours: &[Rgb]| sc.lines.iter().filter(|l| colours.contains(&l.color)).count(); + let count = |sc: &Scene, colours: &[Rgb]| sc.grid.iter().chain(&sc.lines).filter(|l| colours.contains(&l.color)).count(); let all = scene(&s, &cam); let (grid_lines, edges) = (count(&all, &grid), count(&all, &[colors::EDGE])); assert!(grid_lines > 20 && edges >= 12, "{grid_lines} grid lines, {edges} edges"); @@ -622,10 +622,7 @@ mod tests { let no_axes = render(Guides { grid: true, axes: false }); assert_eq!((count(&no_axes, &grid), count(&no_axes, &axes), count(&no_axes, &[colors::EDGE])), (grid_lines, 0, edges)); let bare = render(Guides { grid: false, axes: false }); - assert_eq!(bare.lines.len(), edges); - // The picture changes with each. - let png = |sc: &Scene| solidcraft_render::render_png(sc, &cam, 160, 100); - assert!(png(&all) != png(&no_grid) && png(&all) != png(&no_axes) && png(&no_grid) != png(&bare)); + assert_eq!((bare.lines.len(), bare.grid.len()), (edges, 0)); } /// An origin axis hidden with `browser.visibility` stays out of headless renders, as it does @@ -646,14 +643,43 @@ mod tests { s.execute("browser.visibility", &json!({"folder": "origin", "visible": false})).unwrap(); assert!(shown(&s).is_empty()); let sc = scene_with_guides(&s, &cam, None, Guides { grid: true, axes: true }); - assert!(sc.lines.iter().any(|l| l.color == colors::GRID), "the grid is not part of the origin"); + assert!(sc.grid.iter().any(|l| l.color == colors::GRID), "the grid is not part of the origin"); s.execute("browser.visibility", &json!({"folder": "origin", "visible": true})).unwrap(); assert_eq!(shown(&s), axes); } - /// Curved surfaces get a dark silhouette line in snapshots where they turn away. + /// A section cuts the headless scene: the clip plane and the cap colour, and the outline + /// where the plane cuts each body (the picture: `solidcraft-gpu`'s capture tests). #[test] - fn snapshots_draw_silhouettes_on_curved_surfaces() { + fn the_section_cuts_the_scene() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let cam = home_camera(&s); + assert!(scene(&s, &cam).clip.is_none()); + s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap(); + let sc = scene(&s, &cam); + let (n, d) = sc.clip.unwrap(); + assert!((n - Vec3::new(0.0, -1.0, 0.0)).len() < 1e-12 && (d + 15.0).abs() < 1e-12, "{n:?} {d}"); + assert_eq!(sc.cap, Some(colors::SECTION_CAP)); + assert!(!section_segments(&s.world_state().bodies[0].mesh(), Vec3::new(0.0, 15.0, 0.0), -Vec3::Y).is_empty()); + } + + /// The surface analysis shades the headless scene too, in the palette's colours. + #[test] + fn the_surface_analysis_shades_the_scene() { + let mut s = Session::default(); + s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); + let cam = home_camera(&s); + assert!(scene(&s, &cam).analysis.is_none()); + s.analysis = Some(crate::SurfaceAnalysis::Curvature { radius: 5.0 }); + let a = scene(&s, &cam).analysis.unwrap(); + assert_eq!((a.mode, a.colors), (solidcraft_render::AnalysisMode::Curvature { radius: 5.0 }, Palette::DEFAULT.analysis)); + } + + /// The CPU rasterizer (the last tier of headless pictures, `SOLIDCRAFT_GPU=cpu` or no adapter + /// under `auto`): curved surfaces get a dark silhouette line where they turn away. + #[test] + fn cpu_pictures_draw_silhouettes_on_curved_surfaces() { let mut s = Session::default(); s.execute("solid.sphere", &json!({"diameter": 20})).unwrap(); let cam = home_camera(&s); @@ -662,9 +688,9 @@ mod tests { assert!(dark > 40, "silhouette pixels: {dark}"); } - /// A section shows in snapshots: the cut part is gone and the cut face is drawn. + /// The CPU rasterizer: a section shows; the cut part is gone and the cut face is drawn. #[test] - fn snapshots_show_the_section() { + fn cpu_pictures_show_the_section() { let mut s = Session::default(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); let cam = home_camera(&s); @@ -681,7 +707,7 @@ mod tests { /// plane's depth, not on the far wall like a pane of glass), shows in the cap colour, and /// the cut's outline is drawn. #[test] - fn snapshot_section_caps_hide_the_inside() { + fn cpu_picture_section_caps_hide_the_inside() { let mut s = Session::default(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); // Cut away the front half (y < 15), so the iso camera looks into the cut. diff --git a/crates/engine/src/view_palette_tests.rs b/crates/engine/src/view_palette_tests.rs index 0d65b9b0..31d7fdfe 100644 --- a/crates/engine/src/view_palette_tests.rs +++ b/crates/engine/src/view_palette_tests.rs @@ -24,6 +24,12 @@ const MARK: Palette = Palette { sketch_centerline: Rgb(43, 44, 45), axes: [Rgb(46, 47, 48), Rgb(49, 50, 51), Rgb(52, 53, 54)], cap: Rgb(55, 56, 57), + analysis: solidcraft_render::AnalysisColors { + draft: [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6], [0.7, 0.8, 0.9]], + access: [[0.11, 0.12, 0.13], [0.14, 0.15, 0.16]], + curvature: [[0.17, 0.18, 0.19], [0.21, 0.22, 0.23], [0.24, 0.25, 0.26]], + hatch: true, + }, }; /// Each curve's lines at x = its index, so a line tells which curve it draws. @@ -49,6 +55,7 @@ fn the_default_palette_is_the_colour_table() { (colors::SKETCH_PROJECTED, colors::SKETCH_LOST, colors::SKETCH_CONSTRUCTION, colors::SKETCH_CENTERLINE) ); assert_eq!((p.axes, p.cap), ([colors::AXIS_X, colors::AXIS_Y, colors::AXIS_Z], colors::SECTION_CAP)); + assert_eq!(p.analysis, solidcraft_render::AnalysisColors::DEFAULT); } #[test] @@ -154,11 +161,13 @@ fn every_scene_colour_comes_from_its_palette_and_appearances_stay() { MARK.sketch_construction, MARK.sketch_centerline, ]; - let stray: Vec = marked.lines.iter().map(|l| l.color).filter(|c| !allowed.contains(c)).collect(); + let all_lines = || marked.grid.iter().chain(&marked.lines); + let stray: Vec = all_lines().map(|l| l.color).filter(|c| !allowed.contains(c)).collect(); assert!(stray.is_empty(), "line colours not from the palette: {stray:?}"); for want in [MARK.grid, MARK.grid_major, MARK.edge, MARK.axes[0], MARK.axes[1], MARK.axes[2], MARK.sketch_done] { - assert!(marked.lines.iter().any(|l| l.color == want), "no line in {want:?}"); + assert!(all_lines().any(|l| l.color == want), "no line in {want:?}"); } + assert!(marked.grid.iter().all(|l| [MARK.grid, MARK.grid_major].contains(&l.color)), "only the grid is drawn under the model"); assert_eq!(mesh(&marked, "Grey").0, colors::BODY, "an appearance equal to the default grey is the design's"); assert_eq!(mesh(&marked, "Picked").0, MARK.body_selected); let (plain, faces) = mesh(&marked, "Plain"); @@ -172,8 +181,8 @@ fn every_scene_colour_comes_from_its_palette_and_appearances_stay() { (default.background, default.cap, default.clip, default.radius), (plain_scene.background, plain_scene.cap, plain_scene.clip, plain_scene.radius) ); - assert_eq!(default.lines.len(), plain_scene.lines.len()); - for (a, b) in default.lines.iter().zip(&plain_scene.lines) { + assert_eq!((default.lines.len(), default.grid.len()), (plain_scene.lines.len(), plain_scene.grid.len())); + for (a, b) in default.lines.iter().zip(&plain_scene.lines).chain(default.grid.iter().zip(&plain_scene.grid)) { assert_eq!((&a.points, a.color, a.width, a.on_top), (&b.points, b.color, b.width, b.on_top)); } for name in &names { diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index 077909c4..b8bc671f 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solidcraft-gpu" -description = "SolidCraft GPU compute: evaluates display tessellation plans (surface points and normals) in a compute shader" +description = "SolidCraft GPU rendering: the viewport's render pipelines, offscreen captures, and display tessellation plans evaluated in a compute shader" version.workspace = true edition.workspace = true license.workspace = true @@ -10,9 +10,11 @@ workspace = true [dependencies] solidcraft-geom = { workspace = true } +solidcraft-render = { workspace = true } wgpu = { workspace = true } log = { workspace = true } thiserror = { workspace = true } +serde = { workspace = true } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] pollster = { workspace = true } @@ -21,5 +23,8 @@ wgpu = { workspace = true, features = ["metal", "vulkan", "dx12", "gles"] } [dev-dependencies] solidcraft-kernel = { workspace = true } +# Headless pictures of real designs (capture tests). +solidcraft-engine = { workspace = true } +serde_json = { workspace = true } # The HLSL the D3D12 backend compiles (tests check it suits FXC). naga = { workspace = true, features = ["wgsl-in", "hlsl-out"] } diff --git a/crates/gpu/src/capture.rs b/crates/gpu/src/capture.rs new file mode 100644 index 00000000..07d4e1b9 --- /dev/null +++ b/crates/gpu/src/capture.rs @@ -0,0 +1,428 @@ +//! Pictures of a [`Scene`] drawn offscreen with the viewport's pipelines ([`crate::view`]): the +//! same shading, edges, silhouettes, section cap and surface analysis the viewport shows, read +//! back as RGBA. `solidcraft-cli snapshot`, MCP screenshots, `ui.render` and thumbnails draw +//! through it, on the application's device ([`Capturer::new`]) or on a headless one +//! ([`Capturer::headless`], [`shared`]). Without a qualifying adapter, the `auto` policy falls +//! to the last tier, the CPU rasterizer ([`headless_rgba`]), and says so: every picture names the +//! renderer that drew it (`gpu`, `gpu-software` or `cpu`); `hardware` and `software` fail with an +//! error that names the policy. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use solidcraft_geom::Vec3; +use solidcraft_render::{AnalysisMode, Camera, Rgb, SECTION_CAP, Scene}; +use wgpu::util::DeviceExt; + +use crate::view::{AccessMap, Frame, GpuScene, GpuTarget, Renderer, analysis_colors, analysis_uniform}; +use crate::{GpuError, Result, popped}; + +/// The colour format pictures are drawn in: sRGB bytes as they are, like the viewport's usual +/// (non-sRGB) target. +pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const DEPTH: wgpu::TextureFormat = wgpu::TextureFormat::Depth24PlusStencil8; +/// Samples per pixel (MSAA), as the desktop viewport's default; 1 where the device has no 4×. +pub const SAMPLES: u32 = 4; +/// The largest picture side (pixels); the device's texture limit may lower it. +pub const MAX_SIDE: u32 = 8192; +/// The background of a scene without one of its own: the light theme's viewport gradient. +const BACKGROUND: (Rgb, Rgb) = (Rgb(236, 240, 245), Rgb(205, 212, 222)); +/// How long a picture may take on the GPU before it is given up. +const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +/// The viewport's pipelines on one device, drawing pictures offscreen. Pictures are drawn one at +/// a time (callers on several threads take turns). +pub struct Capturer { + device: wgpu::Device, + queue: wgpu::Queue, + adapter: String, + /// The adapter is a GPU driver running on the CPU (llvmpipe, lavapipe, WARP). + software: bool, + samples: u32, + max_side: u32, + state: Mutex, +} + +struct State { + renderer: Renderer, + background: wgpu::RenderPipeline, + /// The last picture's scene key and accessibility map version (each picture uploads anew). + key: u64, + access_version: u64, +} + +impl Capturer { + /// The pipelines on an existing device (the application's). + pub fn new(adapter: &wgpu::Adapter, device: wgpu::Device, queue: wgpu::Queue) -> Result { + let flags = |f: wgpu::TextureFormat| adapter.get_texture_format_features(f).flags; + let samples = if flags(FORMAT).sample_count_supported(SAMPLES) && flags(DEPTH).sample_count_supported(SAMPLES) { SAMPLES } else { 1 }; + let scopes = error_scopes(&device); + let renderer = Renderer::new(&device, GpuTarget { format: FORMAT, depth: Some(DEPTH), samples, tess: false }, None); + let background = background_pipeline(&device, samples); + caught(scopes).map_or(Ok(()), |e| Err(GpuError::Pipeline(e)))?; + let max_side = device.limits().max_texture_dimension_2d.min(MAX_SIDE); + Ok(Capturer { + adapter: adapter.get_info().name, + software: adapter.get_info().device_type == wgpu::DeviceType::Cpu, + samples, + max_side, + state: Mutex::new(State { renderer, background, key: 0, access_version: 0 }), + device, + queue, + }) + } + + /// A device of its own, without a window (native only): the adapter the `SOLIDCRAFT_GPU` + /// policy picks ([`crate::select`]). + #[cfg(not(target_arch = "wasm32"))] + pub fn headless() -> Result { + let (adapter, device, queue) = crate::open_headless()?; + // Errors are caught per picture (error scopes); anything else is logged, never a panic. + device.on_uncaptured_error(Arc::new(|e: wgpu::Error| log::error!("GPU: {e}"))); + Self::new(&adapter, device, queue) + } + + /// The adapter's name. + pub fn adapter_name(&self) -> &str { + &self.adapter + } + + /// What a picture's reply names as its renderer: `gpu`, or `gpu-software` on a software + /// adapter. + pub fn renderer(&self) -> &'static str { + if self.software { "gpu-software" } else { "gpu" } + } + + /// Samples per pixel the pictures are drawn with. + pub fn samples(&self) -> u32 { + self.samples + } + + /// `scene` seen by `cam`, `width` × `height` pixels: straight RGBA, rows top to bottom. + pub fn rgba(&self, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result> { + if !(1..=self.max_side).contains(&width) || !(1..=self.max_side).contains(&height) { + return Err(GpuError::Render(format!("a picture is 1 to {} pixels a side, not {width} × {height}", self.max_side))); + } + let mut state = self.state.lock().map_err(|_| GpuError::Render("an earlier picture failed on this device".into()))?; + let state = &mut *state; + state.key = state.key.wrapping_add(1); + let mode = scene.analysis.map(|a| a.mode); + let access = match mode { + Some(AnalysisMode::Access { dir }) => { + state.access_version = state.access_version.wrapping_add(1); + AccessMap::build(&scene.meshes.iter().map(|m| m.mesh.as_ref()).collect::>(), dir, state.access_version) + } + _ => None, + }; + let frame = frame(scene, cam, [width, height], state.key, access); + let (device, queue) = (&self.device, &self.queue); + let scopes = error_scopes(device); + let size = wgpu::Extent3d { width, height, depth_or_array_layers: 1 }; + let texture = |label: &str, format: wgpu::TextureFormat, samples: u32, usage: wgpu::TextureUsages| { + device.create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size, + mip_level_count: 1, + sample_count: samples, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + }; + let resolved = texture("sc_capture", FORMAT, 1, wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC); + let multisampled = (self.samples > 1).then(|| texture("sc_capture_ms", FORMAT, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT)); + let depth = texture("sc_capture_depth", DEPTH, self.samples, wgpu::TextureUsages::RENDER_ATTACHMENT); + let (resolved_view, depth_view) = (resolved.create_view(&Default::default()), depth.create_view(&Default::default())); + let multisampled_view = multisampled.as_ref().map(|t| t.create_view(&Default::default())); + let (top, bottom) = scene.background.unwrap_or(BACKGROUND); + let background = background_vertices(device, top, bottom); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("sc_capture") }); + state.renderer.prepare(device, queue, &mut encoder, &frame); + { + let (view, resolve_target, store) = match &multisampled_view { + Some(ms) => (ms, Some(&resolved_view), wgpu::StoreOp::Discard), + None => (&resolved_view, None, wgpu::StoreOp::Store), + }; + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("sc_capture"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view, + depth_slice: None, + resolve_target, + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store }, + })], + depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment { + view: &depth_view, + depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }), + stencil_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }), + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + // The background first, as the viewport paints it before the model. + pass.set_pipeline(&state.background); + pass.set_vertex_buffer(0, background.slice(..)); + pass.draw(0..6, 0..1); + state.renderer.paint(&frame, &mut pass, [0.0, 0.0, width as f32, height as f32]); + } + let row = width * 4; + let padded = row.div_ceil(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) * wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let read = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("sc_capture_read"), + size: u64::from(padded) * u64::from(height), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + encoder.copy_texture_to_buffer( + resolved.as_image_copy(), + wgpu::TexelCopyBufferInfo { + buffer: &read, + layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(height) }, + }, + size, + ); + let submission = queue.submit([encoder.finish()]); + if let Some(e) = caught(scopes) { + return Err(GpuError::Render(e)); + } + let slice = read.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + device + .poll(wgpu::PollType::Wait { submission_index: Some(submission), timeout: Some(TIMEOUT) }) + .map_err(|e| GpuError::Render(format!("waiting for the picture: {e}")))?; + match rx.try_recv() { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(GpuError::Render(format!("reading the picture back: {e}"))), + Err(_) => return Err(GpuError::Unsupported("reading a picture back without waiting for the browser".into())), + } + let data = slice.get_mapped_range().map_err(|e| GpuError::Render(format!("reading the picture back: {e}")))?; + let mut rgba = Vec::with_capacity((row as usize) * (height as usize)); + for r in data.chunks(padded as usize).take(height as usize) { + rgba.extend_from_slice(r.get(..row as usize).unwrap_or_default()); + } + if rgba.len() != (row as usize) * (height as usize) { + return Err(GpuError::Render("the picture read back short".into())); + } + Ok(rgba) + } + + /// [`Capturer::rgba`] as PNG bytes. + pub fn png(&self, scene: &Scene, cam: &Camera, width: u32, height: u32) -> Result> { + let rgba = self.rgba(scene, cam, width, height)?; + solidcraft_render::png(width as usize, height as usize, rgba).ok_or_else(|| GpuError::Render("PNG encoding failed".into())) + } +} + +/// The process's headless capturer (native only), opened on first use under the `SOLIDCRAFT_GPU` +/// policy and kept; the error when no adapter qualifies or the device fails (not retried). +#[cfg(not(target_arch = "wasm32"))] +pub fn shared() -> std::result::Result<&'static Capturer, &'static GpuError> { + static SHARED: std::sync::OnceLock> = std::sync::OnceLock::new(); + SHARED.get_or_init(Capturer::headless).as_ref() +} + +/// What draws this process's headless pictures (native only): the shared headless device, or the +/// CPU rasterizer where the `SOLIDCRAFT_GPU` policy lands there (`cpu`, or `auto` with no +/// qualifying adapter). +#[cfg(not(target_arch = "wasm32"))] +pub enum Headless { + Gpu(&'static Capturer), + Cpu, +} + +/// [`Headless`] under the `SOLIDCRAFT_GPU` policy; an error when the policy is `hardware` or +/// `software` and no adapter of that tier opens (or the variable names no policy). +#[cfg(not(target_arch = "wasm32"))] +pub fn headless() -> std::result::Result { + use crate::select::Policy; + let policy = Policy::from_env()?; + if policy == Policy::Cpu { + return Ok(Headless::Cpu); + } + match shared() { + Ok(c) => Ok(Headless::Gpu(c)), + Err(e) if policy == Policy::Auto => { + log::warn!("pictures are drawn by the CPU rasterizer: {e}"); + Ok(Headless::Cpu) + } + Err(e) => Err(e.to_string()), + } +} + +/// `scene` drawn by [`headless`]: straight RGBA and the renderer that drew it (`gpu`, +/// `gpu-software` or `cpu`). The CPU rasterizer draws no surface analysis, highlights or legends. +#[cfg(not(target_arch = "wasm32"))] +pub fn headless_rgba(scene: &Scene, cam: &Camera, width: u32, height: u32) -> std::result::Result<(Vec, &'static str), String> { + match headless()? { + Headless::Gpu(c) => c.rgba(scene, cam, width, height).map(|rgba| (rgba, c.renderer())).map_err(|e| e.to_string()), + Headless::Cpu => { + if !(1..=MAX_SIDE).contains(&width) || !(1..=MAX_SIDE).contains(&height) { + return Err(format!("a picture is 1 to {MAX_SIDE} pixels a side, not {width} × {height}")); + } + let canvas = solidcraft_render::render(scene, cam, width as usize, height as usize); + Ok((canvas.rgba, "cpu")) + } + } +} + +/// The frame parameters of a picture: the scene's own geometry as the model scene (key `key`), +/// the camera, the section, the analysis, and the silhouette outlines of the default visual style. +fn frame(scene: &Scene, cam: &Camera, [width, height]: [u32; 2], key: u64, access: Option) -> Frame { + let empty = || Arc::new(Mutex::new(None)); + let eye = cam.eye().to_f32(); + let cap = scene.cap.unwrap_or(SECTION_CAP); + let mode = scene.analysis.map(|a| a.mode); + Frame { + product: false, + key, + slot: Arc::new(Mutex::new(Some(scene_geometry(scene)))), + sketch_key: 0, + sketch_slot: empty(), + hl_key: 0, + hl_slot: empty(), + pv_key: 0, + pv_slot: empty(), + view_proj: cam.view_proj(f64::from(width) / f64::from(height.max(1)), scene.radius.max(1.0)).to_f32(), + back: cam.back().to_f32(), + size_px: [width as f32, height as f32], + clip: scene.clip.map_or([0.0; 4], |(n, d)| [n.x as f32, n.y as f32, n.z as f32, d as f32]), + cap: [f32::from(cap.0) / 255.0, f32::from(cap.1) / 255.0, f32::from(cap.2) / 255.0, 1.0], + images: Vec::new(), + analysis: analysis_uniform(mode, access.as_ref()), + analysis_colors: scene.analysis.map_or([0.0; 36], |a| analysis_colors(&a.colors)), + access, + outline: true, + eye: [eye[0], eye[1], eye[2], if cam.fov > 0.0 { 1.0 } else { 0.0 }], + ssaa: 1.0, + } +} + +/// A scene's meshes and lines as the viewport's model scene: faces in their colours (smooth +/// normals; a face normal where a vertex has none), lines as wide lines (over everything when +/// `on_top`), the grid under everything. +pub fn scene_geometry(scene: &Scene) -> GpuScene { + let mut sc = GpuScene::default(); + let c4 = |c: Rgb| [c.0, c.1, c.2, 255]; + for m in &scene.meshes { + let faces: HashMap = m.face_colors.iter().copied().collect(); + let mesh = &m.mesh; + for (ti, t) in mesh.triangles.iter().enumerate() { + let col = c4(mesh.tri_face.get(ti).and_then(|f| faces.get(f)).copied().unwrap_or(m.color)); + let [Some(a), Some(b), Some(c)] = t.map(|k| mesh.positions.get(k as usize).copied()) else { continue }; + let flat = (b - a).cross(c - a).normalized().unwrap_or(Vec3::Z); + for (k, p) in t.iter().zip([a, b, c]) { + let n = mesh.normals.get(*k as usize).copied().filter(|n| n.is_finite() && n.len() > 0.5).unwrap_or(flat); + sc.tri(p.to_f32(), n.to_f32(), col); + } + } + } + for l in &scene.grid { + for w in l.points.windows(2) { + sc.under_line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32); + } + } + for l in &scene.lines { + for w in l.points.windows(2) { + sc.line(w[0].to_f32(), w[1].to_f32(), c4(l.color), l.width as f32, l.on_top); + } + } + sc +} + +/// Error scopes catching what a picture's GPU work may raise (popped by [`caught`]). +fn error_scopes(device: &wgpu::Device) -> [wgpu::ErrorScopeGuard; 3] { + [wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal, wgpu::ErrorFilter::Validation].map(|f| device.push_error_scope(f)) +} + +/// The first error the scopes caught. +fn caught(scopes: [wgpu::ErrorScopeGuard; 3]) -> Option { + let mut failed = None; + for scope in scopes.into_iter().rev() { + if let Some(e) = popped(scope.pop()) { + failed.get_or_insert(e.to_string()); + } + } + failed +} + +/// The background gradient: the viewport's top colour at the top, its bottom one at the bottom, +/// mixed in sRGB as the window paints it. +const BACKGROUND_SHADER: &str = r#" +struct O { @builtin(position) pos: vec4, @location(0) color: vec4 }; +@vertex +fn vs(@location(0) p: vec2, @location(1) c: vec4) -> O { + var o: O; + o.pos = vec4(p, 0.0, 1.0); + o.color = c; + return o; +} +@fragment +fn fs(i: O) -> @location(0) vec4 { + return i.color; +} +"#; + +/// Background vertex: NDC position (2 × f32) and sRGB colour (4 × u8). +const BACKGROUND_VERTEX: u64 = 12; + +fn background_vertices(device: &wgpu::Device, top: Rgb, bottom: Rgb) -> wgpu::Buffer { + let mut bytes = Vec::with_capacity(6 * BACKGROUND_VERTEX as usize); + let corner = |x: f32, y: f32| (x, y, if y > 0.0 { top } else { bottom }); + for (x, y, c) in [corner(-1.0, 1.0), corner(1.0, 1.0), corner(1.0, -1.0), corner(-1.0, 1.0), corner(1.0, -1.0), corner(-1.0, -1.0)] { + bytes.extend_from_slice(&x.to_le_bytes()); + bytes.extend_from_slice(&y.to_le_bytes()); + bytes.extend_from_slice(&[c.0, c.1, c.2, 255]); + } + device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("sc_capture_background"), + contents: &bytes, + usage: wgpu::BufferUsages::VERTEX, + }) +} + +fn background_pipeline(device: &wgpu::Device, samples: u32) -> wgpu::RenderPipeline { + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("sc_capture_background"), + source: wgpu::ShaderSource::Wgsl(BACKGROUND_SHADER.into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_capture_background"), + bind_group_layouts: &[], + immediate_size: 0, + }); + const A: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x2, 1 => Unorm8x4]; + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_capture_background"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs"), + buffers: &[Some(wgpu::VertexBufferLayout { array_stride: BACKGROUND_VERTEX, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A })], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: Default::default(), + bias: Default::default(), + }), + multisample: wgpu::MultisampleState { count: samples, mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { format: FORMAT, blend: None, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index e069db06..98f4515a 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -1,7 +1,9 @@ -//! GPU evaluation of display tessellation plans: the surface point and normal at every -//! vertex's parameters, in a compute shader (`tess.wgsl`), the GPU twin of -//! [`TessPlan::evaluate`]. Shared edge nodes keep their exact positions, so faces still meet -//! exactly; evaluation runs in f32. +//! SolidCraft's GPU work: the viewport's render pipelines ([`view`]), pictures drawn with them +//! offscreen ([`capture`]), and display tessellation plans evaluated in a compute shader. +//! +//! Plan evaluation: the surface point and normal at every vertex's parameters, in a compute +//! shader (`tess.wgsl`), the GPU twin of [`TessPlan::evaluate`]. Shared edge nodes keep their +//! exact positions, so faces still meet exactly; evaluation runs in f32. //! //! Use [`TessGpu::new`] with the application's device, or [`TessGpu::headless`] (native only) //! to open one without a window, as the CLI and tests do. A viewport draws a plan straight from @@ -17,6 +19,12 @@ use solidcraft_geom::Vec3; use solidcraft_geom::tess::{EvalCurve, EvalSurface, TessPlan}; use wgpu::util::DeviceExt; +pub mod capture; +mod pbr; +#[cfg(not(target_arch = "wasm32"))] +pub mod select; +pub mod view; + /// The tessellation compute shader (WGSL). pub const SHADER: &str = include_str!("tess.wgsl"); @@ -30,17 +38,15 @@ fn popped(f: impl std::future::Future>) -> Option Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { - let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle_from_env()); - let adapter = pollster::block_on( - instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), - ) - .map_err(|_| GpuError::NoAdapter)?; + let policy = select::Policy::from_env().map_err(GpuError::Policy)?; + let (adapter, _) = select::pick(policy).ok_or(GpuError::NoAdapter(policy.name()))?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { - label: Some("solidcraft compute"), + label: Some("solidcraft headless"), required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()), ..Default::default() })) @@ -54,12 +60,16 @@ const WORKGROUP: u32 = 64; #[derive(Debug, thiserror::Error)] pub enum GpuError { - #[error("no GPU adapter is available")] - NoAdapter, + #[error("no GPU adapter qualifies under the `{0}` policy (SOLIDCRAFT_GPU); `solidcraft-cli gpu` lists the adapters this machine offers")] + NoAdapter(&'static str), + #[error("{0}")] + Policy(String), #[error("the GPU device could not be opened: {0}")] Device(String), #[error("the GPU evaluation failed: {0}")] Failed(String), + #[error("the GPU render failed: {0}")] + Render(String), #[error("the GPU tessellation pipeline could not be created: {0}")] Pipeline(String), #[error("not supported yet: {0}")] diff --git a/crates/ui-egui/src/pbr_gpu.rs b/crates/gpu/src/pbr.rs similarity index 100% rename from crates/ui-egui/src/pbr_gpu.rs rename to crates/gpu/src/pbr.rs diff --git a/crates/gpu/src/select.rs b/crates/gpu/src/select.rs new file mode 100644 index 00000000..c7a1559f --- /dev/null +++ b/crates/gpu/src/select.rs @@ -0,0 +1,261 @@ +//! What draws a headless picture: every adapter the platform's native backends offer is +//! classified as hardware (a discrete, integrated or virtual GPU) or software (a GPU driver that +//! runs on the CPU, such as llvmpipe, lavapipe or WARP), and a [`Policy`] picks one. The last +//! tier, below any adapter, is SolidCraft's own CPU rasterizer (no surface analysis, highlights +//! or legends): `auto` lands there only when no adapter qualifies, `cpu` forces it, `hardware` +//! and `software` never use it. macOS uses Metal only, and has no software adapter. `SOLIDCRAFT_GPU` +//! (`auto`, `hardware`, `software`, `cpu`) sets the policy from the environment; `WGPU_BACKEND` +//! still narrows the backends. + +use serde::Serialize; + +/// Whether an adapter is a GPU or a software rasterizer. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Tier { + Hardware, + Software, +} + +/// Which adapters a headless device may open. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum Policy { + /// The best hardware adapter, else a software one. + #[default] + Auto, + /// A hardware adapter or nothing. + Hardware, + /// A software adapter or nothing. + Software, + /// The CPU rasterizer, whatever adapters there are. + Cpu, +} + +/// Where a policy lands: an adapter of a tier, or the CPU rasterizer. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum Landing { + Hardware, + Software, + Cpu, +} + +impl Landing { + /// What a picture's reply names as its renderer: `gpu`, `gpu-software` or `cpu`. + pub fn renderer(self) -> &'static str { + match self { + Landing::Hardware => "gpu", + Landing::Software => "gpu-software", + Landing::Cpu => "cpu", + } + } +} + +impl From for Landing { + fn from(t: Tier) -> Self { + match t { + Tier::Hardware => Landing::Hardware, + Tier::Software => Landing::Software, + } + } +} + +impl Policy { + /// `auto`, `hardware`, `software` or `cpu` (case-insensitive). + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "auto" => Some(Policy::Auto), + "hardware" | "hw" | "gpu" => Some(Policy::Hardware), + "software" | "sw" => Some(Policy::Software), + "cpu" => Some(Policy::Cpu), + _ => None, + } + } + + /// The policy `SOLIDCRAFT_GPU` names; [`Policy::Auto`] when it is unset, and an error when + /// it names none. + pub fn from_env() -> Result { + match std::env::var("SOLIDCRAFT_GPU") { + Ok(v) => Policy::parse(&v).ok_or_else(|| format!("SOLIDCRAFT_GPU must be auto, hardware, software or cpu, not `{v}`")), + Err(_) => Ok(Policy::Auto), + } + } + + /// `auto`, `hardware`, `software` or `cpu`. + pub fn name(self) -> &'static str { + match self { + Policy::Auto => "auto", + Policy::Hardware => "hardware", + Policy::Software => "software", + Policy::Cpu => "cpu", + } + } + + fn admits(self, tier: Tier) -> bool { + match self { + Policy::Auto => true, + Policy::Hardware => tier == Tier::Hardware, + Policy::Software => tier == Tier::Software, + Policy::Cpu => false, + } + } + + /// Where the policy lands when the best qualifying adapter is of tier `adapter` (`None`: no + /// adapter qualifies): that adapter; else the CPU rasterizer under `auto` (and always under + /// `cpu`); else nowhere, and a picture is an error. + pub fn lands_on(self, adapter: Option) -> Option { + match (self, adapter) { + (Policy::Cpu, _) => Some(Landing::Cpu), + (_, Some(t)) => Some(t.into()), + (Policy::Auto, None) => Some(Landing::Cpu), + (Policy::Hardware | Policy::Software, None) => None, + } + } +} + +/// One adapter as the survey saw it. +#[derive(Clone, Debug, Serialize)] +pub struct AdapterReport { + pub name: String, + pub backend: String, + pub device_type: String, + pub driver: String, + pub tier: Tier, +} + +/// The backends a headless device looks at on this platform: Metal on macOS, Vulkan and D3D12 +/// on Windows, Vulkan and GL elsewhere; `WGPU_BACKEND` narrows them further. +pub fn native_backends() -> wgpu::Backends { + let native = if cfg!(target_os = "macos") { + wgpu::Backends::METAL + } else if cfg!(target_os = "windows") { + wgpu::Backends::DX12 | wgpu::Backends::VULKAN + } else { + wgpu::Backends::VULKAN | wgpu::Backends::GL + }; + match wgpu::Backends::from_env() { + Some(env) if !(env & native).is_empty() => env & native, + _ => native, + } +} + +fn tier_of(t: wgpu::DeviceType) -> Tier { + match t { + wgpu::DeviceType::Cpu => Tier::Software, + wgpu::DeviceType::DiscreteGpu | wgpu::DeviceType::IntegratedGpu | wgpu::DeviceType::VirtualGpu | wgpu::DeviceType::Other => Tier::Hardware, + } +} + +/// Preference among adapters (higher first): a discrete GPU, an integrated one, a virtual one, +/// an unknown kind, a software rasterizer. +fn rank(t: wgpu::DeviceType) -> u8 { + match t { + wgpu::DeviceType::DiscreteGpu => 4, + wgpu::DeviceType::IntegratedGpu => 3, + wgpu::DeviceType::VirtualGpu => 2, + wgpu::DeviceType::Other => 1, + wgpu::DeviceType::Cpu => 0, + } +} + +fn report(info: &wgpu::AdapterInfo) -> AdapterReport { + AdapterReport { + name: info.name.clone(), + backend: format!("{:?}", info.backend), + device_type: format!("{:?}", info.device_type), + driver: [info.driver.as_str(), info.driver_info.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join(" "), + tier: tier_of(info.device_type), + } +} + +fn instance() -> wgpu::Instance { + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env(); + desc.backends = native_backends(); + wgpu::Instance::new(desc) +} + +/// Every adapter the native backends offer, best first. +#[cfg(not(target_arch = "wasm32"))] +fn adapters(instance: &wgpu::Instance) -> Vec { + let mut all = pollster::block_on(instance.enumerate_adapters(native_backends())); + all.sort_by_key(|a| std::cmp::Reverse(rank(a.get_info().device_type))); + all +} + +/// What a survey found, which adapter the policy picks (`chosen` indexes `adapters`) and where +/// it lands (`None`: nowhere, a picture is an error). +#[derive(Clone, Debug, Serialize)] +pub struct Survey { + pub backends: String, + pub adapters: Vec, + pub chosen: Option, + pub lands_on: Option, +} + +/// Every adapter the native backends offer, best first, and which one `policy` picks. +#[cfg(not(target_arch = "wasm32"))] +pub fn survey(policy: Policy) -> Survey { + let found: Vec = adapters(&instance()).iter().map(|a| report(&a.get_info())).collect(); + let chosen = found.iter().position(|r| policy.admits(r.tier)); + let lands_on = policy.lands_on(chosen.and_then(|i| found.get(i)).map(|r| r.tier)); + Survey { backends: format!("{:?}", native_backends()), adapters: found, chosen, lands_on } +} + +/// The adapter `policy` picks, with its report; `None` when no adapter qualifies. +#[cfg(not(target_arch = "wasm32"))] +pub fn pick(policy: Policy) -> Option<(wgpu::Adapter, AdapterReport)> { + adapters(&instance()) + .into_iter() + .map(|a| { + let r = report(&a.get_info()); + (a, r) + }) + .find(|(_, r)| policy.admits(r.tier)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn policies_parse_and_admit_their_tiers() { + assert_eq!(Policy::parse(" Hardware "), Some(Policy::Hardware)); + assert_eq!(Policy::parse("sw"), Some(Policy::Software)); + assert_eq!(Policy::parse("metal"), None); + assert_eq!(Policy::parse("CPU"), Some(Policy::Cpu)); + assert!(!Policy::Cpu.admits(Tier::Software) && !Policy::Cpu.admits(Tier::Hardware)); + assert!(Policy::Auto.admits(Tier::Software) && Policy::Auto.admits(Tier::Hardware)); + assert!(!Policy::Hardware.admits(Tier::Software)); + assert!(!Policy::Software.admits(Tier::Hardware)); + } + + /// Hardware GPU, then a software adapter, then the CPU rasterizer: `auto` walks all three, + /// `hardware` and `software` never fall to the CPU, and `cpu` always lands there. + #[test] + fn policies_land_on_their_tiers() { + use Landing as L; + let (hw, sw) = (Some(Tier::Hardware), Some(Tier::Software)); + assert_eq!([hw, sw, None].map(|t| Policy::Auto.lands_on(t)), [Some(L::Hardware), Some(L::Software), Some(L::Cpu)]); + assert_eq!(Policy::Hardware.lands_on(hw), Some(L::Hardware)); + assert_eq!(Policy::Hardware.lands_on(None), None); + assert_eq!(Policy::Software.lands_on(sw), Some(L::Software)); + assert_eq!(Policy::Software.lands_on(None), None); + assert_eq!([hw, sw, None].map(|t| Policy::Cpu.lands_on(t)), [Some(L::Cpu); 3]); + assert_eq!([L::Hardware, L::Software, L::Cpu].map(L::renderer), ["gpu", "gpu-software", "cpu"]); + } + + #[test] + fn software_rasterizers_rank_last() { + assert_eq!(tier_of(wgpu::DeviceType::Cpu), Tier::Software); + assert!(rank(wgpu::DeviceType::IntegratedGpu) > rank(wgpu::DeviceType::Cpu)); + assert!(rank(wgpu::DeviceType::DiscreteGpu) > rank(wgpu::DeviceType::IntegratedGpu)); + } + + #[cfg(target_os = "macos")] + #[test] + fn macos_surveys_metal_only() { + assert_eq!(native_backends() & !wgpu::Backends::METAL, wgpu::Backends::empty()); + let s = survey(Policy::Auto); + assert!(s.adapters.iter().all(|a| a.backend == "Metal"), "{s:?}"); + } +} diff --git a/crates/gpu/src/view.rs b/crates/gpu/src/view.rs new file mode 100644 index 00000000..c29542af --- /dev/null +++ b/crates/gpu/src/view.rs @@ -0,0 +1,1590 @@ +//! The viewport's drawing: shaded triangles and screen-space wide lines with a depth buffer, +//! the section cap, surface analysis, reference images and supersampling, as render pipelines on +//! any wgpu device. The desktop and web viewport draws through it (an `egui_wgpu` paint callback +//! hands it the device and the pass); headless pictures draw through it offscreen +//! ([`crate::capture`]). Geometry is uploaded when a scene changes; each frame only the camera +//! uniform changes. Where the device runs compute shaders, opaque bodies are drawn straight from +//! their display plans: the plan is evaluated on the GPU into the vertex buffer the pass draws +//! (see [`PlannedBody`]), with nothing built or read back on the CPU. + +use std::sync::{Arc, Mutex}; + +use solidcraft_geom::Vec3; +use wgpu::util::DeviceExt; + +use crate::pbr; + +/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8). +pub const TRI_SIZE: usize = 28; +pub const PBR_SIZE: usize = pbr::VERTEX_SIZE; +/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32). +pub const LINE_SIZE: usize = 32; +/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours and their +/// flags (`uniform_bytes`). +const UNIFORM_SIZE: u64 = 352; + +/// A body drawn from its display plan, evaluated on the GPU: the plan, and a colour per face +/// (the plan's face numbering; faces past the end take the last colour). +#[derive(Clone)] +pub struct PlannedBody { + pub plan: Arc, + pub face_colors: Vec<[u8; 4]>, +} + +/// CPU-side geometry waiting to be uploaded. The model scene changes with the design; the +/// highlight scene (hover, selection, the origin widget) changes often and is small. +#[derive(Default, Clone)] +pub struct GpuScene { + /// Opaque triangles. A zero normal draws the colour flat (unlit). + pub tris: Vec, + /// Opaque product PBR vertices; linear base color and explicit optical parameters. + pub pbr: Vec, + /// Translucent triangles (alpha from the colour), drawn after the opaque ones without + /// writing depth. + pub trans: Vec, + /// See-through bodies and faces (appearance opacity below 1), closed and outward-wound: + /// their far sides are drawn first, then their near sides, so they read like glass. + pub glass: Vec, + /// Depth-tested lines. + pub lines: Vec, + /// Lines drawn only where the model hides them (hidden edges). + pub hidden: Vec, + /// Lines drawn over everything (active sketch, highlights). + pub overlay: Vec, + /// Translucent triangles pushed slightly back in depth, so they only show where nothing + /// coincides with them (removed material in a preview). + pub ghost: Vec, + /// Translucent triangles drawn through everything (a cut's tool body). + pub xray: Vec, + /// Lines drawn first, under everything (the ground grid: the model hides it, it never + /// hides the model). + pub under: Vec, + /// Origin and construction planes (translucent, like `trans`) and their axes (lines, like + /// `lines`): reference geometry, never cut away by a section (#1229). + pub guide_trans: Vec, + pub guide_lines: Vec, + /// Opaque bodies drawn from their plans on the GPU, like `tris`. + pub planned: Vec, + /// The model's bodies, a few at a time, drawn before the loose geometry above (`tris`, + /// `trans`, `lines` and `hidden` of each block, in order, then the scene's own). A block + /// that comes back unchanged keeps its buffers: a design changing one body at a time (an + /// agent adding bodies over MCP) uploads only the blocks that changed, not the whole model. + pub blocks: Vec, +} + +impl GpuScene { + /// The blocks' geometry moved into the scene's own lists, in drawing order (for tests that + /// count what the scene holds). + pub fn merged(mut self) -> GpuScene { + let mut out = GpuScene { glass: std::mem::take(&mut self.glass), ..Default::default() }; + for block in std::mem::take(&mut self.blocks) { + out.tris.extend_from_slice(&block.bytes.tris); + out.trans.extend_from_slice(&block.bytes.trans); + out.lines.extend_from_slice(&block.bytes.lines); + out.hidden.extend_from_slice(&block.bytes.hidden); + } + out.tris.append(&mut self.tris); + out.trans.append(&mut self.trans); + out.lines.append(&mut self.lines); + out.hidden.append(&mut self.hidden); + GpuScene { tris: out.tris, trans: out.trans, glass: out.glass, lines: out.lines, hidden: out.hidden, ..self } + } +} + +/// A run of the model's bodies in the scene: their triangles and edges, and a key that names +/// exactly this content (the viewport keeps the bytes while the key comes back). +#[derive(Clone)] +pub struct SceneBlock { + pub key: u64, + pub bytes: Arc, +} + +/// A block's geometry, laid out as in [`GpuScene`]. +#[derive(Default)] +pub struct BlockBytes { + pub tris: Vec, + pub trans: Vec, + pub glass: Vec, + pub lines: Vec, + pub hidden: Vec, +} + +impl GpuScene { + pub fn pbr_tri(&mut self, p: [f32; 3], n: [f32; 3], surface: solidcraft_render::surface::OpaqueSurface) { + let base = surface.base_color().to_f32(); + let parameters = [surface.metallic() as f32, surface.roughness() as f32, surface.dielectric_ior() as f32]; + for value in p.into_iter().chain(n).chain(base).chain(parameters) { + self.pbr.extend_from_slice(&value.to_le_bytes()); + } + } + pub fn tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&n) { + self.tris.extend_from_slice(&v.to_le_bytes()); + } + self.tris.extend_from_slice(&c); + } + pub fn trans_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&n) { + self.trans.extend_from_slice(&v.to_le_bytes()); + } + self.trans.extend_from_slice(&c); + } + /// A line shown only where the model hides it. + pub fn hidden_line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32) { + for v in a.iter().chain(&b) { + self.hidden.extend_from_slice(&v.to_le_bytes()); + } + self.hidden.extend_from_slice(&c); + self.hidden.extend_from_slice(&width.to_le_bytes()); + } + pub fn glass_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&n) { + self.glass.extend_from_slice(&v.to_le_bytes()); + } + self.glass.extend_from_slice(&c); + } + pub fn ghost_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&n) { + self.ghost.extend_from_slice(&v.to_le_bytes()); + } + self.ghost.extend_from_slice(&c); + } + pub fn xray_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&n) { + self.xray.extend_from_slice(&v.to_le_bytes()); + } + self.xray.extend_from_slice(&c); + } + /// A line under everything (see [`GpuScene::under`]). + pub fn under_line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32) { + for v in a.iter().chain(&b) { + self.under.extend_from_slice(&v.to_le_bytes()); + } + self.under.extend_from_slice(&c); + self.under.extend_from_slice(&width.to_le_bytes()); + } + /// A translucent triangle of a plane a section does not cut (see [`GpuScene::guide_trans`]). + pub fn guide_tri(&mut self, p: [f32; 3], c: [u8; 4]) { + for v in p.iter().chain(&[0.0; 3]) { + self.guide_trans.extend_from_slice(&v.to_le_bytes()); + } + self.guide_trans.extend_from_slice(&c); + } + /// A depth-tested line a section does not cut (see [`GpuScene::guide_lines`]). + pub fn guide_line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32) { + for v in a.iter().chain(&b) { + self.guide_lines.extend_from_slice(&v.to_le_bytes()); + } + self.guide_lines.extend_from_slice(&c); + self.guide_lines.extend_from_slice(&width.to_le_bytes()); + } + pub fn line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32, on_top: bool) { + let out = if on_top { &mut self.overlay } else { &mut self.lines }; + for v in a.iter().chain(&b) { + out.extend_from_slice(&v.to_le_bytes()); + } + out.extend_from_slice(&c); + out.extend_from_slice(&width.to_le_bytes()); + } +} + +pub type SceneSlot = Arc>>; + +/// A reference image (canvas or decal): a textured quad, depth-tested against the model. +#[derive(Clone)] +pub struct GpuImage { + pub id: u64, + /// Changes when the pixels change (the texture is re-uploaded). + pub version: u64, + pub size: [u32; 2], + /// Straight RGBA8, `size[0] * size[1] * 4` bytes. + pub rgba: Arc>, + /// Bottom-left, bottom-right, top-right, top-left of the image. + pub corners: [[f32; 3]; 4], + pub opacity: f32, +} + +/// Image vertex: position (3 × f32), uv (2 × f32), opacity (f32). +const IMG_SIZE: usize = 24; + +/// Per-frame parameters. +pub struct Frame { + /// Product preview uses its own batches, retaining the Design viewport's uploads. + pub product: bool, + pub key: u64, + pub slot: SceneSlot, + pub sketch_key: u64, + pub sketch_slot: SceneSlot, + /// The highlight scene and its key. + pub hl_key: u64, + pub hl_slot: SceneSlot, + /// The live preview scene and its key. + pub pv_key: u64, + pub pv_slot: SceneSlot, + /// Column-major view-projection. + pub view_proj: [[f32; 4]; 4], + /// Direction toward the eye (for lighting). + pub back: [f32; 3], + /// Viewport size in physical pixels. + pub size_px: [f32; 2], + /// Section cut: (normal, d); fragments with normal·p > d are hidden. A zero normal: none. + pub clip: [f32; 4], + /// Colour of the inside of cut bodies (straight sRGBA, 0..1). + pub cap: [f32; 4], + /// Canvases and decals. + pub images: Vec, + /// Surface analysis: (mode: 0 none, 1 zebra, 2 draft, 3 curvature, 4 environment, + /// 5 accessibility; parameter; scale; size) and the pull / access direction. + pub analysis: [f32; 8], + /// The analysis colours and the stripe flag ([`analysis_colors`]). + pub analysis_colors: [f32; 36], + /// Accessibility: the depth map seen from the access direction. + pub access: Option, + /// Draw silhouette outlines around the model (visual styles with edges). + pub outline: bool, + /// Eye position and 1 for a perspective camera (0: orthographic), for the section cap's depth + /// and the depth bias. + pub eye: [f32; 4], + /// Supersampling (#1288): draw the frame this many times the viewport's pixels in each + /// direction and filter it down (1: off). Under 1 the frame is drawn smaller and filtered up + /// (the product preview past its pixel budget, #1464). + pub ssaa: f32, +} + +/// Heights of the model seen from a direction (an orthographic depth map): a point is reachable +/// from that direction when nothing lies above it. +#[derive(Clone)] +pub struct AccessMap { + pub version: u64, + pub size: u32, + /// Row-major heights along the direction (−∞ where nothing is). + pub depth: Arc>, + /// Map axes: pixel = (p·u + u.w, p·v + v.w) × scale. + pub u: [f32; 4], + pub v: [f32; 4], + pub scale: f32, +} + +/// The accessibility depth map's resolution (pixels per side). +pub const ACCESS_SIZE: u32 = 1024; + +impl AccessMap { + /// The depth map of `meshes` seen along `dir` (`version`: what the GPU uploads it by): + /// rasterise the triangles seen along `dir` (orthographic), keeping the highest point per pixel. + pub fn build(meshes: &[&solidcraft_geom::Mesh], dir: Vec3, version: u64) -> Option { + let d = dir.normalized()?; + let u = d.cross(if d.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).normalized()?; + let v = d.cross(u); + let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); + for m in meshes { + for p in &m.positions { + let (a, b) = (p.dot(u), p.dot(v)); + lo = [lo[0].min(a), lo[1].min(b)]; + hi = [hi[0].max(a), hi[1].max(b)]; + } + } + let ext = (hi[0] - lo[0]).max(hi[1] - lo[1]); + if !(ext.is_finite() && ext > 1e-9) { + return None; + } + let n = ACCESS_SIZE as usize; + let scale = (ACCESS_SIZE - 2) as f64 / ext; + // One pixel of margin on each side. + let (ou, ov) = (-lo[0] + 1.0 / scale, -lo[1] + 1.0 / scale); + let mut depth = vec![f32::NEG_INFINITY; n * n]; + for m in meshes { + for t in &m.triangles { + let q: Vec<(f64, f64, f64)> = t + .iter() + .filter_map(|i| m.positions.get(*i as usize)) + .map(|p| ((p.dot(u) + ou) * scale, (p.dot(v) + ov) * scale, p.dot(d))) + .collect(); + let [a, b, c] = q[..] else { continue }; + let area = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0); + let (x0, x1) = (a.0.min(b.0).min(c.0).floor().max(0.0) as usize, (a.0.max(b.0).max(c.0).ceil() as usize).min(n - 1)); + let (y0, y1) = (a.1.min(b.1).min(c.1).floor().max(0.0) as usize, (a.1.max(b.1).max(c.1).ceil() as usize).min(n - 1)); + // Edge-on triangles: their vertices still count. + if area.abs() < 1e-12 { + for p in [a, b, c] { + let (x, y) = (p.0 as usize, p.1 as usize); + if let Some(z) = depth.get_mut(y * n + x) { + *z = z.max(p.2 as f32); + } + } + continue; + } + for y in y0..=y1 { + for x in x0..=x1 { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let w0 = ((b.0 - px) * (c.1 - py) - (b.1 - py) * (c.0 - px)) / area; + let w1 = ((c.0 - px) * (a.1 - py) - (c.1 - py) * (a.0 - px)) / area; + let w2 = 1.0 - w0 - w1; + if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 { + continue; + } + let z = (w0 * a.2 + w1 * b.2 + w2 * c.2) as f32; + if let Some(cell) = depth.get_mut(y * n + x) { + *cell = cell.max(z); + } + } + } + } + } + Some(AccessMap { + version, + size: ACCESS_SIZE, + depth: Arc::new(depth), + u: [u.x as f32, u.y as f32, u.z as f32, ou as f32], + v: [v.x as f32, v.y as f32, v.z as f32, ov as f32], + scale: scale as f32, + }) + } +} + +/// The shader's surface analysis uniform (see [`Frame::analysis`]) for `mode`; Accessibility +/// reads its depth map's scale and size from `access`. +pub fn analysis_uniform(mode: Option, access: Option<&AccessMap>) -> [f32; 8] { + use solidcraft_render::AnalysisMode as A; + match mode { + Some(A::Zebra { stripes }) => [1.0, stripes as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + Some(A::Draft { pull, angle }) => [2.0, angle as f32, 0.0, 0.0, pull.x as f32, pull.y as f32, pull.z as f32, 0.0], + Some(A::Curvature { radius }) => [3.0, radius as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + Some(A::Environment) => [4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], + Some(A::Access { dir }) => { + let (scale, size, eps) = access.map(|m| (m.scale, m.size as f32, 1.5 / m.scale.max(1e-9))).unwrap_or((0.0, 0.0, 0.0)); + [5.0, eps, scale, size, dir.x as f32, dir.y as f32, dir.z as f32, 0.0] + } + None => [0.0; 8], + } +} + +/// Vertices (or line instances) on the GPU, in as many buffers as the device's buffer size +/// limit needs: a large import's triangles can exceed one buffer (#883). +struct Batch { + /// Each buffer and its vertex (instance) count. + parts: Vec<(wgpu::Buffer, u32)>, + /// Bytes per buffer (all but the last are full). + chunk: usize, +} + +/// A chunk of planned bodies on the GPU: their evaluated mesh and a colour per vertex. +struct PlannedDraw { + mesh: Arc, + colors: wgpu::Buffer, +} + +/// A scene block on the GPU, with the bytes it was uploaded from. +struct BlockGpu { + bytes: Arc, + tris: Option, + trans: Option, + lines: Option, + hidden: Option, +} + +/// The blocks' buffers, then the scene's own, as one batch (the blocks' buffers are shared, +/// not copied). +fn joined<'a>(blocks: impl Iterator>, own: Option) -> Option { + let mut parts: Vec<(wgpu::Buffer, u32)> = blocks.flatten().flat_map(|b| b.parts.iter().cloned()).collect(); + let chunk = own.as_ref().map(|b| b.chunk).unwrap_or(0); + parts.extend(own.into_iter().flat_map(|b| b.parts)); + (!parts.is_empty()).then_some(Batch { parts, chunk }) +} + +/// Planned bodies end a GPU chunk one time in this many (by their plan's address), and a chunk +/// holds at most [`MAX_CHUNK_BODIES`]: a scene of a thousand bodies takes a few dozen dispatches +/// once, and a changed body re-evaluates a few dozen bodies, not all of them. +const CHUNK_SPLIT: u64 = 32; +const MAX_CHUNK_BODIES: usize = 128; + +#[derive(Default)] +struct Batches { + /// The scene blocks on the GPU, by key. + blocks: std::collections::HashMap, + key: Option, + tris: Option, + pbr: Option, + trans: Option, + glass: Option, + /// The glass triangles as built, and the view direction they were last sorted for. + glass_cpu: Vec, + glass_sorted_for: Option<[f32; 3]>, + lines: Option, + hidden: Option, + overlays: Option, + ghost: Option, + xray: Option, + under: Option, + guide_trans: Option, + guide_lines: Option, + planned: Vec, + /// Evaluated chunks by their plans' addresses, holding the plans so the addresses stay + /// theirs: a chunk whose plans are unchanged is not evaluated again when the scene is rebuilt. + resident: std::collections::HashMap, (Vec>, Arc)>, +} + +impl Batches { + /// Upload a new scene. Planned bodies not yet on the GPU are evaluated there: their + /// dispatches go into `encoder`, ahead of this frame's render pass. + fn take(&mut self, device: &wgpu::Device, key: u64, slot: &SceneSlot, tess: Option<(&crate::TessGpu, &mut wgpu::CommandEncoder)>) { + if self.key == Some(key) { + return; + } + if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) { + self.planned = match tess { + Some((tess, encoder)) => self.planned(device, tess, encoder, &sc.planned), + None => Vec::new(), + }; + let mut kept = std::mem::take(&mut self.blocks); + let mut blocks: Vec<&BlockGpu> = Vec::with_capacity(sc.blocks.len()); + for block in &sc.blocks { + let gpu = match kept.remove(&block.key).filter(|g| Arc::ptr_eq(&g.bytes, &block.bytes)) { + Some(g) => g, + None => BlockGpu { + bytes: block.bytes.clone(), + tris: upload(device, "sc_tris", &block.bytes.tris, TRI_SIZE), + trans: upload(device, "sc_trans", &block.bytes.trans, TRI_SIZE), + lines: upload(device, "sc_lines", &block.bytes.lines, LINE_SIZE), + hidden: upload(device, "sc_hidden", &block.bytes.hidden, LINE_SIZE), + }, + }; + self.blocks.insert(block.key, gpu); + } + for block in &sc.blocks { + if let Some(g) = self.blocks.get(&block.key) { + blocks.push(g); + } + } + self.tris = joined(blocks.iter().map(|b| &b.tris), upload(device, "sc_tris", &sc.tris, TRI_SIZE)); + self.pbr = upload(device, "sc_pbr", &sc.pbr, PBR_SIZE); + self.trans = joined(blocks.iter().map(|b| &b.trans), upload(device, "sc_trans", &sc.trans, TRI_SIZE)); + self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST); + self.glass_cpu = sc.glass; + self.glass_sorted_for = None; + self.lines = joined(blocks.iter().map(|b| &b.lines), upload(device, "sc_lines", &sc.lines, LINE_SIZE)); + self.hidden = joined(blocks.iter().map(|b| &b.hidden), upload(device, "sc_hidden", &sc.hidden, LINE_SIZE)); + self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); + self.ghost = upload(device, "sc_ghost", &sc.ghost, TRI_SIZE); + self.xray = upload(device, "sc_xray", &sc.xray, TRI_SIZE); + self.under = upload(device, "sc_under", &sc.under, LINE_SIZE); + self.guide_trans = upload(device, "sc_guide_trans", &sc.guide_trans, TRI_SIZE); + self.guide_lines = upload(device, "sc_guide_lines", &sc.guide_lines, LINE_SIZE); + self.key = Some(key); + } + } + + /// The planned bodies on the GPU: in chunks of at most `MAX_BATCH` vertices (one for any + /// ordinary scene), each uploaded and evaluated in one dispatch and drawn in one call. A + /// chunk whose plans are unchanged is not evaluated again; colours are rebuilt each time. + fn planned( + &mut self, + device: &wgpu::Device, + tess: &crate::TessGpu, + encoder: &mut wgpu::CommandEncoder, + bodies: &[PlannedBody], + ) -> Vec { + // Chunks end where a body's own plan says (or when full), so a body whose plan changes + // (an edit, or a refinement as the view zooms in, #1320) re-evaluates only its own + // chunk, not the whole scene. + let mut chunks: Vec> = Vec::new(); + let mut filled = 0usize; + let mut open = false; + for b in bodies { + let n = b.plan.vertex_count(); + match chunks.last_mut() { + Some(c) if open && filled + n <= crate::MAX_BATCH => c.push(b), + _ => { + chunks.push(vec![b]); + filled = 0; + } + } + filled += n; + let mut h = std::collections::hash_map::DefaultHasher::new(); + std::hash::Hash::hash(&(Arc::as_ptr(&b.plan) as usize), &mut h); + open = !std::hash::Hasher::finish(&h).is_multiple_of(CHUNK_SPLIT) && chunks.last().is_some_and(|c| c.len() < MAX_CHUNK_BODIES); + } + let key = |c: &[&PlannedBody]| c.iter().map(|b| Arc::as_ptr(&b.plan) as usize).collect::>(); + self.resident.retain(|k, _| chunks.iter().any(|c| key(c) == *k)); + let mut out = Vec::with_capacity(chunks.len()); + for chunk in &chunks { + let mesh = match self.resident.get(&key(chunk)) { + Some((_, m)) => m.clone(), + None => { + let plans: Vec<&solidcraft_geom::tess::TessPlan> = chunk.iter().map(|b| &*b.plan).collect(); + match tess.prepare_many(&plans) { + Ok(m) => { + tess.record(encoder, &m); + let m = Arc::new(m); + self.resident.insert(key(chunk), (chunk.iter().map(|b| b.plan.clone()).collect(), m.clone())); + m + } + Err(e) => { + log::warn!("bodies are not drawn on the GPU: {e}"); + continue; + } + } + } + }; + let mut bytes = Vec::with_capacity(mesh.vertex_face.len() * 4); + for (part, b) in mesh.parts.iter().zip(chunk) { + let last = b.face_colors.last().copied().unwrap_or([200, 200, 200, 255]); + let faces = mesh.vertex_face.get(part.vertices.start as usize..part.vertices.end as usize).unwrap_or_default(); + bytes.extend(faces.iter().flat_map(|f| b.face_colors.get(*f as usize).copied().unwrap_or(last))); + } + if let Some((colors, _)) = upload(device, "sc_planned_colors", &bytes, 4).and_then(|c| c.parts.into_iter().next()) { + out.push(PlannedDraw { mesh, colors }); + } + } + out + } +} + +/// The viewport's pipelines and the scenes uploaded to one device, for one kind of target. +pub struct Renderer { + tri: wgpu::RenderPipeline, + /// `tri` and `tri_cut` for planned bodies (positions and normals from the GPU evaluation, + /// colours per vertex, indexed). + tri_planned: wgpu::RenderPipeline, + tri_cut_planned: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>, + /// The tessellation compute pipeline, where the device runs compute shaders. + tess: Option, + /// While a section is shown (when the depth buffer has a stencil): `tri` marking where the + /// cap shows, then the cap again at the section plane's depth, so it hides the inside. + tri_cut: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>, + /// Highlight triangles over the model (depth test ≤, no depth write). + tri_hl: wgpu::RenderPipeline, + pbr: wgpu::RenderPipeline, + trans: wgpu::RenderPipeline, + /// Glass: far sides (front faces culled), then near sides (back faces culled). + glass_far: wgpu::RenderPipeline, + glass_near: wgpu::RenderPipeline, + ghost: wgpu::RenderPipeline, + xray: wgpu::RenderPipeline, + line: wgpu::RenderPipeline, + /// Lines behind the model (depth test >). + line_hidden: wgpu::RenderPipeline, + overlay: wgpu::RenderPipeline, + /// Reference planes and axes: as `trans` and `line`, but not cut by a section. + guide_trans: wgpu::RenderPipeline, + guide_line: wgpu::RenderPipeline, + uniform: wgpu::Buffer, + bind: wgpu::BindGroup, + linear_out: bool, + model: Batches, + product: Batches, + empty: Batches, + sketch: Batches, + highlight: Batches, + preview: Batches, + image: wgpu::RenderPipeline, + image_bgl: wgpu::BindGroupLayout, + sampler: wgpu::Sampler, + /// Textures by image id: (version, bind group). + textures: std::collections::HashMap, + /// This frame's quads: (image id, vertices). + quads: Vec<(u64, Batch)>, + access_bgl: wgpu::BindGroupLayout, + /// The accessibility depth map (a 1 × 1 stand-in when there is none) and its version. + access_bind: wgpu::BindGroup, + access_version: u64, + /// The target the pipelines draw into (format, depth, samples): a supersampled frame is + /// drawn into textures of the same kind. + target: GpuTarget, + /// The device's largest 2D texture side (#869). + max_texture: u32, + /// Supersampling (#1288): the filter that draws the supersampled frame into the viewport. + ss_blit: wgpu::RenderPipeline, + ss_bgl: wgpu::BindGroupLayout, + /// The supersampled frame's textures, kept while its size stays. + ss: Option, + /// This frame was drawn supersampled (in `prepare`): `paint` only filters it down. + ss_ready: bool, +} + +/// A supersampled frame: the scene drawn at a multiple of the viewport's pixels. +struct Supersampled { + size: [u32; 2], + /// Drawn into (multisampled with the viewport's MSAA). + color: wgpu::TextureView, + /// The resolved colour (when `color` is multisampled). + resolve: Option, + depth: Option, + /// The single-sampled colour, for the filter. + bind: wgpu::BindGroup, +} + +/// Most memory (bytes) a supersampled frame's colour and depth may take. +const SS_BUDGET: u64 = 384 << 20; + +/// The size (px) to draw a `size` viewport supersampled `factor` times in each direction: +/// within the device's largest texture side (#869) and the memory budget (at `samples` per pixel, +/// four bytes of colour and four of depth each). `None` when that leaves under 1.25×: the frame +/// is drawn as usual. A `factor` under 1 draws it that much smaller, to be filtered up. +pub fn supersampled_size(size: [f32; 2], factor: f32, max_side: u32, samples: u32) -> Option<[u32; 2]> { + let [w, h] = size; + if w >= 1.0 && h >= 1.0 && factor > 0.0 && factor < 1.0 { + return Some([((w * factor).floor() as u32).clamp(1, max_side.max(1)), ((h * factor).floor() as u32).clamp(1, max_side.max(1))]); + } + if !(w >= 1.0 && h >= 1.0 && factor.is_finite() && factor > 1.0) { + return None; + } + let side = max_side as f32; + let bytes_per_px = 8.0 * samples.max(1) as f32; + let by_memory = (SS_BUDGET as f32 / (w * h * bytes_per_px)).sqrt(); + let f = factor.min(side / w).min(side / h).min(by_memory); + (f >= 1.25).then(|| [((w * f).floor() as u32).clamp(1, max_side), ((h * f).floor() as u32).clamp(1, max_side)]) +} + +/// The filter: each viewport pixel the mean of the supersampled pixels under it (four bilinear +/// taps a quarter of its footprint from its centre: exactly the 2 × 2 box at 2×). +const SS_SHADER: &str = r#" +@group(0) @binding(0) var src: texture_2d; +@group(0) @binding(1) var smp: sampler; +struct O { @builtin(position) pos: vec4, @location(0) uv: vec2 }; +@vertex +fn vs(@builtin(vertex_index) i: u32) -> O { + let x = f32((i << 1u) & 2u); + let y = f32(i & 2u); + var o: O; + o.pos = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); + o.uv = vec2(x, y); + return o; +} +@fragment +fn fs(i: O) -> @location(0) vec4 { + let d = vec2(dpdx(i.uv.x), dpdy(i.uv.y)) * 0.25; + let a = textureSampleLevel(src, smp, i.uv + vec2(-d.x, -d.y), 0.0); + let b = textureSampleLevel(src, smp, i.uv + vec2(d.x, -d.y), 0.0); + let c = textureSampleLevel(src, smp, i.uv + vec2(-d.x, d.y), 0.0); + let e = textureSampleLevel(src, smp, i.uv + vec2(d.x, d.y), 0.0); + return (a + b + c + e) * 0.25; +} +"#; + +fn access_texture(device: &wgpu::Device, queue: Option<&wgpu::Queue>, bgl: &wgpu::BindGroupLayout, size: u32, depth: &[f32]) -> wgpu::BindGroup { + let extent = wgpu::Extent3d { width: size.max(1), height: size.max(1), depth_or_array_layers: 1 }; + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("sc_access"), + size: extent, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::R32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + if let Some(q) = queue + && depth.len() == (size as usize) * (size as usize) + { + let bytes: Vec = depth.iter().flat_map(|x| x.to_le_bytes()).collect(); + q.write_texture( + wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &bytes, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4 * size), rows_per_image: Some(size) }, + extent, + ); + } + let view = tex.create_view(&Default::default()); + device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_access"), + layout: bgl, + entries: &[wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&view) }], + }) +} + +pub const SHADER: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/shaders/viewport.wgsl")); + +fn tri_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4]; + wgpu::VertexBufferLayout { array_stride: TRI_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } +} + +/// A planned body's vertices as the GPU evaluation writes them: the position, then the normal +/// at [`crate::NORMAL_OFFSET`] (each padded to four floats). +fn pbr_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3, 3 => Float32x3]; + wgpu::VertexBufferLayout { array_stride: PBR_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } +} + +fn planned_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 2] = [ + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 0, shader_location: 0 }, + wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: crate::NORMAL_OFFSET, shader_location: 1 }, + ]; + wgpu::VertexBufferLayout { array_stride: crate::VERTEX_STRIDE, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } +} + +/// A planned body's colours, one per vertex. +fn color_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![2 => Unorm8x4]; + wgpu::VertexBufferLayout { array_stride: 4, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } +} + +fn line_layout() -> wgpu::VertexBufferLayout<'static> { + const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32]; + wgpu::VertexBufferLayout { array_stride: LINE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A } +} + +/// The target configuration: colour format, depth format and MSAA sample count of the app. +#[derive(Clone, Copy, Debug)] +pub struct GpuTarget { + pub format: wgpu::TextureFormat, + pub depth: Option, + pub samples: u32, + /// The device evaluates display plans (compute shaders): bodies may be drawn from them. + pub tess: bool, +} + +impl Renderer { + /// The pipelines for target `t` on `device`; `tess` evaluates planned bodies on the GPU. + pub fn new(device: &wgpu::Device, t: GpuTarget, tess: Option) -> Self { + let source = format!("{SHADER}\n{}", pbr::SOURCE); + let module = + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(source.into()) }); + let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_viewport"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }], + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_viewport"), + bind_group_layouts: &[Some(&bgl)], + immediate_size: 0, + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("sc_viewport_u"), + size: UNIFORM_SIZE, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_viewport"), + layout: &bgl, + entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], + }); + let depth = |compare: wgpu::CompareFunction, write: bool| -> Option { + t.depth.map(|format| wgpu::DepthStencilState { + format, + depth_write_enabled: Some(write), + depth_compare: Some(compare), + stencil: Default::default(), + bias: Default::default(), + }) + }; + let pipeline = |label: &str, + vs: &str, + fs: &str, + buf: wgpu::VertexBufferLayout<'static>, + ds: Option, + blend: Option| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { module: &module, entry_point: Some(vs), buffers: &[Some(buf)], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: ds, + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(fs), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }; + let alpha = Some(wgpu::BlendState::ALPHA_BLENDING); + let glass = |label: &str, cull: wgpu::Face| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_tri"), + buffers: &[Some(tri_layout())], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: Some(cull), ..Default::default() }, + depth_stencil: depth(wgpu::CompareFunction::LessEqual, false), + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_tri"), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }; + let image_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_image"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let image_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_image"), + bind_group_layouts: &[Some(&bgl), Some(&image_bgl)], + immediate_size: 0, + }); + const IA: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32]; + let image = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_image"), + layout: Some(&image_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_img"), + buffers: &[Some(wgpu::VertexBufferLayout { + array_stride: IMG_SIZE as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &IA, + })], + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: depth(wgpu::CompareFunction::LessEqual, false), + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some("fs_img"), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }); + let access_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_access"), + entries: &[wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }], + }); + let solid_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_solid"), + bind_group_layouts: &[Some(&bgl), Some(&access_bgl)], + immediate_size: 0, + }); + let built = [Some(tri_layout())]; + let evaluated = [Some(planned_layout()), Some(color_layout())]; + let solid = |label: &str, fs: &str, cull: Option, ds: Option, planned: bool| { + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(label), + layout: Some(&solid_layout), + vertex: wgpu::VertexState { + module: &module, + entry_point: Some("vs_tri"), + buffers: if planned { &evaluated[..] } else { &built[..] }, + compilation_options: Default::default(), + }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: cull, ..Default::default() }, + depth_stencil: ds, + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(fs), + targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }) + }; + // While a section is shown: the stencil marking and the cap (see `Renderer::tri_cut`). + let cut = |planned: bool| { + t.depth.filter(|f| f.has_stencil_aspect()).map(|format| { + // The visible surface marks the stencil: 1 where it is a back face (the cap). + use wgpu::StencilOperation::{Keep, Replace, Zero}; + let face = |pass_op| wgpu::StencilFaceState { compare: wgpu::CompareFunction::Always, fail_op: Keep, depth_fail_op: Keep, pass_op }; + let mark = wgpu::DepthStencilState { + format, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState { front: face(Zero), back: face(Replace), read_mask: 0xff, write_mask: 0xff }, + bias: Default::default(), + }; + // Then the cap's back faces again, only there, at the section plane's depth. + let only = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Equal, ..face(Keep) }; + let cap = wgpu::DepthStencilState { + format, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Always), + stencil: wgpu::StencilState { front: only, back: only, read_mask: 0xff, write_mask: 0 }, + bias: Default::default(), + }; + ( + solid("sc_tris_mark", "fs_solid", None, Some(mark), planned), + solid("sc_tris_cap", "fs_solid_cut", Some(wgpu::Face::Front), Some(cap), planned), + ) + }) + }; + let access_bind = access_texture(device, None, &access_bgl, 1, &[]); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("sc_image"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let ss_module = + device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_ssaa"), source: wgpu::ShaderSource::Wgsl(SS_SHADER.into()) }); + let ss_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sc_ssaa"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let ss_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sc_ssaa"), + bind_group_layouts: &[Some(&ss_bgl)], + immediate_size: 0, + }); + // The frame is premultiplied (drawn over transparent black), so it goes over the + // viewport's background premultiplied; the viewport's depth is left as it is. + let ss_blit = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sc_ssaa"), + layout: Some(&ss_layout), + vertex: wgpu::VertexState { module: &ss_module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, + primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, + depth_stencil: depth(wgpu::CompareFunction::Always, false), + multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, + fragment: Some(wgpu::FragmentState { + module: &ss_module, + entry_point: Some("fs"), + targets: &[Some(wgpu::ColorTargetState { + format: t.format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + multiview_mask: None, + cache: None, + }); + Renderer { + target: t, + max_texture: device.limits().max_texture_dimension_2d, + ss_blit, + ss_bgl, + ss: None, + ss_ready: false, + image, + image_bgl, + sampler, + textures: Default::default(), + quads: Vec::new(), + tri: solid("sc_tris", "fs_solid", None, depth(wgpu::CompareFunction::Less, true), false), + tri_cut: cut(false), + tri_planned: solid("sc_tris_planned", "fs_solid", None, depth(wgpu::CompareFunction::Less, true), true), + tri_cut_planned: cut(true), + tess, + access_bgl, + access_bind, + access_version: 0, + tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha), + pbr: pipeline("sc_pbr", "vs_pbr", "fs_pbr", pbr_layout(), depth(wgpu::CompareFunction::Less, true), None), + trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + glass_far: glass("sc_glass_far", wgpu::Face::Front), + glass_near: glass("sc_glass_near", wgpu::Face::Back), + ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha), + line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + line_hidden: pipeline("sc_lines_hidden", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Greater, false), alpha), + overlay: pipeline("sc_overlay", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha), + guide_trans: pipeline("sc_guide_trans", "vs_tri", "fs_tri_all", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + guide_line: pipeline("sc_guide_lines", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), + uniform, + bind, + linear_out: t.format.is_srgb(), + model: Batches::default(), + product: Batches::default(), + empty: Batches::default(), + sketch: Batches::default(), + highlight: Batches::default(), + preview: Batches::default(), + } + } +} + +/// Depth state of faces lit over the model (hover, selection): tested, not written, and pulled a +/// little toward the eye. They lie on the model's own faces, but the GPU evaluates a body's plan +/// in f32 while the highlight's copy of the same triangles is evaluated on the CPU, so the two +/// depths differ by rounding and an unbiased equal test lights the face in speckles and wedges. +/// The bias is a few depth steps plus the face's own slope: far less than any real gap. +fn highlight_depth(format: Option) -> Option { + format.map(|format| wgpu::DepthStencilState { + format, + depth_write_enabled: Some(false), + depth_compare: Some(wgpu::CompareFunction::LessEqual), + stencil: Default::default(), + bias: HIGHLIGHT_BIAS, + }) +} + +/// See [`highlight_depth`]. Negative: toward the eye (depth grows away from it). +const HIGHLIGHT_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: -8, slope_scale: -1.0, clamp: 0.0 }; + +fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option { + upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX) +} + +fn upload_with(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage: wgpu::BufferUsages) -> Option { + if bytes.len() < stride || stride == 0 { + return None; + } + let chunk = buffer_chunk(stride, device.limits().max_buffer_size); + let parts = bytes + .chunks(chunk) + .filter(|part| part.len() >= stride) + .map(|part| { + let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: part, usage }); + (buffer, u32::try_from(part.len() / stride).unwrap_or(0)) + }) + .collect(); + Some(Batch { parts, chunk }) +} + +/// Bytes per GPU buffer for vertices of `stride` bytes: within the device's limit and a +/// whole number of triangles and line pairs, so no primitive straddles two buffers. +fn buffer_chunk(stride: usize, max_buffer_size: u64) -> usize { + let unit = stride.max(1) * 6; + let max = usize::try_from(max_buffer_size).unwrap_or(usize::MAX).min(1 << 30); + (max / unit).max(1) * unit +} + +/// Triangles (3 vertices of `TRI_SIZE` bytes each, position first) ordered far to near along +/// the view direction `back` (toward the eye), so blended glass draws back to front. +pub fn sort_back_to_front(bytes: &[u8], back: [f32; 3]) -> Vec { + let tri = TRI_SIZE * 3; + let depth = |t: &[u8]| -> f32 { + let f = |o: usize| t.get(o..o + 4).and_then(|b| b.try_into().ok()).map_or(0.0, f32::from_le_bytes); + (0..3).map(|v| (0..3).map(|k| f(v * TRI_SIZE + k * 4) * back[k]).sum::()).sum::() + }; + let mut tris: Vec<(f32, &[u8])> = bytes.chunks_exact(tri).map(|t| (depth(t), t)).collect(); + tris.sort_by(|a, b| a.0.total_cmp(&b.0)); + tris.into_iter().flat_map(|(_, t)| t.iter().copied()).collect() +} + +impl Batches { + /// Re-sort the glass for a new view direction (when it turned more than about 2 degrees). + fn sort_glass(&mut self, queue: &wgpu::Queue, back: [f32; 3]) { + let (Some(g), false) = (&self.glass, self.glass_cpu.is_empty()) else { return }; + let turned = self.glass_sorted_for.is_none_or(|s| s[0] * back[0] + s[1] * back[1] + s[2] * back[2] < 0.9994); + if turned { + let sorted = sort_back_to_front(&self.glass_cpu, back); + for ((buffer, _), bytes) in g.parts.iter().zip(sorted.chunks(g.chunk.max(1))) { + queue.write_buffer(buffer, 0, bytes); + } + self.glass_sorted_for = Some(back); + } + } +} + +/// The tessellation pipeline (`shader`: its source), where display plans are evaluated: on a +/// device that runs compute shaders with the storage buffers the evaluation binds (not WebGL2) +/// and builds the pipeline. Elsewhere `None`, and bodies are built on the CPU: a device whose +/// shader compiler rejects the shader (D3D12's FXC once did) only loses GPU evaluation. +pub fn tessellation(adapter: &wgpu::Adapter, device: &wgpu::Device, queue: &wgpu::Queue, shader: &str) -> Option { + let compute = adapter.get_downlevel_capabilities().flags.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS) + && device.limits().max_storage_buffers_per_shader_stage >= 4; + if !compute { + return None; + } + let info = adapter.get_info(); + if hangs_on_gpu_tessellation(info.backend, &info.name) || std::env::var_os("SOLIDCRAFT_CPU_TESS").is_some() { + log::warn!("bodies are built on the CPU on {} ({:?})", info.name, info.backend); + return None; + } + match crate::TessGpu::with_shader(info.name.clone(), device.clone(), queue.clone(), shader) { + Ok(t) => Some(t), + Err(e) => { + log::warn!("bodies are built on the CPU on {} ({:?}): {e}", info.name, info.backend); + None + } + } +} + +/// A device whose driver hangs on the evaluation shader, so bodies are built on the CPU there: +/// the AMD FirePro D300/D500/D700 (Mac Pro 2013, Tahiti) under Metal, where macOS resets the GPU +/// and the whole desktop freezes. `SOLIDCRAFT_CPU_TESS=1` forces CPU evaluation on any device. +pub fn hangs_on_gpu_tessellation(backend: wgpu::Backend, name: &str) -> bool { + backend == wgpu::Backend::Metal && name.contains("FirePro") +} + +impl Renderer { + /// Before the frame's render pass: upload the scenes that changed (planned bodies not yet on + /// the GPU are evaluated by dispatches recorded into `encoder`), the uniform, the images and + /// the accessibility map, and draw the frame supersampled when it asks for that. + pub fn prepare(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, encoder: &mut wgpu::CommandEncoder, frame: &Frame) { + let res = self; + let cb = frame; + if cb.product { + res.product.take(device, cb.key, &cb.slot, None); + res.product.sort_glass(queue, cb.back); + queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out)); + return; + } + res.model.take(device, cb.key, &cb.slot, res.tess.as_ref().map(|t| (t, &mut *encoder))); + res.sketch.take(device, cb.sketch_key, &cb.sketch_slot, None); + res.highlight.take(device, cb.hl_key, &cb.hl_slot, None); + res.preview.take(device, cb.pv_key, &cb.pv_slot, None); + res.model.sort_glass(queue, cb.back); + queue.write_buffer(&res.uniform, 0, &uniform_bytes(cb, cb.size_px[0], cb.size_px[1], res.linear_out)); + if let Some(a) = &cb.access + && a.version != res.access_version + { + res.access_bind = access_texture(device, Some(queue), &res.access_bgl, a.size, &a.depth); + res.access_version = a.version; + } + // Images: upload new or changed textures, drop removed ones, rebuild the quads. + res.textures.retain(|id, _| cb.images.iter().any(|i| i.id == *id)); + res.quads.clear(); + for img in &cb.images { + let [w, h] = img.size; + if w == 0 || h == 0 || img.rgba.len() != (w as usize) * (h as usize) * 4 { + continue; + } + if res.textures.get(&img.id).is_none_or(|t| t.0 != img.version) { + let size = wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }; + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("sc_image"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + &img.rgba, + wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4 * w), rows_per_image: Some(h) }, + size, + ); + let view = tex.create_view(&Default::default()); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_image"), + layout: &res.image_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.sampler) }, + ], + }); + res.textures.insert(img.id, (img.version, bind)); + } + let c = img.corners; + let uv = [[0.0_f32, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]; + let mut bytes = Vec::with_capacity(6 * IMG_SIZE); + for k in [0, 1, 2, 0, 2, 3] { + for v in c[k].iter().chain(&uv[k]).chain(std::iter::once(&img.opacity)) { + bytes.extend_from_slice(&v.to_le_bytes()); + } + } + if let Some(b) = upload(device, "sc_image_quad", &bytes, IMG_SIZE) { + res.quads.push((img.id, b)); + } + } + res.ss_ready = cb.ssaa != 1.0 && res.supersample(device, encoder, cb); + } + + /// Draw the frame into `pass` within `viewport` (x, y, width, height in physical pixels): + /// filtered down from the supersampled frame [`Renderer::prepare`] drew, or drawn directly. + pub fn paint(&self, frame: &Frame, pass: &mut wgpu::RenderPass<'static>, viewport: [f32; 4]) { + let [x, y, w, h] = viewport; + if !(w > 0.0 && h > 0.0) { + return; + } + pass.set_viewport(x, y, w, h, 0.0, 1.0); + if self.ss_ready + && let Some(ss) = &self.ss + { + pass.set_pipeline(&self.ss_blit); + pass.set_bind_group(0, &ss.bind, &[]); + pass.draw(0..3, 0..1); + return; + } + self.draw(frame, pass); + } + + /// The target the pipelines draw into. + pub fn target(&self) -> GpuTarget { + self.target + } +} + +impl Renderer { + /// Draw the frame's scenes into `pass` (its viewport already set). + fn draw(&self, cb: &Frame, pass: &mut wgpu::RenderPass<'static>) { + let res = self; + pass.set_bind_group(0, &res.bind, &[]); + let tris = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline, b: &Option| { + if let Some(b) = b { + pass.set_pipeline(p); + for (buffer, count) in &b.parts { + pass.set_vertex_buffer(0, buffer.slice(..)); + pass.draw(0..*count, 0..1); + } + } + }; + let lines = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline, b: &Option| { + if let Some(b) = b { + pass.set_pipeline(p); + for (buffer, count) in &b.parts { + pass.set_vertex_buffer(0, buffer.slice(..)); + pass.draw(0..6, 0..*count); + } + } + }; + let (m, sk, h, pv) = + if cb.product { (&res.product, &res.empty, &res.empty, &res.empty) } else { (&res.model, &res.sketch, &res.highlight, &res.preview) }; + lines(pass, &res.overlay, &m.under); + lines(pass, &res.overlay, &sk.under); + pass.set_bind_group(1, &res.access_bind, &[]); + let planned = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline| { + for d in &m.planned { + pass.set_pipeline(p); + pass.set_vertex_buffer(0, d.mesh.vertices.slice(..)); + pass.set_vertex_buffer(1, d.colors.slice(..)); + pass.set_index_buffer(d.mesh.indices.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..d.mesh.index_count, 0, 0..1); + } + }; + let sectioned = cb.clip[..3].iter().map(|c| c * c).sum::() > 0.25; + match (res.tri_cut.as_ref().filter(|_| sectioned), res.tri_cut_planned.as_ref()) { + (Some((mark, cap)), Some((mark_planned, cap_planned))) => { + pass.set_stencil_reference(1); + tris(pass, mark, &m.tris); + tris(pass, mark, &pv.tris); + planned(pass, mark_planned); + tris(pass, cap, &m.tris); + tris(pass, cap, &pv.tris); + planned(pass, cap_planned); + } + _ => { + tris(pass, &res.tri, &m.tris); + tris(pass, &res.tri, &pv.tris); + planned(pass, &res.tri_planned); + } + } + tris(pass, &res.pbr, &m.pbr); + tris(pass, &res.tri_hl, &h.tris); + lines(pass, &res.line, &m.lines); + lines(pass, &res.line, &sk.lines); + lines(pass, &res.line_hidden, &m.hidden); + lines(pass, &res.line_hidden, &h.hidden); + lines(pass, &res.line, &pv.lines); + lines(pass, &res.line, &h.lines); + lines(pass, &res.guide_line, &h.guide_lines); + for (id, q) in res.quads.iter().filter(|_| !cb.product) { + if let Some((_, bind)) = res.textures.get(id) { + pass.set_pipeline(&res.image); + pass.set_bind_group(1, bind, &[]); + for (buffer, count) in &q.parts { + pass.set_vertex_buffer(0, buffer.slice(..)); + pass.draw(0..*count, 0..1); + } + } + } + tris(pass, &res.ghost, &pv.ghost); + tris(pass, &res.glass_far, &m.glass); + tris(pass, &res.glass_near, &m.glass); + tris(pass, &res.trans, &m.trans); + tris(pass, &res.trans, &sk.trans); + tris(pass, &res.trans, &h.trans); + tris(pass, &res.guide_trans, &h.guide_trans); + tris(pass, &res.xray, &pv.xray); + lines(pass, &res.overlay, &m.overlays); + lines(pass, &res.overlay, &sk.overlays); + lines(pass, &res.overlay, &h.overlays); + } +} + +impl Renderer { + /// Draw the frame supersampled (`cb.ssaa` times the viewport's pixels each way, within the + /// device's texture limit and the memory budget) into textures of the viewport's kind. + /// `false` when it cannot be (too small a gain, a zero-sized viewport): drawn as usual. + fn supersample(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, cb: &Frame) -> bool { + let Some(size) = supersampled_size(cb.size_px, cb.ssaa, self.max_texture, self.target.samples) else { + self.ss = None; + return false; + }; + if self.ss.as_ref().is_none_or(|ss| ss.size != size) { + self.ss = Some(self.supersampled(device, size)); + } + let Some(ss) = &self.ss else { return false }; + let depth_format = self.target.depth; + let mut pass = encoder + .begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("sc_ssaa"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &ss.color, + depth_slice: None, + resolve_target: ss.resolve.as_ref(), + ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, + })], + depth_stencil_attachment: ss.depth.as_ref().map(|view| wgpu::RenderPassDepthStencilAttachment { + view, + depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }), + stencil_ops: depth_format + .filter(|f| f.has_stencil_aspect()) + .map(|_| wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }), + }), + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }) + .forget_lifetime(); + pass.set_viewport(0.0, 0.0, size[0] as f32, size[1] as f32, 0.0, 1.0); + self.draw(cb, &mut pass); + true + } + + /// Textures for a supersampled frame of `size` pixels. + fn supersampled(&self, device: &wgpu::Device, size: [u32; 2]) -> Supersampled { + let t = self.target; + let texture = |label: &str, format: wgpu::TextureFormat, samples: u32, usage: wgpu::TextureUsages| { + device + .create_texture(&wgpu::TextureDescriptor { + label: Some(label), + size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: samples, + dimension: wgpu::TextureDimension::D2, + format, + usage, + view_formats: &[], + }) + .create_view(&Default::default()) + }; + let samples = t.samples.max(1); + let sampled = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; + let (color, resolve) = if samples > 1 { + (texture("sc_ssaa_ms", t.format, samples, wgpu::TextureUsages::RENDER_ATTACHMENT), Some(texture("sc_ssaa", t.format, 1, sampled))) + } else { + (texture("sc_ssaa", t.format, 1, sampled), None) + }; + let depth = t.depth.map(|f| texture("sc_ssaa_depth", f, samples, wgpu::TextureUsages::RENDER_ATTACHMENT)); + let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sc_ssaa"), + layout: &self.ss_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(resolve.as_ref().unwrap_or(&color)) }, + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, + ], + }); + Supersampled { size, color, resolve, depth, bind } + } +} + +/// Uniform bytes for a frame. +pub fn uniform_bytes(cb: &Frame, w: f32, h: f32, linear: bool) -> Vec { + let mut v: Vec = cb.view_proj.iter().flatten().copied().collect(); + v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]); + v.extend([w.max(1.0), h.max(1.0), 2e-4, if cb.outline { 1.0 } else { 0.0 }]); + v.extend(cb.clip); + v.extend(cb.cap); + v.extend(cb.analysis); + match &cb.access { + Some(a) => { + v.extend(a.u); + v.extend(a.v); + } + None => v.extend([0.0; 8]), + } + v.extend(cb.eye); + v.extend(cb.analysis_colors); + v.iter().flat_map(|x| x.to_le_bytes()).collect() +} + +/// The shader's analysis colours (`ana_c`: draft positive, between, negative; reachable, +/// blocked; curvature flat, middle, tight; alpha 1) and flags (`ana_f.x`: 1 to stripe the bad +/// faces). +pub fn analysis_colors(c: &solidcraft_render::AnalysisColors) -> [f32; 36] { + let mut v = [0.0; 36]; + let colors = c.draft.iter().chain(&c.access).chain(&c.curvature).flat_map(|rgb| [rgb[0], rgb[1], rgb[2], 1.0]); + for (slot, x) in v.iter_mut().zip(colors) { + *slot = x; + } + v[32] = if c.hatch { 1.0 } else { 0.0 }; + v +} + +#[cfg(test)] +mod tests { + + /// Only the FirePro under Metal skips GPU evaluation; the same card under another backend + /// and other Metal GPUs keep it. + #[test] + fn firepro_under_metal_builds_bodies_on_the_cpu() { + assert!(super::hangs_on_gpu_tessellation(wgpu::Backend::Metal, "AMD FirePro D500")); + assert!(super::hangs_on_gpu_tessellation(wgpu::Backend::Metal, "AMD FirePro D700")); + assert!(!super::hangs_on_gpu_tessellation(wgpu::Backend::Metal, "Apple M2")); + assert!(!super::hangs_on_gpu_tessellation(wgpu::Backend::Metal, "AMD Radeon Pro 560X")); + assert!(!super::hangs_on_gpu_tessellation(wgpu::Backend::Vulkan, "AMD FirePro W5100")); + } + + /// The viewport shader parses and validates (a bad shader would only fail at run time). + #[test] + fn shader_is_valid() { + let module = naga::front::wgsl::parse_str(super::SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::SHADER))); + naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap(); + } + + /// Derivatives and implicit-lod sampling run only in uniform control flow: browsers' WGSL + /// compilers (Tint) reject them after a branch, and the whole viewport shader then fails, + /// a black screen on the web (naga accepts it). Every fragment entry point makes such calls + /// (directly or through a helper) before its first branch or return. + #[test] + fn derivatives_only_in_uniform_control_flow() { + // Without comments (their words are not code). + let src: String = super::SHADER.lines().map(|l| l.split("//").next().unwrap_or("")).collect::>().join("\n"); + let src = src.as_str(); + let derivs = ["dpdx(", "dpdy(", "fwidth(", "textureSample("]; + // Functions: name -> body. + let mut fns: Vec<(String, String, bool)> = Vec::new(); + let mut rest = src; + let mut fragment = false; + while let Some(at) = rest.find("fn ") { + let before = &rest[..at]; + if before.contains("@fragment") { + fragment = true; + } + let Some(open) = rest[at..].find('{') else { break }; + let name = rest[at + 3..].split('(').next().unwrap_or("").trim().to_string(); + let start = at + open; + let mut depth = 0; + let mut end = start; + for (k, ch) in rest[start..].char_indices() { + match ch { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + end = start + k; + break; + } + } + _ => {} + } + } + fns.push((name, rest[start..=end].to_string(), fragment)); + fragment = false; + rest = &rest[end + 1..]; + } + // Helpers that take derivatives (directly or through another helper). + let mut uses: Vec = Vec::new(); + for _ in 0..8 { + for (n, body, _) in &fns { + if !uses.contains(n) && (derivs.iter().any(|d| body.contains(d)) || uses.iter().any(|u| body.contains(&format!("{u}(")))) { + uses.push(n.clone()); + } + } + } + for (n, body, frag) in &fns { + let calls: Vec = derivs + .iter() + .map(|d| d.to_string()) + .chain(uses.iter().map(|u| format!("{u}("))) + .flat_map(|c| body.match_indices(&c).map(|(i, _)| i).collect::>()) + .collect(); + if calls.is_empty() { + continue; + } + if !*frag { + // A helper: its own derivatives must come before any branch or return in it. + let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX); + for d in derivs { + if let Some(i) = body.find(d) { + assert!(i < first, "{n}: {d} after a branch"); + } + } + continue; + } + let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX); + for c in calls { + assert!(c < first, "fragment {n}: a derivative after a branch or return: {}", &body[c..(c + 40).min(body.len())]); + } + } + } +} + +#[cfg(test)] +mod glass_tests { + use super::*; + + /// A large import's triangles once went into one buffer past the device's limit (524 MB + /// against 256 MB) and the app crashed (#883): they are split into whole triangles. + #[test] + fn large_scenes_are_split_into_buffers_within_the_device_limit() { + let limit = 256u64 << 20; + for stride in [TRI_SIZE, LINE_SIZE, 4] { + let chunk = buffer_chunk(stride, limit); + assert!(chunk as u64 <= limit && chunk > limit as usize / 2); + assert_eq!(chunk % (stride * 3), 0, "whole triangles"); + assert_eq!(chunk % (stride * 2), 0, "whole line pairs"); + } + let scene = 524_434_680usize; + assert_eq!(scene.div_ceil(buffer_chunk(TRI_SIZE, limit)), 2); + assert!(buffer_chunk(TRI_SIZE, 1) >= TRI_SIZE * 6, "a tiny limit still holds one unit"); + } + + #[test] + fn glass_is_drawn_far_to_near() { + // Two triangles at z = 5 and z = -5, given near first; looking down from +Z. + let mut sc = GpuScene::default(); + for z in [5.0f32, -5.0] { + for p in [[0.0, 0.0, z], [1.0, 0.0, z], [0.0, 1.0, z]] { + sc.glass_tri(p, [0.0, 0.0, 1.0], [255, 0, 0, 100]); + } + } + let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, 1.0]); + let z0 = f32::from_le_bytes(sorted[8..12].try_into().unwrap()); + assert_eq!(z0, -5.0, "the far triangle first"); + assert_eq!(sorted.len(), sc.glass.len()); + // Seen from below, the order flips. + let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, -1.0]); + assert_eq!(f32::from_le_bytes(sorted[8..12].try_into().unwrap()), 5.0); + } +} diff --git a/crates/gpu/tests/capture.rs b/crates/gpu/tests/capture.rs new file mode 100644 index 00000000..e71d3a7f --- /dev/null +++ b/crates/gpu/tests/capture.rs @@ -0,0 +1,168 @@ +//! Headless pictures drawn with the viewport's pipelines: what they show (appearances, guides, +//! silhouettes, the section and its cap, surface analysis) and that bad requests are errors. +//! GPU output differs a little between drivers, so the checks count pixels of a kind rather than +//! compare bytes. Skipped, with the reason printed, on a machine without a GPU adapter +//! (`SOLIDCRAFT_REQUIRE_GPU=1` makes that a failure). + +use serde_json::json; +use solidcraft_engine::Session; +use solidcraft_engine::geom::Vec3; +use solidcraft_engine::render::{Camera, Rgb, SceneLine}; +use solidcraft_engine::view::{self, Guides, colors}; +use solidcraft_gpu::GpuError; +use solidcraft_gpu::capture::{self, Capturer}; + +fn capturer() -> Option<&'static Capturer> { + match capture::shared() { + Ok(c) => { + eprintln!("GPU: {} ({}x MSAA)", c.adapter_name(), c.samples()); + Some(c) + } + Err(e @ GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => { + eprintln!("skipping: {e}"); + None + } + Err(e) => panic!("{e}"), + } +} + +/// RGBA pixels as `[r, g, b, a]`. +fn pixels(img: &[u8]) -> impl Iterator + '_ { + img.as_chunks::<4>().0.iter().copied() +} + +fn session(script: &[(&str, serde_json::Value)]) -> Session { + let mut s = Session::default(); + for (c, p) in script { + s.execute(c, p).unwrap(); + } + s +} + +/// Appearances show as the viewport shows them: a body's colour, a face's own look. +#[test] +fn pictures_show_appearances() { + let Some(c) = capturer() else { return }; + let s = session(&[ + ("solid.box", json!({"length": 40, "width": 30, "height": 20})), + ("solid.cylinder", json!({"diameter": 14, "height": 30, "base": [50, 15, 0], "operation": "new"})), + ("appearance.assign", json!({"bodies": ["Body2"], "color": [220, 60, 40]})), + ("appearance.assign", json!({"faces": [[20, 15, 20]], "body": "Body1", "color": [40, 170, 80]})), + ]); + let cam = view::home_camera(&s); + let img = c.rgba(&view::scene(&s, &cam), &cam, 200, 140).unwrap(); + assert_eq!(img.len(), 200 * 140 * 4); + let green = pixels(&img).filter(|p| p[1] > 120 && p[0] < 90 && p[2] < 110).count(); + let red = pixels(&img).filter(|p| p[0] > 150 && p[1] < 90 && p[2] < 80).count(); + assert!(green > 100 && red > 100, "green top {green}, red cylinder {red} pixels"); + assert!(pixels(&img).all(|p| p[3] == 255), "the picture is opaque"); +} + +/// The ground grid and the origin's axes each change the picture. +#[test] +fn guides_change_the_picture() { + let Some(c) = capturer() else { return }; + let s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]}))]); + let cam = view::home_camera(&s); + let picture = |grid, axes| c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid, axes }), &cam, 160, 100).unwrap(); + let (all, no_grid, no_axes, bare) = (picture(true, true), picture(false, true), picture(true, false), picture(false, false)); + assert!(all != no_grid && all != no_axes && no_grid != bare && no_axes != bare); +} + +/// Curved surfaces get a dark silhouette where they turn away (the viewport's outline). +#[test] +fn pictures_draw_silhouettes_on_curved_surfaces() { + let Some(c) = capturer() else { return }; + let s = session(&[("solid.sphere", json!({"diameter": 20}))]); + let cam = view::home_camera(&s); + let img = c.rgba(&view::scene_with_guides(&s, &cam, None, Guides { grid: false, axes: false }), &cam, 400, 400).unwrap(); + let dark = pixels(&img).filter(|p| p[0] < 90 && p[1] < 90 && p[2] < 95).count(); + assert!(dark > 40, "silhouette pixels: {dark}"); +} + +/// A section shows: the cut part is gone, the cut face hides what lies inside the body behind +/// it and shows in the cap colour. +#[test] +fn pictures_show_the_section_and_its_cap() { + let Some(c) = capturer() else { return }; + let mut s = session(&[("solid.box", json!({"length": 40, "width": 30, "height": 20}))]); + let cam = view::home_camera(&s); + let whole = c.rgba(&view::scene(&s, &cam), &cam, 200, 140).unwrap(); + // Cut away the front half (y < 15), so the iso camera looks into the cut. + s.execute("inspect.section", &json!({"plane": {"origin": [0, 15, 0], "normal": [0, -1, 0]}})).unwrap(); + let cut = c.rgba(&view::scene(&s, &cam), &cam, 200, 140).unwrap(); + let changed = pixels(&whole).zip(pixels(&cut)).filter(|(a, b)| a != b).count(); + assert!(changed > 500, "{changed} pixels changed"); + for fov in [0.0, 0.6] { + let mut cam = view::home_camera(&s); + cam.fov = fov; + let mut sc = view::scene(&s, &cam); + // A marker inside the body, behind the cut face. + let red = Rgb(255, 0, 0); + sc.lines.push(SceneLine { points: vec![Vec3::new(5.0, 25.0, 10.0), Vec3::new(35.0, 25.0, 10.0)], color: red, width: 3.0, on_top: false }); + let img = c.rgba(&sc, &cam, 300, 220).unwrap(); + let marker = pixels(&img).filter(|p| p[0] > 200 && p[1] < 60 && p[2] < 60).count(); + assert_eq!(marker, 0, "fov {fov}: the inside shows through the cut face"); + // The cap: warm (red over blue), unlike the grey-blue body and background. + let cap = pixels(&img).filter(|p| i32::from(p[0]) - i32::from(p[2]) > 20).count(); + assert!(cap > 500, "fov {fov}: cap pixels {cap}"); + } +} + +/// Surface analysis shades headless pictures as it shades the viewport: a box's flat faces in +/// the curvature map's flat colour, zebra stripes in black and white, the accessibility analysis +/// in reachable and blocked colours. +#[test] +fn pictures_show_the_surface_analysis() { + let Some(c) = capturer() else { return }; + let mut s = session(&[ + ("solid.box", json!({"length": 60, "width": 40, "height": 20})), + ("solid.hole", json!({"position": [30, 20, 20], "diameter": 10, "depth": 20})), + ]); + let cam = view::home_camera(&s); + let picture = |s: &Session| c.rgba(&view::scene_with_guides(s, &cam, None, Guides { grid: false, axes: false }), &cam, 320, 200).unwrap(); + let plain = picture(&s); + s.execute("inspect.curvature_map", &json!({})).unwrap(); + let curvature = picture(&s); + let blue = |img: &[u8]| pixels(img).filter(|p| i32::from(p[2]) - i32::from(p[0]) > 120 && p[1] < 140).count(); + assert!(blue(&curvature) > 3000 && blue(&plain) == 0, "flat faces in the flat colour: {} (plain {})", blue(&curvature), blue(&plain)); + s.execute("inspect.zebra", &json!({})).unwrap(); + let zebra = picture(&s); + let black = pixels(&zebra).filter(|p| p[0] < 40 && p[1] < 40 && p[2] < 40).count(); + let white = pixels(&zebra).filter(|p| p[0] > 225 && p[1] > 225 && p[2] > 225).count(); + assert!(black > 500 && white > 500, "zebra: {black} black, {white} white pixels"); + s.execute("inspect.accessibility", &json!({})).unwrap(); + let access = picture(&s); + let [ok, blocked] = colors_of(solidcraft_engine::render::AnalysisColors::DEFAULT.access); + let near = |p: [u8; 4], c: [u8; 3]| (0..3).all(|k| (i32::from(p[k]) - i32::from(c[k])).abs() < 60); + let (reachable, under) = (pixels(&access).filter(|p| near(*p, ok)).count(), pixels(&access).filter(|p| near(*p, blocked)).count()); + assert!(reachable > 1000 && under > 1000, "accessibility: {reachable} reachable, {under} blocked pixels"); +} + +fn colors_of(c: [[f32; 3]; 2]) -> [[u8; 3]; 2] { + c.map(|rgb| rgb.map(|x| (x * 255.0).round() as u8)) +} + +/// An empty scene is the background gradient: the top colour at the top, the bottom one at the +/// bottom. +#[test] +fn an_empty_picture_is_the_background() { + let Some(c) = capturer() else { return }; + let sc = solidcraft_engine::render::Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() }; + let img = c.rgba(&sc, &Camera::default(), 64, 64).unwrap(); + let at = |row: usize| pixels(&img).nth(row * 64 + 32).unwrap_or_default(); + let near = |p: [u8; 4], c: Rgb| [p[0], p[1], p[2]].iter().zip([c.0, c.1, c.2]).all(|(a, b)| a.abs_diff(b) <= 3); + assert!(near(at(0), colors::BG_TOP) && near(at(63), colors::BG_BOTTOM), "{:?} {:?}", at(0), at(63)); +} + +/// Sizes outside 1 to the device's limit are errors, not crashes. +#[test] +fn bad_picture_sizes_are_errors() { + let Some(c) = capturer() else { return }; + let sc = solidcraft_engine::render::Scene::default(); + for (w, h) in [(0, 10), (10, 0), (capture::MAX_SIDE + 1, 10), (u32::MAX, u32::MAX)] { + assert!(c.rgba(&sc, &Camera::default(), w, h).is_err(), "{w} x {h}"); + } + // And the device still draws. + assert!(c.rgba(&sc, &Camera::default(), 8, 8).is_ok()); +} diff --git a/crates/mcp/Cargo.toml b/crates/mcp/Cargo.toml index b71ede26..057cca25 100644 --- a/crates/mcp/Cargo.toml +++ b/crates/mcp/Cargo.toml @@ -11,3 +11,7 @@ workspace = true [dependencies] solidcraft-engine = { workspace = true } serde_json = { workspace = true } + +[dev-dependencies] +# The renderer `solidcraft-cli mcp` installs, so the tests' screenshots are drawn as the server's. +solidcraft-gpu = { workspace = true } diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index b71a67ef..7d107c16 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -6,7 +6,7 @@ use std::time::Duration; use serde_json::{Value, json}; use solidcraft_engine::Session; -use solidcraft_engine::render::{StandardView, render_png}; +use solidcraft_engine::render::StandardView; use solidcraft_engine::view::Guides; /// Something that answers control-channel methods: `engine.execute`, `engine.commands`, @@ -234,8 +234,8 @@ impl Headless { } } -fn dim(p: &Value, k: &str, default: f64) -> usize { - p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(default).clamp(16.0, 4096.0) as usize +fn dim(p: &Value, k: &str, default: f64) -> u32 { + p.get(k).and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(default).clamp(16.0, 4096.0) as u32 } impl Backend for Headless { @@ -279,11 +279,21 @@ impl Backend for Headless { // The grid and the axes are drawn unless the request leaves them out. let shown = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(true); let guides = Guides { grid: shown("grid"), axes: shown("axes") }; - let scene = solidcraft_engine::view::scene_with_guides(s, &cam, s.section, guides); - let png = render_png(&scene, &cam, w, h).ok_or("render failed")?; let path = str_p("path").ok_or("missing `path`")?; - solidcraft_engine::io::vfs::write(path, &png).map_err(|e| format!("{path}: {e}"))?; - Ok(json!({"path": path, "bytes": png.len(), "width": w, "height": h})) + // A path outside the agent's folders is refused before anything is drawn. + solidcraft_engine::io::vfs::check(path).map_err(|e| format!("{path}: {e}"))?; + let scene = solidcraft_engine::view::scene_with_guides(s, &cam, s.section, guides); + // Drawn on the GPU by the renderer the host installed (`solidcraft-cli mcp`). + let png = solidcraft_engine::capture::png(&scene, &cam, w, h)?; + solidcraft_engine::io::vfs::write(path, &png.bytes).map_err(|e| format!("{path}: {e}"))?; + Ok(json!({ + "path": path, + "bytes": png.bytes.len(), + "width": w, + "height": h, + "renderer": png.renderer, + "warning": solidcraft_engine::capture::warning(png.renderer), + })) } "ui.screenshot" => Err("no window in headless mode; use ui.render".into()), other => Err(format!("unknown method `{other}`")), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 6f895754..bdb3c11b 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -10,6 +10,25 @@ fn server() -> Server { Server::new(Box::new(Headless::default())) } +/// Screenshots are drawn on the GPU by the renderer the host installs (`solidcraft-cli mcp`); the +/// tests install the same one. `false`, with the reason printed, on a machine without a GPU +/// adapter: the screenshots are then left out of the test. +fn renders() -> bool { + match solidcraft_gpu::capture::shared() { + Ok(c) => { + solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| { + c.rgba(scene, cam, w, h).map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }).map_err(|e| e.to_string()) + })); + true + } + Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => { + eprintln!("screenshots left out: {e}"); + false + } + Err(e) => panic!("{e}"), + } +} + fn call(s: &mut Server, id: u64, method: &str, params: Value) -> Value { let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(); let r = s.handle_line(&line).expect("reply"); @@ -166,10 +185,12 @@ fn agent_builds_box_fillet_hole() { assert!(rel(volume(&mut s), expect) < 5e-4); // Look at it from the top: a real PNG comes back as an image block. - let img = tool(&mut s, "screenshot", json!({"view": "top", "width": 320, "height": 200})); - assert_eq!(img["type"], "image"); - assert_eq!(img["mimeType"], "image/png"); - assert!(img["data"].as_str().unwrap().starts_with("iVBORw0KGgo"), "not a PNG"); + if renders() { + let img = tool(&mut s, "screenshot", json!({"view": "top", "width": 320, "height": 200})); + assert_eq!(img["type"], "image"); + assert_eq!(img["mimeType"], "image/png"); + assert!(img["data"].as_str().unwrap().starts_with("iVBORw0KGgo"), "not a PNG"); + } // Export, save, start over, open. let dir = std::env::temp_dir().join(format!("solidcraft-mcp-test-{}", std::process::id())); @@ -181,8 +202,10 @@ fn agent_builds_box_fillet_hole() { tool(&mut s, "export", json!({"path": stl.to_string_lossy(), "format": "stl"})); assert!(std::fs::metadata(&stl).unwrap().len() > 1000); let shot = dir.join("shot.png"); - tool(&mut s, "screenshot", json!({"path": shot.to_string_lossy(), "width": 64, "height": 64})); - assert!(std::fs::metadata(&shot).unwrap().len() > 100); + if renders() { + tool(&mut s, "screenshot", json!({"path": shot.to_string_lossy(), "width": 64, "height": 64})); + assert!(std::fs::metadata(&shot).unwrap().len() > 100); + } let design = dir.join("part.solidcraft"); tool(&mut s, "save", json!({"path": design.to_string_lossy()})); tool(&mut s, "new_design", json!({"name": "Empty"})); @@ -524,7 +547,9 @@ fn the_default_folders_are_the_designs_the_recovery_folder_and_the_scratch_folde tool(&mut s, "execute", json!({"command": "parameters.export", "params": {"path": at(&proj, "params.csv")}})); tool(&mut s, "save", json!({"path": at(&proj, "copy.solidcraft")})); tool(&mut s, "open", json!({"path": at(&proj, "part.solidcraft")})); - tool(&mut s, "screenshot", json!({"width": 32, "height": 32})); + if renders() { + tool(&mut s, "screenshot", json!({"width": 32, "height": 32})); + } tool(&mut s, "execute", json!({"command": "doc.recovery_list", "params": {"dir": recovery.to_string_lossy()}})); assert!(proj.join("part.step").exists() && proj.join("copy.solidcraft").exists()); // Hidden files in it, and folders beside it, stay out of reach. @@ -563,7 +588,9 @@ fn tool_calls_stay_inside_the_agents_folders() { tool(&mut s, "save", json!({"path": at(&root, "part.solidcraft")})); tool(&mut s, "export", json!({"path": at(&root, "part.step")})); tool(&mut s, "open", json!({"path": at(&root, "part.solidcraft")})); - tool(&mut s, "screenshot", json!({"path": at(&root, "shot.png"), "width": 64, "height": 64})); + if renders() { + tool(&mut s, "screenshot", json!({"path": at(&root, "shot.png"), "width": 64, "height": 64})); + } let escapes = vec![at(&out, "x.step"), at(&root, "../out/x.step")]; #[cfg(unix)] let mut escapes = escapes; @@ -644,7 +671,9 @@ fn short_names_child() { assert!(windows_refusal(&temp.to_string_lossy()).is_some_and(|why| why.contains("8.3")), "{}", temp.display()); let mut s = Server::new(Box::new(Headless::default())).with_path_scope(Some(crate::agent_scope([]))); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 10, "width": 10, "height": 10}})); - tool(&mut s, "screenshot", json!({"width": 32, "height": 32})); + if renders() { + tool(&mut s, "screenshot", json!({"width": 32, "height": 32})); + } tool(&mut s, "execute", json!({"command": "doc.recovery_list", "params": {}})); // An agent's own short spelling of the scratch folder stays refused. let scratch = crate::scratch_dir().unwrap(); @@ -687,6 +716,9 @@ fn a_bridged_request_carries_the_agents_folders() { /// standard view. #[test] fn screenshot_renders_a_saved_named_view() { + if !renders() { + return; + } let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 20, "height": 10}})); let camera = json!({"target": [20, 10, 5], "yaw": 0.5, "pitch": -0.6, "distance": 150}); @@ -710,6 +742,9 @@ fn screenshot_renders_a_saved_named_view() { /// `browser.visibility` stays out whatever `axes` says. #[test] fn screenshot_can_leave_the_grid_and_the_axes_out() { + if !renders() { + return; + } let mut s = server(); tool(&mut s, "execute", json!({"command": "solid.box", "params": {"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]}})); let shot = |s: &mut Server, extra: Value| { diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 6330899d..561ecc99 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -658,7 +658,14 @@ fn capture(b: &mut dyn Backend, a: &Map) -> Result<(Vec, Valu _ => (info.get("width").cloned().unwrap_or(Value::Null), info.get("height").cloned().unwrap_or(Value::Null)), }; let bytes = png.len(); - Ok((png, json!({"source": source, "path": keep, "width": w, "height": h, "bytes": bytes}))) + let mut reply = json!({"source": source, "path": keep, "width": w, "height": h, "bytes": bytes}); + // What drew a model render (gpu, gpu-software or cpu), and the warning a CPU render carries. + for k in ["renderer", "warning"] { + if let Some(v) = info.get(k).filter(|v| !v.is_null()) { + reply[k] = v.clone(); + } + } + Ok((png, reply)) } /// Standard base64 with padding (RFC 4648). diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 8e02c73f..b988a4ad 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "solidcraft-render" -description = "SolidCraft view math (orbit camera, projection, picking rays) and a CPU rasterizer for headless snapshots" +description = "SolidCraft view math (orbit camera, projection, picking rays), the description of headless pictures, and product render jobs" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/render/src/analysis.rs b/crates/render/src/analysis.rs new file mode 100644 index 00000000..942a16f1 --- /dev/null +++ b/crates/render/src/analysis.rs @@ -0,0 +1,54 @@ +//! Surface analysis as a view draws it: the shading mode and the colours it uses. + +use solidcraft_geom::Vec3; + +/// Surface analysis colours, straight sRGB 0..1 as the shader takes them. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct AnalysisColors { + /// Draft Analysis: at least the draft angle, between, at most minus the angle. + pub draft: [[f32; 3]; 3], + /// Accessibility Analysis: reachable, blocked. + pub access: [[f32; 3]; 2], + /// Curvature Map: flat, the middle of the scale, at or below the analysis radius. + pub curvature: [[f32; 3]; 3], + /// Stripe the negative draft and blocked faces too, so colour is not the only cue. + pub hatch: bool, +} + +impl AnalysisColors { + /// The light theme's colours (no colour vision mode): what headless pictures use. + pub const DEFAULT: AnalysisColors = AnalysisColors { + draft: [[0.20, 0.75, 0.30], [0.95, 0.80, 0.15], [0.85, 0.22, 0.20]], + access: [[0.20, 0.75, 0.30], [0.85, 0.22, 0.20]], + curvature: [[0.15, 0.35, 0.95], [0.10, 0.80, 0.25], [0.90, 0.15, 0.10]], + hatch: false, + }; +} + +impl Default for AnalysisColors { + fn default() -> Self { + AnalysisColors::DEFAULT + } +} + +/// A way of shading the model's faces to judge their shape. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum AnalysisMode { + /// Reflected stripes: `stripes` across the view. + Zebra { stripes: f64 }, + /// Faces by draft angle (degrees) to `pull`. + Draft { pull: Vec3, angle: f64 }, + /// Colour by curvature, reaching the tight end at 1 / `radius`. + Curvature { radius: f64 }, + /// Mirror finish reflecting a studio. + Environment, + /// Reachable along `dir` (facing it, nothing above) or blocked. + Access { dir: Vec3 }, +} + +/// A surface analysis shading a picture: the mode and its colours. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SceneAnalysis { + pub mode: AnalysisMode, + pub colors: AnalysisColors, +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 126ecf9c..0e12a95a 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -4,6 +4,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] +mod analysis; mod backend; mod camera; mod curve_display; @@ -11,6 +12,7 @@ pub mod job; pub mod lighting; mod product; mod raster; +mod scene; pub mod studio; pub mod surface; pub mod texture_input; @@ -18,6 +20,7 @@ pub mod texture_interpolation; pub mod texture_lod; pub mod texture_sampling; +pub use analysis::{AnalysisColors, AnalysisMode, SceneAnalysis}; pub use backend::{ Availability, BackendDescriptor, BackendId, ExecutorDescriptor, ExecutorId, MAX_RENDER_ID_BYTES, RenderFeature, RenderFormat, RenderLimits, RenderMode, RenderOperation, RenderPolicy, RenderProfile, RenderRequest, ResolveError, ResolvedRequest, ScratchLayout, builtin_backend, @@ -26,4 +29,5 @@ pub use backend::{ pub use camera::{Camera, CameraAnim, Mat4, Quat, StandardView, UpAxis, ease_in_out}; pub use curve_display::{PixelCurveView, SampledPath, SamplingBudget, SamplingQuality, sample_segment, sample_segment_unclipped}; pub use product::{FaceAppearance, ProductMesh, ProductScene}; -pub use raster::{Canvas, Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png, render, render_png, render_transparent}; +pub use raster::{Canvas, render, render_png, render_transparent}; +pub use scene::{Rgb, SECTION_CAP, Scene, SceneLine, SceneMesh, png}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 9946ff72..a9393ae0 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -1,54 +1,10 @@ //! CPU rasterizer for headless snapshots: z-buffered, smooth-shaded triangles and depth-tested //! lines, rendered at 2× and downsampled for anti-aliasing. -use std::sync::Arc; - -use solidcraft_geom::{Mesh, Vec3}; +use solidcraft_geom::Vec3; use crate::Camera; - -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct Rgb(pub u8, pub u8, pub u8); - -impl Rgb { - fn f(self) -> [f64; 3] { - [self.0 as f64 / 255.0, self.1 as f64 / 255.0, self.2 as f64 / 255.0] - } -} - -pub struct SceneMesh { - pub mesh: Arc, - pub color: Rgb, - /// Faces in colours of their own: (B-rep face index, colour). - pub face_colors: Vec<(u32, Rgb)>, -} - -pub struct SceneLine { - pub points: Vec, - pub color: Rgb, - /// Width in output pixels. - pub width: f64, - /// Draw over everything (sketch geometry being edited). - pub on_top: bool, -} - -#[derive(Default)] -pub struct Scene { - pub meshes: Vec, - pub lines: Vec, - /// Background gradient top → bottom. - pub background: Option<(Rgb, Rgb)>, - /// Scene radius for clipping planes. - pub radius: f64, - /// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside - /// shows as a hatched cap. - pub clip: Option<(Vec3, f64)>, - /// The colour the section cap is tinted toward (default: [`SECTION_CAP`]). - pub cap: Option, -} - -/// The section cap's default tint, as in the viewport. -pub const SECTION_CAP: Rgb = Rgb(214, 128, 72); +use crate::scene::{Rgb, SECTION_CAP, Scene, png}; /// RGBA pixels plus depth. pub struct Canvas { @@ -220,7 +176,7 @@ pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { *s = s.max(cap_slope); } } - for l in &scene.lines { + for l in scene.grid.iter().chain(&scene.lines) { let col = l.color.f(); let half = (l.width * SS as f64 * 0.5).max(0.5); for seg in l.points.windows(2) { @@ -326,14 +282,6 @@ pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option) -> Option> { - let img = image::RgbaImage::from_raw(u32::try_from(w).ok()?, u32::try_from(h).ok()?, rgba)?; - let mut buf = std::io::Cursor::new(Vec::new()); - img.write_to(&mut buf, image::ImageFormat::Png).ok()?; - Some(buf.into_inner()) -} - /// Render on a transparent background: the scene drawn over black and over white, and the /// alpha recovered from the difference (exact for the anti-aliased edges). pub fn render_transparent(scene: &mut Scene, cam: &Camera, w: usize, h: usize) -> Canvas { @@ -359,6 +307,9 @@ pub fn render_transparent(scene: &mut Scene, cam: &Camera, w: usize, h: usize) - mod tests { use super::*; use crate::StandardView; + use crate::scene::{SceneLine, SceneMesh}; + use solidcraft_geom::Mesh; + use std::sync::Arc; /// Lines are cut by the section in 3D: the kept piece ends on the plane. #[test] @@ -391,6 +342,7 @@ mod tests { radius: 20.0, clip: None, cap: None, + ..Default::default() }; let mut cam = Camera { distance: 50.0, ..Default::default() }; cam.set_view(StandardView::Front); diff --git a/crates/render/src/scene.rs b/crates/render/src/scene.rs new file mode 100644 index 00000000..a8700ad3 --- /dev/null +++ b/crates/render/src/scene.rs @@ -0,0 +1,65 @@ +//! What a picture of the model shows, independent of how it is drawn: meshes in colours, lines, +//! the background, a section cut and a surface analysis. The GPU draws it with the viewport's +//! pipelines (`solidcraft_gpu::capture`). + +use std::sync::Arc; + +use solidcraft_geom::{Mesh, Vec3}; + +use crate::SceneAnalysis; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rgb(pub u8, pub u8, pub u8); + +impl Rgb { + /// The colour as 0..1 per channel. + pub fn f(self) -> [f64; 3] { + [self.0 as f64 / 255.0, self.1 as f64 / 255.0, self.2 as f64 / 255.0] + } +} + +pub struct SceneMesh { + pub mesh: Arc, + pub color: Rgb, + /// Faces in colours of their own: (B-rep face index, colour). + pub face_colors: Vec<(u32, Rgb)>, +} + +pub struct SceneLine { + pub points: Vec, + pub color: Rgb, + /// Width in output pixels. + pub width: f64, + /// Draw over everything (sketch geometry being edited). + pub on_top: bool, +} + +#[derive(Default)] +pub struct Scene { + pub meshes: Vec, + pub lines: Vec, + /// The ground grid: drawn under everything (the model hides it; it never hides the model). + pub grid: Vec, + /// Background gradient top → bottom. + pub background: Option<(Rgb, Rgb)>, + /// Scene radius for clipping planes. + pub radius: f64, + /// A section cut: (normal, d); what lies where normal·p > d is cut away, and the inside + /// shows as a hatched cap. + pub clip: Option<(Vec3, f64)>, + /// The colour the section cap is tinted toward (default: [`SECTION_CAP`]). + pub cap: Option, + /// A surface analysis shading the meshes (zebra, draft, curvature map, ...). + pub analysis: Option, +} + +/// The section cap's default tint, as in the viewport. +pub const SECTION_CAP: Rgb = Rgb(214, 128, 72); + +/// RGBA pixels (`w × h`) to PNG bytes. +pub fn png(w: usize, h: usize, rgba: Vec) -> Option> { + let img = image::RgbaImage::from_raw(u32::try_from(w).ok()?, u32::try_from(h).ok()?, rgba)?; + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png).ok()?; + Some(buf.into_inner()) +} diff --git a/crates/ui-egui/src/access.rs b/crates/ui-egui/src/access.rs index 0274704d..19bdc8ea 100644 --- a/crates/ui-egui/src/access.rs +++ b/crates/ui-egui/src/access.rs @@ -2,17 +2,12 @@ //! to tell reachable surface from surface under something else. use std::cell::RefCell; -use std::sync::Arc; use solidcraft_engine::SurfaceAnalysis; -use solidcraft_engine::geom::Vec3; use crate::SolveApp; use crate::gpu::AccessMap; -/// Map resolution (pixels per side). -const SIZE: u32 = 1024; - thread_local! { /// The last map, by (model revision, direction). static CACHE: RefCell> = const { RefCell::new(None) }; @@ -33,74 +28,7 @@ pub fn map(app: &SolveApp) -> Option { v.set(v.get() + 1); v.get() }); - let m = build(&meshes.iter().map(|m| m.as_ref()).collect::>(), dir, version)?; + let m = AccessMap::build(&meshes.iter().map(|m| m.as_ref()).collect::>(), dir, version)?; CACHE.with(|c| *c.borrow_mut() = Some((key, m.clone()))); Some(m) } - -/// Rasterise the triangles seen along `dir` (orthographic), keeping the highest point per pixel. -fn build(meshes: &[&solidcraft_engine::geom::Mesh], dir: Vec3, version: u64) -> Option { - let d = dir.normalized()?; - let u = d.cross(if d.x.abs() < 0.9 { Vec3::X } else { Vec3::Y }).normalized()?; - let v = d.cross(u); - let (mut lo, mut hi) = ([f64::INFINITY; 2], [f64::NEG_INFINITY; 2]); - for m in meshes { - for p in &m.positions { - let (a, b) = (p.dot(u), p.dot(v)); - lo = [lo[0].min(a), lo[1].min(b)]; - hi = [hi[0].max(a), hi[1].max(b)]; - } - } - let ext = (hi[0] - lo[0]).max(hi[1] - lo[1]); - if !(ext.is_finite() && ext > 1e-9) { - return None; - } - let n = SIZE as usize; - let scale = (SIZE - 2) as f64 / ext; - // One pixel of margin on each side. - let (ou, ov) = (-lo[0] + 1.0 / scale, -lo[1] + 1.0 / scale); - let mut depth = vec![f32::NEG_INFINITY; n * n]; - for m in meshes { - for t in &m.triangles { - let q: Vec<(f64, f64, f64)> = - t.iter().filter_map(|i| m.positions.get(*i as usize)).map(|p| ((p.dot(u) + ou) * scale, (p.dot(v) + ov) * scale, p.dot(d))).collect(); - let [a, b, c] = q[..] else { continue }; - let area = (b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0); - let (x0, x1) = (a.0.min(b.0).min(c.0).floor().max(0.0) as usize, (a.0.max(b.0).max(c.0).ceil() as usize).min(n - 1)); - let (y0, y1) = (a.1.min(b.1).min(c.1).floor().max(0.0) as usize, (a.1.max(b.1).max(c.1).ceil() as usize).min(n - 1)); - // Edge-on triangles: their vertices still count. - if area.abs() < 1e-12 { - for p in [a, b, c] { - let (x, y) = (p.0 as usize, p.1 as usize); - if let Some(z) = depth.get_mut(y * n + x) { - *z = z.max(p.2 as f32); - } - } - continue; - } - for y in y0..=y1 { - for x in x0..=x1 { - let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); - let w0 = ((b.0 - px) * (c.1 - py) - (b.1 - py) * (c.0 - px)) / area; - let w1 = ((c.0 - px) * (a.1 - py) - (c.1 - py) * (a.0 - px)) / area; - let w2 = 1.0 - w0 - w1; - if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 { - continue; - } - let z = (w0 * a.2 + w1 * b.2 + w2 * c.2) as f32; - if let Some(cell) = depth.get_mut(y * n + x) { - *cell = cell.max(z); - } - } - } - } - } - Some(AccessMap { - version, - size: SIZE, - depth: Arc::new(depth), - u: [u.x as f32, u.y as f32, u.z as f32, ou as f32], - v: [v.x as f32, v.y as f32, v.z as f32, ov as f32], - scale: scale as f32, - }) -} diff --git a/crates/ui-egui/src/agent_chat/mod.rs b/crates/ui-egui/src/agent_chat/mod.rs index af90cfbb..d7a1cefc 100644 --- a/crates/ui-egui/src/agent_chat/mod.rs +++ b/crates/ui-egui/src/agent_chat/mod.rs @@ -50,7 +50,7 @@ const MAX_PROMPT: usize = 100_000; /// up on a request after five minutes). const APPROVAL_TIMEOUT_MS: f64 = 240_000.0; /// Size of the picture of the view sent with a message. -const VIEW_IMAGE: (usize, usize) = (960, 600); +const VIEW_IMAGE: (u32, u32) = (960, 600); #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum State { @@ -521,8 +521,11 @@ pub fn send(app: &mut SolveApp, ctx: &egui::Context, text: String) { fn start_turn(app: &mut SolveApp, text: String, image: bool) { let mut parts = vec![PromptPart::Text(text.clone())]; - if image && let Some(png) = crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) { - parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }); + if image { + match crate::control::view_png(app, VIEW_IMAGE.0, VIEW_IMAGE.1) { + Ok(png) => parts.push(PromptPart::Image { mime: "image/png".into(), base64: backend::base64(&png) }), + Err(e) => log::warn!("the view's picture is left out of the prompt: {e}"), + } } let label: String = text.lines().next().unwrap_or("").chars().take(48).collect(); app.agent_chat.turn = diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index bf3ced9a..f7cb7ebe 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -43,7 +43,7 @@ use std::sync::mpsc::Sender; use serde_json::{Value, json}; use solidcraft_engine::io::vfs; -use solidcraft_engine::render::{StandardView, render_png}; +use solidcraft_engine::render::StandardView; use crate::SolveApp; @@ -172,19 +172,35 @@ pub fn request_scope(app: &SolveApp, params: &Value) -> Result Option> { - let guides = - solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() }; - let scene = solidcraft_engine::view::scene_with_palette_and_guides( +/// What a picture of the view shows: the model as the window shows it (theme and colour vision +/// mode, the section the viewport shows: an open Section Analysis dialog's preview, a sketch's +/// Slice), with the grid and the origin's axes as `guides` says. +pub(crate) fn view_scene(app: &SolveApp, guides: solidcraft_engine::view::Guides) -> solidcraft_engine::render::Scene { + solidcraft_engine::view::scene_with_palette_and_guides( &app.session, &app.cam, crate::viewport::section_plane(app), solidcraft_engine::view::world_curve_parts, &crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision), guides, - ); - render_png(&scene, &app.cam, w, h) + ) +} + +/// The grid and the origin's axes as the window shows them (the Grid and Origin switches). +fn window_guides(app: &SolveApp) -> solidcraft_engine::view::Guides { + solidcraft_engine::view::Guides { grid: app.ui.show_grid && !crate::viewport::active_view(app).hide_grid, axes: app.origin_visible() } +} + +/// A picture of the view as the window shows it, RGBA, drawn on the GPU with the viewport's +/// pipelines (the renderer the app installs). +pub(crate) fn view_rgba(app: &SolveApp, w: u32, h: u32) -> Result, String> { + solidcraft_engine::capture::rgba(&view_scene(app, window_guides(app)), &app.cam, w, h).map(|p| p.rgba) +} + +/// [`view_rgba`] as PNG. +pub fn view_png(app: &SolveApp, w: u32, h: u32) -> Result, String> { + let rgba = view_rgba(app, w, h)?; + solidcraft_engine::render::png(w as usize, h as usize, rgba).ok_or_else(|| "PNG encoding failed".to_string()) } pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> Outcome { @@ -694,31 +710,32 @@ fn dispatch(app: &mut SolveApp, ctx: &egui::Context, method: &str, p: &Value) -> _ => Outcome::Screenshot { path: s("path").map(str::to_string) }, }, "ui.render" => { - let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as usize; - let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as usize; - // In the window's theme and colour vision mode, and cut by the section the viewport shows (an open Section - // Analysis dialog's preview, a sketch's Slice), not only the committed one. - // The grid and the axes as the window shows them (the Grid and Origin switches), unless - // the request says: `grid` and `axes` leave them out of the picture or put them in. + let w = f("width").unwrap_or(1280.0).clamp(16.0, 8192.0) as u32; + let h = f("height").unwrap_or(800.0).clamp(16.0, 8192.0) as u32; + // As the window shows it (`view_scene`). The grid and the axes as the window shows + // them, unless the request says: `grid` and `axes` leave them out or put them in. + let shown = window_guides(app); let guides = solidcraft_engine::view::Guides { - grid: p.get("grid").and_then(Value::as_bool).unwrap_or(app.ui.show_grid && !crate::viewport::active_view(app).hide_grid), - axes: p.get("axes").and_then(Value::as_bool).unwrap_or_else(|| app.origin_visible()), + grid: p.get("grid").and_then(Value::as_bool).unwrap_or(shown.grid), + axes: p.get("axes").and_then(Value::as_bool).unwrap_or(shown.axes), }; - let scene = solidcraft_engine::view::scene_with_palette_and_guides( - &app.session, - &app.cam, - crate::viewport::section_plane(app), - solidcraft_engine::view::world_curve_parts, - &crate::viewport::scene_palette(app.ui.dark, app.preferences.color_vision), - guides, - ); - let Some(png) = render_png(&scene, &app.cam, w, h) else { return err("render failed") }; + // A path outside the request's folders is refused before anything is drawn. + if let Some(Err(e)) = s("path").map(vfs::check) { + return err(e); + } + let scene = view_scene(app, guides); + let png = match solidcraft_engine::capture::png(&scene, &app.cam, w, h) { + Ok(png) => png, + Err(e) => return err(format!("render failed: {e}")), + }; + // What drew it (gpu, gpu-software or cpu), and the warning a CPU render carries. + let (renderer, warning) = (png.renderer, solidcraft_engine::capture::warning(png.renderer)); match s("path") { - Some(path) => match vfs::check(path).and_then(|()| std::fs::write(path, &png)) { - Ok(()) => ok(json!({"path": path, "bytes": png.len()})), + Some(path) => match vfs::check(path).and_then(|()| std::fs::write(path, &png.bytes)) { + Ok(()) => ok(json!({"path": path, "bytes": png.bytes.len(), "renderer": renderer, "warning": warning})), Err(e) => err(e), }, - None => ok(json!({"bytes": png.len()})), + None => ok(json!({"bytes": png.bytes.len(), "renderer": renderer, "warning": warning})), } } "app.quit" => { @@ -949,6 +966,9 @@ mod tests { /// or dark. #[test] fn model_render_is_in_the_windows_theme() { + if !crate::gpu::test_picture_renderer() { + return; + } let mut s = Session::default(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); let mut app = SolveApp::new(s, Services::default()); @@ -975,6 +995,9 @@ mod tests { /// preview too, not only a committed `inspect.section`. #[test] fn model_render_shows_the_section_dialog_preview() { + if !crate::gpu::test_picture_renderer() { + return; + } let mut s = Session::default(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20})).unwrap(); let mut app = SolveApp::new(s, Services::default()); @@ -1014,6 +1037,9 @@ mod tests { /// in the request say otherwise for that render. #[test] fn model_render_follows_the_grid_and_origin_switches_unless_asked() { + if !crate::gpu::test_picture_renderer() { + return; + } let mut s = Session::default(); s.execute("solid.box", &json!({"length": 40, "width": 30, "height": 20, "corner": [10, -15, -10]})).unwrap(); let mut app = SolveApp::new(s, Services::default()); @@ -1246,7 +1272,9 @@ mod tests { }; let export = |p: String| json!({"command": "file.export", "params": {"path": p}}); assert_eq!(call("engine.execute", export(at(&root, "a.step")), true)["ok"], true); - assert_eq!(call("ui.render", json!({"path": at(&root, "a.png"), "width": 32, "height": 32}), true)["ok"], true); + if crate::gpu::test_picture_renderer() { + assert_eq!(call("ui.render", json!({"path": at(&root, "a.png"), "width": 32, "height": 32}), true)["ok"], true); + } assert_eq!(call("ui.screenshot", json!({"path": at(&root, "b.png")}), true)["ok"], true); for (method, params) in [ ("engine.execute", export(at(&out, "a.step"))), @@ -1279,7 +1307,6 @@ mod tests { s.execute("component.activate", &json!({"component": "root"})).unwrap(); let mut h = crate::scenario::Harness::new(); h.app = SolveApp::new(s, Services::default()); - h.app.viewport.no_pixels = true; h.frames(2); let before = h.app.cam; // Reading during an animated view change leaves it running (no frames run between). @@ -1341,7 +1368,6 @@ mod tests { } // Nothing shown: nothing to fit. let mut empty = crate::scenario::Harness::new(); - empty.app.viewport.no_pixels = true; let before = empty.app.cam; let r = empty.call("ui.camera", json!({"yaw": 1, "fit": true})); assert!(r["ok"] == false && empty.app.cam == before, "{r}"); @@ -1364,7 +1390,6 @@ mod tests { s.execute("joint.create", &json!({"type": "slider", "a": at("B", 55), "b": at("C", 55), "flip": true, "name": "Rail"})).unwrap(); let mut h = crate::scenario::Harness::new(); h.app = SolveApp::new(s, Services::default()); - h.app.viewport.no_pixels = true; let before = h.app.session.doc.clone(); let drive = |h: &crate::scenario::Harness| match h.app.dialog.as_ref().map(|d| &d.kind) { Some(crate::dialogs::Kind::Assembly(crate::dialogs_assembly::Asm::Drive { joint, values, .. })) => { diff --git a/crates/ui-egui/src/dialogs_assembly_tests.rs b/crates/ui-egui/src/dialogs_assembly_tests.rs index 7ee32d2f..c306440a 100644 --- a/crates/ui-egui/src/dialogs_assembly_tests.rs +++ b/crates/ui-egui/src/dialogs_assembly_tests.rs @@ -330,7 +330,6 @@ fn drive_value_takes_the_keyboard() { fn drive_slider_previews_intermediate_positions_before_release() { let mut h = crate::scenario::Harness::new(); h.app = two_boxes(); - h.app.viewport.no_pixels = true; let (a, b) = (occ(&h.app, "A"), occ(&h.app, "B")); h.app .run( diff --git a/crates/ui-egui/src/dialogs_sheet_tests.rs b/crates/ui-egui/src/dialogs_sheet_tests.rs index 8a9f303a..1265e4ab 100644 --- a/crates/ui-egui/src/dialogs_sheet_tests.rs +++ b/crates/ui-egui/src/dialogs_sheet_tests.rs @@ -554,7 +554,6 @@ fn flat_app(preset: &str) -> SolveApp { app.start("sheet.flat_pattern"); let Some(Kind::Sheet(Sm::FlatPattern { data, .. })) = app.dialog.as_mut().map(|d| &mut d.kind) else { panic!("flat dialog") }; *data = Some(Ok(flat)); - app.viewport.no_pixels = true; app.preferences.nav = preset.into(); app.preferences.zoom_to_cursor = true; app diff --git a/crates/ui-egui/src/file_load.rs b/crates/ui-egui/src/file_load.rs index 49462e5e..bfe3b711 100644 --- a/crates/ui-egui/src/file_load.rs +++ b/crates/ui-egui/src/file_load.rs @@ -554,8 +554,7 @@ fn partial_picture(world: &solidcraft_engine::doc::ModelState, stop: &dyn Fn() - if stop() || !preview_work_fits(&scene, &camera) { return None; } - let canvas = solidcraft_engine::render::render(&scene, &camera, 320, 200); - Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &canvas.rgba)) + preview_image(&scene, &camera) } #[cfg(not(target_arch = "wasm32"))] @@ -601,12 +600,24 @@ fn saved_picture(preview: solidcraft_engine::io::DesignPreview) -> Option Option { + match solidcraft_engine::capture::rgba(scene, camera, 320, 200) { + Ok(picture) => Some(egui::ColorImage::from_rgba_unmultiplied([320, 200], &picture.rgba)), + Err(e) => { + log::warn!("no preview picture: {e}"); + None + } + } +} + +/// Bound drawing work as well as file size: repeated full-screen triangles (a hostile file) must +/// not monopolize the file worker or a software GPU. Counts each triangle's pixel box at twice the +/// picture's size. #[cfg(not(target_arch = "wasm32"))] fn preview_work_fits(scene: &solidcraft_engine::render::Scene, camera: &solidcraft_engine::render::Camera) -> bool { let mut pixels = 0.0; @@ -950,6 +961,9 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn provisional_picture_renders_without_adopting_cached_meshes() { + if !crate::gpu::test_picture_renderer() { + return; + } let preview = solidcraft_engine::io::DesignPreview { meshes: vec![solidcraft_engine::doc::MeshData { name: "Saved triangle".into(), @@ -965,7 +979,7 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] - fn repeated_large_preview_triangles_are_a_cache_miss_before_rasterization() { + fn repeated_large_preview_triangles_are_a_cache_miss_before_drawing() { let preview = solidcraft_engine::io::DesignPreview { meshes: vec![solidcraft_engine::doc::MeshData { name: "Hostile overdraw".into(), diff --git a/crates/ui-egui/src/file_load_ui_tests.rs b/crates/ui-egui/src/file_load_ui_tests.rs index a899f0a4..1b791dd4 100644 --- a/crates/ui-egui/src/file_load_ui_tests.rs +++ b/crates/ui-egui/src/file_load_ui_tests.rs @@ -18,7 +18,6 @@ impl Win { fn new() -> Self { let mut app = SolveApp::new(Session::default(), Default::default()); app.custom_titlebar = true; - app.viewport.no_pixels = true; let mut w = Win { ctx: egui::Context::default(), app, time: 0.0 }; for _ in 0..3 { w.frame(vec![]); diff --git a/crates/ui-egui/src/gpu.rs b/crates/ui-egui/src/gpu.rs index 12ec2b88..83e2e3f8 100644 --- a/crates/ui-egui/src/gpu.rs +++ b/crates/ui-egui/src/gpu.rs @@ -1,1071 +1,88 @@ -//! GPU viewport: shaded triangles and screen-space wide lines with a depth buffer, drawn in an -//! `egui_wgpu` paint callback. Geometry is uploaded when the scene changes; each frame only the -//! camera uniform changes. Where the device runs compute shaders, opaque bodies are drawn -//! straight from their display plans: the plan is evaluated on the GPU into the vertex buffer -//! the pass draws (see [`PlannedBody`]), with nothing built or read back on the CPU. +//! The GPU viewport in egui: the viewport's pipelines ([`solidcraft_gpu::view`]) drawn in an +//! `egui_wgpu` paint callback. Everything that draws lives in the GPU crate, so headless pictures +//! (`solidcraft-cli snapshot`, MCP screenshots, `ui.render`) use the same pipelines; this is the +//! glue that hands them egui's device and render pass. +#[cfg(all(test, not(target_arch = "wasm32")))] use std::sync::{Arc, Mutex}; use egui_wgpu::wgpu; -use egui_wgpu::wgpu::util::DeviceExt; +pub use solidcraft_gpu::view::{ + AccessMap, BlockBytes, Frame, GpuImage, GpuScene, GpuTarget, LINE_SIZE, PBR_SIZE, PlannedBody, Renderer, SceneBlock, SceneSlot, TRI_SIZE, + supersampled_size, +}; -/// Triangle vertex: position (3 × f32), normal (3 × f32), sRGB colour (4 × u8). -pub const TRI_SIZE: usize = 28; -#[path = "pbr_gpu.rs"] -mod pbr; -pub const PBR_SIZE: usize = pbr::VERTEX_SIZE; -/// Line instance: a, b (3 × f32 each), colour (4 × u8), width in pixels (f32). -pub const LINE_SIZE: usize = 32; -/// The shader's `U`: the view-projection matrix, 9 `vec4`s, the 8 analysis colours and their -/// flags (`uniform_bytes`). -const UNIFORM_SIZE: u64 = 352; - -/// A body drawn from its display plan, evaluated on the GPU: the plan, and a colour per face -/// (the plan's face numbering; faces past the end take the last colour). -#[derive(Clone)] -pub struct PlannedBody { - pub plan: Arc, - pub face_colors: Vec<[u8; 4]>, -} - -/// CPU-side geometry waiting to be uploaded. The model scene changes with the design; the -/// highlight scene (hover, selection, the origin widget) changes often and is small. -#[derive(Default, Clone)] -pub struct GpuScene { - /// Opaque triangles. A zero normal draws the colour flat (unlit). - pub tris: Vec, - /// Opaque product PBR vertices; linear base color and explicit optical parameters. - pub pbr: Vec, - /// Translucent triangles (alpha from the colour), drawn after the opaque ones without - /// writing depth. - pub trans: Vec, - /// See-through bodies and faces (appearance opacity below 1), closed and outward-wound: - /// their far sides are drawn first, then their near sides, so they read like glass. - pub glass: Vec, - /// Depth-tested lines. - pub lines: Vec, - /// Lines drawn only where the model hides them (hidden edges). - pub hidden: Vec, - /// Lines drawn over everything (active sketch, highlights). - pub overlay: Vec, - /// Translucent triangles pushed slightly back in depth, so they only show where nothing - /// coincides with them (removed material in a preview). - pub ghost: Vec, - /// Translucent triangles drawn through everything (a cut's tool body). - pub xray: Vec, - /// Lines drawn first, under everything (the ground grid: the model hides it, it never - /// hides the model). - pub under: Vec, - /// Origin and construction planes (translucent, like `trans`) and their axes (lines, like - /// `lines`): reference geometry, never cut away by a section (#1229). - pub guide_trans: Vec, - pub guide_lines: Vec, - /// Opaque bodies drawn from their plans on the GPU, like `tris`. - pub planned: Vec, - /// The model's bodies, a few at a time, drawn before the loose geometry above (`tris`, - /// `trans`, `lines` and `hidden` of each block, in order, then the scene's own). A block - /// that comes back unchanged keeps its buffers: a design changing one body at a time (an - /// agent adding bodies over MCP) uploads only the blocks that changed, not the whole model. - pub blocks: Vec, -} - -#[cfg(test)] -impl GpuScene { - /// The blocks' geometry moved into the scene's own lists, in drawing order (for tests that - /// count what the scene holds). - pub fn merged(mut self) -> GpuScene { - let mut out = GpuScene { glass: std::mem::take(&mut self.glass), ..Default::default() }; - for block in std::mem::take(&mut self.blocks) { - out.tris.extend_from_slice(&block.bytes.tris); - out.trans.extend_from_slice(&block.bytes.trans); - out.lines.extend_from_slice(&block.bytes.lines); - out.hidden.extend_from_slice(&block.bytes.hidden); - } - out.tris.append(&mut self.tris); - out.trans.append(&mut self.trans); - out.lines.append(&mut self.lines); - out.hidden.append(&mut self.hidden); - GpuScene { tris: out.tris, trans: out.trans, glass: out.glass, lines: out.lines, hidden: out.hidden, ..self } - } -} - -/// A run of the model's bodies in the scene: their triangles and edges, and a key that names -/// exactly this content (the viewport keeps the bytes while the key comes back). -#[derive(Clone)] -pub struct SceneBlock { - pub key: u64, - pub bytes: Arc, -} - -/// A block's geometry, laid out as in [`GpuScene`]. -#[derive(Default)] -pub struct BlockBytes { - pub tris: Vec, - pub trans: Vec, - pub glass: Vec, - pub lines: Vec, - pub hidden: Vec, -} - -impl GpuScene { - pub fn pbr_tri(&mut self, p: [f32; 3], n: [f32; 3], surface: solidcraft_engine::render::surface::OpaqueSurface) { - let base = surface.base_color().to_f32(); - let parameters = [surface.metallic() as f32, surface.roughness() as f32, surface.dielectric_ior() as f32]; - for value in p.into_iter().chain(n).chain(base).chain(parameters) { - self.pbr.extend_from_slice(&value.to_le_bytes()); - } - } - pub fn tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { - for v in p.iter().chain(&n) { - self.tris.extend_from_slice(&v.to_le_bytes()); - } - self.tris.extend_from_slice(&c); - } - pub fn trans_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { - for v in p.iter().chain(&n) { - self.trans.extend_from_slice(&v.to_le_bytes()); - } - self.trans.extend_from_slice(&c); - } - /// A line shown only where the model hides it. - pub fn hidden_line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32) { - for v in a.iter().chain(&b) { - self.hidden.extend_from_slice(&v.to_le_bytes()); - } - self.hidden.extend_from_slice(&c); - self.hidden.extend_from_slice(&width.to_le_bytes()); - } - pub fn glass_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { - for v in p.iter().chain(&n) { - self.glass.extend_from_slice(&v.to_le_bytes()); - } - self.glass.extend_from_slice(&c); - } - pub fn ghost_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { - for v in p.iter().chain(&n) { - self.ghost.extend_from_slice(&v.to_le_bytes()); - } - self.ghost.extend_from_slice(&c); - } - pub fn xray_tri(&mut self, p: [f32; 3], n: [f32; 3], c: [u8; 4]) { - for v in p.iter().chain(&n) { - self.xray.extend_from_slice(&v.to_le_bytes()); - } - self.xray.extend_from_slice(&c); - } - /// A line under everything (see [`GpuScene::under`]). - pub fn under_line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32) { - for v in a.iter().chain(&b) { - self.under.extend_from_slice(&v.to_le_bytes()); - } - self.under.extend_from_slice(&c); - self.under.extend_from_slice(&width.to_le_bytes()); - } - /// A translucent triangle of a plane a section does not cut (see [`GpuScene::guide_trans`]). - pub fn guide_tri(&mut self, p: [f32; 3], c: [u8; 4]) { - for v in p.iter().chain(&[0.0; 3]) { - self.guide_trans.extend_from_slice(&v.to_le_bytes()); - } - self.guide_trans.extend_from_slice(&c); - } - /// A depth-tested line a section does not cut (see [`GpuScene::guide_lines`]). - pub fn guide_line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32) { - for v in a.iter().chain(&b) { - self.guide_lines.extend_from_slice(&v.to_le_bytes()); - } - self.guide_lines.extend_from_slice(&c); - self.guide_lines.extend_from_slice(&width.to_le_bytes()); - } - pub fn line(&mut self, a: [f32; 3], b: [f32; 3], c: [u8; 4], width: f32, on_top: bool) { - let out = if on_top { &mut self.overlay } else { &mut self.lines }; - for v in a.iter().chain(&b) { - out.extend_from_slice(&v.to_le_bytes()); - } - out.extend_from_slice(&c); - out.extend_from_slice(&width.to_le_bytes()); - } -} - -pub type SceneSlot = Arc>>; - -/// A reference image (canvas or decal): a textured quad, depth-tested against the model. -#[derive(Clone)] -pub struct GpuImage { - pub id: u64, - /// Changes when the pixels change (the texture is re-uploaded). - pub version: u64, - pub size: [u32; 2], - /// Straight RGBA8, `size[0] * size[1] * 4` bytes. - pub rgba: Arc>, - /// Bottom-left, bottom-right, top-right, top-left of the image. - pub corners: [[f32; 3]; 4], - pub opacity: f32, -} - -/// Image vertex: position (3 × f32), uv (2 × f32), opacity (f32). -const IMG_SIZE: usize = 24; - -/// Per-frame parameters. -pub struct ViewportCallback { - /// Product preview uses its own batches, retaining the Design viewport's uploads. - pub product: bool, - pub key: u64, - pub slot: SceneSlot, - pub sketch_key: u64, - pub sketch_slot: SceneSlot, - /// The highlight scene and its key. - pub hl_key: u64, - pub hl_slot: SceneSlot, - /// The live preview scene and its key. - pub pv_key: u64, - pub pv_slot: SceneSlot, - /// Column-major view-projection. - pub view_proj: [[f32; 4]; 4], - /// Direction toward the eye (for lighting). - pub back: [f32; 3], - /// Viewport size in physical pixels. - pub size_px: [f32; 2], - /// Section cut: (normal, d); fragments with normal·p > d are hidden. A zero normal: none. - pub clip: [f32; 4], - /// Colour of the inside of cut bodies (straight sRGBA, 0..1). - pub cap: [f32; 4], - /// Canvases and decals. - pub images: Vec, - /// Surface analysis: (mode: 0 none, 1 zebra, 2 draft, 3 curvature, 4 environment, - /// 5 accessibility; parameter; scale; size) and the pull / access direction. - pub analysis: [f32; 8], - /// The analysis colours and the stripe flag ([`analysis_colors`]). - pub analysis_colors: [f32; 36], - /// Accessibility: the depth map seen from the access direction. - pub access: Option, - /// Draw silhouette outlines around the model (visual styles with edges). - pub outline: bool, - /// Eye position and 1 for a perspective camera (0: orthographic), for the section cap's depth - /// and the depth bias. - pub eye: [f32; 4], - /// Supersampling (#1288): draw the frame this many times the viewport's pixels in each - /// direction and filter it down (1: off). Under 1 the frame is drawn smaller and filtered up - /// (the product preview past its pixel budget, #1464). - pub ssaa: f32, -} - -/// Heights of the model seen from a direction (an orthographic depth map): a point is reachable -/// from that direction when nothing lies above it. -#[derive(Clone)] -pub struct AccessMap { - pub version: u64, - pub size: u32, - /// Row-major heights along the direction (−∞ where nothing is). - pub depth: Arc>, - /// Map axes: pixel = (p·u + u.w, p·v + v.w) × scale. - pub u: [f32; 4], - pub v: [f32; 4], - pub scale: f32, -} - -/// Vertices (or line instances) on the GPU, in as many buffers as the device's buffer size -/// limit needs: a large import's triangles can exceed one buffer (#883). -struct Batch { - /// Each buffer and its vertex (instance) count. - parts: Vec<(wgpu::Buffer, u32)>, - /// Bytes per buffer (all but the last are full). - chunk: usize, -} - -/// A chunk of planned bodies on the GPU: their evaluated mesh and a colour per vertex. -struct PlannedDraw { - mesh: Arc, - colors: wgpu::Buffer, -} - -/// A scene block on the GPU, with the bytes it was uploaded from. -struct BlockGpu { - bytes: Arc, - tris: Option, - trans: Option, - lines: Option, - hidden: Option, -} - -/// The blocks' buffers, then the scene's own, as one batch (the blocks' buffers are shared, -/// not copied). -fn joined<'a>(blocks: impl Iterator>, own: Option) -> Option { - let mut parts: Vec<(wgpu::Buffer, u32)> = blocks.flatten().flat_map(|b| b.parts.iter().cloned()).collect(); - let chunk = own.as_ref().map(|b| b.chunk).unwrap_or(0); - parts.extend(own.into_iter().flat_map(|b| b.parts)); - (!parts.is_empty()).then_some(Batch { parts, chunk }) -} - -/// Planned bodies end a GPU chunk one time in this many (by their plan's address), and a chunk -/// holds at most [`MAX_CHUNK_BODIES`]: a scene of a thousand bodies takes a few dozen dispatches -/// once, and a changed body re-evaluates a few dozen bodies, not all of them. -const CHUNK_SPLIT: u64 = 32; -const MAX_CHUNK_BODIES: usize = 128; - -#[derive(Default)] -struct Batches { - /// The scene blocks on the GPU, by key. - blocks: std::collections::HashMap, - key: Option, - tris: Option, - pbr: Option, - trans: Option, - glass: Option, - /// The glass triangles as built, and the view direction they were last sorted for. - glass_cpu: Vec, - glass_sorted_for: Option<[f32; 3]>, - lines: Option, - hidden: Option, - overlays: Option, - ghost: Option, - xray: Option, - under: Option, - guide_trans: Option, - guide_lines: Option, - planned: Vec, - /// Evaluated chunks by their plans' addresses, holding the plans so the addresses stay - /// theirs: a chunk whose plans are unchanged is not evaluated again when the scene is rebuilt. - resident: std::collections::HashMap, (Vec>, Arc)>, -} - -impl Batches { - /// Upload a new scene. Planned bodies not yet on the GPU are evaluated there: their - /// dispatches go into `encoder`, ahead of this frame's render pass. - fn take(&mut self, device: &wgpu::Device, key: u64, slot: &SceneSlot, tess: Option<(&solidcraft_gpu::TessGpu, &mut wgpu::CommandEncoder)>) { - if self.key == Some(key) { - return; - } - if let Some(sc) = slot.lock().ok().and_then(|mut s| s.take()) { - self.planned = match tess { - Some((tess, encoder)) => self.planned(device, tess, encoder, &sc.planned), - None => Vec::new(), - }; - let mut kept = std::mem::take(&mut self.blocks); - let mut blocks: Vec<&BlockGpu> = Vec::with_capacity(sc.blocks.len()); - for block in &sc.blocks { - let gpu = match kept.remove(&block.key).filter(|g| Arc::ptr_eq(&g.bytes, &block.bytes)) { - Some(g) => g, - None => BlockGpu { - bytes: block.bytes.clone(), - tris: upload(device, "sc_tris", &block.bytes.tris, TRI_SIZE), - trans: upload(device, "sc_trans", &block.bytes.trans, TRI_SIZE), - lines: upload(device, "sc_lines", &block.bytes.lines, LINE_SIZE), - hidden: upload(device, "sc_hidden", &block.bytes.hidden, LINE_SIZE), - }, - }; - self.blocks.insert(block.key, gpu); - } - for block in &sc.blocks { - if let Some(g) = self.blocks.get(&block.key) { - blocks.push(g); - } - } - self.tris = joined(blocks.iter().map(|b| &b.tris), upload(device, "sc_tris", &sc.tris, TRI_SIZE)); - self.pbr = upload(device, "sc_pbr", &sc.pbr, PBR_SIZE); - self.trans = joined(blocks.iter().map(|b| &b.trans), upload(device, "sc_trans", &sc.trans, TRI_SIZE)); - self.glass = upload_with(device, "sc_glass", &sc.glass, TRI_SIZE, wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST); - self.glass_cpu = sc.glass; - self.glass_sorted_for = None; - self.lines = joined(blocks.iter().map(|b| &b.lines), upload(device, "sc_lines", &sc.lines, LINE_SIZE)); - self.hidden = joined(blocks.iter().map(|b| &b.hidden), upload(device, "sc_hidden", &sc.hidden, LINE_SIZE)); - self.overlays = upload(device, "sc_overlay", &sc.overlay, LINE_SIZE); - self.ghost = upload(device, "sc_ghost", &sc.ghost, TRI_SIZE); - self.xray = upload(device, "sc_xray", &sc.xray, TRI_SIZE); - self.under = upload(device, "sc_under", &sc.under, LINE_SIZE); - self.guide_trans = upload(device, "sc_guide_trans", &sc.guide_trans, TRI_SIZE); - self.guide_lines = upload(device, "sc_guide_lines", &sc.guide_lines, LINE_SIZE); - self.key = Some(key); - } - } - - /// The planned bodies on the GPU: in chunks of at most `MAX_BATCH` vertices (one for any - /// ordinary scene), each uploaded and evaluated in one dispatch and drawn in one call. A - /// chunk whose plans are unchanged is not evaluated again; colours are rebuilt each time. - fn planned( - &mut self, - device: &wgpu::Device, - tess: &solidcraft_gpu::TessGpu, - encoder: &mut wgpu::CommandEncoder, - bodies: &[PlannedBody], - ) -> Vec { - // Chunks end where a body's own plan says (or when full), so a body whose plan changes - // (an edit, or a refinement as the view zooms in, #1320) re-evaluates only its own - // chunk, not the whole scene. - let mut chunks: Vec> = Vec::new(); - let mut filled = 0usize; - let mut open = false; - for b in bodies { - let n = b.plan.vertex_count(); - match chunks.last_mut() { - Some(c) if open && filled + n <= solidcraft_gpu::MAX_BATCH => c.push(b), - _ => { - chunks.push(vec![b]); - filled = 0; - } - } - filled += n; - let mut h = std::collections::hash_map::DefaultHasher::new(); - std::hash::Hash::hash(&(Arc::as_ptr(&b.plan) as usize), &mut h); - open = !std::hash::Hasher::finish(&h).is_multiple_of(CHUNK_SPLIT) && chunks.last().is_some_and(|c| c.len() < MAX_CHUNK_BODIES); - } - let key = |c: &[&PlannedBody]| c.iter().map(|b| Arc::as_ptr(&b.plan) as usize).collect::>(); - self.resident.retain(|k, _| chunks.iter().any(|c| key(c) == *k)); - let mut out = Vec::with_capacity(chunks.len()); - for chunk in &chunks { - let mesh = match self.resident.get(&key(chunk)) { - Some((_, m)) => m.clone(), - None => { - let plans: Vec<&solidcraft_engine::geom::tess::TessPlan> = chunk.iter().map(|b| &*b.plan).collect(); - match tess.prepare_many(&plans) { - Ok(m) => { - tess.record(encoder, &m); - let m = Arc::new(m); - self.resident.insert(key(chunk), (chunk.iter().map(|b| b.plan.clone()).collect(), m.clone())); - m - } - Err(e) => { - log::warn!("bodies are not drawn on the GPU: {e}"); - continue; - } - } - } - }; - let mut bytes = Vec::with_capacity(mesh.vertex_face.len() * 4); - for (part, b) in mesh.parts.iter().zip(chunk) { - let last = b.face_colors.last().copied().unwrap_or([200, 200, 200, 255]); - let faces = mesh.vertex_face.get(part.vertices.start as usize..part.vertices.end as usize).unwrap_or_default(); - bytes.extend(faces.iter().flat_map(|f| b.face_colors.get(*f as usize).copied().unwrap_or(last))); - } - if let Some((colors, _)) = upload(device, "sc_planned_colors", &bytes, 4).and_then(|c| c.parts.into_iter().next()) { - out.push(PlannedDraw { mesh, colors }); - } - } - out - } -} - -struct Resources { - tri: wgpu::RenderPipeline, - /// `tri` and `tri_cut` for planned bodies (positions and normals from the GPU evaluation, - /// colours per vertex, indexed). - tri_planned: wgpu::RenderPipeline, - tri_cut_planned: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>, - /// The tessellation compute pipeline, where the device runs compute shaders. - tess: Option, - /// While a section is shown (when the depth buffer has a stencil): `tri` marking where the - /// cap shows, then the cap again at the section plane's depth, so it hides the inside. - tri_cut: Option<(wgpu::RenderPipeline, wgpu::RenderPipeline)>, - /// Highlight triangles over the model (depth test ≤, no depth write). - tri_hl: wgpu::RenderPipeline, - pbr: wgpu::RenderPipeline, - trans: wgpu::RenderPipeline, - /// Glass: far sides (front faces culled), then near sides (back faces culled). - glass_far: wgpu::RenderPipeline, - glass_near: wgpu::RenderPipeline, - ghost: wgpu::RenderPipeline, - xray: wgpu::RenderPipeline, - line: wgpu::RenderPipeline, - /// Lines behind the model (depth test >). - line_hidden: wgpu::RenderPipeline, - overlay: wgpu::RenderPipeline, - /// Reference planes and axes: as `trans` and `line`, but not cut by a section. - guide_trans: wgpu::RenderPipeline, - guide_line: wgpu::RenderPipeline, - uniform: wgpu::Buffer, - bind: wgpu::BindGroup, - linear_out: bool, - model: Batches, - product: Batches, - empty: Batches, - sketch: Batches, - highlight: Batches, - preview: Batches, - image: wgpu::RenderPipeline, - image_bgl: wgpu::BindGroupLayout, - sampler: wgpu::Sampler, - /// Textures by image id: (version, bind group). - textures: std::collections::HashMap, - /// This frame's quads: (image id, vertices). - quads: Vec<(u64, Batch)>, - access_bgl: wgpu::BindGroupLayout, - /// The accessibility depth map (a 1 × 1 stand-in when there is none) and its version. - access_bind: wgpu::BindGroup, - access_version: u64, - /// The target the pipelines draw into (format, depth, samples): a supersampled frame is - /// drawn into textures of the same kind. - target: GpuTarget, - /// The device's largest 2D texture side (#869). - max_texture: u32, - /// Supersampling (#1288): the filter that draws the supersampled frame into the viewport. - ss_blit: wgpu::RenderPipeline, - ss_bgl: wgpu::BindGroupLayout, - /// The supersampled frame's textures, kept while its size stays. - ss: Option, - /// This frame was drawn supersampled (in `prepare`): `paint` only filters it down. - ss_ready: bool, -} - -/// A supersampled frame: the scene drawn at a multiple of the viewport's pixels. -struct Supersampled { - size: [u32; 2], - /// Drawn into (multisampled with the viewport's MSAA). - color: wgpu::TextureView, - /// The resolved colour (when `color` is multisampled). - resolve: Option, - depth: Option, - /// The single-sampled colour, for the filter. - bind: wgpu::BindGroup, -} - -/// Most memory (bytes) a supersampled frame's colour and depth may take. -const SS_BUDGET: u64 = 384 << 20; - -/// The size (px) to draw a `size` viewport supersampled `factor` times in each direction: -/// within the device's largest texture side (#869) and the memory budget (at `samples` per pixel, -/// four bytes of colour and four of depth each). `None` when that leaves under 1.25×: the frame -/// is drawn as usual. A `factor` under 1 draws it that much smaller, to be filtered up. -pub(crate) fn supersampled_size(size: [f32; 2], factor: f32, max_side: u32, samples: u32) -> Option<[u32; 2]> { - let [w, h] = size; - if w >= 1.0 && h >= 1.0 && factor > 0.0 && factor < 1.0 { - return Some([((w * factor).floor() as u32).clamp(1, max_side.max(1)), ((h * factor).floor() as u32).clamp(1, max_side.max(1))]); - } - if !(w >= 1.0 && h >= 1.0 && factor.is_finite() && factor > 1.0) { - return None; - } - let side = max_side as f32; - let bytes_per_px = 8.0 * samples.max(1) as f32; - let by_memory = (SS_BUDGET as f32 / (w * h * bytes_per_px)).sqrt(); - let f = factor.min(side / w).min(side / h).min(by_memory); - (f >= 1.25).then(|| [((w * f).floor() as u32).clamp(1, max_side), ((h * f).floor() as u32).clamp(1, max_side)]) -} - -/// The filter: each viewport pixel the mean of the supersampled pixels under it (four bilinear -/// taps a quarter of its footprint from its centre: exactly the 2 × 2 box at 2×). -const SS_SHADER: &str = r#" -@group(0) @binding(0) var src: texture_2d; -@group(0) @binding(1) var smp: sampler; -struct O { @builtin(position) pos: vec4, @location(0) uv: vec2 }; -@vertex -fn vs(@builtin(vertex_index) i: u32) -> O { - let x = f32((i << 1u) & 2u); - let y = f32(i & 2u); - var o: O; - o.pos = vec4(x * 2.0 - 1.0, 1.0 - y * 2.0, 0.0, 1.0); - o.uv = vec2(x, y); - return o; -} -@fragment -fn fs(i: O) -> @location(0) vec4 { - let d = vec2(dpdx(i.uv.x), dpdy(i.uv.y)) * 0.25; - let a = textureSampleLevel(src, smp, i.uv + vec2(-d.x, -d.y), 0.0); - let b = textureSampleLevel(src, smp, i.uv + vec2(d.x, -d.y), 0.0); - let c = textureSampleLevel(src, smp, i.uv + vec2(-d.x, d.y), 0.0); - let e = textureSampleLevel(src, smp, i.uv + vec2(d.x, d.y), 0.0); - return (a + b + c + e) * 0.25; -} -"#; - -fn access_texture(device: &wgpu::Device, queue: Option<&wgpu::Queue>, bgl: &wgpu::BindGroupLayout, size: u32, depth: &[f32]) -> wgpu::BindGroup { - let extent = wgpu::Extent3d { width: size.max(1), height: size.max(1), depth_or_array_layers: 1 }; - let tex = device.create_texture(&wgpu::TextureDescriptor { - label: Some("sc_access"), - size: extent, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::R32Float, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - if let Some(q) = queue - && depth.len() == (size as usize) * (size as usize) - { - let bytes: Vec = depth.iter().flat_map(|x| x.to_le_bytes()).collect(); - q.write_texture( - wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - &bytes, - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4 * size), rows_per_image: Some(size) }, - extent, - ); - } - let view = tex.create_view(&Default::default()); - device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("sc_access"), - layout: bgl, - entries: &[wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::TextureView(&view) }], - }) -} - -const SHADER: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/shaders/viewport.wgsl")); - -fn tri_layout() -> wgpu::VertexBufferLayout<'static> { - const A: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4]; - wgpu::VertexBufferLayout { array_stride: TRI_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } -} - -/// A planned body's vertices as the GPU evaluation writes them: the position, then the normal -/// at [`solidcraft_gpu::NORMAL_OFFSET`] (each padded to four floats). -fn pbr_layout() -> wgpu::VertexBufferLayout<'static> { - const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x3, 3 => Float32x3]; - wgpu::VertexBufferLayout { array_stride: PBR_SIZE as u64, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } -} - -fn planned_layout() -> wgpu::VertexBufferLayout<'static> { - const A: [wgpu::VertexAttribute; 2] = [ - wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: 0, shader_location: 0 }, - wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x3, offset: solidcraft_gpu::NORMAL_OFFSET, shader_location: 1 }, - ]; - wgpu::VertexBufferLayout { array_stride: solidcraft_gpu::VERTEX_STRIDE, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } -} - -/// A planned body's colours, one per vertex. -fn color_layout() -> wgpu::VertexBufferLayout<'static> { - const A: [wgpu::VertexAttribute; 1] = wgpu::vertex_attr_array![2 => Unorm8x4]; - wgpu::VertexBufferLayout { array_stride: 4, step_mode: wgpu::VertexStepMode::Vertex, attributes: &A } -} - -fn line_layout() -> wgpu::VertexBufferLayout<'static> { - const A: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Unorm8x4, 3 => Float32]; - wgpu::VertexBufferLayout { array_stride: LINE_SIZE as u64, step_mode: wgpu::VertexStepMode::Instance, attributes: &A } -} - -/// The target configuration: colour format, depth format and MSAA sample count of the app. -#[derive(Clone, Copy, Debug)] -pub struct GpuTarget { - pub format: wgpu::TextureFormat, - pub depth: Option, - pub samples: u32, - /// The device evaluates display plans (compute shaders): bodies may be drawn from them. - pub tess: bool, -} - -impl Resources { - fn new(device: &wgpu::Device, t: GpuTarget, tess: Option) -> Self { - let source = format!("{SHADER}\n{}", pbr::SOURCE); - let module = - device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_viewport"), source: wgpu::ShaderSource::Wgsl(source.into()) }); - let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("sc_viewport"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, - ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None }, - count: None, - }], - }); - let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("sc_viewport"), - bind_group_layouts: &[Some(&bgl)], - immediate_size: 0, - }); - let uniform = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("sc_viewport_u"), - size: UNIFORM_SIZE, - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("sc_viewport"), - layout: &bgl, - entries: &[wgpu::BindGroupEntry { binding: 0, resource: uniform.as_entire_binding() }], - }); - let depth = |compare: wgpu::CompareFunction, write: bool| -> Option { - t.depth.map(|format| wgpu::DepthStencilState { - format, - depth_write_enabled: Some(write), - depth_compare: Some(compare), - stencil: Default::default(), - bias: Default::default(), - }) - }; - let pipeline = |label: &str, - vs: &str, - fs: &str, - buf: wgpu::VertexBufferLayout<'static>, - ds: Option, - blend: Option| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(&layout), - vertex: wgpu::VertexState { module: &module, entry_point: Some(vs), buffers: &[Some(buf)], compilation_options: Default::default() }, - primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, - depth_stencil: ds, - multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, - fragment: Some(wgpu::FragmentState { - module: &module, - entry_point: Some(fs), - targets: &[Some(wgpu::ColorTargetState { format: t.format, blend, write_mask: wgpu::ColorWrites::ALL })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }) - }; - let alpha = Some(wgpu::BlendState::ALPHA_BLENDING); - let glass = |label: &str, cull: wgpu::Face| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(&layout), - vertex: wgpu::VertexState { - module: &module, - entry_point: Some("vs_tri"), - buffers: &[Some(tri_layout())], - compilation_options: Default::default(), - }, - primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: Some(cull), ..Default::default() }, - depth_stencil: depth(wgpu::CompareFunction::LessEqual, false), - multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, - fragment: Some(wgpu::FragmentState { - module: &module, - entry_point: Some("fs_tri"), - targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }) - }; - let image_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("sc_image"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let image_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("sc_image"), - bind_group_layouts: &[Some(&bgl), Some(&image_bgl)], - immediate_size: 0, - }); - const IA: [wgpu::VertexAttribute; 3] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Float32]; - let image = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("sc_image"), - layout: Some(&image_layout), - vertex: wgpu::VertexState { - module: &module, - entry_point: Some("vs_img"), - buffers: &[Some(wgpu::VertexBufferLayout { - array_stride: IMG_SIZE as u64, - step_mode: wgpu::VertexStepMode::Vertex, - attributes: &IA, - })], - compilation_options: Default::default(), - }, - primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, - depth_stencil: depth(wgpu::CompareFunction::LessEqual, false), - multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, - fragment: Some(wgpu::FragmentState { - module: &module, - entry_point: Some("fs_img"), - targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: alpha, write_mask: wgpu::ColorWrites::ALL })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }); - let access_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("sc_access"), - entries: &[wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: false }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }], - }); - let solid_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("sc_solid"), - bind_group_layouts: &[Some(&bgl), Some(&access_bgl)], - immediate_size: 0, - }); - let built = [Some(tri_layout())]; - let evaluated = [Some(planned_layout()), Some(color_layout())]; - let solid = |label: &str, fs: &str, cull: Option, ds: Option, planned: bool| { - device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some(label), - layout: Some(&solid_layout), - vertex: wgpu::VertexState { - module: &module, - entry_point: Some("vs_tri"), - buffers: if planned { &evaluated[..] } else { &built[..] }, - compilation_options: Default::default(), - }, - primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, cull_mode: cull, ..Default::default() }, - depth_stencil: ds, - multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, - fragment: Some(wgpu::FragmentState { - module: &module, - entry_point: Some(fs), - targets: &[Some(wgpu::ColorTargetState { format: t.format, blend: None, write_mask: wgpu::ColorWrites::ALL })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }) - }; - // While a section is shown: the stencil marking and the cap (see `Resources::tri_cut`). - let cut = |planned: bool| { - t.depth.filter(|f| f.has_stencil_aspect()).map(|format| { - // The visible surface marks the stencil: 1 where it is a back face (the cap). - use wgpu::StencilOperation::{Keep, Replace, Zero}; - let face = |pass_op| wgpu::StencilFaceState { compare: wgpu::CompareFunction::Always, fail_op: Keep, depth_fail_op: Keep, pass_op }; - let mark = wgpu::DepthStencilState { - format, - depth_write_enabled: Some(true), - depth_compare: Some(wgpu::CompareFunction::Less), - stencil: wgpu::StencilState { front: face(Zero), back: face(Replace), read_mask: 0xff, write_mask: 0xff }, - bias: Default::default(), - }; - // Then the cap's back faces again, only there, at the section plane's depth. - let only = wgpu::StencilFaceState { compare: wgpu::CompareFunction::Equal, ..face(Keep) }; - let cap = wgpu::DepthStencilState { - format, - depth_write_enabled: Some(true), - depth_compare: Some(wgpu::CompareFunction::Always), - stencil: wgpu::StencilState { front: only, back: only, read_mask: 0xff, write_mask: 0 }, - bias: Default::default(), - }; - ( - solid("sc_tris_mark", "fs_solid", None, Some(mark), planned), - solid("sc_tris_cap", "fs_solid_cut", Some(wgpu::Face::Front), Some(cap), planned), - ) - }) - }; - let access_bind = access_texture(device, None, &access_bgl, 1, &[]); - let sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("sc_image"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); - let ss_module = - device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("sc_ssaa"), source: wgpu::ShaderSource::Wgsl(SS_SHADER.into()) }); - let ss_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("sc_ssaa"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Texture { - sample_type: wgpu::TextureSampleType::Float { filterable: true }, - view_dimension: wgpu::TextureViewDimension::D2, - multisampled: false, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: wgpu::ShaderStages::FRAGMENT, - ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let ss_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { - label: Some("sc_ssaa"), - bind_group_layouts: &[Some(&ss_bgl)], - immediate_size: 0, - }); - // The frame is premultiplied (drawn over transparent black), so it goes over the - // viewport's background premultiplied; the viewport's depth is left as it is. - let ss_blit = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { - label: Some("sc_ssaa"), - layout: Some(&ss_layout), - vertex: wgpu::VertexState { module: &ss_module, entry_point: Some("vs"), buffers: &[], compilation_options: Default::default() }, - primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() }, - depth_stencil: depth(wgpu::CompareFunction::Always, false), - multisample: wgpu::MultisampleState { count: t.samples.max(1), mask: !0, alpha_to_coverage_enabled: false }, - fragment: Some(wgpu::FragmentState { - module: &ss_module, - entry_point: Some("fs"), - targets: &[Some(wgpu::ColorTargetState { - format: t.format, - blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), - write_mask: wgpu::ColorWrites::ALL, - })], - compilation_options: Default::default(), - }), - multiview_mask: None, - cache: None, - }); - Resources { - target: t, - max_texture: device.limits().max_texture_dimension_2d, - ss_blit, - ss_bgl, - ss: None, - ss_ready: false, - image, - image_bgl, - sampler, - textures: Default::default(), - quads: Vec::new(), - tri: solid("sc_tris", "fs_solid", None, depth(wgpu::CompareFunction::Less, true), false), - tri_cut: cut(false), - tri_planned: solid("sc_tris_planned", "fs_solid", None, depth(wgpu::CompareFunction::Less, true), true), - tri_cut_planned: cut(true), - tess, - access_bgl, - access_bind, - access_version: 0, - tri_hl: pipeline("sc_tris_hl", "vs_tri", "fs_tri", tri_layout(), highlight_depth(t.depth), alpha), - pbr: pipeline("sc_pbr", "vs_pbr", "fs_pbr", pbr_layout(), depth(wgpu::CompareFunction::Less, true), None), - trans: pipeline("sc_trans", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), - glass_far: glass("sc_glass_far", wgpu::Face::Front), - glass_near: glass("sc_glass_near", wgpu::Face::Back), - ghost: pipeline("sc_ghost", "vs_ghost", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), - xray: pipeline("sc_xray", "vs_tri", "fs_tri", tri_layout(), depth(wgpu::CompareFunction::Always, false), alpha), - line: pipeline("sc_lines", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), - line_hidden: pipeline("sc_lines_hidden", "vs_line", "fs_line", line_layout(), depth(wgpu::CompareFunction::Greater, false), alpha), - overlay: pipeline("sc_overlay", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::Always, false), alpha), - guide_trans: pipeline("sc_guide_trans", "vs_tri", "fs_tri_all", tri_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), - guide_line: pipeline("sc_guide_lines", "vs_line", "fs_line_all", line_layout(), depth(wgpu::CompareFunction::LessEqual, false), alpha), - uniform, - bind, - linear_out: t.format.is_srgb(), - model: Batches::default(), - product: Batches::default(), - empty: Batches::default(), - sketch: Batches::default(), - highlight: Batches::default(), - preview: Batches::default(), - } - } -} - -/// Depth state of faces lit over the model (hover, selection): tested, not written, and pulled a -/// little toward the eye. They lie on the model's own faces, but the GPU evaluates a body's plan -/// in f32 while the highlight's copy of the same triangles is evaluated on the CPU, so the two -/// depths differ by rounding and an unbiased equal test lights the face in speckles and wedges. -/// The bias is a few depth steps plus the face's own slope: far less than any real gap. -fn highlight_depth(format: Option) -> Option { - format.map(|format| wgpu::DepthStencilState { - format, - depth_write_enabled: Some(false), - depth_compare: Some(wgpu::CompareFunction::LessEqual), - stencil: Default::default(), - bias: HIGHLIGHT_BIAS, - }) -} - -/// See [`highlight_depth`]. Negative: toward the eye (depth grows away from it). -const HIGHLIGHT_BIAS: wgpu::DepthBiasState = wgpu::DepthBiasState { constant: -8, slope_scale: -1.0, clamp: 0.0 }; - -fn upload(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize) -> Option { - upload_with(device, label, bytes, stride, wgpu::BufferUsages::VERTEX) -} - -fn upload_with(device: &wgpu::Device, label: &str, bytes: &[u8], stride: usize, usage: wgpu::BufferUsages) -> Option { - if bytes.len() < stride || stride == 0 { - return None; - } - let chunk = buffer_chunk(stride, device.limits().max_buffer_size); - let parts = bytes - .chunks(chunk) - .filter(|part| part.len() >= stride) - .map(|part| { - let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: part, usage }); - (buffer, u32::try_from(part.len() / stride).unwrap_or(0)) - }) - .collect(); - Some(Batch { parts, chunk }) -} - -/// Bytes per GPU buffer for vertices of `stride` bytes: within the device's limit and a -/// whole number of triangles and line pairs, so no primitive straddles two buffers. -fn buffer_chunk(stride: usize, max_buffer_size: u64) -> usize { - let unit = stride.max(1) * 6; - let max = usize::try_from(max_buffer_size).unwrap_or(usize::MAX).min(1 << 30); - (max / unit).max(1) * unit -} - -/// Triangles (3 vertices of `TRI_SIZE` bytes each, position first) ordered far to near along -/// the view direction `back` (toward the eye), so blended glass draws back to front. -pub fn sort_back_to_front(bytes: &[u8], back: [f32; 3]) -> Vec { - let tri = TRI_SIZE * 3; - let depth = |t: &[u8]| -> f32 { - let f = |o: usize| t.get(o..o + 4).and_then(|b| b.try_into().ok()).map_or(0.0, f32::from_le_bytes); - (0..3).map(|v| (0..3).map(|k| f(v * TRI_SIZE + k * 4) * back[k]).sum::()).sum::() - }; - let mut tris: Vec<(f32, &[u8])> = bytes.chunks_exact(tri).map(|t| (depth(t), t)).collect(); - tris.sort_by(|a, b| a.0.total_cmp(&b.0)); - tris.into_iter().flat_map(|(_, t)| t.iter().copied()).collect() -} - -impl Batches { - /// Re-sort the glass for a new view direction (when it turned more than about 2 degrees). - fn sort_glass(&mut self, queue: &wgpu::Queue, back: [f32; 3]) { - let (Some(g), false) = (&self.glass, self.glass_cpu.is_empty()) else { return }; - let turned = self.glass_sorted_for.is_none_or(|s| s[0] * back[0] + s[1] * back[1] + s[2] * back[2] < 0.9994); - if turned { - let sorted = sort_back_to_front(&self.glass_cpu, back); - for ((buffer, _), bytes) in g.parts.iter().zip(sorted.chunks(g.chunk.max(1))) { - queue.write_buffer(buffer, 0, bytes); - } - self.glass_sorted_for = Some(back); - } - } -} - -/// The tessellation pipeline (`shader`: its source), where display plans are evaluated: on a -/// device that runs compute shaders with the storage buffers the evaluation binds (not WebGL2) -/// and builds the pipeline. Elsewhere `None`, and bodies are built on the CPU: a device whose -/// shader compiler rejects the shader (D3D12's FXC once did) only loses GPU evaluation. -fn tessellation(adapter: &wgpu::Adapter, device: &wgpu::Device, queue: &wgpu::Queue, shader: &str) -> Option { - let compute = adapter.get_downlevel_capabilities().flags.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS) - && device.limits().max_storage_buffers_per_shader_stage >= 4; - if !compute { - return None; - } - let info = adapter.get_info(); - if hangs_on_gpu_tessellation(info.backend, &info.name) || std::env::var_os("SOLIDCRAFT_CPU_TESS").is_some() { - log::warn!("bodies are built on the CPU on {} ({:?})", info.name, info.backend); - return None; - } - match solidcraft_gpu::TessGpu::with_shader(info.name.clone(), device.clone(), queue.clone(), shader) { - Ok(t) => Some(t), - Err(e) => { - log::warn!("bodies are built on the CPU on {} ({:?}): {e}", info.name, info.backend); - None - } - } -} - -/// A device whose driver hangs on the evaluation shader, so bodies are built on the CPU there: -/// the AMD FirePro D300/D500/D700 (Mac Pro 2013, Tahiti) under Metal, where macOS resets the GPU -/// and the whole desktop freezes. `SOLIDCRAFT_CPU_TESS=1` forces CPU evaluation on any device. -fn hangs_on_gpu_tessellation(backend: wgpu::Backend, name: &str) -> bool { - backend == wgpu::Backend::Metal && name.contains("FirePro") -} +/// A frame of the viewport as an egui paint callback. +pub struct ViewportCallback(pub Frame); /// Create the pipelines. Call once with the app's render state and its depth/stencil/MSAA /// settings. Without a stencil the section cap does not hide the inside of cut bodies. Without /// `gpu_meshing` (safe mode) bodies are meshed on the CPU. pub fn install(rs: &egui_wgpu::RenderState, depth_bits: u8, stencil_bits: u8, samples: u32, gpu_meshing: bool) -> GpuTarget { - let tess = if gpu_meshing { tessellation(&rs.adapter, &rs.device, &rs.queue, solidcraft_gpu::SHADER) } else { None }; + let tess = if gpu_meshing { solidcraft_gpu::view::tessellation(&rs.adapter, &rs.device, &rs.queue, solidcraft_gpu::SHADER) } else { None }; let t = GpuTarget { format: rs.target_format, depth: egui_wgpu::depth_format_from_bits(depth_bits, stencil_bits), samples, tess: tess.is_some() }; - let res = Resources::new(&rs.device, t, tess); + let res = Renderer::new(&rs.device, t, tess); rs.renderer.write().callback_resources.insert(res); t } +/// Draw this process's headless pictures (`ui.render`, the agent's view image, thumbnails of +/// designs and saves) on the app's own device, with the viewport's pipelines (native only: a +/// browser cannot wait for the GPU to hand a picture back); where the device cannot, the CPU +/// rasterizer draws them. +#[cfg(not(target_arch = "wasm32"))] +pub fn install_picture_renderer(rs: &egui_wgpu::RenderState) { + match solidcraft_gpu::capture::Capturer::new(&rs.adapter, rs.device.clone(), rs.queue.clone()) { + Ok(capturer) => { + let capturer = std::sync::Arc::new(capturer); + solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| { + let rgba = capturer.rgba(scene, cam, w, h).map_err(|e| e.to_string())?; + Ok(solidcraft_engine::capture::Picture { rgba, renderer: capturer.renderer() }) + })); + } + Err(e) => { + log::warn!("pictures of the model are drawn by the CPU rasterizer: the GPU cannot draw them: {e}"); + install_cpu_picture_renderer(); + } + } +} + +/// The last tier: draw this process's headless pictures with the CPU rasterizer, and every +/// picture says so. The app uses it when its device cannot draw pictures; the web app always +/// does, since a browser cannot wait for the GPU to hand a picture back. +pub fn install_cpu_picture_renderer() { + solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| { + let canvas = solidcraft_engine::render::render(scene, cam, w as usize, h as usize); + Ok(solidcraft_engine::capture::Picture { rgba: canvas.rgba, renderer: "cpu" }) + })); +} + +#[cfg(test)] +mod test_support { + /// For tests that look at pictures: install the headless GPU renderer (the CLI's). `false`, with + /// the reason printed, on a machine without a GPU adapter, and the test skips. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn test_picture_renderer() -> bool { + match solidcraft_gpu::capture::shared() { + Ok(c) => { + solidcraft_engine::capture::install(std::sync::Arc::new(move |scene, cam, w, h| { + c.rgba(scene, cam, w, h) + .map(|rgba| solidcraft_engine::capture::Picture { rgba, renderer: c.renderer() }) + .map_err(|e| e.to_string()) + })); + true + } + Err(e @ solidcraft_gpu::GpuError::NoAdapter(_)) if std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none() => { + eprintln!("skipping: {e}"); + false + } + Err(e) => panic!("{e}"), + } + } +} +#[cfg(all(test, not(target_arch = "wasm32")))] +pub(crate) use test_support::test_picture_renderer; + impl egui_wgpu::CallbackTrait for ViewportCallback { fn prepare( &self, @@ -1075,260 +92,22 @@ impl egui_wgpu::CallbackTrait for ViewportCallback { encoder: &mut wgpu::CommandEncoder, resources: &mut egui_wgpu::CallbackResources, ) -> Vec { - let Some(res) = resources.get_mut::() else { return Vec::new() }; - if self.product { - res.product.take(device, self.key, &self.slot, None); - res.product.sort_glass(queue, self.back); - queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out)); - return Vec::new(); + if let Some(res) = resources.get_mut::() { + res.prepare(device, queue, encoder, &self.0); } - res.model.take(device, self.key, &self.slot, res.tess.as_ref().map(|t| (t, &mut *encoder))); - res.sketch.take(device, self.sketch_key, &self.sketch_slot, None); - res.highlight.take(device, self.hl_key, &self.hl_slot, None); - res.preview.take(device, self.pv_key, &self.pv_slot, None); - res.model.sort_glass(queue, self.back); - queue.write_buffer(&res.uniform, 0, &uniform_bytes(self, self.size_px[0], self.size_px[1], res.linear_out)); - if let Some(a) = &self.access - && a.version != res.access_version - { - res.access_bind = access_texture(device, Some(queue), &res.access_bgl, a.size, &a.depth); - res.access_version = a.version; - } - // Images: upload new or changed textures, drop removed ones, rebuild the quads. - res.textures.retain(|id, _| self.images.iter().any(|i| i.id == *id)); - res.quads.clear(); - for img in &self.images { - let [w, h] = img.size; - if w == 0 || h == 0 || img.rgba.len() != (w as usize) * (h as usize) * 4 { - continue; - } - if res.textures.get(&img.id).is_none_or(|t| t.0 != img.version) { - let size = wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 }; - let tex = device.create_texture(&wgpu::TextureDescriptor { - label: Some("sc_image"), - size, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D2, - format: wgpu::TextureFormat::Rgba8Unorm, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - queue.write_texture( - wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, - &img.rgba, - wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(4 * w), rows_per_image: Some(h) }, - size, - ); - let view = tex.create_view(&Default::default()); - let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("sc_image"), - layout: &res.image_bgl, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&res.sampler) }, - ], - }); - res.textures.insert(img.id, (img.version, bind)); - } - let c = img.corners; - let uv = [[0.0_f32, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]; - let mut bytes = Vec::with_capacity(6 * IMG_SIZE); - for k in [0, 1, 2, 0, 2, 3] { - for v in c[k].iter().chain(&uv[k]).chain(std::iter::once(&img.opacity)) { - bytes.extend_from_slice(&v.to_le_bytes()); - } - } - if let Some(b) = upload(device, "sc_image_quad", &bytes, IMG_SIZE) { - res.quads.push((img.id, b)); - } - } - res.ss_ready = self.ssaa != 1.0 && res.supersample(device, encoder, self); Vec::new() } fn paint(&self, info: egui::PaintCallbackInfo, pass: &mut wgpu::RenderPass<'static>, resources: &egui_wgpu::CallbackResources) { - let Some(res) = resources.get::() else { return }; + let Some(res) = resources.get::() else { return }; let vp = info.viewport_in_pixels(); - if vp.width_px <= 0 || vp.height_px <= 0 { - return; - } - pass.set_viewport(vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32, 0.0, 1.0); - // Drawn supersampled in `prepare`: filter it down into the viewport. - if res.ss_ready - && let Some(ss) = &res.ss - { - pass.set_pipeline(&res.ss_blit); - pass.set_bind_group(0, &ss.bind, &[]); - pass.draw(0..3, 0..1); - return; - } - res.draw(self, pass); + res.paint(&self.0, pass, [vp.left_px as f32, vp.top_px as f32, vp.width_px as f32, vp.height_px as f32]); } } -impl Resources { - /// Draw the frame's scenes into `pass` (its viewport already set). - fn draw(&self, cb: &ViewportCallback, pass: &mut wgpu::RenderPass<'static>) { - let res = self; - pass.set_bind_group(0, &res.bind, &[]); - let tris = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline, b: &Option| { - if let Some(b) = b { - pass.set_pipeline(p); - for (buffer, count) in &b.parts { - pass.set_vertex_buffer(0, buffer.slice(..)); - pass.draw(0..*count, 0..1); - } - } - }; - let lines = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline, b: &Option| { - if let Some(b) = b { - pass.set_pipeline(p); - for (buffer, count) in &b.parts { - pass.set_vertex_buffer(0, buffer.slice(..)); - pass.draw(0..6, 0..*count); - } - } - }; - let (m, sk, h, pv) = - if cb.product { (&res.product, &res.empty, &res.empty, &res.empty) } else { (&res.model, &res.sketch, &res.highlight, &res.preview) }; - lines(pass, &res.overlay, &m.under); - lines(pass, &res.overlay, &sk.under); - pass.set_bind_group(1, &res.access_bind, &[]); - let planned = |pass: &mut wgpu::RenderPass<'static>, p: &wgpu::RenderPipeline| { - for d in &m.planned { - pass.set_pipeline(p); - pass.set_vertex_buffer(0, d.mesh.vertices.slice(..)); - pass.set_vertex_buffer(1, d.colors.slice(..)); - pass.set_index_buffer(d.mesh.indices.slice(..), wgpu::IndexFormat::Uint32); - pass.draw_indexed(0..d.mesh.index_count, 0, 0..1); - } - }; - let sectioned = cb.clip[..3].iter().map(|c| c * c).sum::() > 0.25; - match (res.tri_cut.as_ref().filter(|_| sectioned), res.tri_cut_planned.as_ref()) { - (Some((mark, cap)), Some((mark_planned, cap_planned))) => { - pass.set_stencil_reference(1); - tris(pass, mark, &m.tris); - tris(pass, mark, &pv.tris); - planned(pass, mark_planned); - tris(pass, cap, &m.tris); - tris(pass, cap, &pv.tris); - planned(pass, cap_planned); - } - _ => { - tris(pass, &res.tri, &m.tris); - tris(pass, &res.tri, &pv.tris); - planned(pass, &res.tri_planned); - } - } - tris(pass, &res.pbr, &m.pbr); - tris(pass, &res.tri_hl, &h.tris); - lines(pass, &res.line, &m.lines); - lines(pass, &res.line, &sk.lines); - lines(pass, &res.line_hidden, &m.hidden); - lines(pass, &res.line_hidden, &h.hidden); - lines(pass, &res.line, &pv.lines); - lines(pass, &res.line, &h.lines); - lines(pass, &res.guide_line, &h.guide_lines); - for (id, q) in res.quads.iter().filter(|_| !cb.product) { - if let Some((_, bind)) = res.textures.get(id) { - pass.set_pipeline(&res.image); - pass.set_bind_group(1, bind, &[]); - for (buffer, count) in &q.parts { - pass.set_vertex_buffer(0, buffer.slice(..)); - pass.draw(0..*count, 0..1); - } - } - } - tris(pass, &res.ghost, &pv.ghost); - tris(pass, &res.glass_far, &m.glass); - tris(pass, &res.glass_near, &m.glass); - tris(pass, &res.trans, &m.trans); - tris(pass, &res.trans, &sk.trans); - tris(pass, &res.trans, &h.trans); - tris(pass, &res.guide_trans, &h.guide_trans); - tris(pass, &res.xray, &pv.xray); - lines(pass, &res.overlay, &m.overlays); - lines(pass, &res.overlay, &sk.overlays); - lines(pass, &res.overlay, &h.overlays); - } -} - -impl Resources { - /// Draw the frame supersampled (`cb.ssaa` times the viewport's pixels each way, within the - /// device's texture limit and the memory budget) into textures of the viewport's kind. - /// `false` when it cannot be (too small a gain, a zero-sized viewport): drawn as usual. - fn supersample(&mut self, device: &wgpu::Device, encoder: &mut wgpu::CommandEncoder, cb: &ViewportCallback) -> bool { - let Some(size) = supersampled_size(cb.size_px, cb.ssaa, self.max_texture, self.target.samples) else { - self.ss = None; - return false; - }; - if self.ss.as_ref().is_none_or(|ss| ss.size != size) { - self.ss = Some(self.supersampled(device, size)); - } - let Some(ss) = &self.ss else { return false }; - let depth_format = self.target.depth; - let mut pass = encoder - .begin_render_pass(&wgpu::RenderPassDescriptor { - label: Some("sc_ssaa"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { - view: &ss.color, - depth_slice: None, - resolve_target: ss.resolve.as_ref(), - ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store }, - })], - depth_stencil_attachment: ss.depth.as_ref().map(|view| wgpu::RenderPassDepthStencilAttachment { - view, - depth_ops: Some(wgpu::Operations { load: wgpu::LoadOp::Clear(1.0), store: wgpu::StoreOp::Discard }), - stencil_ops: depth_format - .filter(|f| f.has_stencil_aspect()) - .map(|_| wgpu::Operations { load: wgpu::LoadOp::Clear(0), store: wgpu::StoreOp::Discard }), - }), - timestamp_writes: None, - occlusion_query_set: None, - multiview_mask: None, - }) - .forget_lifetime(); - pass.set_viewport(0.0, 0.0, size[0] as f32, size[1] as f32, 0.0, 1.0); - self.draw(cb, &mut pass); - true - } - - /// Textures for a supersampled frame of `size` pixels. - fn supersampled(&self, device: &wgpu::Device, size: [u32; 2]) -> Supersampled { - let t = self.target; - let texture = |label: &str, format: wgpu::TextureFormat, samples: u32, usage: wgpu::TextureUsages| { - device - .create_texture(&wgpu::TextureDescriptor { - label: Some(label), - size: wgpu::Extent3d { width: size[0], height: size[1], depth_or_array_layers: 1 }, - mip_level_count: 1, - sample_count: samples, - dimension: wgpu::TextureDimension::D2, - format, - usage, - view_formats: &[], - }) - .create_view(&Default::default()) - }; - let samples = t.samples.max(1); - let sampled = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING; - let (color, resolve) = if samples > 1 { - (texture("sc_ssaa_ms", t.format, samples, wgpu::TextureUsages::RENDER_ATTACHMENT), Some(texture("sc_ssaa", t.format, 1, sampled))) - } else { - (texture("sc_ssaa", t.format, 1, sampled), None) - }; - let depth = t.depth.map(|f| texture("sc_ssaa_depth", f, samples, wgpu::TextureUsages::RENDER_ATTACHMENT)); - let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("sc_ssaa"), - layout: &self.ss_bgl, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(resolve.as_ref().unwrap_or(&color)) }, - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&self.sampler) }, - ], - }); - Supersampled { size, color, resolve, depth, bind } - } +/// The shader's analysis colours and flags for the theme's colours. +pub fn analysis_colors(c: &crate::theme::AnalysisColors) -> [f32; 36] { + solidcraft_gpu::view::analysis_colors(c) } /// The backends the offscreen tests start, chosen once per test process as the app chooses them @@ -1375,7 +154,7 @@ fn offscreen_device() -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> { #[cfg(all(test, not(target_arch = "wasm32")))] pub(crate) fn offscreen_tessellates(shader: &str) -> Option { let (adapter, device, queue) = offscreen_device()?; - Some(tessellation(&adapter, &device, &queue, shader).is_some()) + Some(solidcraft_gpu::view::tessellation(&adapter, &device, &queue, shader).is_some()) } /// Render `scene` offscreen on a device of its own, through the viewport's real resources and @@ -1475,12 +254,12 @@ fn offscreen( eprintln!("offscreen adapter: {:?}", adapter.get_info().backend); let format = if srgb { wgpu::TextureFormat::Rgba8UnormSrgb } else { wgpu::TextureFormat::Rgba8Unorm }; let depth = wgpu::TextureFormat::Depth24PlusStencil8; - let compute = tess.and_then(|shader| tessellation(&adapter, &device, &queue, shader)); + let compute = tess.and_then(|shader| solidcraft_gpu::view::tessellation(&adapter, &device, &queue, shader)); let target = GpuTarget { format, depth: Some(depth), samples: 1, tess: compute.is_some() }; let mut resources = egui_wgpu::CallbackResources::default(); - resources.insert(Resources::new(&device, target, compute)); + resources.insert(Renderer::new(&device, target, compute)); let none = || Arc::new(Mutex::new(None)); - let cb = ViewportCallback { + let cb = ViewportCallback(Frame { product, key: 1, slot: Arc::new(Mutex::new(Some(scene))), @@ -1502,7 +281,7 @@ fn offscreen( outline: false, eye, ssaa, - }; + }); let texture = |format, usage| { device.create_texture(&wgpu::TextureDescriptor { label: Some("offscreen"), @@ -1567,54 +346,8 @@ fn offscreen( Some(bytes) } -/// Uniform bytes for a frame. -pub fn uniform_bytes(cb: &ViewportCallback, w: f32, h: f32, linear: bool) -> Vec { - let mut v: Vec = cb.view_proj.iter().flatten().copied().collect(); - v.extend([cb.back[0], cb.back[1], cb.back[2], if linear { 1.0 } else { 0.0 }]); - v.extend([w.max(1.0), h.max(1.0), 2e-4, if cb.outline { 1.0 } else { 0.0 }]); - v.extend(cb.clip); - v.extend(cb.cap); - v.extend(cb.analysis); - match &cb.access { - Some(a) => { - v.extend(a.u); - v.extend(a.v); - } - None => v.extend([0.0; 8]), - } - v.extend(cb.eye); - v.extend(cb.analysis_colors); - v.iter().flat_map(|x| x.to_le_bytes()).collect() -} - -/// The shader's analysis colours (`ana_c`: draft positive, between, negative; reachable, -/// blocked; curvature flat, middle, tight; alpha 1) and flags (`ana_f.x`: 1 to stripe the bad -/// faces). -pub fn analysis_colors(c: &crate::theme::AnalysisColors) -> [f32; 36] { - let mut v = [0.0; 36]; - let colors = c.draft.iter().chain(&c.access).chain(&c.curvature).flat_map(|rgb| [rgb[0], rgb[1], rgb[2], 1.0]); - for (slot, x) in v.iter_mut().zip(colors) { - *slot = x; - } - v[32] = if c.hatch { 1.0 } else { 0.0 }; - v -} - #[cfg(test)] mod tests { - use egui_wgpu::wgpu::{self, naga}; - - /// Only the FirePro under Metal skips GPU evaluation; the same card under another backend - /// and other Metal GPUs keep it. - #[test] - fn firepro_under_metal_builds_bodies_on_the_cpu() { - assert!(super::hangs_on_gpu_tessellation(wgpu::Backend::Metal, "AMD FirePro D500")); - assert!(super::hangs_on_gpu_tessellation(wgpu::Backend::Metal, "AMD FirePro D700")); - assert!(!super::hangs_on_gpu_tessellation(wgpu::Backend::Metal, "Apple M2")); - assert!(!super::hangs_on_gpu_tessellation(wgpu::Backend::Metal, "AMD Radeon Pro 560X")); - assert!(!super::hangs_on_gpu_tessellation(wgpu::Backend::Vulkan, "AMD FirePro W5100")); - } - /// The offscreen tests start the backends the app would: on Windows without `WGPU_BACKEND` /// exactly one, from the app's order (D3D12, Vulkan, OpenGL); otherwise wgpu's default. #[cfg(not(target_arch = "wasm32"))] @@ -1635,127 +368,4 @@ mod tests { assert!(chosen.contains(used), "the offscreen device runs on {used:?}, not on {chosen:?}"); } } - - /// The viewport shader parses and validates (a bad shader would only fail at run time). - #[test] - fn shader_is_valid() { - let module = naga::front::wgsl::parse_str(super::SHADER).unwrap_or_else(|e| panic!("{}", e.emit_to_string(super::SHADER))); - naga::valid::Validator::new(naga::valid::ValidationFlags::all(), naga::valid::Capabilities::empty()).validate(&module).unwrap(); - } - - /// Derivatives and implicit-lod sampling run only in uniform control flow: browsers' WGSL - /// compilers (Tint) reject them after a branch, and the whole viewport shader then fails, - /// a black screen on the web (naga accepts it). Every fragment entry point makes such calls - /// (directly or through a helper) before its first branch or return. - #[test] - fn derivatives_only_in_uniform_control_flow() { - // Without comments (their words are not code). - let src: String = super::SHADER.lines().map(|l| l.split("//").next().unwrap_or("")).collect::>().join("\n"); - let src = src.as_str(); - let derivs = ["dpdx(", "dpdy(", "fwidth(", "textureSample("]; - // Functions: name -> body. - let mut fns: Vec<(String, String, bool)> = Vec::new(); - let mut rest = src; - let mut fragment = false; - while let Some(at) = rest.find("fn ") { - let before = &rest[..at]; - if before.contains("@fragment") { - fragment = true; - } - let Some(open) = rest[at..].find('{') else { break }; - let name = rest[at + 3..].split('(').next().unwrap_or("").trim().to_string(); - let start = at + open; - let mut depth = 0; - let mut end = start; - for (k, ch) in rest[start..].char_indices() { - match ch { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - end = start + k; - break; - } - } - _ => {} - } - } - fns.push((name, rest[start..=end].to_string(), fragment)); - fragment = false; - rest = &rest[end + 1..]; - } - // Helpers that take derivatives (directly or through another helper). - let mut uses: Vec = Vec::new(); - for _ in 0..8 { - for (n, body, _) in &fns { - if !uses.contains(n) && (derivs.iter().any(|d| body.contains(d)) || uses.iter().any(|u| body.contains(&format!("{u}(")))) { - uses.push(n.clone()); - } - } - } - for (n, body, frag) in &fns { - let calls: Vec = derivs - .iter() - .map(|d| d.to_string()) - .chain(uses.iter().map(|u| format!("{u}("))) - .flat_map(|c| body.match_indices(&c).map(|(i, _)| i).collect::>()) - .collect(); - if calls.is_empty() { - continue; - } - if !*frag { - // A helper: its own derivatives must come before any branch or return in it. - let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX); - for d in derivs { - if let Some(i) = body.find(d) { - assert!(i < first, "{n}: {d} after a branch"); - } - } - continue; - } - let first = ["if (", "return", "discard"].iter().filter_map(|k| body.find(k)).min().unwrap_or(usize::MAX); - for c in calls { - assert!(c < first, "fragment {n}: a derivative after a branch or return: {}", &body[c..(c + 40).min(body.len())]); - } - } - } -} - -#[cfg(test)] -mod glass_tests { - use super::*; - - /// A large import's triangles once went into one buffer past the device's limit (524 MB - /// against 256 MB) and the app crashed (#883): they are split into whole triangles. - #[test] - fn large_scenes_are_split_into_buffers_within_the_device_limit() { - let limit = 256u64 << 20; - for stride in [TRI_SIZE, LINE_SIZE, 4] { - let chunk = buffer_chunk(stride, limit); - assert!(chunk as u64 <= limit && chunk > limit as usize / 2); - assert_eq!(chunk % (stride * 3), 0, "whole triangles"); - assert_eq!(chunk % (stride * 2), 0, "whole line pairs"); - } - let scene = 524_434_680usize; - assert_eq!(scene.div_ceil(buffer_chunk(TRI_SIZE, limit)), 2); - assert!(buffer_chunk(TRI_SIZE, 1) >= TRI_SIZE * 6, "a tiny limit still holds one unit"); - } - - #[test] - fn glass_is_drawn_far_to_near() { - // Two triangles at z = 5 and z = -5, given near first; looking down from +Z. - let mut sc = GpuScene::default(); - for z in [5.0f32, -5.0] { - for p in [[0.0, 0.0, z], [1.0, 0.0, z], [0.0, 1.0, z]] { - sc.glass_tri(p, [0.0, 0.0, 1.0], [255, 0, 0, 100]); - } - } - let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, 1.0]); - let z0 = f32::from_le_bytes(sorted[8..12].try_into().unwrap()); - assert_eq!(z0, -5.0, "the far triangle first"); - assert_eq!(sorted.len(), sc.glass.len()); - // Seen from below, the order flips. - let sorted = sort_back_to_front(&sc.glass, [0.0, 0.0, -1.0]); - assert_eq!(f32::from_le_bytes(sorted[8..12].try_into().unwrap()), 5.0); - } } diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs index b2b1d237..6a9c72c6 100644 --- a/crates/ui-egui/src/home.rs +++ b/crates/ui-egui/src/home.rs @@ -69,8 +69,9 @@ pub fn thumbnail(s: &Session) -> Option { // Fill the card: the fit leaves room for a whole window's margins. cam.distance *= 0.7; let scene = solidcraft_engine::view::scene(s, &cam); - let c = solidcraft_engine::render::render(&scene, &cam, THUMB[0], THUMB[1]); - (c.rgba.len() == c.w * c.h * 4).then(|| egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba)) + // Drawn on the GPU by the renderer the app installs; none (a browser): no picture. + let picture = solidcraft_engine::capture::rgba(&scene, &cam, THUMB[0] as u32, THUMB[1] as u32).ok()?; + Some(egui::ColorImage::from_rgba_unmultiplied(THUMB, &picture.rgba)) } /// File identity, not just its path: saving or replacing a design invalidates its picture. @@ -570,6 +571,9 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn cached_recent_uses_a_separate_png_without_reading_or_changing_design_json() { + if !crate::gpu::test_picture_renderer() { + return; + } let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos(); let path = std::env::temp_dir().join(format!("solidcraft-thumb-{}-{nonce}.solidcraft", std::process::id())); // Deliberately invalid design: a cached recent must not parse/evaluate it. @@ -594,6 +598,9 @@ mod tests { #[cfg(not(target_arch = "wasm32"))] #[test] fn portable_thumbnail_loads_without_a_local_picture_or_model_rebuild() { + if !crate::gpu::test_picture_renderer() { + return; + } let mut session = Session::default(); session.execute("solid.box", &serde_json::json!({"length":10,"width":20,"height":30})).unwrap(); for body in &session.world_state().bodies { @@ -630,8 +637,12 @@ mod tests { } /// Samples build in the background and make a picture; picking one opens its design. + #[cfg(not(target_arch = "wasm32"))] #[test] fn sample_card_makes_a_picture_and_opens() { + if !crate::gpu::test_picture_renderer() { + return; + } let (key, img, session) = make("sample:0"); assert_eq!(key, "sample:0"); let img = img.unwrap(); diff --git a/crates/ui-egui/src/render_workspace.rs b/crates/ui-egui/src/render_workspace.rs index 68c9e8e9..332ba2ca 100644 --- a/crates/ui-egui/src/render_workspace.rs +++ b/crates/ui-egui/src/render_workspace.rs @@ -11,7 +11,7 @@ use solidcraft_engine::render::{ ScratchLayout, }; -use crate::gpu::{GpuScene, SceneSlot, ViewportCallback}; +use crate::gpu::{Frame, GpuScene, SceneSlot, ViewportCallback}; use crate::{SolveApp, theme::Tokens}; const MAX_GEOMETRY_BYTES: usize = 128 * 1024 * 1024; @@ -518,7 +518,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let radius = scene.bounds.diagonal().max(10.0) + scene.bounds.center().dist(camera.target); let eye = camera.eye(); let slot = app.render_workspace.slot.clone(); - let callback = ViewportCallback { + let callback = ViewportCallback(Frame { product: true, key, slot: slot.clone(), @@ -540,7 +540,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { outline: false, eye: [eye.x as f32, eye.y as f32, eye.z as f32, if camera.fov > 0.0 { 1.0 } else { 0.0 }], ssaa: if width < full.0 { width as f32 / full.0 as f32 } else { 1.0 }, - }; + }); ui.painter().add(egui_wgpu::Callback::new_paint_callback(rect, callback)); Ok(()) })(); diff --git a/crates/ui-egui/src/render_workspace_tests.rs b/crates/ui-egui/src/render_workspace_tests.rs index 027dc4c8..4c53f216 100644 --- a/crates/ui-egui/src/render_workspace_tests.rs +++ b/crates/ui-egui/src/render_workspace_tests.rs @@ -496,7 +496,6 @@ fn nav_app(render: bool, preset: &str, around: crate::prefs::OrbitAround) -> cra let mut s = Session::default(); s.execute("solid.box", &json!({"length":40,"width":30,"height":20,"body_name":"Product"})).unwrap(); let mut app = crate::SolveApp::new(s, crate::Services::default()); - app.viewport.no_pixels = true; if render { enter(&mut app, true).unwrap(); assert!(active(&app)); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 798b4029..a2fea596 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -179,8 +179,6 @@ impl Harness { /// File pickers answer like a person would: save into the scenario's folder, open the file /// the scenario chose. fn install(&mut self) { - // Frames are laid out, never painted. - self.app.viewport.no_pixels = true; let dir = self.dir.clone(); self.app.services.pick_save = Some(Box::new(move |name, _| Box::pin(std::future::ready(Some(dir.join(name).to_string_lossy().into_owned()))))); @@ -444,8 +442,19 @@ impl Harness { } else if let Some(name) = s.get("shot").and_then(Value::as_str) { // With SOLIDCRAFT_SCENARIO_SHOTS set: a render of the model as the camera sees it. if let Ok(dir) = solidcraft_engine::geom::env::var("SCENARIO_SHOTS").ok_or(()) { + // Drawn on the GPU: the headless device, as the CLI draws. + #[cfg(not(target_arch = "wasm32"))] + if solidcraft_engine::capture::installed().is_none() { + solidcraft_engine::capture::install(std::sync::Arc::new(|scene, cam, w, h| { + let (rgba, renderer) = solidcraft_gpu::capture::headless_rgba(scene, cam, w, h)?; + Ok(solidcraft_engine::capture::Picture { rgba, renderer }) + })); + } let path = std::path::Path::new(&dir).join(format!("{name}.png")); - self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700})); + let r = self.call("ui.render", json!({"path": path.to_string_lossy(), "width": 1000, "height": 700})); + if r["ok"] != true { + return Err(format!("shot {name}: {}", r["error"])); + } } return Ok(()); } else if let Some(c) = s.get("camera") { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index beb974b7..ef64fc23 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -108,18 +108,8 @@ pub fn srgb(c: [f32; 3]) -> Color32 { } /// Surface analysis colours, straight sRGB 0..1 as the shader takes them (exact, so they read -/// the same as when they were literals in the WGSL). -#[derive(Clone, Copy, Debug, PartialEq)] -pub struct AnalysisColors { - /// Draft Analysis: at least the draft angle, between, at most minus the angle. - pub draft: [[f32; 3]; 3], - /// Accessibility Analysis: reachable, blocked. - pub access: [[f32; 3]; 2], - /// Curvature Map: flat, the middle of the scale, at or below the analysis radius. - pub curvature: [[f32; 3]; 3], - /// Stripe the negative draft and blocked faces too, so colour is not the only cue. - pub hatch: bool, -} +/// the same as when they were literals in the WGSL). The light theme's are the headless default. +pub use solidcraft_engine::render::AnalysisColors; /// The colours icons draw of their own (`icons::paint` takes ink, fill and accent from its /// caller). Every icon colour is a token so a colour vision mode can reach it. diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index a033c456..72928841 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -10,7 +10,7 @@ use solidcraft_engine::sketch::CurveKind; use solidcraft_engine::view::{colors, grid_step, sketch_lines_with_sampler}; use std::hash::{Hash, Hasher}; -use crate::gpu::{GpuScene, GpuTarget, SceneSlot, ViewportCallback}; +use crate::gpu::{Frame, GpuScene, GpuTarget, SceneSlot, ViewportCallback}; use crate::navigation::{CameraInput, NavCaps, OrbitSnap, orbit_drag}; use crate::selection::{BoxSel, origin_quads, origin_size, ray_quad}; use crate::theme::Tokens; @@ -69,9 +69,6 @@ pub struct ViewportState { pub(crate) display: std::sync::Mutex, hl_slot: SceneSlot, hl_key: u64, - cpu: Option<(u64, egui::TextureHandle)>, - /// Nothing will look at the frame (the headless scenario harness): skip the software render. - pub no_pixels: bool, pub nav: Option, /// The preset's navigation for the left-button drag under way (Alias: Shift+Alt), taken /// when the button went down, so the drag neither selects nor moves sketch geometry. @@ -1501,20 +1498,9 @@ pub fn selection_bounds(app: &SolveApp) -> Option [f32; 8] { - use solidcraft_engine::SurfaceAnalysis as A; - match app.session.analysis { - Some(A::Zebra { stripes }) => [1.0, stripes as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], - Some(A::Draft { pull, angle }) => [2.0, angle as f32, 0.0, 0.0, pull.x as f32, pull.y as f32, pull.z as f32, 0.0], - Some(A::Curvature { radius }) => [3.0, radius as f32, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], - Some(A::Environment) => [4.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0], - Some(A::Access { dir }) => { - let (scale, size, eps) = crate::access::map(app).map(|m| (m.scale, m.size as f32, 1.5 / m.scale.max(1e-9))).unwrap_or((0.0, 0.0, 0.0)); - [5.0, eps, scale, size, dir.x as f32, dir.y as f32, dir.z as f32, 0.0] - } - None => [0.0; 8], - } + solidcraft_gpu::view::analysis_uniform(app.session.analysis.map(|a| a.mode()), crate::access::map(app).as_ref()) } /// While Draft, Accessibility or Curvature Map analysis is on, what each of its colours means and @@ -2193,7 +2179,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { update_refinement(app, ui.ctx(), &proj); let key = scene_key(app); let t0 = crate::now_ms(); - if let Some(_g) = app.viewport.gpu { + // Drawn on the GPU; without one (the headless harness, tests) the model is not drawn. + if app.viewport.gpu.is_some() { if key != app.viewport.key { let sc = build_scene(app); if let Ok(mut slot) = app.viewport.slot.lock() { @@ -2221,7 +2208,7 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let ppp = ui.ctx().pixels_per_point(); let size_px = [rect.width() * ppp, rect.height() * ppp]; let ssaa = supersampling(app, ui.ctx(), proj.vp.to_f32(), size_px); - let cb = ViewportCallback { + let cb = ViewportCallback(Frame { ssaa, product: false, key: app.viewport.key, @@ -2246,10 +2233,8 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) { let e = proj.cam.eye().to_f32(); [e[0], e[1], e[2], if proj.cam.fov > 0.0 { 1.0 } else { 0.0 }] }, - }; + }); painter.add(egui_wgpu::Callback::new_paint_callback(rect, cb)); - } else { - cpu_render(app, ui.ctx(), &painter, rect, &proj); } analysis_legend(app, &painter, rect); @@ -2952,44 +2937,9 @@ fn overlays(app: &SolveApp, painter: &egui::Painter, proj: &Proj) { } } -fn cpu_render(app: &mut SolveApp, ctx: &egui::Context, painter: &egui::Painter, rect: Rect, proj: &Proj) { - if app.viewport.no_pixels { - return; - } - let mut h = std::collections::hash_map::DefaultHasher::new(); - sketch_scene_key(app, proj).hash(&mut h); - for v in [proj.cam.yaw, proj.cam.pitch, proj.cam.roll, proj.cam.distance, proj.cam.target.x, proj.cam.target.y, proj.cam.target.z] { - v.to_bits().hash(&mut h); - } - (rect.width() as u32, rect.height() as u32).hash(&mut h); - app.ui.dark.hash(&mut h); - app.preferences.color_vision.hash(&mut h); - let key = h.finish(); - if app.viewport.cpu.as_ref().is_none_or(|(k, _)| *k != key) { - let mut budget = solidcraft_engine::render::SamplingBudget::new(131_072, 1_048_576); - let palette = scene_palette(app.ui.dark, app.preferences.color_vision); - let scene = solidcraft_engine::view::scene_with_palette( - &app.session, - &proj.cam, - section_plane(app), - |sk, index, plane| crate::sketch_display::curve_with_budget(sk, index, plane, proj, app.viewport.pixels_per_point(), &mut budget).parts, - &palette, - ); - let w = (rect.width() as usize).clamp(16, 4096); - let hh = (rect.height() as usize).clamp(16, 4096); - let c = solidcraft_engine::render::render(&scene, &proj.cam, w, hh); - let img = egui::ColorImage::from_rgba_unmultiplied([c.w, c.h], &c.rgba); - let tex = ctx.load_texture("sc_cpu_viewport", img, egui::TextureOptions::LINEAR); - app.viewport.cpu = Some((key, tex)); - } - if let Some((_, tex)) = &app.viewport.cpu { - painter.image(tex.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); - } -} - -/// The colours of a CPU picture of the model (`ui.render` and the CPU viewport) in the dark or -/// the light theme and a colour vision mode, from the tokens the window draws with, so the picture -/// matches the window. The light theme's tokens (Off) are the engine's own colours, so a light +/// The colours of a headless picture of the model (`ui.render`, the agent's view image) in the +/// dark or the light theme and a colour vision mode, from the tokens the window draws with, so the +/// picture matches the window. The light theme's tokens (Off) are the engine's own colours, so a light /// render is unchanged. pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> solidcraft_engine::view::Palette { let t = Tokens::themed(dark, mode); @@ -3011,6 +2961,7 @@ pub(crate) fn scene_palette(dark: bool, mode: crate::theme::ColorVision) -> soli sketch_centerline: rgb(t.sketch_centerline), axes: t.triad.map(rgb), cap: rgb(t.section_cap), + analysis: t.analysis, } } @@ -3337,7 +3288,6 @@ mod tests { let mut s = solidcraft_engine::Session::default(); s.run_script(&solidcraft_engine::sample::script()).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); - app.viewport.no_pixels = true; app.fit_view(); const SIZE: u32 = 256; let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); @@ -3400,7 +3350,6 @@ mod tests { let mut s = solidcraft_engine::Session::default(); s.run_script(&solidcraft_engine::sample::script()).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); - app.viewport.no_pixels = true; app.fit_view(); app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); const SIZE: u32 = 256; @@ -3439,7 +3388,6 @@ mod tests { let mut s = solidcraft_engine::Session::default(); s.run_script(&solidcraft_engine::sample::script()).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); - app.viewport.no_pixels = true; app.fit_view(); app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); const SIZE: u32 = 256; @@ -3498,7 +3446,6 @@ mod tests { s.execute("solid.box", &json!({"length": 16, "width": 16, "height": 20, "corner": [12, 12, 20], "operation": "cut"})).unwrap(); s.execute("solid.cylinder", &json!({"base": [33, 33, 40], "diameter": 10, "height": 10, "operation": "join"})).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); - app.viewport.no_pixels = true; app.fit_view(); const SIZE: u32 = 256; let rect = Rect::from_min_size(Pos2::ZERO, vec2(SIZE as f32, SIZE as f32)); @@ -3599,13 +3546,13 @@ mod tests { crate::sketch_palette::set_mode(&mut h.app, 0); } - /// The CPU pictures (`ui.render`, the CPU viewport) are drawn in the window's theme (#974) - /// and colour vision mode (#778). The light theme's tokens are the engine's own colours, so a + /// Headless pictures of the view (`ui.render`, the agent's view image) are drawn in the + /// window's theme (#974) and colour vision mode (#778). The light theme's tokens are the engine's own colours, so a /// light picture in Off is unchanged; otherwise each part takes the token the window draws it /// with. Sketch states follow what the window does with them (`sketch_color`) and the axes the /// mode's triad, so the two can't drift apart. #[test] - fn cpu_pictures_are_drawn_in_the_windows_theme() { + fn pictures_are_drawn_in_the_windows_theme() { use crate::theme::ColorVision; use solidcraft_engine::view::Palette; assert_eq!(scene_palette(false, ColorVision::Off), Palette::DEFAULT, "the light theme is the engine's colours"); @@ -3620,6 +3567,7 @@ mod tests { for (dark, mode) in [false, true].into_iter().flat_map(|d| ColorVision::ALL.map(|m| (d, m))) { let (p, t) = (scene_palette(dark, mode), Tokens::themed(dark, mode)); assert_eq!(p.axes, t.triad.map(rgb), "dark {dark}, {mode:?}: the axes"); + assert_eq!(p.analysis, t.analysis, "dark {dark}, {mode:?}: the surface analysis"); for (tag, cpu) in [ (colors::SKETCH, p.sketch), (colors::SKETCH_FIXED, p.sketch_fixed), @@ -3636,65 +3584,43 @@ mod tests { } } - /// The CPU viewport follows a theme or colour vision mode change at once: both are part of its - /// picture's key. + /// The view's picture follows a theme change: its background is the window's. + #[cfg(not(target_arch = "wasm32"))] #[test] - fn cpu_viewport_redraws_in_the_new_theme() { + fn the_view_picture_follows_the_theme() { + if !crate::gpu::test_picture_renderer() { + return; + } let mut h = crate::scenario::Harness::new(); h.app.run("solid.box", json!({"length": 40, "width": 30, "height": 20})).unwrap(); - let rect = Rect::from_min_size(Pos2::ZERO, vec2(160.0, 120.0)); - let cam = solidcraft_engine::view::home_camera(&h.app.session); - let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect }; - h.app.viewport.no_pixels = false; - // The new picture's top row, most common colour first (the background's top); None when - // nothing new was drawn. - let top_row = |h: &mut crate::scenario::Harness| { - let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { - cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj); - }); - let id = h.app.viewport.cpu.as_ref().map(|(_, tex)| tex.id()); - let top = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.iter().find(|delta| delta.pos.is_none())).map(|upload| { - let egui::ImageData::Color(image) = &upload.image; - let mut counts = std::collections::HashMap::new(); - for px in image.pixels.iter().take(image.size[0]) { - *counts.entry(*px).or_insert(0) += 1; - } - counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap_or_default() - }); - out.textures_delta.clear(); - top - }; + h.app.cam = solidcraft_engine::view::home_camera(&h.app.session); for dark in [false, true, false] { h.app.ui.dark = dark; - let Some(px) = top_row(&mut h) else { panic!("dark {dark}: the CPU viewport kept the old picture") }; + let img = crate::control::view_rgba(&h.app, 160, 120).unwrap(); + // The top row's most common colour: the background's top. + let mut counts = std::collections::HashMap::new(); + for px in img.as_chunks::<4>().0.iter().take(160) { + *counts.entry([px[0], px[1], px[2]]).or_insert(0) += 1; + } + let px = counts.into_iter().max_by_key(|(_, n)| *n).map(|(c, _)| c).unwrap_or_default(); let want = if dark { Tokens::dark() } else { Tokens::light() }.viewport_top; - let near = [px.r(), px.g(), px.b()].iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2); + let near = px.iter().zip([want.r(), want.g(), want.b()]).all(|(a, b)| a.abs_diff(b) <= 2); assert!(near, "dark {dark}: top row {px:?}, the window's background {want:?}"); } - // And a colour vision mode change, on its own. - h.app.preferences.color_vision = crate::theme::ColorVision::Deutan; - assert!(top_row(&mut h).is_some(), "a new mode kept the old picture"); - assert!(top_row(&mut h).is_none(), "no change, no new picture"); } + /// The view's picture is cut by the section the viewport shows: an open Section Analysis + /// dialog's preview, a sketch's Slice. + #[cfg(not(target_arch = "wasm32"))] #[test] - fn cpu_viewport_uses_section_preview_and_sketch_slice() { - fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec { - let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0)); - let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect }; - h.app.viewport.no_pixels = false; - // Force a texture upload so this test reads the actual CPU rasterizer result. - h.app.viewport.cpu = None; - let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { - cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj); - }); - let id = h.app.viewport.cpu.as_ref().unwrap().1.id(); - let upload = out.textures_delta.set.get(&id).unwrap().iter().find(|delta| delta.pos.is_none()).unwrap(); - let egui::ImageData::Color(image) = &upload.image; - assert_eq!(image.size, [320, 240]); - let pixels = image.pixels.clone(); - out.textures_delta.clear(); - pixels + fn the_view_picture_uses_section_preview_and_sketch_slice() { + fn pixels(h: &mut crate::scenario::Harness, cam: Camera) -> Vec<[u8; 4]> { + h.app.cam = cam; + let img = crate::control::view_rgba(&h.app, 320, 240).unwrap(); + img.as_chunks::<4>().0.to_vec() + } + if !crate::gpu::test_picture_renderer() { + return; } let mut h = crate::scenario::Harness::new(); h.app.run("solid.box", json!({"length":40,"width":30,"height":20})).unwrap(); @@ -3708,12 +3634,12 @@ mod tests { *offset = "15 mm".into(); assert!(h.app.session.section.is_none()); let preview = pixels(&mut h, cam); - assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored uncommitted section preview"); + assert!(whole.iter().zip(&preview).filter(|(a, b)| a != b).count() > 100, "the picture ignored the uncommitted section preview"); assert!(std::sync::Arc::ptr_eq(&before, &h.app.session.doc)); assert_eq!(h.app.session.undo.len(), undo); h.app.run("inspect.section", json!({"plane":"XZ","offset":15})).unwrap(); h.app.dialog = None; - assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must rasterize identically"); + assert_eq!(preview, pixels(&mut h, cam), "preview and committed section must draw identically"); h.app.session.section = None; h.app.run("sketch.create", json!({"plane":{"origin":[0,0,10],"x_dir":[1,0,0],"y_dir":[0,1,0]}})).unwrap(); @@ -3721,7 +3647,7 @@ mod tests { h.app.run("sketch.options", json!({"slice":true})).unwrap(); assert!(h.app.session.section.is_none()); let sliced = pixels(&mut h, cam); - assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "CPU viewport ignored active Sketch Slice"); + assert!(whole.iter().zip(&sliced).filter(|(a, b)| a != b).count() > 100, "the picture ignored the active Sketch Slice"); h.app.run("sketch.options", json!({"slice":false})).unwrap(); assert_eq!(whole, pixels(&mut h, cam), "disabling Slice restores the whole-body pixels"); } @@ -3876,7 +3802,6 @@ mod tests { s.execute("sketch.create", &json!({"plane": "XY"})).unwrap(); s.execute("sketch.point", &json!({"point": [0, 0]})).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); - app.viewport.no_pixels = true; if gpu { // The callback is queued by egui; no device is needed to inspect scene updates. app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); @@ -4106,7 +4031,6 @@ mod tests { s.selection = vec![Sel::Body { name: "Body1".into() }]; let mut app = SolveApp::new(s, crate::Services::default()); app.preferences.nav = "touchpad".into(); - app.viewport.no_pixels = true; app.start("solid.move"); app.dialog.as_mut().unwrap().focus = false; let p = pos2(100.0, 100.0); @@ -4341,7 +4265,6 @@ mod tests { }; for tess in [true, false] { let mut app = cylinder_app(); - app.viewport.no_pixels = true; app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess }); let body = app.session.world_state().bodies[0].clone(); let before = build_scene(&app).merged(); @@ -4444,8 +4367,9 @@ mod tests { if wireframe { scene.meshes.clear(); } - let png = solidcraft_engine::render::render_png(&scene, &cam, 1200, 800).unwrap(); - std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png).unwrap(); + assert!(crate::gpu::test_picture_renderer(), "SMOOTH_SCREENSHOTS needs a GPU"); + let png = solidcraft_engine::capture::png(&scene, &cam, 1200, 800).unwrap(); + std::fs::write(std::path::Path::new(&directory).join(format!("smooth-ring-{name}.png")), png.bytes).unwrap(); } } } @@ -4846,7 +4770,6 @@ mod tests { let ctx = egui::Context::default(); let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); assert!(app.preferences.trackpad_pan); - app.viewport.no_pixels = true; crate::help::run(&mut app, &ctx, "shortcuts"); let mut time = 0.0; let pan = @@ -4905,7 +4828,6 @@ mod tests { let mut app = SolveApp::new(solidcraft_engine::Session::default(), crate::Services::default()); app.preferences.smooth_wheel_zoom = smooth; app.preferences.trackpad_pan = false; - app.viewport.no_pixels = true; crate::help::run(&mut app, &ctx, "shortcuts"); let mut time = 0.0; let mut frame = |app: &mut SolveApp, pointer: Option, wheel: f32| { @@ -5396,7 +5318,6 @@ mod tests { let mut s = solidcraft_engine::Session::default(); s.run_script(&solidcraft_engine::sample::script()).unwrap(); let mut app = SolveApp::new(s, crate::Services::default()); - app.viewport.no_pixels = true; app.fit_view(); app.run("inspect.draft", json!({"pull": [-1, -1, 0]})).unwrap(); app.viewport.gpu = Some(GpuTarget { format: egui_wgpu::wgpu::TextureFormat::Rgba8Unorm, depth: None, samples: 1, tess: false }); @@ -5448,46 +5369,39 @@ mod tests { assert!(changed.iter().all(|&i| b[4 * i..4 * i + 3].iter().zip(&c[4 * i..4 * i + 3]).all(|(x, y)| x <= y)), "stripes are darker"); } - /// The CPU viewport draws in the colour vision mode: a fixed sketch line in the light theme + /// The view's picture draws in the colour vision mode: a fixed sketch line in the light theme /// takes Deutan's value, and Off's is gone. + #[cfg(not(target_arch = "wasm32"))] #[test] - fn cpu_viewport_draws_in_the_colour_vision_mode() { + fn the_view_picture_draws_in_the_colour_vision_mode() { use crate::theme::ColorVision; + if !crate::gpu::test_picture_renderer() { + return; + } let mut h = crate::scenario::Harness::new(); h.app.ui.dark = false; h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); let made = h.app.run("sketch.line", json!({"points": [[-30, -20], [30, 20]]})).unwrap(); h.app.run("sketch.constraint.fix", json!({"entity": made["created"]["curves"][0]})).unwrap(); - let rect = Rect::from_min_size(Pos2::ZERO, vec2(320.0, 240.0)); let mut cam = solidcraft_engine::view::home_camera(&h.app.session); cam.set_view(solidcraft_engine::render::StandardView::Top); - let proj = Proj { cam, vp: cam.view_proj(4.0 / 3.0, 100.0), rect }; - h.app.viewport.no_pixels = false; - let count = |h: &mut crate::scenario::Harness, c: Color32| { - h.app.viewport.cpu = None; - let mut out = h.ctx.run_ui(egui::RawInput { screen_rect: Some(rect), ..Default::default() }, |ui| { - cpu_render(&mut h.app, ui.ctx(), ui.painter(), rect, &proj); - }); - let id = h.app.viewport.cpu.as_ref().map(|(_, tex)| tex.id()); - let n = id.and_then(|id| out.textures_delta.set.get(&id)).and_then(|d| d.iter().find(|delta| delta.pos.is_none())).map_or(0, |upload| { - let egui::ImageData::Color(image) = &upload.image; - image.pixels.iter().filter(|p| [p.r(), p.g(), p.b()].iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count() - }); - out.textures_delta.clear(); - n + h.app.cam = cam; + let count = |h: &crate::scenario::Harness, c: Color32| { + let img = crate::control::view_rgba(&h.app, 320, 240).unwrap(); + img.as_chunks::<4>().0.iter().filter(|p| p[..3].iter().zip([c.r(), c.g(), c.b()]).all(|(a, b)| a.abs_diff(b) <= 8)).count() }; let (off, deutan) = (Tokens::light().sketch_locked, Tokens::themed(false, ColorVision::Deutan).sketch_locked); - let (a, b) = (count(&mut h, off), count(&mut h, deutan)); + let (a, b) = (count(&h, off), count(&h, deutan)); assert!(a > 20 && b == 0, "Off: {a} fixed-line pixels, {b} in Deutan's colour"); h.app.preferences.color_vision = ColorVision::Deutan; - let (a, b) = (count(&mut h, off), count(&mut h, deutan)); + let (a, b) = (count(&h, off), count(&h, deutan)); assert!(a == 0 && b > 20, "Deutan: {a} fixed-line pixels in Off's colour, {b} in Deutan's"); } - /// A mode's table reaches the CPU picture: the sketch states and axes it changes, and nothing - /// else (the light theme in Off is the engine's own colours). + /// A mode's table reaches the headless picture's palette: the sketch states and axes it + /// changes, and nothing else (the light theme in Off is the engine's own colours). #[test] - fn cpu_picture_follows_the_colour_vision_mode() { + fn picture_palette_follows_the_colour_vision_mode() { use crate::theme::ColorVision; let rgb = |c: Color32| Rgb(c.r(), c.g(), c.b()); let off = scene_palette(false, ColorVision::Off); @@ -5505,6 +5419,7 @@ mod tests { /// The mode is on from the next frame (theme, widgets, caches, `ui.render`), and Cancel puts /// it back. + #[cfg(not(target_arch = "wasm32"))] #[test] fn colour_vision_mode_reaches_the_frame_and_the_view_caches() { use crate::theme::ColorVision; @@ -5524,8 +5439,11 @@ mod tests { h.frame(); assert_eq!((h.app.preferences.color_vision, crate::theme::vision()), (ColorVision::Off, ColorVision::Off)); assert_eq!(keys(&h.app), before); - // `ui.render` (the CPU picture) shows the mode too: a fixed sketch line in Deutan's value - // (in the light theme, where the red-green table changes it; the render follows the theme). + // `ui.render` shows the mode too: a fixed sketch line in Deutan's value (in the light + // theme, where the red-green table changes it; the render follows the theme). + if !crate::gpu::test_picture_renderer() { + return; + } h.call("ui.prefs", json!({"theme": "light"})); h.frame(); h.app.run("sketch.create", json!({"plane": "XY"})).unwrap(); diff --git a/docs/book/src/dev/architecture.md b/docs/book/src/dev/architecture.md index 026a01cf..61912142 100644 --- a/docs/book/src/dev/architecture.md +++ b/docs/book/src/dev/architecture.md @@ -18,5 +18,12 @@ A few rules follow from the layering: - **Everything the user can do is a command** in `crates/engine`. See [The command engine](commands.md). - **The front end is swappable.** Nothing below `ui-egui` depends on egui. The CLI and the MCP - server run the same engine without a window, and `crates/render` has a CPU rasterizer so - headless screenshots work anywhere. + server run the same engine without a window. Headless pictures (`solidcraft-cli snapshot`, MCP + screenshots, `ui.render`, thumbnails) are drawn on the GPU with the viewport's own pipelines + (`crates/gpu`, `capture`): the engine describes the scene (`view::scene`) and the host installs + the renderer (`engine::capture`). The tiers, in order: a hardware GPU, a software adapter (Mesa + lavapipe or llvmpipe, WARP), and last `crates/render`'s CPU rasterizer, which draws no surface + analysis, highlights or legends. `SOLIDCRAFT_GPU=auto` walks all three, `hardware` and `software` + stay on their tier (an error without one), `cpu` forces the rasterizer; every picture's reply + names its `renderer` (`gpu`, `gpu-software`, `cpu`) and a CPU picture carries a warning. + `solidcraft-cli gpu` lists the adapters and the tier the policy lands on. diff --git a/docs/book/src/guide/getting-started.md b/docs/book/src/guide/getting-started.md index 7aff90d5..30940c6e 100644 --- a/docs/book/src/guide/getting-started.md +++ b/docs/book/src/guide/getting-started.md @@ -186,7 +186,7 @@ A script is a list of commands: ![The bracket example, rendered by solidcraft-cli snapshot](../generated/bracket.png) -`snapshot` looks at the model from the iso view. `--view NAME` takes another standard view (`front`, `back`, `top`, `bottom`, `left`, `right`) or the name of a view saved in the design with `view.save`. `--camera JSON` takes any camera, as the MCP `screenshot` tool does (its conventions are in [the MCP page](mcp.md#camera-and-close-up-recipe)). A script that applies a view itself with `view.apply` sets the camera too. The ground grid lies on Z 0, so it cuts through a part that straddles it: `--no-grid` leaves it out of the picture, and `--no-axes` the origin's axes. +`snapshot` looks at the model from the iso view. `--view NAME` takes another standard view (`front`, `back`, `top`, `bottom`, `left`, `right`) or the name of a view saved in the design with `view.save`. `--camera JSON` takes any camera, as the MCP `screenshot` tool does (its conventions are in [the MCP page](mcp.md#camera-and-close-up-recipe)). A script that applies a view itself with `view.apply` sets the camera too. The ground grid lies on Z 0, so it cuts through a part that straddles it: `--no-grid` leaves it out of the picture, and `--no-axes` the origin's axes. The picture is drawn on the GPU with the viewport's own pipelines, so it shows what the window would (a surface analysis such as `inspect.zebra` included); a machine without a GPU uses a software one (Mesa's lavapipe on Linux, WARP on Windows), and with neither (a Mac without usable Metal, a Linux box without Mesa's Vulkan) the CPU rasterizer draws it, without surface analysis colours, and the reply's `renderer` says `cpu` with a warning. `SOLIDCRAFT_GPU=auto|hardware|software|cpu` chooses (`hardware` and `software` fail rather than fall to the CPU), and `solidcraft-cli gpu` lists the adapters and the tier a snapshot would use. ```sh solidcraft-cli snapshot part.solidcraft --out photo.png --view Photo diff --git a/docs/control-protocol.md b/docs/control-protocol.md index eb2360fd..3de543ff 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -136,7 +136,7 @@ labels; `prefs:measure_axis_distances` identifies the checkbox. | `ui.key` | `key` (egui key name, `Enter`, `Escape`…), `cmd?`, `shift?` | | | `ui.text` | `text` | typed text | | `ui.screenshot` | `path?` | PNG of the window (needs a presented frame) | -| `ui.render` | `path`, `width?`, `height?`, `grid?`, `axes?` | CPU render of the model with the current camera (no window needed), in the window's theme (light or dark; `solidcraft-cli snapshot` keeps the light colours); cut by the section the viewport shows (`inspect.section`, an open Section Analysis dialog's preview, a sketch's Slice), with the cut face capped. The ground grid and the origin's axes are drawn as the window shows them (`ui.set` `showGrid` and `showOrigin`, `hiddenOrigin`); `grid` and `axes` (booleans) say otherwise for this render | +| `ui.render` | `path`, `width?`, `height?`, `grid?`, `axes?` | Render of the model with the current camera (no window needed), drawn on the app's GPU device with the viewport's pipelines (the reply's `renderer`: `gpu`, or `gpu-software` on a software adapter), in the window's theme (light or dark; `solidcraft-cli snapshot` keeps the light colours); cut by the section the viewport shows (`inspect.section`, an open Section Analysis dialog's preview, a sketch's Slice), with the cut face capped. The ground grid and the origin's axes are drawn as the window shows them (`ui.set` `showGrid` and `showOrigin`, `hiddenOrigin`); `grid` and `axes` (booleans) say otherwise for this render | | `ui.confirm` | `accept?`: bool | answers the Delete confirmation (shown when a delete takes other features with it or makes some fail); without `accept`, what it lists | | `ui.documents` | `action?`: `new`, `switch`, `close`, `quit`; `index?`; `force?` (close without asking); `answer?`: `save`, `dont_save`, `cancel` (answers "save changes?") | the open designs (tabs): names, unsaved flags, the active one, a pending "save changes?" prompt, whether a quit is asking | | `ui.file` | `path`; `insert?`: bool | starts an interactive background Open or Insert; poll `ui.inspect.file_busy` for completion; the window stays responsive | diff --git a/docs/mcp.md b/docs/mcp.md index 4a2ba252..4085cd84 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -18,7 +18,7 @@ document remains the reference implementation today. ## Start it ```sh -# Headless: an in-process design session, no window (CPU renders for screenshots). +# Headless: an in-process design session, no window (screenshots are drawn on the GPU). solidcraft-cli mcp # Headless, starting from a design file or a command script. @@ -149,7 +149,7 @@ They cover the workflow, the command shapes, how to verify with `measure`, and t | `measure` | `body?` or `items?` | Volume (mm³), area (mm²), centre of mass, bbox and topology counts per body, with totals. With `items` (one or two selections as `execute` takes them: `face`, `edge`, `vertex`, `sketch_curve`, `profile`), the picked geometry instead: see below. | | `body_topology` | `body` | Edges (index, midpoint, length, ends) and faces (index, name, area, centroid, normal, `point`, and for a cylindrical face its `cylinder`: `origin`, `axis`, `radius`). An edge midpoint is the `[x,y,z]` that picks that edge for fillet and chamfer. A face's `point` lies on it and picks it (its centroid may not: that of a face with a hole is in the hole); a face's `name` picks it for the commands that take faces (`face.delete`, `solid.shell`, `solid.draft`, `solid.offset_face`, `solid.split_face`, `solid.replace_face`, and `face` of `sheet.convert`). | | `set_parameter` | `name`, `value` (number or expression), `unit?`, `comment?` | Create or change a user parameter and recompute; reports `recomputed` (features rebuilt), `restored` (features whose earlier result for the same inputs was reused, e.g. a value set back) and features that now fail. | -| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: CPU render fitted to the model, or from the named view or camera. Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. | +| `screenshot` | `view?` (iso, top, front, back, bottom, left, right, home, fit, or a named view saved with `view.save`), `camera?` (`{target, yaw, pitch, distance, fov?}`, as `view.apply`), `path?`, `width?`, `height?`, `source?` (window, model), `grid?`, `axes?` | A PNG image content block. Headless: a render with the viewport's pipelines on a headless GPU device (see `solidcraft-cli gpu`; with no adapter, the CPU rasterizer, without analysis colours), fitted to the model, or from the named view or camera; the reply's `renderer` says which (`gpu`, `gpu-software`, `cpu`, with a `warning` for `cpu`). Bridged: the app window (default) or a model render with the app's camera; a named view or camera moves the app's viewport there first (`execute` `view.apply` does the same without a screenshot). `grid: false` and `axes: false` leave the ground grid (it lies on Z 0 and cuts through a part that straddles it) and the origin's axes out of a model render; axes hidden with `browser.visibility` stay out whatever `axes` says. Bridged, a screenshot that gives `grid` or `axes` is a model render, which follows the app's Grid and Origin switches for what it does not give; `source: "window"` with either is an error. | | `export` | `path`, `format?` (step, 3mf, 3mf-tagged, stl, stla, obj, urdf), `bodies?`, `mesh_prefix?`, `effort?`, `velocity?` | STEP, 3MF or mesh export; URDF writes the whole assembly with its meshes next to it (`docs/urdf.md`). | | `undo`, `redo` | — | Undo / redo one change. | | `crash_reports` | `id?`, `limit?` | The crash reports on this computer, newest first; with `id`, the whole report (backtrace, command, recent commands, `design_path`/`replay_path`). See [crash-reports.md](crash-reports.md). | diff --git a/xtask/src/book.rs b/xtask/src/book.rs index b20f0115..03311f7b 100644 --- a/xtask/src/book.rs +++ b/xtask/src/book.rs @@ -2,7 +2,7 @@ //! //! The command reference (`docs/book/src/reference/commands.md`) comes from //! `solidcraft-cli commands`, so it can't drift from the registry. The screenshots in -//! `docs/book/src/generated/` are CPU renders by `solidcraft-cli snapshot` of `examples/*.json` +//! `docs/book/src/generated/` are GPU renders by `solidcraft-cli snapshot` of `examples/*.json` //! and `docs/book/scripts/*.json`, plus the app screenshot from `docs/screenshots/`. Both are //! gitignored and attributed in ATTRIBUTION.md. The book itself is built by `mdbook` (a separate //! tool, not a dependency) into `target/book`. diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index b2a5413f..2118c8e0 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -56,8 +56,9 @@ pub const TABLE: &[(&str, Class)] = &[ ]; /// Explicit orderings *within* a layer (earlier may be used by later). -/// The viewport draws display plans the GPU crate evaluates. -pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["gpu", "ui-egui"]]; +/// The viewport draws display plans the GPU crate evaluates and draws with its pipelines; the MCP +/// server's tests draw screenshots with them. +pub const INTRA_LAYER_ORDER: &[&[&str]] = &[&["gpu", "ui-egui"], &["gpu", "mcp"]]; fn intra_layer_allowed(from: &str, to: &str) -> bool { let (from, to) = (short_name(from), short_name(to));