Headless pictures draw on the GPU, with the viewport's pipelines (CPU rasterizer as last tier) (#1555)

Thanks @jyviko.
This commit is contained in:
Irakli Kourtis authored and GitHub committed 2026-10-15 22:19:26 -04:00
1 parent 48322f6464
commit 1a0b33fcf3
54 files changed
+3525 -1998

No files matched your search

+14 -1
View File
@@ -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
+7
View File
@@ -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
+1 -1
View File
@@ -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 |
Generated
+7
View File
@@ -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]]
+1 -1
View File
@@ -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) |
+5
View File
@@ -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 }
+63 -5
View File
@@ -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 <id> | replay <id> | dir | clear [<id>]]
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<u8>)]) -> Result<Vec<u8>, 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 {
+14 -5
View File
@@ -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));
+217 -22
View File
@@ -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<u8>) {
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::<Value>(&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]
+2
View File
@@ -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);
+1
View File
@@ -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")]
+1 -6
View File
@@ -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),
+114
View File
@@ -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<u8>,
pub renderer: &'static str,
}
/// A picture as PNG bytes, and the renderer that drew it.
pub struct Png {
pub bytes: Vec<u8>,
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<Picture, String> + Send + Sync;
static RENDERER: RwLock<Option<Arc<Renderer>>> = RwLock::new(None);
/// Make `renderer` draw this process's headless pictures (replacing any installed before).
pub fn install(renderer: Arc<Renderer>) {
if let Ok(mut r) = RENDERER.write() {
*r = Some(renderer);
}
}
/// The installed renderer, if any.
pub fn installed() -> Option<Arc<Renderer>> {
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<Picture, String> {
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, String> {
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<Picture, String> {
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<Png, String> {
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));
}
}
+2
View File
@@ -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();
+26 -62
View File
@@ -4,6 +4,15 @@ use solidcraft_geom::Aabb3;
use solidcraft_render::{Camera, Rgb, Scene, SceneMesh, StandardView};
pub(super) fn saved(s: &Session) -> Option<Vec<u8>> {
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<Vec<u8>> {
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());
}
}
+15
View File
@@ -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;
+58 -32
View File
@@ -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.
+13 -4
View File
@@ -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<Rgb> = 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<Rgb> = 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 {
+6 -1
View File
@@ -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"] }
+428
View File
@@ -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<State>,
}
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<Self> {
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<Self> {
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<Vec<u8>> {
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::<Vec<_>>(), 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<Vec<u8>> {
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<Result<Capturer>> = 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<Headless, String> {
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<u8>, &'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<AccessMap>) -> 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<u32, Rgb> = 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<String> {
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<f32>, @location(0) color: vec4<f32> };
@vertex
fn vs(@location(0) p: vec2<f32>, @location(1) c: vec4<f32>) -> O {
var o: O;
o.pos = vec4<f32>(p, 0.0, 1.0);
o.color = c;
return o;
}
@fragment
fn fs(i: O) -> @location(0) vec4<f32> {
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,
})
}
+24 -14
View File
@@ -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<Output = Option<wgpu::Error>>) -> Option<w
}
}
/// An adapter and device without a window (native only). The environment may pick the backend
/// and the D3D12 shader compiler (`WGPU_BACKEND`, `WGPU_DX12_COMPILER`).
/// An adapter and device without a window (native only): the adapter [`select::pick`] chooses
/// under the `SOLIDCRAFT_GPU` policy. The environment may narrow the backends and pick the D3D12
/// shader compiler (`WGPU_BACKEND`, `WGPU_DX12_COMPILER`).
#[cfg(not(target_arch = "wasm32"))]
fn open_headless() -> 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}")]
File renamed without changes.
+261
View File
@@ -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<Tier> 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<Policy> {
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<Policy, String> {
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<Tier>) -> Option<Landing> {
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::<Vec<_>>().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<wgpu::Adapter> {
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<AdapterReport>,
pub chosen: Option<usize>,
pub lands_on: Option<Landing>,
}
/// 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<AdapterReport> = 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:?}");
}
}
File diff suppressed because it is too large. Load diff
+168
View File
@@ -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<Item = [u8; 4]> + '_ {
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());
}
+4
View File
@@ -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 }
+17 -7
View File
@@ -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}`")),
+44 -9
View File
@@ -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| {
+8 -1
View File
@@ -658,7 +658,14 @@ fn capture(b: &mut dyn Backend, a: &Map<String, Value>) -> Result<(Vec<u8>, 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).
+1 -1
View File
@@ -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
+54
View File
@@ -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,
}
+5 -1
View File
@@ -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};
+7 -55
View File
@@ -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<Mesh>,
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<Vec3>,
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<SceneMesh>,
pub lines: Vec<SceneLine>,
/// 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<Rgb>,
}
/// 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<Vec
png(c.w, c.h, c.rgba)
}
/// RGBA pixels (`w × h`) to PNG bytes.
pub fn png(w: usize, h: usize, rgba: Vec<u8>) -> Option<Vec<u8>> {
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);
+65
View File
@@ -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<Mesh>,
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<Vec3>,
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<SceneMesh>,
pub lines: Vec<SceneLine>,
/// The ground grid: drawn under everything (the model hides it; it never hides the model).
pub grid: Vec<SceneLine>,
/// 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<Rgb>,
/// A surface analysis shading the meshes (zebra, draft, curvature map, ...).
pub analysis: Option<SceneAnalysis>,
}
/// 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<u8>) -> Option<Vec<u8>> {
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())
}
+1 -73
View File
@@ -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<Option<((u64, [u64; 3]), AccessMap)>> = const { RefCell::new(None) };
@@ -33,74 +28,7 @@ pub fn map(app: &SolveApp) -> Option<AccessMap> {
v.set(v.get() + 1);
v.get()
});
let m = build(&meshes.iter().map(|m| m.as_ref()).collect::<Vec<_>>(), dir, version)?;
let m = AccessMap::build(&meshes.iter().map(|m| m.as_ref()).collect::<Vec<_>>(), 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<AccessMap> {
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,
})
}
+6 -3
View File
@@ -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 =
+57 -32
View File
@@ -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<vfs::PathS
Ok(Some(vfs::PathScope::new(roots).with_design(app.session.path.as_deref())))
}
/// A model render of the view as the window shows it (theme, section, grid and axes), as PNG.
pub fn view_png(app: &SolveApp, w: usize, h: usize) -> Option<Vec<u8>> {
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<Vec<u8>, 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<Vec<u8>, 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, .. })) => {
@@ -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(
@@ -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
+21 -7
View File
@@ -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<egui::
if !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)
}
/// Bound raster work as well as file size: repeated full-screen triangles must
/// not monopolize the file worker. Match the rasterizer's 2x sampling rectangles.
/// The preview picture, drawn on the GPU by the renderer the app installs (none: no picture).
#[cfg(not(target_arch = "wasm32"))]
fn preview_image(scene: &solidcraft_engine::render::Scene, camera: &solidcraft_engine::render::Camera) -> Option<egui::ColorImage> {
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(),
-1
View File
@@ -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![]);
+82 -1472
View File
File diff suppressed because it is too large. Load diff
+13 -2
View File
@@ -69,8 +69,9 @@ pub fn thumbnail(s: &Session) -> Option<egui::ColorImage> {
// 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();
+3 -3
View File
@@ -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(())
})();
@@ -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));
+12 -3
View File
@@ -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") {
+2 -12
View File
@@ -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.
+68 -150
View File
@@ -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<crate::sketch_display::DisplayCache>,
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<NavMode>,
/// 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<solidcraft_engine::geom::Aabb3
}
/// The section as the shader's clip plane (normal, d), zero when there is none.
/// The surface analysis for the shader (see `gpu::ViewportCallback::analysis`).
/// The surface analysis for the shader (see `gpu::Frame::analysis`).
fn analysis_uniform(app: &SolveApp) -> [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<Color32> {
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<Pos2>, 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();
+9 -2
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 |
+2 -2
View File
@@ -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). |
+1 -1
View File
@@ -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`.
+3 -2
View File
@@ -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));