mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-13 20:20:39 +00:00
5c99792a8b
* solidcore-tess: re-export the evaluator types and vector conversions its plans expose
TessPlan's public `surfaces` are solidcore::eval::EvalSurface values built with
solidcore_convert's vector conversions, so a client that builds or reads a plan had to name the
kernel crate and solidcore-convert, which the solidcore seam check refuses outside the public
crates. solidcore-tess now re-exports EvalCurve, EvalSurface, vec3 and record_vec3; a test
builds and reads a plan's surfaces through those names alone.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* Viewport: the background is painted from two colours, not the theme (#12943)
paint_background takes the top and bottom colours instead of Tokens, so the
renderer that will move into a shared crate needs no theme. SolidCraft's
callers pass the viewport tokens as before; a bare-context test pins the
gradient.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewport: the renderer's own vector maths, not geom's prelude (#12943)
The camera, CPU rasterizer, shading models, section hatch and GPU pipelines
use solidcraft_render::vec (Vec3Ops, Vec2Ops: the methods they call, with
geom's formulas and threshold) instead of solidcraft_geom::prelude, one step
toward a renderer with no geometry crate. The GPU crate's test skip lines
print as its other tests do. Part of #12943.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Geometry: Mesh and simplify::Level are solidcore-tess's (#12943)
solidcraft_geom's Mesh and Level were field-for-field copies of
solidcore-tess's. geom now re-exports them, so the kernel's tessellation,
the model and the viewport hold one type and nothing is copied or converted
between them. geom's own mesh queries (bounds, measure, mass_properties,
contains, raycast, crosses, inside_index, the edge chains and patches)
are the MeshGeom trait in its prelude; the five methods solidcore-tess
already has are dropped. Callers that did not import the prelude get the
trait. solidcore-tess is a workspace dependency at solidcore's pin.
Tests pin the identity (geom's types are solidcore-tess's) and that a
headless picture draws the body's own mesh allocation. Refs #13399.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Render: the job tests import MeshGeom only where they use it (#12943)
retained_request_tests calls Mesh::bounds only in its raytrace-cpu tests, so
the import is gated with them; without the feature it was an unused import.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewport: the renderer names solidcore-tess's Mesh and Level (#12943)
The renderer's scene, CPU rasterizer, GPU pipelines and offscreen captures
take solidcore_tess::Mesh and simplify::Level, the types SolidCraft's model
already holds (geom re-exports them), so they no longer name geom for
meshes. render and gpu depend on solidcore-tess directly.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewport: the camera and ground occlusion take the renderer's Bounds (#12943)
Camera::fit, fit_aspect, clip_bounds and view_proj_bounds, GroundAo and
Frame::preview_bounds take solidcraft_render::vec::Bounds (plain min/max with
the box queries they use) instead of geom's Aabb3, so the viewport API names
no geometry crate. A model's Aabb3 becomes a Bounds in one place,
solidcraft_render::view_bounds, on the app side of the renderer; every
caller in engine, ui-egui, mcp, the CLI and the render job code converts
through it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU tessellation: what the shader is given is pinned byte for byte (#12943)
A digest of the packed surface headers, data, vertices and pinned nodes, for
the GPU tests' kernel bodies and for synthetic plans that use every packed
field (a pinned node, a flipped rational NURBS closed along u, a revolved
face swapped under a mirroring transform), alone and packed together. The
upload moves to solidcore-tess's plans next; this holds it to the same bytes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewport: the GPU evaluates solidcore-tess's plans, made once per plan cell (#12943)
TessGpu packs and evaluates solidcore_tess::TessPlan (solidcore's surface
forms, read through their accessors and solidcore-convert), so the GPU
tessellation names no geom type. The kernel still plans in geom's form:
doc converts each display plan once, in cells beside its plan cells
(ModelBody::gpu_plan_at, shared by clones and placements like the plan), so
the viewport's uploads, keyed by each plan's allocation, stay put from frame
to frame. A refined plan is converted once when it is made and kept in that
form by the viewport. The conversion (doc::gpu_plan) is temporary until the
kernel plans in solidcore-tess's form (#13399).
A plan with pole nodes is refused as GpuError::PoleNodes and drawn from its
CPU mesh like any refused plan. The packed bytes match the base digests for
the kernel and synthetic plans.
Extra memory, live malloc bytes (macOS heap) for each design's display plans
at the current quality, geom plans then GPU plans:
SELENE-functional (2116 bodies): 189 MiB, then 157 MiB more (0.83x)
modern-sheet-metal: 213 KiB, then 61 KiB (0.29x)
modern-plastic: 95 KiB, then 77 KiB (0.81x)
joints-hinge (4 bodies): 110 KiB, then 104 KiB (0.95x)
components-arm (3 bodies): 99 KiB, then 85 KiB (0.86x)
m0-revolve-primitives: 80 KiB, then 69 KiB (0.86x)
m0-box-fillet-hole: 42 KiB, then 39 KiB (0.94x)
The geom figure includes what planning keeps beside the plan.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewport: a Shared solidcraft-viewport crate with the camera, pictures and shading (#12943)
The orbit camera, the description of a picture and its CPU rasterizer, the
opaque and clear-coat shading models, the section hatches and the renderer's
vector and box maths move from solidcraft-render into a new Shared crate,
solidcraft-viewport (xtask layers: shared). It depends on solidcore-record,
solidcore-tess and solidcraft-color only, and names no geometry or app type.
Every consumer (render's jobs and path tracer, doc, engine, gpu, mcp, ui-egui,
the apps and their tests) uses solidcraft_viewport paths; render and engine
re-export none of it. The path tracer's use of the shading model
(OpaqueSurface::with_authored_finish, specular_fresnel) and of the CPU shade
is now public API. The GPU pipelines join the crate next.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU: the host passes the adapter policy and CPU tessellation; the viewport code reads no app variable (#12943)
The GPU code that moves to the shared viewport crate no longer reads
SOLIDCRAFT_GPU or SOLIDCRAFT_CPU_TESS: open_headless, TessGpu::headless,
Capturer::headless*, capture::{shared, headless, headless_rgba} take the
Policy, and view::tessellation and TessLater::start take force_cpu.
capture::headless returns the GpuError, and drawing is Headless::rgba.
NoAdapter names only the policy; device labels and thread names are neutral,
and the docs name no product.
SolidCraft reads its variables in solidcraft_gpu::env (app side; it stays in
crates/gpu): policy(), force_cpu_tessellation(), and headless entry points
that add how to choose another adapter (SOLIDCRAFT_GPU, `solidcraft-cli gpu`)
to a missing adapter. Every caller (startup, CLI, MCP, ui-egui, path tracer,
tests) passes them. Tests: the app's parsers and hint, force_cpu leaving the
evaluation out on a device that would run it, and a scan that the moving
files' code names no SolidCraft variable, product or CLI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewport: its shaders live in crates/viewport/shaders (#12943)
viewport.wgsl (from assets/shaders) and tess.wgsl (from crates/gpu/src) move
beside the crate that will compile them, so a Shared crate includes nothing
from outside its own directory. The GPU code includes them from there until
it moves too; the HLSL check, the colour-token check, the ATTRIBUTION.md row
and assets/README.md point at the new place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* GPU tessellation: PackedPlans::of, the shader's input as public data (#12943)
What prepare_many uploads (surface headers, their f32 data, vertex words, the
pinned nodes) is a public PackedPlans, made by PackedPlans::of, with
SURFACE_WORDS and VERTEX_WORDS public beside it. The byte-parity digests of
the kernel and synthetic plans and the pole refusal read it there, so they
can stay app-side once the GPU code moves into the viewport crate. Same
digests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewport: the GPU pipelines, pictures and tessellation join the shared crate (#12943)
crates/gpu's viewport code (view, view/*, pbr, capture, concurrent, select,
and its root: TessGpu, PackedPlans, devices, depth formats, errors) moves to
solidcraft_viewport::gpu. crates/gpu keeps the app side: the path tracer
(path), SolidCraft's GPU environment (env) and the tests built on kernel
bodies. Every caller uses solidcraft_viewport::gpu paths; nothing is
re-exported from solidcraft_gpu.
The shared crate's tests read their own VIEWPORT_TEST_GPU and
VIEWPORT_TEST_REQUIRE_GPU (gpu::test_env; with REQUIRE set a missing adapter
fails, pinned), and ci.yml sets them beside the SOLIDCRAFT_* ones on the
Linux, GPU path and Windows FXC jobs. The shader, HLSL and empty-dispatch
tests move with the code. TessGpu gains device()/queue() and open_headless
and Policy::admits are public for the app's tests and path tracer. The app
test that the crate names no SolidCraft variable or product now scans all of
crates/viewport. README and docs/render-path.md describe the variables.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewport: the egui glue is the shared crate's solidcraft_viewport::egui (#12943)
The paint callbacks (ViewportCallback, ProductHighlightCallback), install,
TessReady, paint_background and log_uncaptured_errors move from ui-egui's
gpu.rs to solidcraft_viewport::egui. install takes force_cpu and a reporter
instead of reading SolidCraft's environment and writing its stderr line;
ui-egui passes solidcraft_gpu::env::force_cpu_tessellation() and its own
report_gpu_error. The background test moves with paint_background. ui-egui
keeps the app glue: tick_tessellation, the picture renderers, the background
picture preference and the offscreen test rigs. Every caller uses the new
paths; the viewport crate builds for wasm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Viewport: base, -gpu and -egui crates, so egui and wgpu stay above the engine (#12943)
solidcraft-viewport had grown the GPU code and the egui glue, so doc, io,
render and engine, which use it for the camera, pictures and shading, built
wgpu, egui and egui-wgpu: against "nothing below ui-egui depends on egui".
It is now three Shared crates: solidcraft-viewport (camera, pictures, CPU
rasterizer, shading, hatches; solidcore and color only),
solidcraft-viewport-gpu (the former gpu module, its shaders and test
variables) and solidcraft-viewport-egui (the former egui module). Every
caller uses solidcraft_viewport_gpu / solidcraft_viewport_egui paths; each
crate has its README. cargo tree shows no egui, egui-wgpu or wgpu under
engine, doc, render or io.
xtask layers now follows workspace dependencies: a crate below L5 that
reaches a UI crate (egui, egui-wgpu, wgpu, ...) through any workspace crate,
Shared ones included, is a violation naming the crate it comes through. On
the graph before this change it reported 12 (doc, io, render and engine
reaching egui, egui-wgpu and wgpu through solidcraft-viewport); it passes now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Docs: the shared viewport crates in the architecture, and their egui-wgpu patch (#12943)
docs/architecture.md lists viewport, viewport-gpu and viewport-egui among the
Shared crates, with what each holds and the layers rule that keeps UI
toolkits and wgpu above L5 through them; render and gpu describe what stays
app-side. The book's architecture page names the crates that draw headless
pictures. vendor/README.md says that solidcraft-viewport-egui relies on the
vendored egui-wgpu and that another workspace using it patches to the same
copy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Workspace: solidcore-tess and solidcore-convert from the vendored solidcore/ (#12943)
Since the viewport split was branched, main vendored solidcore at solidcore/
and takes its crates as path dependencies. The split still pinned
solidcore-tess, solidcore-convert and solidcore-record from git; they now
come from the vendored tree, the lock has no git source for solidcore left,
and the architecture doc's doubled L1 row is merged into one.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* Viewport: the evaluator types and vector conversions through solidcore-tess (#12943)
Since the split was branched, main's solidcore seam check (cargo xtask
layers, xtask/solidcore-public.txt) lets SolidCraft use only solidcore-ops,
solidcore-tess and solidcore-record. The doc's GPU plan, the GPU tests and
the shared viewport-gpu crate named solidcore::eval's EvalCurve and
EvalSurface and solidcore-convert's vec3 and record_vec3 directly; they now
take solidcore-tess's re-exports of the same items, and drop their
solidcore and solidcore-convert dependencies. No crate uses
solidcore-convert any more, so its workspace entry goes too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* Render workspace tests: the product preview light test on the split viewport (#12943)
Main's newer test product_preview_lights_decode_simple_colours_as_the_cpu_image_does
took StandardView from solidcraft_engine::render and fitted the camera to the
scene's Aabb3. The split moved StandardView to the shared viewport crate and
the camera now fits the renderer's Bounds, so the test names
solidcraft_viewport::StandardView and fits through render::view_bounds, as
its sibling tests do. What it asserts is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* xtask shared: a shared crate builds on its own with the vendored solidcore (#12943)
cargo xtask shared builds each shared crate in a scratch workspace that
keeps only external dependencies and the copied shared crates. Since the
viewport split was branched, main vendored solidcore at solidcore/ and takes
its crates as path dependencies, which the scratch workspace dropped; the
shared viewport crate is the first shared crate to depend on solidcore
(which shared crates may), so it failed with solidcore-record missing from
workspace.dependencies. Workspace dependencies whose path lies under the
vendored solidcore/ tree now stay, with the path made absolute as patches
already are; the module comment says so and the scratch-manifest test
covers it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* xtask mesh-decisions: the GPU display plan is display work (#12943)
cargo xtask mesh-decisions on main flags any function that reads a display
plan or a mesh's triangles outside display, export, previews and mesh
bodies. The viewport split added crates/doc/src/gpu_plan.rs, which copies a
body's display plan into the form the viewport evaluates on the GPU, and
ModelBody::gpu_plan_at, which caches that copy per display quality. Both
are the display plan itself, so they join the skipped modules and functions
beside display_quality.rs and ModelBody::display_plan; mesh-decisions.txt
is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* Viewport: main's render update on the split crates (#12943)
Main's render quality update (#12546) landed after the split was made and still named the
render crate's moved surface model, camera and standard views. Its texture, denoise,
indirect-light and image code and tests now name them in solidcraft_viewport, and the GPU path
tests take srgb8_to_linear from there. The GGX visible-normal sampler it added to glass_model
is shared by the clear coat's test in the viewport crate and by render's glass and indirect
light, so it moves to solidcraft_viewport::surface::visible_normal (render keeps its own error
mapping). with_metal_energy_compensation and metal_energy_compensation are public, since
render calls them from another crate now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* Saved models: the viewport crate is in the cache key (#12943)
doc and render compute models with the shared viewport crate now (its camera bounds and
surface model), so a change there must invalidate the cached models, as the io test asks.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* GPU: pin plans_pack_to_the_same_bytes to main's own packing
The expected digests were made on the older solidcore (a445bdaac). The
values here are main's own packer run on current main (the export-main
run: main plus only this pin test), not a re-bless of the split's output.
The split already packs those bytes: geom's plan through gpu_plan and
PackedPlans gives surface headers, data, vertices, count and fixed nodes
equal field for field to main's packer over the same geom plan, for all
five bodies. No code change is needed; only the stale expectations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* Cargo.lock: the viewport dependency in cargo's order (#12943)
A hand-merged entry listed sysinfo before solidcraft-viewport; cargo sorts them by name and
rewrites the file, which a --locked build refuses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* GPU: pin packing byte for byte on the literal plans; kernel-made plans round per platform (#12943)
CI on x86-64 Linux and a run on arm64 macOS pack the same three kernel-made bodies (a box
with a hole, a cross hole, a mirrored pin) to different bytes: the kernel's plans round
differently per platform, and both main's packer and the split's agree on each platform. The
pin keeps the plans built from literal numbers (one of each surface kind), alone and packed
together, which pack identically everywhere; the real bodies must pack, and pack the same way
twice.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
* Viewport GPU: main's resolve-clear test opens its capturer with the tests' adapter policy (#12943)
The test (#14090) landed on main after the split and called Capturer::headless() without the
policy the host now passes; it takes the tests' VIEWPORT_TEST_GPU policy, as the crate's other
tests do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DtL5M2MmcDrkcWhQeHS2K8
---------
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
20 KiB
Rust
413 lines
20 KiB
Rust
//! `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). 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};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
use serde_json::{Value, json};
|
|
use solidcraft_engine::Session;
|
|
use solidcraft_mcp::{Headless, Server};
|
|
|
|
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
/// 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_viewport_gpu::capture::shared(solidcraft_gpu::env::policy().unwrap_or_else(|e| panic!("{e}"))) {
|
|
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_viewport_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})
|
|
}
|
|
|
|
struct Scratch(PathBuf);
|
|
|
|
impl Scratch {
|
|
fn new() -> Self {
|
|
let dir = std::env::temp_dir().join(format!("solidcraft-snapshot-camera-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
|
std::fs::create_dir(&dir).unwrap();
|
|
Self(dir)
|
|
}
|
|
|
|
/// A box that straddles Z 0 and keeps clear of the origin (its axes show beside it), with a view
|
|
/// saved in the design, plus `more` steps.
|
|
fn script(&self, name: &str, more: &[Value]) -> PathBuf {
|
|
let mut steps = vec![
|
|
json!({"command": "solid.box", "params": {"length": 60, "width": 40, "height": 30, "corner": [10, -20, -15]}}),
|
|
json!({"command": "view.save", "params": {"name": "Detail", "camera": camera()}}),
|
|
];
|
|
steps.extend(more.iter().cloned());
|
|
let path = self.0.join(name);
|
|
std::fs::write(&path, json!({"commands": steps}).to_string()).unwrap();
|
|
path
|
|
}
|
|
|
|
fn run(&self, script: &Path, out: &str, extra: &[&str]) -> Output {
|
|
let (w, h) = (SIZE.0.to_string(), SIZE.1.to_string());
|
|
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
|
.arg("snapshot")
|
|
.arg(script)
|
|
.arg("--out")
|
|
.arg(self.0.join(out))
|
|
.args(["--width", &w, "--height", &h])
|
|
.args(extra)
|
|
.output()
|
|
.unwrap()
|
|
}
|
|
|
|
/// The PNG `snapshot` writes.
|
|
fn snapshot(&self, script: &Path, out: &str, extra: &[&str]) -> Vec<u8> {
|
|
let output = self.run(script, out, extra);
|
|
assert!(output.status.success(), "{extra:?}: {}", String::from_utf8_lossy(&output.stderr));
|
|
let png = std::fs::read(self.0.join(out)).unwrap();
|
|
assert!(png.starts_with(b"\x89PNG"), "{extra:?}");
|
|
png
|
|
}
|
|
|
|
/// The PNG the MCP `screenshot` tool writes for the same design.
|
|
fn mcp(&self, script: &Path, out: &str, arguments: Value) -> Vec<u8> {
|
|
let mut session = Session::default();
|
|
session.run_script(&serde_json::from_slice(&std::fs::read(script).unwrap()).unwrap()).unwrap();
|
|
let mut server = Server::new(Box::new(Headless::new(session)));
|
|
let mut arguments = arguments;
|
|
arguments["path"] = json!(self.0.join(out).to_string_lossy());
|
|
arguments["width"] = json!(SIZE.0);
|
|
arguments["height"] = json!(SIZE.1);
|
|
let call = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": {"name": "screenshot", "arguments": arguments}});
|
|
let reply: Value = serde_json::from_str(&server.handle_line(&call.to_string()).unwrap()).unwrap();
|
|
assert!(reply.get("error").is_none() && reply["result"]["isError"] != json!(true), "{reply}");
|
|
std::fs::read(self.0.join(out)).unwrap()
|
|
}
|
|
}
|
|
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
/// The picture of `--camera` is the picture of the MCP `screenshot` with the same camera, and
|
|
/// 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_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_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_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();
|
|
s.run_script(&serde_json::from_slice(&std::fs::read(&script).unwrap()).unwrap()).unwrap();
|
|
let mut cam = solidcraft_engine::view::home_camera(&s).unwrap();
|
|
cam.set_view(solidcraft_viewport::StandardView::Top);
|
|
let scene = solidcraft_engine::view::scene(&s, &cam).unwrap();
|
|
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_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_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_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();
|
|
for view in [&["--view", "front"][..], &["--camera", &json][..]] {
|
|
let with = |extra: &[&str], out: &str| dir.snapshot(&script, out, &[view, extra].concat());
|
|
let (all, no_grid, no_axes, bare) = (
|
|
with(&[], "all.png"),
|
|
with(&["--no-grid"], "no-grid.png"),
|
|
with(&["--no-axes"], "no-axes.png"),
|
|
with(&["--no-grid", "--no-axes"], "bare.png"),
|
|
);
|
|
let different = [&all, &no_grid, &no_axes, &bare];
|
|
for (i, a) in different.iter().enumerate() {
|
|
for b in &different[i + 1..] {
|
|
assert_differs(a, b, &format!("{view:?}: two of the pictures"));
|
|
}
|
|
}
|
|
// The same as the MCP's `grid` and `axes`.
|
|
let mcp = |extra: Value, out: &str| {
|
|
let mut a = match view[0] {
|
|
"--view" => json!({"view": "front"}),
|
|
_ => json!({"camera": camera()}),
|
|
};
|
|
a.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
|
dir.mcp(&script, out, a)
|
|
};
|
|
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_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 = solidcore_record::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).unwrap();
|
|
cam.set_view(solidcraft_viewport::StandardView::Top);
|
|
let scene = solidcraft_engine::view::scene(&s, &cam).unwrap();
|
|
let cpu = solidcraft_viewport::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]
|
|
fn a_view_or_a_camera_that_cannot_be_used_is_an_error_and_writes_nothing() {
|
|
let dir = Scratch::new();
|
|
let script = dir.script("part.json", &[]);
|
|
let cases: [(&[&str], &str); 7] = [
|
|
(&["--view", "Nowhere"], "Nowhere"),
|
|
(&["--view", "iso", "--camera", "{}"], "not both"),
|
|
(&["--camera", "not json"], "--camera"),
|
|
(&["--camera", "{\"target\": [0, 0, 0]}"], "yaw"),
|
|
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 0}"], "distance"),
|
|
(&["--camera", "{\"target\": [0, 0, 0], \"yaw\": 0, \"pitch\": 0, \"distance\": 50, \"fov\": -1}"], "camera"),
|
|
(&["--camera", "[1, 2, 3]"], "camera"),
|
|
];
|
|
for (extra, expect) in cases {
|
|
let output = dir.run(&script, "never.png", extra);
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
assert_eq!(output.status.code(), Some(1), "{extra:?}: {stderr}");
|
|
assert!(stderr.contains(expect), "{extra:?}: {stderr}");
|
|
assert!(output.stdout.is_empty() && !dir.0.join("never.png").exists(), "{extra:?}");
|
|
}
|
|
}
|
|
|
|
/// The usage text says what the new options do.
|
|
#[test]
|
|
fn the_usage_names_the_camera_and_the_guides() {
|
|
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).arg("snapshot").output().unwrap();
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
for word in ["--camera", "--view", "view.save", "--no-grid", "--no-axes"] {
|
|
assert!(stderr.contains(word), "usage lacks {word}: {stderr}");
|
|
}
|
|
}
|