mirror of
https://github.com/bherbruck/solidcraft.git
synced 2026-11-03 10:35:58 +00:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a4707404a | |||
| cad0767a10 | |||
| 644a70960e | |||
| c2de6c636d | |||
| 6fbcb5f1ea | |||
| d94b0a1a7b | |||
| a29a45eca1 | |||
| f443a388cc | |||
| e4cceb3db1 | |||
| d8c2abdb2b | |||
| 81d3e4cb14 | |||
| 5c0e48ed2b | |||
| ce1c8db7fb | |||
| f0995dab1c | |||
| 600f12ea0e | |||
| d4ec5e6811 | |||
| 0b3e9a9e1e | |||
| 24c9d14663 | |||
| 934d4ddae2 | |||
| 7a81a7fc15 | |||
| 04acdc251f | |||
| 8cef71d2d2 | |||
| 47a1459060 | |||
| ee5ca2430b | |||
| ac08c3f106 | |||
| 6a197b6cf6 | |||
| e3d7cbe830 | |||
| e3c9f22ead | |||
| c6cbde3706 | |||
| b0d7572daf | |||
| 30449418ac | |||
| 95a56ca00e | |||
| fbf4a5458d | |||
| 2f8677a640 | |||
| bf69db04dd | |||
| e9f2f251e4 |
@@ -70,6 +70,16 @@ filter = """
|
||||
"""
|
||||
priority = 100
|
||||
|
||||
# Each nextest test has its own process/device. Concurrent cold lavapipe path
|
||||
# compiles contend for CPU and can exhaust the 600 s pipeline setup budget.
|
||||
# Serialize this module per CI shard; other tests retain their normal parallelism.
|
||||
[test-groups.gpu-path]
|
||||
max-threads = 1
|
||||
|
||||
[[profile.ci.overrides]]
|
||||
filter = "package(=solidcraft-gpu) & test(/^path::/)"
|
||||
test-group = "gpu-path"
|
||||
|
||||
# Full local validation opts into its own scheduling policy. Keep GitHub CI's
|
||||
# worker reservation and slow-test priorities above independent of this runner.
|
||||
[profile.local-ci]
|
||||
|
||||
@@ -15,8 +15,20 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| File | Author | Source | Licence |
|
||||
|---|---|---|---|
|
||||
| examples/chebyshev-motion-trace.json | SolidCraft contributors | Original parametric Chebyshev lambda mechanism and associative-trace demo, constructed from analytic circle intersections; classical link proportions documented at https://doi.org/10.1002/rcs.70095; no external CAD geometry or artwork | MIT OR Apache-2.0 |
|
||||
| examples/material-card.json | SolidCraft contributors | original analytic box and explicitly synthetic engineering card; invented illustrative constants, no external material dataset | MIT OR Apache-2.0 |
|
||||
| examples/studio/author_sweep.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/author_scene.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/materials.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded-sweep.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded-render.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/stage1-sweep.json | SolidCraft contributors | original tangent circular-arc CAD cyclorama; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/stage1.json | SolidCraft contributors | original editable CAD studio scene; no imported geometry or external image assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/stage1-render.json | SolidCraft contributors | original studio camera and lighting parameters | MIT OR Apache-2.0 |
|
||||
| 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 |
|
||||
| examples/render-path-studio.json | SolidCraft contributors | original procedural opaque enclosure, teal insert and metal fasteners for progressive rendering; no imported models | MIT OR Apache-2.0 |
|
||||
| crates/gpu/src/path/path.wgsl | SolidCraft contributors | original software BVH traversal and path integration using the existing SolidCraft surface/studio equations; no external shader source | 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/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 |
|
||||
@@ -98,6 +110,7 @@ Editable drawing code, native projects and shipped exports are collected under `
|
||||
| tests/oracle/63-hinge-knuckle/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/63-hinge-knuckle/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| tests/oracle/65-sketch-gear-tooth/reference.stl | SolidCraft contributors | triangulation of our own recipe (tests/oracle/65-sketch-gear-tooth/recipe.json) built with FreeCAD as an external tool by tools/freecad-oracle | MIT OR Apache-2.0 |
|
||||
| assets/appearances/library.rs | SolidCraft contributors | Original SolidCraft resource data or drawing code, collected without redesign | MIT OR Apache-2.0 |
|
||||
| assets/appearances/starter_pack.rs | SolidCraft contributors | original illustrative optical recipes, authored scalar values and future procedural resource descriptors; no measured or external material dataset | MIT OR Apache-2.0 |
|
||||
| assets/fonts/biz-udp-gothic/OFL.txt | The BIZ UDGothic Project Authors | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-mono/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
| assets/fonts/ibm-plex-sans/OFL.txt | IBM Corp. | Unmodified accompanying font licence | SIL OFL 1.1 |
|
||||
@@ -546,6 +559,12 @@ copied into SolidCraft.
|
||||
|
||||
| Directory | Source | Licence | Changes |
|
||||
|---|---|---|---|
|
||||
| examples/studio/author_sweep.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/author_scene.py | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/materials.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded-sweep.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded-render.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-exploded.json | SolidCraft contributors | original editable CAD scene and authoring metadata; no external assets | MIT OR Apache-2.0 |
|
||||
| vendor/truck-meshalgo | truck-meshalgo 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | meshing: bounded parameter searches, collapsed-boundary projection, subdivision and provisional fixed-grid previews (vendor/README.md) |
|
||||
| vendor/truck-stepio | truck-stepio 0.3.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | STEP writer: intersection-curve surfaces written at unique ids (vendor/README.md) |
|
||||
| vendor/truck-shapeops | truck-shapeops 0.4.0, https://github.com/ricosjp/truck (RICOS Co. Ltd.) | Apache-2.0 | robust meshing and nearest-parameter fallback in booleans; degenerate intersection-curve rejection; face and edge history of booleans; trace macro; corner choice at vertices several intersection curves end on; reuse of an operand's meshes (vendor/README.md) |
|
||||
@@ -564,3 +583,75 @@ A frame whose meshes the GPU refuses to stage (device lost or out of memory) is
|
||||
skipped with a warning instead of panicking (#1469).
|
||||
Paint callbacks are prepared only after a drawable is acquired, so a skipped
|
||||
surface frame cannot strand cached meshes without their GPU initialization.
|
||||
|
||||
## Runtime studio lighting mathematics
|
||||
|
||||
`crates/gpu/src/path/path.wgsl` implements the power-heuristic estimator from the
|
||||
mathematical description in [PBRT fourth edition, section 13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer).
|
||||
No PBRT code or assets were copied. `crates/render/src/job/lighting.rs` evaluates
|
||||
[Krzysztof Narkowicz's published 2016 ACES rational fit](https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/)
|
||||
using its published coefficients (author licenses the fit under CC0/MIT).
|
||||
These original implementations are under SolidCraft's MIT OR Apache-2.0 licence.
|
||||
|
||||
## Original coffee framing variant
|
||||
|
||||
| File | Author | Provenance | Licence |
|
||||
|---|---|---|---|
|
||||
| examples/studio/author_coffee_framing.py | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-framing.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-framing-sweep.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-framing-render.json | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-framing.md | SolidCraft contributors | original editable composition, camera and documentation; no external assets | MIT OR Apache-2.0 |
|
||||
|
||||
## Original cylindrical coffee housing variant
|
||||
|
||||
| File | Author | Provenance | Licence |
|
||||
|---|---|---|---|
|
||||
| examples/studio/author_coffee_cylinder.py | SolidCraft contributors | original parametric cylindrical CAD construction and editable command recipes; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-cylinder.json | SolidCraft contributors | original cylindrical housing on the original exploded coffee composition; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-cylinder-sweep.json | SolidCraft contributors | original cylindrical housing and continuous CAD cyclorama; no external assets | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-cylinder-render.json | SolidCraft contributors | original camera, studio lighting and optical settings retained from the framing variant | MIT OR Apache-2.0 |
|
||||
| examples/studio/coffee-cylinder.md | SolidCraft contributors | original dimensional recipe, provenance and validation limitations | MIT OR Apache-2.0 |
|
||||
|
||||
## Runtime environment sampling
|
||||
|
||||
`crates/render/src/lighting/environment.rs` and the environment/sun portions of
|
||||
`crates/gpu/src/path/path.wgsl` are original SolidCraft implementations of
|
||||
solid-angle importance sampling and MIS, informed by the mathematical description
|
||||
in [PBRT fourth edition, sections 12.5](https://pbr-book.org/4ed/Light_Sources/Infinite_Area_Lights)
|
||||
and [13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer).
|
||||
The procedural gradient and inline RGBE test fixtures are original mathematical
|
||||
colours, not downloaded imagery. No third-party environment images or shader code
|
||||
are included. Licence: MIT OR Apache-2.0.
|
||||
|
||||
## Original tumbler showcase assets
|
||||
|
||||
The CAD room, tumblers, window opening, wood/weave patterns and RGBE sky below
|
||||
are original SolidCraft contributor work. No third-party scene, photograph, HDRI,
|
||||
texture, or AI-generated artwork was used. Python authoring sources are included.
|
||||
All listed files use the repository MIT OR Apache-2.0 licence.
|
||||
|
||||
| File | Source / author | Use | License |
|
||||
| --- | --- | --- | --- |
|
||||
| examples/studio/author_showcase.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/author_tumblers.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/textures/ATTRIBUTION.md | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/textures/author_textures.py | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/textures/environment-manifest.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/textures/manifest.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-hdr-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-hdr-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-materials.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-sun-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-sun-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-sun-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-sun.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-textures-blocked-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers-textures-render.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
| examples/studio/tumblers.json | Original SolidCraft contributors | Editable scene / runtime resource / reproducible authoring metadata | MIT OR Apache-2.0 |
|
||||
|
||||
The original studio generators produce local ignored PNG maps and an RGBE gradient.
|
||||
Generated image bytes are not source assets; their authorship, licences and expected
|
||||
hashes are documented under `examples/studio/textures`. Rendered evidence belongs
|
||||
on the results branch. No photographs, scans or external image assets are included.
|
||||
|
||||
Generated
+2
@@ -4312,9 +4312,11 @@ dependencies = [
|
||||
name = "solidcraft-gpu"
|
||||
version = "0.21.0"
|
||||
dependencies = [
|
||||
"image",
|
||||
"log",
|
||||
"naga",
|
||||
"pollster 0.4.0",
|
||||
"robust",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"solidcraft-engine",
|
||||
|
||||
@@ -143,6 +143,12 @@ completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Render foundation
|
||||
|
||||
- Original starter appearances (#1722): 34 opaque recipes through existing commands, with six explicitly unavailable glass recipes and preserved legacy looks; see [the contract](docs/render-starter-appearances.md).
|
||||
|
||||
- Bounded linear film accumulation (#1696) supplies caller-admitted mean/variance storage and complete-pass views; actual progressive consumers remain separate. See [the film contract](docs/render-film.md).
|
||||
|
||||
- Definition-local visual mapping (#1214) supplies a bounded numerical frame, physical repeat coordinates and a deterministic one-time grain-axis suggestion. It adds no saved record or texture consumer; see [the mapping contract](docs/render-visual-mapping.md).
|
||||
|
||||
- Explicit texture normalization (#1218) produces completed float RGBA with
|
||||
role-aware linear color/data semantics, bounded matrix/TRC ICC conversion and
|
||||
opt-in EXIF orientation. It retains high-precision raw samples and records lossy
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Reproducible evidence through the production command/job interfaces.
|
||||
//! Usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png
|
||||
//! Writes the editable design, command timings and actual adapter/job metadata beside PNG.
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_engine::{
|
||||
Session,
|
||||
render::job::{CpuHost, ErrorCode, JobId, Phase},
|
||||
};
|
||||
use std::{
|
||||
io::Read,
|
||||
path::Path,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
fn read_json(path: &str) -> Result<Value, String> {
|
||||
let mut bytes = Vec::new();
|
||||
std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?.take(16_777_217).read_to_end(&mut bytes).map_err(|e| e.to_string())?;
|
||||
if bytes.len() > 16_777_216 {
|
||||
return Err("evidence input exceeds 16 MiB".into());
|
||||
}
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let [recipe_path, params_path, output_path] = args.as_slice() else {
|
||||
return Err("usage: studio_evidence RECIPE.json RENDER-PARAMS.json OUTPUT.png".into());
|
||||
};
|
||||
let output = Path::new(output_path);
|
||||
if !output.extension().is_some_and(|e| e.eq_ignore_ascii_case("png")) {
|
||||
return Err("output must have a .png extension".into());
|
||||
}
|
||||
let recipe = read_json(recipe_path)?;
|
||||
let params = read_json(params_path)?;
|
||||
let mut session = Session::default();
|
||||
eprintln!("Generating original CAD scene from {recipe_path}");
|
||||
let generation_start = Instant::now();
|
||||
let (commands, command_timings) = session.run_script_timed(&recipe, false).map_err(|e| e.to_string())?;
|
||||
let generation_seconds = generation_start.elapsed().as_secs_f64();
|
||||
let design_path = output.with_extension("solidcraft");
|
||||
session.execute("file.save_as", &json!({"path":design_path})).map_err(|e| e.to_string())?;
|
||||
let scene = solidcraft_engine::product_scene::extract(&session).map_err(|e| e.to_string())?;
|
||||
let bodies: Vec<Value> = scene
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|mesh| json!({"name":mesh.name,"vertices":mesh.mesh.positions.len(),"triangles":mesh.mesh.triangles.len()}))
|
||||
.collect();
|
||||
drop(scene);
|
||||
|
||||
let policy = solidcraft_gpu::select::Policy::from_env().map_err(|e| e.to_string())?;
|
||||
let (adapter, adapter_report) = solidcraft_gpu::select::pick(policy).ok_or("no adapter satisfies the requested GPU policy")?;
|
||||
let host_start = Instant::now();
|
||||
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
|
||||
session.register_render_job_host(solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
let host_seconds = host_start.elapsed().as_secs_f64();
|
||||
let preflight = session.execute("render.preflight", ¶ms).map_err(|e| e.to_string())?;
|
||||
let start = Instant::now();
|
||||
let started = session.execute("render.start", ¶ms).map_err(|e| e.to_string())?;
|
||||
let id: JobId = serde_json::from_value(started.get("job").cloned().ok_or("missing job id")?).map_err(|e| e.to_string())?;
|
||||
let handle = session.render_job_handle(id).map_err(|e| e.to_string())?;
|
||||
eprintln!("Rendering through production gpu-path host: {adapter_report:?}");
|
||||
let status = loop {
|
||||
match handle.wait_timeout(Duration::from_secs(10)) {
|
||||
Ok(status) => break status,
|
||||
Err(error) if error.code == ErrorCode::TimedOut && start.elapsed() < Duration::from_secs(3600) => {
|
||||
let status = handle.status().map_err(|e| e.to_string())?;
|
||||
eprintln!(
|
||||
"Render progress: {:?} {}/{} after {:.1}s",
|
||||
status.progress.phase,
|
||||
status.progress.done,
|
||||
status.progress.total,
|
||||
start.elapsed().as_secs_f64()
|
||||
);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = handle.cancel();
|
||||
return Err(error.to_string());
|
||||
}
|
||||
}
|
||||
};
|
||||
let render_and_encode_seconds = start.elapsed().as_secs_f64();
|
||||
if status.progress.phase != Phase::Completed {
|
||||
return Err(status.error.map_or_else(|| "render did not complete".into(), |e| e.to_string()));
|
||||
}
|
||||
let exported = session.execute("render.export", &json!({"job":id,"path":output_path,"overwrite":true})).map_err(|e| e.to_string())?;
|
||||
let report = json!({
|
||||
"recipe":recipe_path,"render_params":params,"adapter":adapter_report,
|
||||
"environment":{"SOLIDCRAFT_GPU":std::env::var("SOLIDCRAFT_GPU").ok(),"WGPU_BACKEND":std::env::var("WGPU_BACKEND").ok()},
|
||||
"cad_generation_seconds":generation_seconds,"gpu_host_setup_seconds":host_seconds,
|
||||
"render_and_encode_seconds":render_and_encode_seconds,
|
||||
"timing_scope":"wall time from render.start through completed PNG encoding; includes job preparation and readback, excludes CAD generation and initial GPU pipeline setup",
|
||||
"bodies":bodies,"commands":commands,"command_timings":command_timings,
|
||||
"preflight":preflight,"export":exported,"editable_design":design_path
|
||||
});
|
||||
std::fs::write(output.with_extension("json"), serde_json::to_vec_pretty(&report).map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
println!("{}", serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), String> {
|
||||
run()
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const USAGE_BENCH_ASSISTANT: &str = " solidcraft-cli bench-assistant --connect
|
||||
/// `snapshot`'s lines in the usage.
|
||||
const USAGE_SNAPSHOT: &str = " solidcraft-cli snapshot <script|design> [--in design.solidcraft|part.step] --out shot.png [--width W] [--height H] [--view NAME | --camera JSON] [--no-grid] [--no-axes]\n [--body NAME:opacity=0.3,color=#rrggbb]... [--tool NAME]... [--section z=27]\n solidcraft-cli snapshot --in <design.solidcraft|part.step> --out shot.png ...\n (as with run, --in starts from a design and the script runs on top of it; an option snapshot doesn't take is an error;\n --view: iso, front, back, top, bottom, left, right, or the name of a view saved in the design with view.save;\n --camera: {target: [x,y,z], yaw, pitch, distance, fov?} in mm and radians, as the MCP screenshot takes it (docs/mcp.md);\n --no-grid and --no-axes leave the ground grid (on Z 0) and the origin's axes out of the picture;\n --body: how one body is drawn, as appearance.assign takes it: opacity 0-1 (below 1 is see-through, so what is inside\n it shows) and color #rrggbb, either or both, once per body;\n --tool: draw this body solid in the tool colour and every other body see-through, so a cut's or a join's tool body\n is visible inside the material it meets; a later --body overrides it;\n --section: cut the model as inspect.section does, so the inside shows: z=27 (the plane through that world\n coordinate, keeping the smaller side), add ,flip to keep the other side, or XY, XZ, YZ, or its own JSON)\n";
|
||||
/// `render`'s lines in the usage.
|
||||
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n";
|
||||
const USAGE_RENDER: &str = " solidcraft-cli render <script|design> --out image.png [--setup NAME] [--width W] [--height H] [--transparent] [--overwrite] [--timeout SECONDS]\n [--progressive] [--samples N] [--max-depth N] [--seed N] [--exposure EV] [--lighting FILE.json] [--environment FILE.json] [--tone-map reinhard|aces_fitted]\n";
|
||||
/// `exec`'s lines in the usage.
|
||||
const USAGE_EXEC: &str = " solidcraft-cli exec <command> [json-params]\n";
|
||||
/// `commands`'s lines in the usage.
|
||||
|
||||
@@ -11,15 +11,25 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut setup = None;
|
||||
let mut width = None;
|
||||
let mut height = None;
|
||||
let mut progressive = false;
|
||||
let mut samples = None;
|
||||
let mut max_depth = None;
|
||||
let mut seed = None;
|
||||
let mut exposure = None;
|
||||
let mut lighting = None;
|
||||
let mut tone_map = None;
|
||||
let mut environment = None;
|
||||
let mut transparent = false;
|
||||
let mut overwrite = false;
|
||||
let mut timeout = 120u64;
|
||||
let mut i = 1;
|
||||
while let Some(arg) = args.get(i) {
|
||||
match arg.as_str() {
|
||||
"--progressive" => progressive = true,
|
||||
"--transparent" => transparent = true,
|
||||
"--overwrite" => overwrite = true,
|
||||
"--out" | "--setup" | "--width" | "--height" | "--timeout" => {
|
||||
"--out" | "--setup" | "--width" | "--height" | "--timeout" | "--samples" | "--max-depth" | "--seed" | "--exposure" | "--lighting"
|
||||
| "--tone-map" | "--environment" => {
|
||||
i += 1;
|
||||
let value = args.get(i).ok_or_else(|| format!("missing value for {arg}"))?;
|
||||
match arg.as_str() {
|
||||
@@ -27,6 +37,26 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
"--setup" => setup = Some(value.clone()),
|
||||
"--width" => width = Some(value.parse::<u32>().map_err(|_| "invalid width")?),
|
||||
"--height" => height = Some(value.parse::<u32>().map_err(|_| "invalid height")?),
|
||||
"--samples" => samples = Some(value.parse::<u32>().map_err(|_| "invalid samples")?),
|
||||
"--max-depth" => max_depth = Some(value.parse::<u32>().map_err(|_| "invalid depth")?),
|
||||
"--seed" => seed = Some(value.parse::<u32>().map_err(|_| "invalid seed")?),
|
||||
"--exposure" => exposure = Some(value.parse::<f64>().map_err(|_| "invalid exposure")?),
|
||||
"--environment" => {
|
||||
let text =
|
||||
solidcraft_engine::io::vfs::read_to_string(value, 65536).map_err(|e| format!("could not read environment: {e}"))?;
|
||||
environment = Some(serde_json::from_str::<serde_json::Value>(&text).map_err(|e| e.to_string())?);
|
||||
}
|
||||
"--lighting" => {
|
||||
let text = solidcraft_engine::io::vfs::read_to_string(value, 65536).map_err(|e| format!("could not read lighting: {e}"))?;
|
||||
lighting = Some(serde_json::from_str::<solidcraft_engine::render::job::StudioLighting>(&text).map_err(|e| e.to_string())?);
|
||||
}
|
||||
"--tone-map" => {
|
||||
tone_map = Some(match value.as_str() {
|
||||
"reinhard" => solidcraft_engine::render::job::ToneMap::Reinhard,
|
||||
"aces_fitted" => solidcraft_engine::render::job::ToneMap::AcesFitted,
|
||||
_ => return Err("tone-map must be reinhard or aces_fitted".into()),
|
||||
})
|
||||
}
|
||||
_ => timeout = value.parse::<u64>().map_err(|_| "invalid timeout")?,
|
||||
}
|
||||
}
|
||||
@@ -40,6 +70,9 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let out = out.ok_or("render requires --out FILE.png")?;
|
||||
let mut session = crate::load(input)?;
|
||||
session.set_render_job_host(Some(CpuHost::for_platform().map_err(|e| e.to_string())?)).map_err(|e| e.to_string())?;
|
||||
if progressive {
|
||||
session.register_render_job_host(solidcraft_gpu::path::host().map_err(|e| e.to_string())?).map_err(|e| e.to_string())?;
|
||||
}
|
||||
session.execute("render.scene", &json!({})).map_err(|e| e.to_string())?;
|
||||
let mut settings = if let Some(name) = setup {
|
||||
session.doc.render.setups.get(&name).cloned().ok_or("no saved render setup with that name")?
|
||||
@@ -58,7 +91,10 @@ pub fn run(args: &[String]) -> Result<(), String> {
|
||||
if let Some(height) = height {
|
||||
settings.height = height;
|
||||
}
|
||||
let params = json!({"settings":settings,"transparent_background":transparent});
|
||||
if let Some(exposure) = exposure {
|
||||
settings.exposure_ev = exposure;
|
||||
}
|
||||
let params = json!({"settings":settings,"transparent_background":transparent,"progressive":progressive,"samples":samples,"max_depth":max_depth,"seed":seed,"lighting":lighting,"tone_map":tone_map,"environment":environment});
|
||||
let preflight = session.execute("render.preflight", ¶ms).map_err(|e| e.to_string())?;
|
||||
let started = session.execute("render.start", ¶ms).map_err(|e| e.to_string())?;
|
||||
let id: JobId = serde_json::from_value(started["job"].clone()).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -144,3 +144,16 @@ fn fuzz_refuses_an_argument_it_does_not_take() {
|
||||
assert!(stderr.contains(arg), "`fuzz {arg}`: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_help_keeps_the_registered_gpu_request_controls() {
|
||||
for args in [vec!["--help"], vec!["render", "--help"]] {
|
||||
let output = Command::new(env!("CARGO_BIN_EXE_solidcraft-cli")).args(&args).output().unwrap();
|
||||
assert!(output.status.success(), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
|
||||
assert!(output.stderr.is_empty());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for flag in ["--progressive", "--samples", "--max-depth", "--seed", "--exposure", "--lighting", "--environment", "--tone-map"] {
|
||||
assert!(stdout.contains(flag), "{args:?} lost {flag}: {stdout}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Original starter visual data, MIT OR Apache-2.0. No external material library.
|
||||
use crate::appearance_pack::{Category, GlassBoundary, GlassRecipe, OpaqueRecipe, TextureAxis, TextureRecipe, VisualPreset};
|
||||
|
||||
const WOOD: &str = "Uniform opaque preview only; grain resources/mapping are not applied";
|
||||
const SOLAR: &str = "Uniform opaque preview only; no cell grid, busbars, electricity or efficiency claim";
|
||||
const REGOLITH: &str = "Uniform opaque preview only; no granular displacement or measured lunar optical data";
|
||||
const OPAQUE: &str = "Illustrative opaque isotropic PBR; no measured alloy, polymer or engineering properties";
|
||||
|
||||
const fn look(color: [u8; 3], metallic: f64, roughness: f64, dielectric_ior: f64) -> OpaqueRecipe {
|
||||
OpaqueRecipe { color, metallic, roughness, dielectric_ior }
|
||||
}
|
||||
const fn maps(id: &'static str, repeat_mm: [f64; 2], grain: bool) -> Option<TextureRecipe> {
|
||||
Some(TextureRecipe { id, repeat_mm, grain_axis: if grain { Some(TextureAxis::V) } else { None } })
|
||||
}
|
||||
const fn glass(transmission_linear_rgb: [f64; 3], ior: f64, reference_path_mm: f64, boundary: GlassBoundary) -> GlassRecipe {
|
||||
GlassRecipe { transmission_linear_rgb, dielectric_ior: ior, reference_path_mm, boundary }
|
||||
}
|
||||
|
||||
pub const STARTER_PACK: [VisualPreset; 40] = [
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.oak-honey",
|
||||
"Wood - Honey Oak Uniform Preview",
|
||||
Category::Wood,
|
||||
look([190, 145, 88], 0.0, 0.64, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.oak-honey.maps-v1", [120.0, 240.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.oak-smoked",
|
||||
"Wood - Smoked Oak Uniform Preview",
|
||||
Category::Wood,
|
||||
look([91, 72, 55], 0.0, 0.68, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.oak-smoked.maps-v1", [120.0, 240.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.walnut-dark",
|
||||
"Wood - Dark Walnut Uniform Preview",
|
||||
Category::Wood,
|
||||
look([99, 66, 43], 0.0, 0.60, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.walnut-dark.maps-v1", [100.0, 220.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.maple-light",
|
||||
"Wood - Light Maple Uniform Preview",
|
||||
Category::Wood,
|
||||
look([221, 192, 141], 0.0, 0.57, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.maple-light.maps-v1", [100.0, 200.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.cherry-warm",
|
||||
"Wood - Warm Cherry Uniform Preview",
|
||||
Category::Wood,
|
||||
look([164, 99, 65], 0.0, 0.58, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.cherry-warm.maps-v1", [110.0, 220.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.ash-pale",
|
||||
"Wood - Pale Ash Uniform Preview",
|
||||
Category::Wood,
|
||||
look([209, 186, 146], 0.0, 0.70, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.ash-pale.maps-v1", [120.0, 260.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.birch-cream",
|
||||
"Wood - Cream Birch Uniform Preview",
|
||||
Category::Wood,
|
||||
look([228, 206, 163], 0.0, 0.63, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.birch-cream.maps-v1", [100.0, 200.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.wood.teak-golden",
|
||||
"Wood - Golden Teak Uniform Preview",
|
||||
Category::Wood,
|
||||
look([167, 122, 66], 0.0, 0.61, 1.5),
|
||||
WOOD,
|
||||
maps("starter.wood.teak-golden.maps-v1", [110.0, 250.0], true),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.carbon-steel-polished",
|
||||
"Metal - Polished Carbon Steel",
|
||||
Category::Metal,
|
||||
look([161, 166, 173], 1.0, 0.16, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.stainless-soft-satin",
|
||||
"Metal - Soft Satin Stainless",
|
||||
Category::Metal,
|
||||
look([187, 193, 201], 1.0, 0.36, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.aluminum-bead-blast",
|
||||
"Metal - Bead Blast Aluminum",
|
||||
Category::Metal,
|
||||
look([195, 201, 210], 1.0, 0.58, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.aluminum-silver-polish",
|
||||
"Metal - Silver Polish Aluminum",
|
||||
Category::Metal,
|
||||
look([218, 224, 232], 1.0, 0.13, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.copper-aged-warm",
|
||||
"Metal - Warm Aged Copper",
|
||||
Category::Metal,
|
||||
look([171, 95, 57], 1.0, 0.48, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.brass-champagne",
|
||||
"Metal - Champagne Satin Brass",
|
||||
Category::Metal,
|
||||
look([205, 173, 102], 1.0, 0.32, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.bronze-dark-cast",
|
||||
"Metal - Dark Cast Bronze",
|
||||
Category::Metal,
|
||||
look([133, 95, 58], 1.0, 0.66, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.titanium-matte",
|
||||
"Metal - Matte Gray Titanium",
|
||||
Category::Metal,
|
||||
look([147, 149, 157], 1.0, 0.53, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.gold-warm",
|
||||
"Metal - Warm Reflective Gold",
|
||||
Category::Metal,
|
||||
look([235, 190, 82], 1.0, 0.18, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.metal.silver-cool",
|
||||
"Metal - Cool Reflective Silver",
|
||||
Category::Metal,
|
||||
look([225, 230, 237], 1.0, 0.12, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.abs-charcoal",
|
||||
"Plastic - Charcoal ABS",
|
||||
Category::Plastic,
|
||||
look([43, 45, 48], 0.0, 0.44, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.abs-warm-white",
|
||||
"Plastic - Warm White ABS",
|
||||
Category::Plastic,
|
||||
look([232, 229, 220], 0.0, 0.40, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque("starter.plastic.pla-coral", "Plastic - Coral PLA", Category::Plastic, look([218, 99, 83], 0.0, 0.43, 1.5), OPAQUE, None),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.petg-cyan-opaque",
|
||||
"Plastic - Opaque Cyan PETG",
|
||||
Category::Plastic,
|
||||
look([36, 148, 167], 0.0, 0.29, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.nylon-ivory",
|
||||
"Plastic - Ivory Nylon",
|
||||
Category::Plastic,
|
||||
look([220, 213, 190], 0.0, 0.62, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.pc-graphite-opaque",
|
||||
"Plastic - Opaque Graphite PC",
|
||||
Category::Plastic,
|
||||
look([70, 76, 84], 0.0, 0.27, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.hdpe-natural-opaque",
|
||||
"Plastic - Opaque Natural HDPE",
|
||||
Category::Plastic,
|
||||
look([221, 222, 207], 0.0, 0.48, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque("starter.plastic.pom-black", "Plastic - Black POM", Category::Plastic, look([29, 31, 35], 0.0, 0.32, 1.5), OPAQUE, None),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.tpu-matte-black",
|
||||
"Plastic - Matte Black TPU",
|
||||
Category::Plastic,
|
||||
look([31, 30, 32], 0.0, 0.83, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.plastic.peek-amber-opaque",
|
||||
"Plastic - Opaque Amber PEEK",
|
||||
Category::Plastic,
|
||||
look([157, 119, 62], 0.0, 0.51, 1.5),
|
||||
OPAQUE,
|
||||
None,
|
||||
),
|
||||
VisualPreset::glass(
|
||||
"starter.glass.clear-soda-lime",
|
||||
"Glass Recipe - Clear Soda Lime",
|
||||
glass([0.92, 0.98, 0.96], 1.52, 3.0, GlassBoundary::Closed),
|
||||
),
|
||||
VisualPreset::glass("starter.glass.low-iron-clear", "Glass Recipe - Low Iron Clear", glass([0.98, 0.99, 1.0], 1.50, 4.0, GlassBoundary::Closed)),
|
||||
VisualPreset::glass("starter.glass.smoky-neutral", "Glass Recipe - Smoky Neutral", glass([0.35, 0.38, 0.42], 1.52, 3.0, GlassBoundary::Closed)),
|
||||
VisualPreset::glass("starter.glass.amber-tint", "Glass Recipe - Amber Tint", glass([0.84, 0.38, 0.08], 1.51, 3.0, GlassBoundary::Closed)),
|
||||
VisualPreset::glass("starter.glass.cobalt-blue", "Glass Recipe - Cobalt Blue", glass([0.06, 0.14, 0.76], 1.53, 3.0, GlassBoundary::Closed)),
|
||||
VisualPreset::glass(
|
||||
"starter.glass.thin-clear",
|
||||
"Glass Recipe - Thin Clear Sheet",
|
||||
glass([0.95, 0.98, 0.99], 1.50, 2.0, GlassBoundary::Thin { thickness_mm: 2.0 }),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.solar.mono-blue",
|
||||
"Solar Panel - Mono Blue Uniform Preview",
|
||||
Category::Solar,
|
||||
look([20, 41, 85], 0.0, 0.20, 1.5),
|
||||
SOLAR,
|
||||
maps("starter.solar.mono-blue.maps-v1", [160.0, 160.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.solar.thin-film-dark",
|
||||
"Solar Panel - Dark Thin Film Uniform Preview",
|
||||
Category::Solar,
|
||||
look([28, 33, 42], 0.0, 0.28, 1.5),
|
||||
SOLAR,
|
||||
maps("starter.solar.thin-film-dark.maps-v1", [100.0, 200.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.regolith.mare-charcoal",
|
||||
"Regolith - Mare Charcoal Uniform Preview",
|
||||
Category::Regolith,
|
||||
look([56, 56, 55], 0.0, 0.97, 1.5),
|
||||
REGOLITH,
|
||||
maps("starter.regolith.mare-charcoal.maps-v1", [200.0, 200.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.regolith.highland-pale",
|
||||
"Regolith - Highland Pale Uniform Preview",
|
||||
Category::Regolith,
|
||||
look([116, 113, 106], 0.0, 0.93, 1.5),
|
||||
REGOLITH,
|
||||
maps("starter.regolith.highland-pale.maps-v1", [200.0, 200.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.regolith.dust-neutral",
|
||||
"Regolith - Neutral Dust Uniform Preview",
|
||||
Category::Regolith,
|
||||
look([86, 85, 81], 0.0, 1.0, 1.5),
|
||||
REGOLITH,
|
||||
maps("starter.regolith.dust-neutral.maps-v1", [150.0, 150.0], false),
|
||||
),
|
||||
VisualPreset::opaque(
|
||||
"starter.regolith.agglutinate-dark",
|
||||
"Regolith - Dark Agglutinate Uniform Preview",
|
||||
Category::Regolith,
|
||||
look([66, 63, 60], 0.0, 0.89, 1.5),
|
||||
REGOLITH,
|
||||
maps("starter.regolith.agglutinate-dark.maps-v1", [200.0, 200.0], false),
|
||||
),
|
||||
];
|
||||
@@ -1012,9 +1012,11 @@
|
||||
"render.image_help": "Náhledový obrázek z CPU · PNG · přibližná průhlednost",
|
||||
"render.image_overwrite": "Nahradit existující soubor",
|
||||
"render.image_preparing": "Příprava obrázku",
|
||||
"render.image_progressive": "Progresivní render",
|
||||
"render.image_queued": "Čeká na render",
|
||||
"render.image_reduce": "Zkuste menší obrázek nebo jednodušší scénu.",
|
||||
"render.image_running": "Renderování obrázku",
|
||||
"render.image_samples": "Vzorků na pixel",
|
||||
"render.image_stale": "Tento obrázek používá scénu zachycenou před pozdějšími změnami.",
|
||||
"render.image_start": "Spustit render",
|
||||
"render.image_supported": "Požadavek na render je podporován; fronta a paměť se znovu ověří při spuštění.",
|
||||
@@ -1030,6 +1032,7 @@
|
||||
"render.perspective": "Perspektivní kamera",
|
||||
"render.preview": "Integrovaný náhled",
|
||||
"render.preview_help": "Rastrové stínování a smíšená průhlednost. Lom světla, HDRI a finální render zatím nejsou k dispozici.",
|
||||
"render.progressive_image_summary": "Náhledové PNG · neprůhledné PBR · postupné vzorkování",
|
||||
"render.reload_settings": "Znovu načíst uložené nastavení",
|
||||
"render.restore_camera": "Obnovit uloženou kameru",
|
||||
"render.return_design": "Zpět k návrhu",
|
||||
|
||||
@@ -1341,6 +1341,9 @@
|
||||
"recovery.never_saved": "never saved",
|
||||
"render.background": "Scene background",
|
||||
"render.fit": "Fit Product",
|
||||
"render.gpu_path_failed": "GPU path tracer unavailable",
|
||||
"render.gpu_path_first_setup": "The first setup on this GPU can take several minutes; SolidCraft stays usable meanwhile. Later starts reuse the compiled shaders where the driver keeps them.",
|
||||
"render.gpu_path_preparing": "GPU path tracer: compiling shaders…",
|
||||
"render.image_cancelled": "Render cancelled",
|
||||
"render.image_cancelling": "Cancelling",
|
||||
"render.image_completed": "Image ready to export",
|
||||
@@ -1351,9 +1354,11 @@
|
||||
"render.image_help": "CPU preview image · PNG · approximate transparency",
|
||||
"render.image_overwrite": "Replace existing file",
|
||||
"render.image_preparing": "Preparing image",
|
||||
"render.image_progressive": "Progressive rendering",
|
||||
"render.image_queued": "Waiting to render",
|
||||
"render.image_reduce": "Try a smaller image or simpler scene.",
|
||||
"render.image_running": "Rendering image",
|
||||
"render.image_samples": "Samples per pixel",
|
||||
"render.image_stale": "This image uses the scene captured before subsequent changes.",
|
||||
"render.image_start": "Start Render",
|
||||
"render.image_supported": "Render request is supported; the queue and memory pool are checked again at start.",
|
||||
@@ -1369,6 +1374,7 @@
|
||||
"render.perspective": "Perspective camera",
|
||||
"render.preview": "Integrated preview",
|
||||
"render.preview_help": "Raster shading and blended transparency. Refraction, HDRI and final rendering are not available yet.",
|
||||
"render.progressive_image_summary": "Preview PNG · PBR and glass · progressive samples",
|
||||
"render.reload_settings": "Reload Saved Settings",
|
||||
"render.restore_camera": "Restore Saved Camera",
|
||||
"render.return_design": "Return to Design",
|
||||
|
||||
@@ -1341,6 +1341,9 @@
|
||||
"recovery.never_saved": "未保存",
|
||||
"render.background": "シーンの背景",
|
||||
"render.fit": "製品を画面に合わせる",
|
||||
"render.gpu_path_failed": "GPUパストレーサーを利用できません",
|
||||
"render.gpu_path_first_setup": "このGPUでの初回セットアップには数分かかることがあります。その間もSolidCraftは使用できます。以降の起動では、ドライバーが保持していればコンパイル済みのシェーダーを再利用します。",
|
||||
"render.gpu_path_preparing": "GPUパストレーサー:シェーダーをコンパイル中…",
|
||||
"render.image_cancelled": "レンダリングをキャンセルしました",
|
||||
"render.image_cancelling": "キャンセル中",
|
||||
"render.image_completed": "画像をエクスポートできます",
|
||||
@@ -1351,9 +1354,11 @@
|
||||
"render.image_help": "CPUプレビュー画像 · PNG · 近似透過",
|
||||
"render.image_overwrite": "既存のファイルを置換",
|
||||
"render.image_preparing": "画像を準備中",
|
||||
"render.image_progressive": "プログレッシブレンダリング",
|
||||
"render.image_queued": "レンダリング待機中",
|
||||
"render.image_reduce": "画像を小さくするかシーンを簡略化してください。",
|
||||
"render.image_running": "画像をレンダリング中",
|
||||
"render.image_samples": "ピクセルあたりのサンプル数",
|
||||
"render.image_stale": "この画像は後続の変更前に取得したシーンを使用しています。",
|
||||
"render.image_start": "レンダリングを開始",
|
||||
"render.image_supported": "レンダリング要求に対応しています。開始時にキューとメモリを再確認します。",
|
||||
@@ -1369,6 +1374,7 @@
|
||||
"render.perspective": "透視投影カメラ",
|
||||
"render.preview": "統合プレビュー",
|
||||
"render.preview_help": "ラスタシェーディングとブレンド透明度を使用します。屈折、HDRI、最終レンダリングはまだ利用できません。",
|
||||
"render.progressive_image_summary": "プレビューPNG · 不透明PBR · プログレッシブサンプリング",
|
||||
"render.reload_settings": "保存した設定を再読み込み",
|
||||
"render.restore_camera": "保存したカメラを復元",
|
||||
"render.return_design": "設計に戻る",
|
||||
|
||||
@@ -1341,6 +1341,9 @@
|
||||
"recovery.never_saved": "从未保存",
|
||||
"render.background": "场景背景",
|
||||
"render.fit": "适配产品视图",
|
||||
"render.gpu_path_failed": "GPU 路径追踪器不可用",
|
||||
"render.gpu_path_first_setup": "首次在此 GPU 上设置可能需要几分钟,期间 SolidCraft 仍可正常使用。之后启动时,如果驱动程序保留了已编译的着色器,将直接复用。",
|
||||
"render.gpu_path_preparing": "GPU 路径追踪器:正在编译着色器…",
|
||||
"render.image_cancelled": "渲染已取消",
|
||||
"render.image_cancelling": "正在取消",
|
||||
"render.image_completed": "图像可导出",
|
||||
@@ -1351,9 +1354,11 @@
|
||||
"render.image_help": "CPU 预览图像 · PNG · 近似透明度",
|
||||
"render.image_overwrite": "替换现有文件",
|
||||
"render.image_preparing": "正在准备图像",
|
||||
"render.image_progressive": "渐进式渲染",
|
||||
"render.image_queued": "等待渲染",
|
||||
"render.image_reduce": "请尝试更小的图像或更简单的场景。",
|
||||
"render.image_running": "正在渲染图像",
|
||||
"render.image_samples": "每像素采样数",
|
||||
"render.image_stale": "此图像使用后续更改之前捕获的场景。",
|
||||
"render.image_start": "开始渲染",
|
||||
"render.image_supported": "支持此渲染请求;开始时将再次检查队列和内存池。",
|
||||
@@ -1369,6 +1374,7 @@
|
||||
"render.perspective": "透视相机",
|
||||
"render.preview": "集成预览",
|
||||
"render.preview_help": "光栅着色和混合透明度。暂不支持折射、HDRI 和最终渲染。",
|
||||
"render.progressive_image_summary": "预览 PNG · 不透明 PBR · 渐进采样",
|
||||
"render.reload_settings": "重新加载已保存设置",
|
||||
"render.restore_camera": "恢复已保存相机",
|
||||
"render.return_design": "返回设计",
|
||||
|
||||
@@ -138,12 +138,11 @@ pub use defaults::LIBRARY;
|
||||
|
||||
/// A library appearance by name (case-insensitive).
|
||||
pub fn library(name: &str) -> Option<Look> {
|
||||
LIBRARY.iter().find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim())).map(|(n, c, o)| Look {
|
||||
name: n.to_string(),
|
||||
color: *c,
|
||||
opacity: *o,
|
||||
pbr: None,
|
||||
})
|
||||
LIBRARY
|
||||
.iter()
|
||||
.find(|(n, _, _)| n.eq_ignore_ascii_case(name.trim()))
|
||||
.map(|(n, c, o)| Look { name: n.to_string(), color: *c, opacity: *o, pbr: None })
|
||||
.or_else(|| crate::appearance_pack::find(name)?.opaque_look().ok())
|
||||
}
|
||||
|
||||
/// The look a physical material gives a body that has no appearance of its own.
|
||||
@@ -327,4 +326,122 @@ mod tests {
|
||||
assert_eq!(Look::custom([1, 2, 3], 7.0).opacity, 1.0);
|
||||
assert_eq!(Look::custom([255, 0, 16], 1.0).hex(), "#ff0010");
|
||||
}
|
||||
mod starter_pack {
|
||||
use crate::appearance::Look;
|
||||
use crate::appearance_pack::*;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
#[test]
|
||||
fn stable_ids_categories_and_original_licences_cover_the_requested_pack() {
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut names = BTreeSet::new();
|
||||
for preset in &STARTER_PACK {
|
||||
assert!(preset.id.starts_with("starter.") && preset.id.len() <= 128);
|
||||
assert!(preset.id.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b'-'));
|
||||
assert!(ids.insert(preset.id));
|
||||
assert!(names.insert(preset.name.to_ascii_lowercase()));
|
||||
assert_eq!(preset.revision, 1);
|
||||
assert_eq!(preset.provenance.license, "MIT OR Apache-2.0");
|
||||
assert!(!preset.provenance.author.is_empty());
|
||||
assert_eq!(preset.provenance.source, "assets/appearances/starter_pack.rs");
|
||||
assert_eq!(find(preset.id), Some(preset));
|
||||
assert_eq!(find(preset.name), Some(preset));
|
||||
}
|
||||
for (category, count) in [
|
||||
(Category::Wood, 8),
|
||||
(Category::Metal, 10),
|
||||
(Category::Plastic, 10),
|
||||
(Category::Glass, 6),
|
||||
(Category::Solar, 2),
|
||||
(Category::Regolith, 4),
|
||||
] {
|
||||
assert_eq!(STARTER_PACK.iter().filter(|p| p.category == category).count(), count);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_construction_and_owned_look_roundtrip_preserve_current_response() {
|
||||
let mut admitted = 0;
|
||||
for preset in &STARTER_PACK {
|
||||
if let Recipe::Opaque(recipe) = preset.recipe {
|
||||
let look = preset.opaque_look().unwrap();
|
||||
assert_eq!(look.opacity, 1.0);
|
||||
assert_eq!(look.color, recipe.color);
|
||||
let surface = look.opaque_surface().unwrap().unwrap();
|
||||
assert_eq!(surface.metallic(), recipe.metallic);
|
||||
assert_eq!(surface.roughness(), recipe.roughness);
|
||||
let json = serde_json::to_value(&look).unwrap();
|
||||
assert_eq!(serde_json::from_value::<Look>(json.clone()).unwrap(), look);
|
||||
assert!(json.get("provenance").is_none() && json.get("requirements").is_none() && json.get("id").is_none());
|
||||
assert_eq!(preset.requirements, &[Requirement::OpaquePbr]);
|
||||
admitted += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(admitted, 34);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn glass_transmission_recipes_are_valid_but_never_scalar_or_alpha_looks() {
|
||||
for preset in STARTER_PACK.iter().filter(|p| p.category == Category::Glass) {
|
||||
let Recipe::Glass(recipe) = preset.recipe else { panic!("glass category must carry a glass recipe") };
|
||||
recipe.validate().unwrap();
|
||||
assert!(!preset.is_opaque());
|
||||
assert!(preset.opaque_look().is_err());
|
||||
assert_eq!(preset.requirements, &[Requirement::Refraction, Requirement::CertifiedThinOrClosed]);
|
||||
// Compatibility of illustrative values only: the real optics adapter must
|
||||
// derive and admit these absorption values after geometry certification.
|
||||
assert!(recipe.transmission_linear_rgb.iter().all(|t| (-t.ln() / recipe.reference_path_mm) <= 100.0));
|
||||
for channel in [0.0, -1.0, 1.01, f64::NAN, f64::INFINITY] {
|
||||
let mut bad = recipe;
|
||||
bad.transmission_linear_rgb[0] = channel;
|
||||
assert!(bad.validate().is_err());
|
||||
}
|
||||
let mut bad = recipe;
|
||||
bad.reference_path_mm = 0.0;
|
||||
assert!(bad.validate().is_err());
|
||||
bad = recipe;
|
||||
bad.boundary = GlassBoundary::Thin { thickness_mm: 10001.0 };
|
||||
assert!(bad.validate().is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_library_and_six_existing_finish_names_are_not_overridden_or_duplicated() {
|
||||
assert_eq!(crate::appearance::LIBRARY.len(), 20);
|
||||
for (name, color, opacity) in crate::appearance::LIBRARY {
|
||||
let look = crate::appearance::library(name).unwrap();
|
||||
assert_eq!(look.color, color);
|
||||
assert_eq!(look.opacity, opacity);
|
||||
assert!(look.pbr.is_none());
|
||||
assert!(find(name).is_none());
|
||||
}
|
||||
for name in [
|
||||
"Steel - Satin PBR",
|
||||
"Aluminum - Satin PBR",
|
||||
"Copper - Clean PBR",
|
||||
"ABS - Molded PBR",
|
||||
"PLA - Smooth PBR",
|
||||
"Wood - Oak Uniform PBR Preview",
|
||||
] {
|
||||
assert!(STARTER_PACK.iter().all(|p| !p.name.eq_ignore_ascii_case(name)), "existing #1366 finish must remain independent");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uniform_previews_and_planned_resource_scale_are_explicit_and_not_bindings() {
|
||||
let mut recipes = 0;
|
||||
for preset in &STARTER_PACK {
|
||||
if let Some(recipe) = preset.future_texture_recipe {
|
||||
assert!(preset.name.contains("Uniform Preview"));
|
||||
assert!(preset.preview_note.contains("Uniform opaque preview only"));
|
||||
assert_eq!(recipe.id, format!("{}.maps-v1", preset.id));
|
||||
assert!(recipe.repeat_mm.iter().all(|v| v.is_finite() && *v > 0.0 && *v <= 10000.0));
|
||||
assert_eq!(recipe.grain_axis, if preset.category == Category::Wood { Some(TextureAxis::V) } else { None });
|
||||
assert_eq!(preset.requirements, &[Requirement::OpaquePbr]);
|
||||
recipes += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(recipes, 14);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
//! Original visual authoring choices; never engineering material grades or a catalog codec.
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::{DocError, Result, appearance::Look, pbr_appearance::PbrParameters};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Category {
|
||||
Wood,
|
||||
Metal,
|
||||
Plastic,
|
||||
Glass,
|
||||
Solar,
|
||||
Regolith,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Requirement {
|
||||
OpaquePbr,
|
||||
Refraction,
|
||||
CertifiedThinOrClosed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TextureAxis {
|
||||
V,
|
||||
}
|
||||
|
||||
/// Planned original procedural resources, not a loaded texture or a saved binding.
|
||||
/// Albedo is sRGB; roughness and positive-Y tangent normals are linear data.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct TextureRecipe {
|
||||
pub id: &'static str,
|
||||
pub repeat_mm: [f64; 2],
|
||||
pub grain_axis: Option<TextureAxis>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct Provenance {
|
||||
pub author: &'static str,
|
||||
pub license: &'static str,
|
||||
pub source: &'static str,
|
||||
pub kind: &'static str,
|
||||
}
|
||||
pub const ORIGINAL: Provenance = Provenance {
|
||||
author: "SolidCraft contributors",
|
||||
license: "MIT OR Apache-2.0",
|
||||
source: "assets/appearances/starter_pack.rs",
|
||||
kind: "original illustrative optical authoring; not measured material data",
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct OpaqueRecipe {
|
||||
pub color: [u8; 3],
|
||||
pub metallic: f64,
|
||||
pub roughness: f64,
|
||||
pub dielectric_ior: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "mode", rename_all = "snake_case")]
|
||||
pub enum GlassBoundary {
|
||||
Thin { thickness_mm: f64 },
|
||||
Closed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct GlassRecipe {
|
||||
pub dielectric_ior: f64,
|
||||
/// Linear transmittance over reference_path_mm, not sRGB tint or alpha.
|
||||
pub transmission_linear_rgb: [f64; 3],
|
||||
pub reference_path_mm: f64,
|
||||
pub boundary: GlassBoundary,
|
||||
}
|
||||
impl GlassRecipe {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if !self.dielectric_ior.is_finite()
|
||||
|| !(1.0..=3.0).contains(&self.dielectric_ior)
|
||||
|| self.transmission_linear_rgb.iter().any(|v| !v.is_finite() || *v <= 0.0 || *v > 1.0)
|
||||
|| !self.reference_path_mm.is_finite()
|
||||
|| self.reference_path_mm <= 0.0
|
||||
|| self.reference_path_mm > 1e6
|
||||
|| matches!(self.boundary,GlassBoundary::Thin {thickness_mm} if !thickness_mm.is_finite() || thickness_mm<=0.0 || thickness_mm>10000.0)
|
||||
{
|
||||
return Err(DocError::Invalid(
|
||||
"invalid illustrative glass recipe: finite IOR1..3, linear transmission0<T<=1, bounded positive reference path/thickness required"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum Recipe {
|
||||
Opaque(OpaqueRecipe),
|
||||
Glass(GlassRecipe),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
pub struct VisualPreset {
|
||||
pub id: &'static str,
|
||||
pub name: &'static str,
|
||||
pub category: Category,
|
||||
pub revision: u32,
|
||||
pub provenance: Provenance,
|
||||
pub requirements: &'static [Requirement],
|
||||
pub recipe: Recipe,
|
||||
pub preview_note: &'static str,
|
||||
pub future_texture_recipe: Option<TextureRecipe>,
|
||||
}
|
||||
|
||||
impl VisualPreset {
|
||||
pub const fn opaque(
|
||||
id: &'static str,
|
||||
name: &'static str,
|
||||
category: Category,
|
||||
recipe: OpaqueRecipe,
|
||||
preview_note: &'static str,
|
||||
future_texture_recipe: Option<TextureRecipe>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
category,
|
||||
revision: 1,
|
||||
provenance: ORIGINAL,
|
||||
requirements: &[Requirement::OpaquePbr],
|
||||
recipe: Recipe::Opaque(recipe),
|
||||
preview_note,
|
||||
future_texture_recipe,
|
||||
}
|
||||
}
|
||||
pub const fn glass(id: &'static str, name: &'static str, recipe: GlassRecipe) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
category: Category::Glass,
|
||||
revision: 1,
|
||||
provenance: ORIGINAL,
|
||||
requirements: &[Requirement::Refraction, Requirement::CertifiedThinOrClosed],
|
||||
recipe: Recipe::Glass(recipe),
|
||||
preview_note: "Recipe only: requires actual refraction and a certified thin/closed geometry path; no alpha substitute",
|
||||
future_texture_recipe: None,
|
||||
}
|
||||
}
|
||||
pub fn is_opaque(&self) -> bool {
|
||||
matches!(self.recipe, Recipe::Opaque(_))
|
||||
}
|
||||
pub fn preview_color(&self) -> [u8; 3] {
|
||||
match self.recipe {
|
||||
Recipe::Opaque(r) => r.color,
|
||||
// A UI swatch of the transmission values, not rendered glass or absorption.
|
||||
Recipe::Glass(r) => r.transmission_linear_rgb.map(|v| {
|
||||
let encoded = if v <= 0.0031308 { 12.92 * v } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 };
|
||||
(encoded.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// All opaque presets share the existing finite PBR constructor and owned Look.
|
||||
/// Refraction recipes cannot be smuggled into a scalar/alpha appearance.
|
||||
pub fn opaque_look(&self) -> Result<Look> {
|
||||
let Recipe::Opaque(recipe) = self.recipe else {
|
||||
return Err(DocError::Invalid(
|
||||
"glass recipe unavailable: actual refraction and certified thin/closed geometry are required; alpha transparency is not a substitute"
|
||||
.into(),
|
||||
));
|
||||
};
|
||||
let pbr = PbrParameters::new(recipe.metallic, recipe.roughness, recipe.dielectric_ior).map_err(|e| DocError::Invalid(e.to_string()))?;
|
||||
Ok(Look { name: self.name.into(), color: recipe.color, opacity: 1.0, pbr: Some(pbr) })
|
||||
}
|
||||
}
|
||||
|
||||
#[path = "../../../assets/appearances/starter_pack.rs"]
|
||||
mod data;
|
||||
pub use data::STARTER_PACK;
|
||||
|
||||
/// Stable IDs are preferred for commands; display-name lookup is unambiguous.
|
||||
pub fn find(id_or_name: &str) -> Option<&'static VisualPreset> {
|
||||
let key = id_or_name.trim();
|
||||
STARTER_PACK.iter().find(|p| p.id.eq_ignore_ascii_case(key) || p.name.eq_ignore_ascii_case(key))
|
||||
}
|
||||
@@ -3709,8 +3709,14 @@ pub struct UserMaterial {
|
||||
pub density: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub appearance: Option<crate::appearance::Look>,
|
||||
/// Candidate engineering data; independent of assigned mass density and optics.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub physical_card: Option<crate::physical_material::ValidatedCard>,
|
||||
}
|
||||
|
||||
/// Bound design-owned definitions consistently in commands and normal file loading.
|
||||
pub const MAX_USER_MATERIALS: usize = 1000;
|
||||
|
||||
/// Largest density a material may have (g/cm³); the densest elements are about 22.6.
|
||||
pub const MAX_DENSITY: f64 = 1000.0;
|
||||
|
||||
|
||||
@@ -1041,10 +1041,36 @@ const MAX_REAL: f64 = 1e9;
|
||||
/// alone: it is other tools' data, kept as it is and never read as a number (#1015); parsing
|
||||
/// already bounds its nesting.
|
||||
fn check_design_numbers(v: &Value) -> Result<()> {
|
||||
match v {
|
||||
Value::Object(m) => m.iter().filter(|(k, _)| k.as_str() != "metadata").try_for_each(|(_, x)| check_numbers(x, 1)),
|
||||
v => check_numbers(v, 0),
|
||||
let Some(root) = v.as_object() else { return check_numbers(v, 0) };
|
||||
for (key, value) in root {
|
||||
if key == "metadata" {
|
||||
continue;
|
||||
}
|
||||
if key != "user_materials" {
|
||||
check_numbers(value, 1)?;
|
||||
continue;
|
||||
}
|
||||
let Some(materials) = value.as_array() else { return check_numbers(value, 1) };
|
||||
if materials.len() > crate::MAX_USER_MATERIALS {
|
||||
return Err(DocError::Invalid("document exceeds 1000 user materials".into()));
|
||||
}
|
||||
for material in materials {
|
||||
let Some(fields) = material.as_object() else {
|
||||
check_numbers(material, 2)?;
|
||||
continue;
|
||||
};
|
||||
for (field, data) in fields {
|
||||
// SI engineering constants have separate typed bounds. No other field,
|
||||
// including nested fields with this name, bypasses coordinate limits.
|
||||
if field == "physical_card" && !data.is_null() {
|
||||
crate::physical_material::validate_card(data)?;
|
||||
} else {
|
||||
check_numbers(data, 3)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The range check again, on the typed design: an integer such as `9223372036854775808` passes
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
//! User materials can save a validated SI engineering card (`physical_card`) with property
|
||||
//! conditions and source claims, independently of assigned mass density and visual appearance.
|
||||
//! Older designs need no transformation: an absent card means unspecified engineering data.
|
||||
//! The last release refuses this newer format; pending builds without this change refuse its
|
||||
//! `material-engineering-cards` descriptor instead of silently dropping the saved card.
|
||||
@@ -6,6 +6,7 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod appearance;
|
||||
pub mod appearance_pack;
|
||||
mod assembly;
|
||||
mod body_transfer;
|
||||
mod body_transfer_sheet;
|
||||
|
||||
@@ -17,6 +17,31 @@ pub struct MaterialCard {
|
||||
pub properties: Vec<Property>,
|
||||
}
|
||||
|
||||
/// Design-owned candidate data. Private storage prevents bypassing admission;
|
||||
/// validation does not certify sources, applicability or analysis capability.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize)]
|
||||
#[serde(transparent)]
|
||||
pub struct ValidatedCard(MaterialCard);
|
||||
|
||||
impl ValidatedCard {
|
||||
pub fn from_value(value: &Value) -> Result<Self> {
|
||||
let card = admit_card(value)?;
|
||||
card.validate()?;
|
||||
Ok(Self(card))
|
||||
}
|
||||
|
||||
pub fn card(&self) -> &MaterialCard {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for ValidatedCard {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
Self::from_value(&value).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Family {
|
||||
|
||||
@@ -261,7 +261,7 @@ fn a_body_reads_the_material_its_mass_is_computed_with() {
|
||||
let usable = [("Walnut", 0.65), ("Mote", f64::MIN_POSITIVE), ("Limit", MAX_DENSITY)];
|
||||
let unusable = [("Broken", -3.0), ("Weightless", 0.0), ("Beyond", MAX_DENSITY.next_up())];
|
||||
for (name, density) in usable.into_iter().chain(unusable) {
|
||||
doc.user_materials.push(UserMaterial { name: name.into(), density, appearance: None });
|
||||
doc.user_materials.push(UserMaterial { name: name.into(), density, appearance: None, physical_card: None });
|
||||
}
|
||||
// (body, its assignment as stored, the material it reads and weighs, that material's density)
|
||||
let cases = [
|
||||
|
||||
@@ -30,19 +30,19 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.params("bodies: [names]; material: name (Steel, Aluminum, ABS Plastic, …, or one made with material.create; `material.list`)"),
|
||||
CommandSpec::new("appearance.assign", "Appearance", appearance).at("SOLID", "MODIFY").icon("appearance").key("A").params(
|
||||
"bodies?: [names]; faces?: [[x,y,z] on faces] (body?: which body, default the nearest); components?: [names or ids, or occurrence names]; \
|
||||
appearance?: library name (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
|
||||
pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); omitted/null uses legacy scalar shading. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material",
|
||||
appearance?: library name or stable starter.* ID (appearance.library) | color: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1, default 1); \
|
||||
name?: inert copied look label (requires color, mutually exclusive with appearance); pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (requires color/appearance and opacity 1); a starter ID or registered starter name resolves its recipe before color overrides; omitted pbr retains that response and pbr: null explicitly clears it. Unavailable glass recipes refuse through either selector. Other custom labels with an explicit color retain legacy scalar behavior. Neither appearance nor color (or color: null): clear, so the face follows its body, the body its component, the component its material",
|
||||
),
|
||||
CommandSpec::new("appearance.library", "Appearance Library", appearance_library).icon("library")
|
||||
.noundo()
|
||||
.params("→ the built-in appearances (name, colour, opacity)"),
|
||||
.params("→ 20 legacy scalar appearances and 40 starter recipes with IDs, categories, provenance, requirements and availability; six starter glass recipes are unavailable"),
|
||||
CommandSpec::new("appearance.list", "List Appearances", appearance_list).icon("list")
|
||||
.noundo()
|
||||
.params("body?: name → assigned appearances; with body, its resolved look and its faces' (face index, look)"),
|
||||
CommandSpec::new("material.list", "List Materials", material_list).icon("list")
|
||||
.noundo()
|
||||
.doc_only()
|
||||
.params("→ the built-in materials, then the design's own (user: true, with their look)"),
|
||||
.params("→ built-in and design-owned materials (user: true, look, optional physical_card/physical_validation/density_comparison). Read-only; candidate engineering records do not certify sources or supply a solver"),
|
||||
CommandSpec::new("engine.commands", "List Commands", commands).icon("list").noundo(),
|
||||
CommandSpec::new("engine.describe", "Describe Command", crate::checked!("engine.describe", DESCRIBE_CONTRACT, describe))
|
||||
.icon("list")
|
||||
@@ -387,36 +387,68 @@ pub(super) fn color_param(v: &Value) -> Option<[u8; 3]> {
|
||||
Some([c(0)?, c(1)?, c(2)?])
|
||||
}
|
||||
|
||||
fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
pub(super) fn appearance(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
use solidcraft_doc::appearance::{FaceLook, Look, library};
|
||||
let cmd = "appearance.assign";
|
||||
let mut look: Option<Look> = match (str_(p, "appearance"), p.get("color")) {
|
||||
// A colour with a name: that colour, called that (a name not in the library is fine).
|
||||
(Some(n), Some(v)) if !v.is_null() => {
|
||||
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
|
||||
let o = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(1.0);
|
||||
let mut l = Look::custom(c, o);
|
||||
l.name = n.chars().take(128).collect();
|
||||
Some(l)
|
||||
// A saved label is data, not a request to resolve a recipe. Keep the explicit
|
||||
// appearance selector separate so an owned look may have any recipe's ID as its name.
|
||||
let copied_name = match p.get("name") {
|
||||
None | Some(Value::Null) => None,
|
||||
Some(Value::String(name)) if !name.is_empty() && name.chars().count() <= 128 && !name.chars().any(char::is_control) => Some(name),
|
||||
_ => return Err(bad(cmd, "`name` must be a nonempty label of at most 128 characters without controls")),
|
||||
};
|
||||
if copied_name.is_some() && (p.get("color").is_none_or(Value::is_null) || p.get("appearance").is_some_and(|value| !value.is_null())) {
|
||||
return Err(bad(cmd, "`name` requires an explicit color and cannot be combined with `appearance`"));
|
||||
}
|
||||
// Both the stable ID and a registered display name explicitly select a recipe.
|
||||
// Copied labels use `name`, so color overrides cannot bypass unsupported optics.
|
||||
let preset = str_(p, "appearance").and_then(solidcraft_doc::appearance_pack::find);
|
||||
let mut look: Option<Look> = if let Some(preset) = preset {
|
||||
// Refuse unsupported optics before any color/PBR/opacity override.
|
||||
let mut look = preset.opaque_look().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if let Some(color) = p.get("color").filter(|v| !v.is_null()) {
|
||||
look.color = color_param(color).ok_or_else(|| bad(cmd, "invalid preset color override"))?;
|
||||
}
|
||||
(Some(n), _) => {
|
||||
let mut l = library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?;
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()) {
|
||||
l.opacity = o.clamp(0.0, 1.0);
|
||||
if let Some(opacity) = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()) {
|
||||
look.opacity = opacity.clamp(0.0, 1.0);
|
||||
}
|
||||
Some(look)
|
||||
} else {
|
||||
match (str_(p, "appearance"), p.get("color")) {
|
||||
// A colour with a name: that colour, called that (a name not in the library is fine).
|
||||
(Some(n), Some(v)) if !v.is_null() => {
|
||||
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
|
||||
let o = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()).unwrap_or(1.0);
|
||||
let mut l = Look::custom(c, o);
|
||||
l.name = n.chars().take(128).collect();
|
||||
Some(l)
|
||||
}
|
||||
(Some(n), _) => {
|
||||
let mut l = if let Some(preset) = solidcraft_doc::appearance_pack::find(n) {
|
||||
preset.opaque_look().map_err(|e| bad(cmd, e.to_string()))?
|
||||
} else {
|
||||
library(n).ok_or_else(|| bad(cmd, format!("no appearance `{n}` in the library (appearance.library)")))?
|
||||
};
|
||||
if let Some(o) = p.get("opacity").and_then(Value::as_f64).filter(|x| x.is_finite()) {
|
||||
l.opacity = o.clamp(0.0, 1.0);
|
||||
}
|
||||
Some(l)
|
||||
}
|
||||
(None, None | Some(Value::Null)) => None,
|
||||
(None, Some(v)) => {
|
||||
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
|
||||
let o = match p.get("opacity") {
|
||||
Some(o) => o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?,
|
||||
None => 1.0,
|
||||
};
|
||||
Some(Look::custom(c, o))
|
||||
}
|
||||
Some(l)
|
||||
}
|
||||
(None, None | Some(Value::Null)) => None,
|
||||
(None, Some(v)) => {
|
||||
let c = color_param(v).ok_or_else(|| bad(cmd, "`color` must be \"#rrggbb\" or [r, g, b] with values 0–255"))?;
|
||||
let o = match p.get("opacity") {
|
||||
Some(o) => o.as_f64().filter(|x| x.is_finite() && (0.0..=1.0).contains(x)).ok_or_else(|| bad(cmd, "`opacity` must be 0…1"))?,
|
||||
None => 1.0,
|
||||
};
|
||||
Some(Look::custom(c, o))
|
||||
}
|
||||
};
|
||||
if let Some(l) = &mut look {
|
||||
if let Some(name) = copied_name {
|
||||
l.name = name.clone();
|
||||
}
|
||||
attach_pbr(cmd, p, l)?;
|
||||
} else if p.get("pbr").is_some_and(|v| !v.is_null()) {
|
||||
return Err(bad(cmd, "`pbr` requires a color or appearance"));
|
||||
@@ -543,38 +575,52 @@ pub(super) fn look_json(l: &solidcraft_doc::appearance::Look) -> Value {
|
||||
mod pbr_tests;
|
||||
|
||||
pub(super) fn attach_pbr(cmd: &str, params: &Value, look: &mut solidcraft_doc::appearance::Look) -> Result<()> {
|
||||
let Some(value) = params.get("pbr") else { return Ok(()) };
|
||||
if value.is_null() {
|
||||
look.pbr = None;
|
||||
return Ok(());
|
||||
if let Some(value) = params.get("pbr") {
|
||||
look.pbr = if value.is_null() {
|
||||
None
|
||||
} else {
|
||||
let object = value
|
||||
.as_object()
|
||||
.filter(|o| o.len() == 3 && o.keys().all(|k| matches!(k.as_str(), "metallic" | "roughness" | "dielectric_ior")))
|
||||
.ok_or_else(|| bad(cmd, "`pbr` requires exactly metallic, roughness and dielectric_ior"))?;
|
||||
let number = |key: &str| object.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("`pbr.{key}` must be a finite number")));
|
||||
Some(
|
||||
solidcraft_doc::pbr_appearance::PbrParameters::new(number("metallic")?, number("roughness")?, number("dielectric_ior")?)
|
||||
.map_err(|e| bad(cmd, e.to_string()))?,
|
||||
)
|
||||
};
|
||||
}
|
||||
if let Some(opacity) = params.get("opacity")
|
||||
&& opacity.as_f64().filter(|v| v.is_finite() && *v == 1.0).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "opaque PBR requires numeric opacity 1"));
|
||||
// Validate the final look, including a named preset's inherited response.
|
||||
// Both appearance assignments and physical-material defaults use this path.
|
||||
if look.pbr.is_some() {
|
||||
if let Some(opacity) = params.get("opacity")
|
||||
&& opacity.as_f64().filter(|v| v.is_finite() && *v == 1.0).is_none()
|
||||
{
|
||||
return Err(bad(cmd, "opaque PBR requires numeric opacity 1"));
|
||||
}
|
||||
if look.opacity != 1.0 {
|
||||
return Err(bad(cmd, "opaque PBR requires opacity 1; refraction/transmission is not supported"));
|
||||
}
|
||||
}
|
||||
let object = value
|
||||
.as_object()
|
||||
.filter(|o| o.len() == 3 && o.keys().all(|k| matches!(k.as_str(), "metallic" | "roughness" | "dielectric_ior")))
|
||||
.ok_or_else(|| bad(cmd, "`pbr` requires exactly metallic, roughness and dielectric_ior"))?;
|
||||
let number = |key: &str| object.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("`pbr.{key}` must be a finite number")));
|
||||
let response = solidcraft_doc::pbr_appearance::PbrParameters::new(number("metallic")?, number("roughness")?, number("dielectric_ior")?)
|
||||
.map_err(|e| bad(cmd, e.to_string()))?;
|
||||
if look.opacity != 1.0 {
|
||||
return Err(bad(cmd, "opaque PBR requires opacity 1; refraction/transmission is not supported"));
|
||||
}
|
||||
look.pbr = Some(response);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn appearance_library(_s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let list: Vec<Value> = solidcraft_doc::appearance::LIBRARY
|
||||
let mut list: Vec<Value> = solidcraft_doc::appearance::LIBRARY
|
||||
.iter()
|
||||
.map(|(n, c, o)| {
|
||||
let category = n.split(" - ").next().unwrap_or(n);
|
||||
json!({"name": n, "category": category, "color": format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]), "opacity": o})
|
||||
})
|
||||
.collect();
|
||||
for preset in &solidcraft_doc::appearance_pack::STARTER_PACK {
|
||||
let c = preset.preview_color();
|
||||
let pbr = preset.opaque_look().ok().and_then(|look| look.pbr);
|
||||
list.push(json!({"id":preset.id,"name":preset.name,"category":preset.category,"revision":preset.revision,
|
||||
"color":format!("#{:02x}{:02x}{:02x}",c[0],c[1],c[2]),"opacity":1.0,"pbr":pbr,
|
||||
"available":preset.is_opaque(),"requirements":preset.requirements,"provenance":preset.provenance,
|
||||
"recipe":preset.recipe,"preview_note":preset.preview_note,"future_texture_recipe":preset.future_texture_recipe}));
|
||||
}
|
||||
Ok(json!({"appearances": list}))
|
||||
}
|
||||
|
||||
@@ -640,8 +686,15 @@ fn appearance_list(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn material_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||||
let built_in = solidcraft_doc::MATERIALS.iter().map(|(n, d)| json!({"name": n, "density_g_cm3": d}));
|
||||
let user = s.doc.user_materials.iter().map(
|
||||
|m| json!({"name": m.name, "density_g_cm3": m.density, "appearance": s.doc.material_look(&m.name).as_ref().map(look_json), "user": true}),
|
||||
);
|
||||
let user = s
|
||||
.doc
|
||||
.user_materials
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let mut out = super::materials::material_json(m)?;
|
||||
out["appearance"] = json!(s.doc.material_look(&m.name).as_ref().map(look_json));
|
||||
Ok(out)
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(json!({"materials": built_in.chain(user).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Scalar local libraries: inspect/export files and copy exact revisions into a design.
|
||||
//! Local material libraries: inspect/export files and copy exact revisions into a design.
|
||||
use super::CommandSpec;
|
||||
use crate::{Result, Session, params::bad};
|
||||
use serde::Deserialize;
|
||||
@@ -7,9 +7,9 @@ use solidcraft_io::catalog::{Catalog, Definition, Provenance};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("library.inspect", "Inspect Local Library", inspect).icon("inspect").noundo().doc_only()
|
||||
.params("path: scalar library JSON; returns validated catalog and exact entry revisions, without changing the design"),
|
||||
.params("path: v1/v2 library JSON; returns validated catalog and exact entry revisions, without changing the design"),
|
||||
CommandSpec::new("library.export", "Export Design Library", export).icon("export").noundo().doc_only()
|
||||
.params("path: .json; id? (default user.local), name?, provenance?, overwrite? false. Copies assigned looks and design-local physical definitions; never changes the design"),
|
||||
.params("path: .json; id? (default user.local), name?, provenance?, overwrite? false, format? solidcraft-library/2 (default) | solidcraft-library/1 (refuses PBR/cards). Copies assigned looks and design-local physical definitions; never changes the design"),
|
||||
CommandSpec::new("library.apply", "Apply Local Library Entry", apply).icon("import").enabled(super::not_in_sketch)
|
||||
.params("path, entry: qualified ID, revision: exact content SHA-256; targets?: {bodies?, faces?: [{body,point}], components?}; name?: physical material name on import. Copies values in one undo step; material conflicts are explicit"),
|
||||
];
|
||||
@@ -31,6 +31,11 @@ struct Export {
|
||||
provenance: Provenance,
|
||||
#[serde(default)]
|
||||
overwrite: bool,
|
||||
#[serde(default = "default_format")]
|
||||
format: String,
|
||||
}
|
||||
fn default_format() -> String {
|
||||
solidcraft_io::catalog::FORMAT.into()
|
||||
}
|
||||
fn default_id() -> String {
|
||||
"user.local".into()
|
||||
@@ -107,7 +112,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if !request.path.to_ascii_lowercase().ends_with(".json") {
|
||||
return Err(bad(cmd, "library export path must end in .json"));
|
||||
}
|
||||
let catalog = Catalog::from_document(&s.doc, &request.id, &request.name, request.provenance).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let catalog = Catalog::from_document(&s.doc, &request.id, &request.name, request.provenance)
|
||||
.and_then(|catalog| catalog.in_format(&request.format))
|
||||
.map_err(|e| bad(cmd, e.to_string()))?;
|
||||
let bytes = catalog.encode().map_err(|e| bad(cmd, e.to_string()))?;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let result = if request.overwrite {
|
||||
@@ -143,9 +150,11 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "appearance entries need targets and do not take a material name"));
|
||||
}
|
||||
let mut target = serde_json::to_value(&request.targets).map_err(|e| bad(cmd, e.to_string()))?;
|
||||
target["appearance"] = json!(look.name);
|
||||
target["name"] = json!(look.name);
|
||||
target["color"] = json!(look.color);
|
||||
target["opacity"] = json!(look.opacity);
|
||||
// Catalog labels are inert; a full replacement explicitly clears absent PBR.
|
||||
target["pbr"] = json!(look.pbr);
|
||||
nested(s, "appearance.assign", &target)?
|
||||
}
|
||||
Definition::Material(material) => {
|
||||
@@ -172,6 +181,12 @@ fn apply(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(look) = &material.appearance {
|
||||
params["color"] = json!(look.color);
|
||||
params["opacity"] = json!(look.opacity);
|
||||
if let Some(pbr) = look.pbr {
|
||||
params["pbr"] = json!(pbr);
|
||||
}
|
||||
}
|
||||
if let Some(card) = &material.physical_card {
|
||||
params["physical_card"] = json!(card);
|
||||
}
|
||||
nested(s, "material.create", ¶ms)?;
|
||||
// The existing colour API labels a custom look after its material.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use solidcraft_io::catalog::{Definition, Entry, FORMAT, ScalarLook, ScalarMaterial};
|
||||
use solidcraft_io::catalog::{Definition, Entry, FORMAT, LibraryLook, LibraryMaterial};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
@@ -33,16 +33,17 @@ fn catalog() -> Catalog {
|
||||
entries: vec![
|
||||
Entry::new(
|
||||
"user.test",
|
||||
Definition::Appearance(ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }),
|
||||
Definition::Appearance(LibraryLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8, pbr: None }),
|
||||
Provenance::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
Entry::new(
|
||||
"user.test",
|
||||
Definition::Material(ScalarMaterial {
|
||||
Definition::Material(LibraryMaterial {
|
||||
name: "Timber density".into(),
|
||||
density_g_cm3: 0.7,
|
||||
appearance: Some(ScalarLook { name: "Timber tint".into(), color: [180, 120, 70], opacity: 1.0 }),
|
||||
physical_card: None,
|
||||
appearance: Some(LibraryLook { name: "Timber tint".into(), color: [180, 120, 70], opacity: 1.0, pbr: None }),
|
||||
}),
|
||||
Provenance::default(),
|
||||
)
|
||||
@@ -60,6 +61,113 @@ fn apply_entry(s: &mut Session, file: &File, entry: &Entry, targets: Value) -> R
|
||||
s.execute("library.apply", &json!({"path":file.0,"entry":entry.id,"revision":entry.revision,"targets":targets}))
|
||||
}
|
||||
|
||||
fn rich_catalog() -> Catalog {
|
||||
use solidcraft_doc::{pbr_appearance::PbrParameters, physical_material::ValidatedCard};
|
||||
let mut c = catalog();
|
||||
if let Definition::Appearance(l) = &mut c.entries[0].definition {
|
||||
l.opacity = 1.0;
|
||||
l.pbr = Some(PbrParameters::new(1., 0.25, 1.5).unwrap());
|
||||
}
|
||||
if let Definition::Material(m) = &mut c.entries[1].definition {
|
||||
m.density_g_cm3 = 0.8;
|
||||
m.appearance.as_mut().unwrap().pbr = Some(PbrParameters::new(0., 0.65, 1.4).unwrap());
|
||||
m.physical_card = Some(
|
||||
ValidatedCard::from_value(&json!({"schema_version":1,"designation":"Original synthetic candidate","family":"other",
|
||||
"sources":[{"id":"fixture","title":"Original invented density","kind":"illustrative"}],
|
||||
"properties":[{"source_id":"fixture","data":{"kind":"density","kg_m3":700.0}}]}))
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
for e in &mut c.entries {
|
||||
*e = Entry::new("user.test", e.definition.clone(), e.provenance.clone()).unwrap();
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_appearance_copy_preserves_product_response_and_mass_without_source_file() {
|
||||
let file = File::new();
|
||||
let c = rich_catalog();
|
||||
file.write(&c);
|
||||
let mut s = session();
|
||||
let mass = s.execute("component.properties", &json!({})).unwrap();
|
||||
let scene = crate::product_scene::extract(&s).unwrap();
|
||||
let undo = s.undo.len();
|
||||
apply_entry(&mut s, &file, &c.entries[0], json!({"bodies":["Body1"]})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let after = crate::product_scene::extract(&s).unwrap();
|
||||
assert!(Arc::ptr_eq(&scene.meshes[0].mesh, &after.meshes[0].mesh));
|
||||
assert_eq!(after.meshes[0].surface.unwrap().metallic(), 1.0);
|
||||
assert_eq!(s.execute("component.properties", &json!({})).unwrap(), mass);
|
||||
let saved = solidcraft_io::write_design(&s.doc);
|
||||
std::fs::remove_file(&file.0).unwrap();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc.appearances.bodies.is_empty());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let mut reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
|
||||
assert_eq!(reopened.doc.appearances, s.doc.appearances);
|
||||
assert_eq!(reopened.execute("component.properties", &json!({})).unwrap(), mass);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_material_copy_retains_engineering_card_label_optics_and_explicit_mass_density() {
|
||||
let file = File::new();
|
||||
let c = rich_catalog();
|
||||
file.write(&c);
|
||||
let mut s = session();
|
||||
let undo = s.undo.len();
|
||||
apply_entry(&mut s, &file, &c.entries[1], json!({"bodies":["Body1"]})).unwrap();
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
let material = &s.doc.user_materials[0];
|
||||
assert_eq!(material.appearance.as_ref().unwrap().name, "Timber tint");
|
||||
assert_eq!(material.appearance.as_ref().unwrap().pbr.unwrap().roughness(), 0.65);
|
||||
assert!(material.physical_card.is_some());
|
||||
assert_eq!(material.density, 0.8);
|
||||
let mass = s.execute("component.properties", &json!({})).unwrap()["total"]["mass_g"].as_f64().unwrap();
|
||||
assert!((mass - 0.8).abs() < 1e-8);
|
||||
let saved = solidcraft_io::write_design(&s.doc);
|
||||
std::fs::remove_file(&file.0).unwrap();
|
||||
let reopened = Session::new(solidcraft_io::read_design(&saved).unwrap());
|
||||
assert_eq!(reopened.doc.user_materials, s.doc.user_materials);
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc.user_materials.is_empty());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.user_materials, reopened.doc.user_materials);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_v1_export_refuses_richer_data_before_overwriting_and_scalar_v1_still_works() {
|
||||
let file = File::new();
|
||||
let sentinel = b"Existing catalog must survive";
|
||||
std::fs::write(&file.0, sentinel).unwrap();
|
||||
let mut s = session();
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Body1"],"color":"#b47846",
|
||||
"pbr":{"metallic":1,"roughness":0.3,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
let before = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
assert!(s.execute("library.export", &json!({"path":file.0,"format":solidcraft_io::catalog::V1_FORMAT,"overwrite":true})).is_err());
|
||||
assert_eq!(std::fs::read(&file.0).unwrap(), sentinel);
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, (s.undo.len(), s.redo.len(), s.revision));
|
||||
s.execute("library.export", &json!({"path":file.0,"overwrite":true})).unwrap();
|
||||
assert_eq!(Catalog::read(&file.0).unwrap().format, FORMAT);
|
||||
s.execute("appearance.assign", &json!({"bodies":["Body1"],"color":"#b47846"})).unwrap();
|
||||
s.execute("library.export", &json!({"path":file.0,"format":solidcraft_io::catalog::V1_FORMAT,"overwrite":true})).unwrap();
|
||||
assert_eq!(Catalog::read(&file.0).unwrap().format, solidcraft_io::catalog::V1_FORMAT);
|
||||
let rich = rich_catalog();
|
||||
file.write(&rich);
|
||||
s.execute("material.create", &json!({"name":"Timber density","density_g_cm3":0.8,"color":"#b47846"})).unwrap();
|
||||
let before = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
assert!(apply_entry(&mut s, &file, &rich.entries[1], json!({"bodies":["Body1"]})).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!(history, (s.undo.len(), s.redo.len(), s.revision));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_export_obeys_file_scope_in_new_file_and_overwrite_modes() {
|
||||
let file = File::new();
|
||||
@@ -237,7 +345,8 @@ fn lossy_scalar_export_never_creates_or_replaces_a_file_or_changes_history() {
|
||||
std::fs::write(&file.0, b"preserve existing library").unwrap();
|
||||
}
|
||||
for overwrite in [false, true] {
|
||||
let error = s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite})).unwrap_err();
|
||||
let error =
|
||||
s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite,"format":solidcraft_io::catalog::V1_FORMAT})).unwrap_err();
|
||||
assert!(error.to_string().contains("cannot preserve PBR"));
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&file.0).unwrap(), b"preserve existing library");
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
use crate::{Session, doc::Document, product_scene};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::Arc;
|
||||
|
||||
fn card() -> Value {
|
||||
json!({"schema_version":1,"designation":"Original synthetic card, not design data","family":"other",
|
||||
"sources":[{"id":"fixture","title":"Original analytic constants","kind":"illustrative","license":"MIT OR Apache-2.0"}],
|
||||
"properties":[{"source_id":"fixture","conditions":{"temperature_k":293.15,"state":"synthetic"},
|
||||
"data":{"kind":"density","kg_m3":700.0}},
|
||||
{"source_id":"fixture","data":{"kind":"elasticity","model":{
|
||||
"symmetry":"isotropic","young_modulus_pa":12.25e9,"poisson_ratio":0.25}}}]})
|
||||
}
|
||||
|
||||
fn fixture() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":20,"height":5})).unwrap();
|
||||
s.execute(
|
||||
"material.create",
|
||||
&json!({"name":"Synthetic","density_g_cm3":0.8,"color":"#b47846",
|
||||
"pbr":{"metallic":0,"roughness":0.4,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("material.assign", &json!({"bodies":["Specimen"],"material":"Synthetic"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_edits_preserve_mass_optics_and_geometry_and_survive_undo_reopen() {
|
||||
let mut s = fixture();
|
||||
let scene = product_scene::extract(&s).unwrap();
|
||||
let mass = s.execute("component.properties", &json!({})).unwrap();
|
||||
let before_undo = s.undo.len();
|
||||
let out = s.execute("material.edit", &json!({"material":"Synthetic","physical_card":card()})).unwrap();
|
||||
assert_eq!(out["physical_validation"]["isotropic_moduli"]["shear_pa"], 4.9e9);
|
||||
assert_eq!(out["density_comparison"]["assigned_density_kg_m3"], 800.0);
|
||||
assert_eq!(out["density_comparison"]["matches_within_relative_tolerance"], false);
|
||||
assert_eq!(out["physical_validation"]["source_review_required"], json!(["fixture"]));
|
||||
assert_eq!(out["physical_validation"]["analysis_solver_available"], false);
|
||||
assert_eq!(s.undo.len(), before_undo + 1);
|
||||
assert_eq!(s.execute("component.properties", &json!({})).unwrap(), mass);
|
||||
let after = product_scene::extract(&s).unwrap();
|
||||
assert!(Arc::ptr_eq(&scene.meshes[0].mesh, &after.meshes[0].mesh));
|
||||
assert_eq!(scene.meshes[0].surface, after.meshes[0].surface);
|
||||
let saved = s.doc.to_json();
|
||||
assert_eq!(serde_json::from_str::<Value>(&saved).unwrap()["format"], solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert!(s.doc.user_materials[0].physical_card.is_none());
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
let mut reopened = Session::new(Document::from_json(&saved).unwrap());
|
||||
assert_eq!(reopened.execute("component.properties", &json!({})).unwrap(), mass);
|
||||
assert_eq!(reopened.doc.user_materials, s.doc.user_materials);
|
||||
let modulus = reopened.doc.user_materials[0].physical_card.as_ref().unwrap().card().directional_modulus([1., 2., 3.]).unwrap();
|
||||
assert_eq!(modulus.young_modulus_pa, 12.25e9);
|
||||
let doc = reopened.doc.clone();
|
||||
let before = (reopened.revision, reopened.undo.len(), reopened.camera, reopened.selection.clone());
|
||||
let list = reopened.execute("material.list", &json!({})).unwrap();
|
||||
let material = list["materials"].as_array().unwrap().iter().find(|m| m["name"] == "Synthetic").unwrap();
|
||||
assert_eq!(material["physical_card"], out["physical_card"]);
|
||||
assert!(Arc::ptr_eq(&doc, &reopened.doc));
|
||||
assert_eq!(before, (reopened.revision, reopened.undo.len(), reopened.camera, reopened.selection.clone()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omission_retains_null_clears_and_unrelated_material_edits_retain_cards() {
|
||||
let mut s = fixture();
|
||||
s.execute("material.create", &json!({"name":"With card","density_g_cm3":0.7,"physical_card":card()})).unwrap();
|
||||
let original = s.doc.user_materials[1].physical_card.clone();
|
||||
for p in [json!({"density_g_cm3":0.9}), json!({"color":"#112233"}), json!({"name":"Renamed"})] {
|
||||
let mut p = p;
|
||||
p["material"] = json!(s.doc.user_materials[1].name);
|
||||
s.execute("material.edit", &p).unwrap();
|
||||
assert_eq!(s.doc.user_materials[1].physical_card, original);
|
||||
}
|
||||
let mut only_card = Document::default();
|
||||
only_card.user_materials.push(s.doc.user_materials[1].clone());
|
||||
let error = solidcraft_io::catalog::Catalog::from_document(&only_card, "user.fixture", "Original", Default::default())
|
||||
.and_then(|c| c.in_format(solidcraft_io::catalog::V1_FORMAT))
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("engineering cards"));
|
||||
s.execute("material.edit", &json!({"material":"Renamed","physical_card":null})).unwrap();
|
||||
assert!(s.doc.user_materials[1].physical_card.is_none());
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(s.doc.user_materials[1].physical_card, original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_cards_refuse_atomically_and_saved_card_admission_is_not_bypassed() {
|
||||
let mut s = fixture();
|
||||
let mut bad = card();
|
||||
bad["properties"][1]["data"]["model"]["poisson_ratio"] = json!(0.6);
|
||||
let mut oversized = card();
|
||||
oversized["sources"] = json!(vec![json!({}); 33]);
|
||||
let mut unknown = card();
|
||||
unknown["unrecognized"] = json!(true);
|
||||
let mut missing = card();
|
||||
missing["properties"][0]["source_id"] = json!("missing");
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.undo.len(), s.redo.len(), s.revision, s.camera, s.selection.clone());
|
||||
for candidate in [bad, oversized, unknown, missing, json!(true)] {
|
||||
assert!(s.execute("material.edit", &json!({"material":"Synthetic","physical_card":candidate})).is_err());
|
||||
assert!(s.execute("material.create", &json!({"name":"Refused","density_g_cm3":1,"physical_card":candidate})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.undo.len(), s.redo.len(), s.revision, s.camera, s.selection.clone()));
|
||||
let mut saved = serde_json::to_value(&*s.doc).unwrap();
|
||||
saved["user_materials"][0]["physical_card"] = candidate;
|
||||
assert!(Document::from_json(&saved.to_string()).is_err());
|
||||
assert!(serde_json::from_value::<Document>(saved).is_err());
|
||||
}
|
||||
// Large finite SI constants are accepted only in this typed card, not geometry.
|
||||
let mut saved = serde_json::to_value(&*s.doc).unwrap();
|
||||
saved["user_materials"][0]["physical_card"] = card();
|
||||
assert!(Document::from_json(&saved.to_string()).is_ok());
|
||||
saved["hostile_numeric_probe"] = json!(12.25e9);
|
||||
assert!(Document::from_json(&saved.to_string()).unwrap_err().to_string().contains("number"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configurations_rename_and_paste_new_keep_design_owned_material_semantics() {
|
||||
let mut s = Session::default();
|
||||
let part = s.execute("component.create", &json!({"name":"Part"})).unwrap()["component"].as_u64().unwrap();
|
||||
s.execute("solid.box", &json!({"body_name":"Specimen","length":10,"width":10,"height":10})).unwrap();
|
||||
s.execute("material.create", &json!({"name":"Synthetic","density_g_cm3":0.8,"physical_card":card()})).unwrap();
|
||||
s.execute("config.configure", &json!({"materials":["Specimen"]})).unwrap();
|
||||
s.execute("config.row", &json!({"name":"Candidate","values":{"material:Specimen":"Synthetic"}})).unwrap();
|
||||
s.execute("config.activate", &json!({"row":"Candidate"})).unwrap();
|
||||
s.execute("component.activate", &json!({"component":0})).unwrap();
|
||||
s.execute("component.paste_new", &json!({"component":part,"translate":[30,0,0]})).unwrap();
|
||||
assert_eq!(s.doc.user_materials.len(), 1);
|
||||
assert_eq!(s.doc.materials.values().filter(|m| *m == "Synthetic").count(), 2);
|
||||
let original = s.doc.user_materials[0].physical_card.clone();
|
||||
s.execute("material.edit", &json!({"material":"Synthetic","name":"Renamed candidate"})).unwrap();
|
||||
s.execute("config.activate", &json!({"row":"Default"})).unwrap();
|
||||
s.execute("config.activate", &json!({"row":"Candidate"})).unwrap();
|
||||
let loaded = Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(loaded.user_materials[0].physical_card, original);
|
||||
assert_eq!(loaded.materials.values().filter(|m| *m == "Renamed candidate").count(), 2);
|
||||
assert!(loaded.configs.rows.iter().any(|r| r.cells.iter().any(|c| c == "Renamed candidate")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format21_materials_without_cards_keep_their_optical_look() {
|
||||
let s = fixture();
|
||||
let mut saved = serde_json::to_value(&*s.doc).unwrap();
|
||||
saved["format"] = json!("solidcraft/21");
|
||||
let loaded = Document::from_json(&saved.to_string()).unwrap();
|
||||
assert_eq!(loaded.user_materials, s.doc.user_materials);
|
||||
assert!(loaded.user_materials[0].physical_card.is_none());
|
||||
assert_eq!(loaded.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_catalog_refuses_card_export_without_creating_or_replacing_files_or_history() {
|
||||
struct ProbeFile(std::path::PathBuf);
|
||||
impl Drop for ProbeFile {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
let unique = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let file = ProbeFile(std::env::temp_dir().join(format!("solidcraft-card-refusal-{}-{unique}.json", std::process::id())));
|
||||
let mut s = Session::default();
|
||||
s.execute("material.create", &json!({"name":"Card only","density_g_cm3":0.8,"physical_card":card()})).unwrap();
|
||||
s.mark_saved();
|
||||
let doc = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
for existing in [false, true] {
|
||||
if existing {
|
||||
std::fs::write(&file.0, b"keep existing catalog").unwrap();
|
||||
}
|
||||
for overwrite in [false, true] {
|
||||
// The default writer is rich v2. Request scalar v1 explicitly so this
|
||||
// regression still exercises refusal rather than a valid lossless copy.
|
||||
let error =
|
||||
s.execute("library.export", &json!({"path":file.0,"overwrite":overwrite,"format":solidcraft_io::catalog::V1_FORMAT})).unwrap_err();
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("catalog v1") && message.contains("engineering cards"), "{message}");
|
||||
if existing {
|
||||
assert_eq!(std::fs::read(&file.0).unwrap(), b"keep existing catalog");
|
||||
} else {
|
||||
assert!(!file.0.exists());
|
||||
}
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
||||
assert!(!s.is_dirty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_container_keeps_large_si_card_values_sources_conditions_and_density_separate() {
|
||||
let mut s = fixture();
|
||||
s.execute("material.edit", &json!({"material":"Synthetic","physical_card":card()})).unwrap();
|
||||
let before = s.execute("component.properties", &json!({})).unwrap();
|
||||
let scene = product_scene::extract(&s).unwrap();
|
||||
let native = solidcraft_io::write_design_container(&s.doc).unwrap();
|
||||
assert!(solidcraft_io::is_design_container(&native));
|
||||
let loaded = solidcraft_io::read_design(&native).unwrap();
|
||||
assert_eq!(loaded.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
|
||||
assert_eq!(loaded.user_materials, s.doc.user_materials);
|
||||
let mut reopened = Session::new(loaded);
|
||||
assert_eq!(reopened.execute("component.properties", &json!({})).unwrap(), before);
|
||||
assert_eq!(product_scene::extract(&reopened).unwrap().meshes[0].surface, scene.meshes[0].surface);
|
||||
let list = reopened.execute("material.list", &json!({})).unwrap();
|
||||
let material = list["materials"].as_array().unwrap().iter().find(|m| m["name"] == "Synthetic").unwrap();
|
||||
assert_eq!(material["physical_card"], serde_json::to_value(s.doc.user_materials[0].physical_card.as_ref().unwrap()).unwrap());
|
||||
assert_eq!(material["density_comparison"]["assigned_density_kg_m3"], 800.0);
|
||||
assert_eq!(material["density_comparison"]["card_density_kg_m3"], 700.0);
|
||||
assert_eq!(material["physical_validation"]["source_review_required"], json!(["fixture"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format20_materials_without_cards_upgrade_to21_without_changing_their_optical_look() {
|
||||
let s = fixture();
|
||||
let mut saved = serde_json::to_value(&*s.doc).unwrap();
|
||||
saved["format"] = json!("solidcraft/20");
|
||||
let loaded = Document::from_json(&saved.to_string()).unwrap();
|
||||
assert_eq!(loaded.user_materials, s.doc.user_materials);
|
||||
assert!(loaded.user_materials[0].physical_card.is_none());
|
||||
assert_eq!(loaded.format, solidcraft_doc::format::format_string(solidcraft_doc::format::FORMAT));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_number_admission_preserves_metadata_without_exempting_other_material_fields() {
|
||||
let s = fixture();
|
||||
let mut saved = serde_json::to_value(&*s.doc).unwrap();
|
||||
saved["user_materials"][0]["physical_card"] = card();
|
||||
let metadata = json!({"org.example.measurement":{"value":12.25e9,"physical_card":{"untyped":12.25e9}}});
|
||||
saved["metadata"] = metadata.clone();
|
||||
let loaded = Document::from_json(&saved.to_string()).unwrap();
|
||||
let reopened = solidcraft_io::read_design(&solidcraft_io::write_design_container(&loaded).unwrap()).unwrap();
|
||||
assert_eq!(serde_json::to_value(&reopened.metadata).unwrap(), metadata);
|
||||
assert_eq!(reopened.user_materials, loaded.user_materials);
|
||||
|
||||
// Only the exact root user-material card receives SI admission. The same key
|
||||
// nested elsewhere must not exempt a hostile number from geometry limits.
|
||||
for field in ["appearance", "unrecognized"] {
|
||||
let mut hostile = saved.clone();
|
||||
hostile["user_materials"][0][field] = json!({"physical_card":{"untyped":12.25e9}});
|
||||
assert!(Document::from_json(&hostile.to_string()).unwrap_err().to_string().contains("number"));
|
||||
}
|
||||
saved["user_materials"][0]["density"] = json!(12.25e9);
|
||||
assert!(Document::from_json(&saved.to_string()).unwrap_err().to_string().contains("number"));
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::appearance::{Look, library};
|
||||
use solidcraft_doc::config::Column;
|
||||
use solidcraft_doc::{DEFAULT_MATERIAL, MATERIALS, UserMaterial, valid_density};
|
||||
use solidcraft_doc::{DEFAULT_MATERIAL, MATERIALS, MAX_USER_MATERIALS, UserMaterial, valid_density};
|
||||
|
||||
use super::CommandSpec;
|
||||
use super::inspect::{color_param, look_json};
|
||||
@@ -16,18 +16,19 @@ use crate::{Result, Session};
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("material.create", "New Physical Material", create).icon("material").params(
|
||||
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1), pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (opaque only, needs color/appearance): the default look. Optical edits do not change density. Assign it with material.assign",
|
||||
"name (not a built-in material's); density_g_cm3: (0, 1000]; appearance?: library name (appearance.library) | color?: \"#rrggbb\" or [r, g, b] (0–255), opacity? (0–1), pbr?: {metallic:0..1, roughness:0.045..1, dielectric_ior:1..3} (opaque only, needs color/appearance): the default look. Optical edits do not change density. physical_card?: validated SI candidate record (null/omitted means unknown); card density never changes assigned density. Assign it with material.assign",
|
||||
),
|
||||
CommandSpec::new("material.edit", "Edit Physical Material", edit).icon("material").enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). Omitted appearance/color retains the entire current look",
|
||||
"material: a material defined in this design; name? (bodies and configuration cells follow); density_g_cm3?; appearance? | color?, opacity?, pbr? (as material.create; color: null clears the look). physical_card?: validated SI candidate record (omitted retains, null clears). Omitted appearance/color retains the entire current look",
|
||||
),
|
||||
CommandSpec::new("material.delete", "Delete Physical Material", delete).icon("delete").enabled(has_user_materials).params(
|
||||
"material: a material defined in this design; replace?: material for the bodies and configuration cells that use it (without it, a material in use is refused)",
|
||||
),
|
||||
];
|
||||
|
||||
/// Most materials a design defines.
|
||||
const MAX_USER_MATERIALS: usize = 1000;
|
||||
#[cfg(test)]
|
||||
#[path = "material_card_persistence_tests.rs"]
|
||||
mod persistence_tests;
|
||||
|
||||
fn has_user_materials(s: &Session) -> std::result::Result<(), String> {
|
||||
if s.doc.user_materials.is_empty() { Err("the design defines no physical materials (material.create)".into()) } else { Ok(()) }
|
||||
@@ -86,8 +87,35 @@ fn look(cmd: &str, p: &Value, material: &str) -> Result<Option<Option<Look>>> {
|
||||
Ok(Some(Some(l)))
|
||||
}
|
||||
|
||||
fn material_json(m: &UserMaterial) -> Value {
|
||||
json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true})
|
||||
pub(super) fn material_json(m: &UserMaterial) -> Result<Value> {
|
||||
let mut out = json!({"name": m.name, "density_g_cm3": m.density, "appearance": m.appearance.as_ref().map(look_json), "user": true});
|
||||
if let Some(record) = &m.physical_card {
|
||||
let card = record.card();
|
||||
out["physical_card"] = json!(record);
|
||||
out["physical_validation"] = json!(card.validate()?);
|
||||
let measured = card.properties.iter().find_map(|p| match p.data {
|
||||
solidcraft_doc::physical_material::PropertyData::Density { kg_m3 } => Some(kg_m3),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(measured) = measured {
|
||||
let assigned = m.density * 1000.0;
|
||||
let relative_difference = (assigned - measured).abs() / assigned.max(measured);
|
||||
out["density_comparison"] = json!({"assigned_density_kg_m3":assigned,"card_density_kg_m3":measured,
|
||||
"relative_difference":relative_difference,"matches_within_relative_tolerance":relative_difference <= 1e-9,
|
||||
"relative_tolerance":1e-9});
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn physical_card(cmd: &str, p: &Value) -> Result<Option<Option<solidcraft_doc::physical_material::ValidatedCard>>> {
|
||||
match p.get("physical_card") {
|
||||
None => Ok(None),
|
||||
Some(Value::Null) => Ok(Some(None)),
|
||||
Some(value) => {
|
||||
solidcraft_doc::physical_material::ValidatedCard::from_value(value).map(|card| Some(Some(card))).map_err(|e| bad(cmd, e.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The index of the design material `material` names.
|
||||
@@ -106,8 +134,9 @@ fn create(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if s.doc.user_materials.len() >= MAX_USER_MATERIALS {
|
||||
return Err(bad(cmd, format!("a design defines at most {MAX_USER_MATERIALS} materials")));
|
||||
}
|
||||
let material = UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name };
|
||||
let out = material_json(&material);
|
||||
let material =
|
||||
UserMaterial { density: density(cmd, p)?, appearance: look(cmd, p, &name)?.flatten(), name, physical_card: physical_card(cmd, p)?.flatten() };
|
||||
let out = material_json(&material)?;
|
||||
s.doc_mut().user_materials.push(material);
|
||||
Ok(out)
|
||||
}
|
||||
@@ -173,8 +202,9 @@ fn edit(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
l
|
||||
}),
|
||||
};
|
||||
let material = UserMaterial { name: name.clone(), density, appearance };
|
||||
let mut out = material_json(&material);
|
||||
let physical_card = physical_card(cmd, p)?.unwrap_or(old.physical_card);
|
||||
let material = UserMaterial { name: name.clone(), density, appearance, physical_card };
|
||||
let mut out = material_json(&material)?;
|
||||
if let Some(m) = s.doc_mut().user_materials.get_mut(i) {
|
||||
*m = material;
|
||||
}
|
||||
|
||||
@@ -89,7 +89,11 @@ fn invalid_optical_requests_refuse_atomically_and_scalar_export_cannot_lose_them
|
||||
"pbr":{"metallic":0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err());
|
||||
assert!(
|
||||
solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default())
|
||||
.and_then(|c| c.in_format(solidcraft_io::catalog::V1_FORMAT))
|
||||
.is_err()
|
||||
);
|
||||
let mut invalid: serde_json::Value = serde_json::from_str(&s.doc.to_json()).unwrap();
|
||||
invalid["appearances"]["bodies"]["Specimen"]["opacity"] = json!(0.5);
|
||||
assert!(crate::doc::Document::from_json(&invalid.to_string()).is_err());
|
||||
@@ -115,7 +119,11 @@ fn physical_default_look_and_face_override_keep_independent_optical_meaning() {
|
||||
assert!(scene.meshes[0].surface.is_some());
|
||||
assert!(scene.meshes[0].faces.iter().any(|f| f.color == [0, 128, 160] && f.surface.is_none()));
|
||||
assert_eq!(s.doc.user_materials[0].density, 0.7);
|
||||
assert!(solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default()).is_err());
|
||||
assert!(
|
||||
solidcraft_io::catalog::Catalog::from_document(&s.doc, "user.fixture", "Original", Default::default())
|
||||
.and_then(|c| c.in_format(solidcraft_io::catalog::V1_FORMAT))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -144,3 +152,213 @@ fn independent_component_copy_keeps_its_opaque_response_after_source_edits_and_r
|
||||
let reopened = crate::doc::Document::from_json(&s.doc.to_json()).unwrap();
|
||||
assert_eq!(reopened.appearances.components[&copied], original);
|
||||
}
|
||||
|
||||
fn pack_session() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":20,"height":30,"body_name":"Swatch"})).unwrap();
|
||||
s.execute("material.assign", &json!({"bodies":["Swatch"],"material":"Aluminum"})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn copied_recipe_id_labels_are_inert_and_explicit_recipe_requests_stay_strict() {
|
||||
let mut s = fixture();
|
||||
for name in ["starter.metal.gold-warm", "starter.glass.clear-soda-lime"] {
|
||||
for pbr in [serde_json::Value::Null, json!({"metallic":0.0,"roughness":0.73,"dielectric_ior":1.37})] {
|
||||
s.execute("appearance.assign", &json!({"bodies":["Specimen"],"name":name,"color":[31,61,91],"opacity":1.0,"pbr":pbr})).unwrap();
|
||||
let look = &s.doc.appearances.bodies["Specimen"];
|
||||
assert_eq!(look.name, name);
|
||||
assert_eq!(look.color, [31, 61, 91]);
|
||||
assert_eq!(serde_json::to_value(look.pbr).unwrap(), pbr);
|
||||
assert_eq!(product_scene::extract(&s).unwrap().meshes[0].surface.is_some(), !pbr.is_null());
|
||||
}
|
||||
}
|
||||
let before = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
for params in [
|
||||
json!({"bodies":["Specimen"],"name":"Owned label"}),
|
||||
json!({"bodies":["Specimen"],"name":"Owned label","appearance":"starter.metal.gold-warm","color":[31,61,91]}),
|
||||
json!({"bodies":["Specimen"],"appearance":"starter.glass.clear-soda-lime","color":[31,61,91],"pbr":null}),
|
||||
json!({"bodies":["Specimen"],"name":"x".repeat(129),"color":[31,61,91]}),
|
||||
] {
|
||||
assert!(s.execute("appearance.assign", ¶ms).is_err());
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
||||
}
|
||||
s.execute("appearance.assign", &json!({"bodies":["Specimen"],"appearance":"starter.metal.gold-warm"})).unwrap();
|
||||
assert_eq!(s.doc.appearances.bodies["Specimen"].name, "Metal - Warm Reflective Gold");
|
||||
assert_eq!(s.doc.appearances.bodies["Specimen"].pbr.unwrap().metallic(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_listing_keeps_legacy_rows_and_labels_unavailable_recipes_without_mutation() {
|
||||
let mut s = pack_session();
|
||||
let doc = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let reply = s.execute("appearance.library", &json!({})).unwrap();
|
||||
let rows = reply["appearances"].as_array().unwrap();
|
||||
// Compatibility fixture: exact pre-pack scalar library, independent of the
|
||||
// separate six PBR finishes (#1366), which may also have no catalog IDs.
|
||||
let legacy: [(&str, [u8; 3], f64); 20] = [
|
||||
("Aluminum - Satin", [204, 208, 214], 1.0),
|
||||
("Aluminum - Anodized Black", [44, 46, 50], 1.0),
|
||||
("Aluminum - Anodized Blue", [52, 92, 160], 1.0),
|
||||
("Steel - Satin", [158, 164, 172], 1.0),
|
||||
("Stainless Steel - Brushed", [186, 191, 198], 1.0),
|
||||
("Cast Iron", [104, 104, 110], 1.0),
|
||||
("Brass - Polished", [208, 172, 88], 1.0),
|
||||
("Copper", [204, 122, 80], 1.0),
|
||||
("Titanium", [150, 148, 158], 1.0),
|
||||
("ABS - White", [236, 236, 230], 1.0),
|
||||
("ABS - Black", [36, 36, 38], 1.0),
|
||||
("ABS - Red", [196, 40, 40], 1.0),
|
||||
("ABS - Blue", [40, 90, 186], 1.0),
|
||||
("ABS - Yellow", [236, 196, 40], 1.0),
|
||||
("ABS - Green", [52, 150, 80], 1.0),
|
||||
("Rubber - Black", [28, 28, 28], 1.0),
|
||||
("Glass - Clear", [190, 220, 228], 0.25),
|
||||
("Glass - Smoked", [90, 96, 104], 0.45),
|
||||
("Wood - Oak", [190, 146, 92], 1.0),
|
||||
("Paint - Gloss White", [246, 246, 244], 1.0),
|
||||
];
|
||||
assert_eq!(solidcraft_doc::appearance::LIBRARY, legacy);
|
||||
for (name, color, opacity) in legacy {
|
||||
let matching: Vec<_> = rows.iter().filter(|row| row["name"] == name).collect();
|
||||
assert_eq!(matching.len(), 1, "legacy appearance must occur exactly once: {name}");
|
||||
let row = matching[0];
|
||||
assert_eq!(row["color"], format!("#{:02x}{:02x}{:02x}", color[0], color[1], color[2]));
|
||||
assert_eq!(row["opacity"], opacity);
|
||||
assert!(row.get("id").is_none() && row.get("pbr").is_none(), "legacy scalar meaning changed: {name}");
|
||||
}
|
||||
assert_eq!(rows.len(), 60);
|
||||
assert_eq!(rows.iter().filter(|row| row["available"] != false).count(), 54);
|
||||
assert_eq!(rows.iter().filter(|row| row["available"] != false && row["opacity"] == 1.0).count(), 52);
|
||||
assert_eq!(rows.iter().filter(|row| row["opacity"] != 1.0).count(), 2);
|
||||
assert_eq!(rows.iter().filter(|v| v.get("id").is_some()).count(), 40);
|
||||
assert_eq!(rows.iter().filter(|v| v["available"] == false).count(), 6);
|
||||
for row in rows.iter().filter(|v| v["category"] == "glass") {
|
||||
assert_eq!(row["available"], false);
|
||||
assert_eq!(row["opacity"], 1.0);
|
||||
assert!(row["pbr"].is_null());
|
||||
assert_eq!(row["requirements"], json!(["refraction", "certified_thin_or_closed"]));
|
||||
}
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(s.undo.len(), undo);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_pack_uses_normal_assignment_undo_and_preserves_engineering_properties() {
|
||||
let mut s = pack_session();
|
||||
let before = s.doc.clone();
|
||||
let undo = s.undo.len();
|
||||
let density = s.doc.density("Swatch");
|
||||
let physical = s.execute("component.properties", &json!({"component":"root"})).unwrap()["total"].clone();
|
||||
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":"starter.metal.gold-warm"})).unwrap();
|
||||
let look = s.doc.appearances.bodies.get("Swatch").unwrap();
|
||||
assert_eq!(look.pbr.unwrap().metallic(), 1.0);
|
||||
assert_eq!(look.opacity, 1.0);
|
||||
assert_eq!(s.undo.len(), undo + 1);
|
||||
assert_eq!(s.doc.density("Swatch"), density);
|
||||
assert_eq!(s.execute("component.properties", &json!({"component":"root"})).unwrap()["total"], physical);
|
||||
let assigned = s.doc.clone();
|
||||
s.execute("edit.undo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc, *before);
|
||||
s.execute("edit.redo", &json!({})).unwrap();
|
||||
assert_eq!(*s.doc, *assigned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unavailable_glass_and_invalid_opaque_override_refuse_atomically() {
|
||||
let mut s = pack_session();
|
||||
let doc = s.doc.clone();
|
||||
let before = (s.revision, s.undo.len(), s.redo.len(), s.is_dirty());
|
||||
for extra in [json!({}), json!({"color":"#ffffff"}), json!({"opacity":0.25,"pbr":null})] {
|
||||
let mut params = json!({"bodies":["Swatch"],"appearance":"starter.glass.clear-soda-lime"});
|
||||
params.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone());
|
||||
assert!(s.execute("appearance.assign", ¶ms).unwrap_err().to_string().contains("refraction"));
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.is_dirty()));
|
||||
}
|
||||
assert!(s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":"starter.wood.oak-honey","opacity":0.4})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!(before, (s.revision, s.undo.len(), s.redo.len(), s.is_dirty()));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn cold_native_reopen_keeps_owned_opaque_values_without_pack_metadata_or_assets() {
|
||||
let mut s = pack_session();
|
||||
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":"starter.wood.walnut-dark"})).unwrap();
|
||||
let look = s.doc.appearances.bodies.get("Swatch").unwrap().clone();
|
||||
let density = s.doc.density("Swatch");
|
||||
let nonce = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_nanos();
|
||||
let dir = std::env::temp_dir().join(format!("solidcraft-pack-{}-{nonce}", std::process::id()));
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
let path = dir.join("opaque.solidcraft");
|
||||
s.execute("file.save", &json!({"path":path.to_str().unwrap()})).unwrap();
|
||||
let mut reopened = Session::default();
|
||||
reopened.execute("doc.open", &json!({"path":path.to_str().unwrap()})).unwrap();
|
||||
assert_eq!(reopened.doc.appearances.bodies.get("Swatch"), Some(&look));
|
||||
assert_eq!(reopened.doc.density("Swatch"), density);
|
||||
let json = reopened.doc.to_json();
|
||||
assert!(!json.contains("starter.wood.walnut-dark") && !json.contains("starter_pack.rs"));
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_selectors_keep_final_validation_and_custom_names_stay_inert() {
|
||||
let mut s = pack_session();
|
||||
let preset = solidcraft_doc::appearance_pack::find("starter.wood.oak-honey").unwrap();
|
||||
let doc = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
for selector in [preset.id, preset.name] {
|
||||
for opacity in [json!(0.5), json!(2.0), json!("1"), json!(null)] {
|
||||
assert!(s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":selector,"opacity":opacity})).is_err());
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
||||
}
|
||||
}
|
||||
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":preset.id,"pbr":null,"opacity":0.4})).unwrap();
|
||||
assert!(s.doc.appearances.bodies["Swatch"].pbr.is_none());
|
||||
assert_eq!(s.doc.appearances.bodies["Swatch"].opacity, 0.4);
|
||||
// A copied name is inert; it is not a request to select the registered recipe.
|
||||
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"name":preset.name,"color":"#123456","opacity":0.6})).unwrap();
|
||||
let look = &s.doc.appearances.bodies["Swatch"];
|
||||
assert_eq!((look.name.as_str(), look.color, look.opacity, look.pbr), (preset.name, [18, 52, 86], 0.6, None));
|
||||
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":preset.name})).unwrap();
|
||||
assert_eq!(s.doc.appearances.bodies["Swatch"], preset.opaque_look().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn starter_names_and_ids_share_admission_before_all_overrides() {
|
||||
use crate::doc::appearance_pack::{Recipe, STARTER_PACK};
|
||||
let mut s = pack_session();
|
||||
for preset in &STARTER_PACK {
|
||||
for selector in [preset.id, preset.name] {
|
||||
if matches!(preset.recipe, Recipe::Glass(_)) {
|
||||
for overrides in [
|
||||
json!({}),
|
||||
json!({"color":"#aabbcc"}),
|
||||
json!({"color":"#aabbcc","opacity":0.5,"pbr":null}),
|
||||
json!({"color":"#aabbcc","pbr":{"metallic":0.0,"roughness":0.5,"dielectric_ior":1.5}}),
|
||||
] {
|
||||
let before = s.doc.clone();
|
||||
let history = (s.undo.len(), s.redo.len(), s.revision);
|
||||
let mut params = overrides;
|
||||
params["bodies"] = json!(["Swatch"]);
|
||||
params["appearance"] = json!(selector);
|
||||
let error = s.execute("appearance.assign", ¶ms).unwrap_err().to_string();
|
||||
assert!(error.contains("refraction"), "{selector}: {error}");
|
||||
assert!(Arc::ptr_eq(&before, &s.doc));
|
||||
assert_eq!((s.undo.len(), s.redo.len(), s.revision), history);
|
||||
}
|
||||
} else {
|
||||
s.execute("appearance.assign", &json!({"bodies":["Swatch"],"appearance":selector,"color":[31,61,91]})).unwrap();
|
||||
let look = &s.doc.appearances.bodies["Swatch"];
|
||||
assert_eq!(look.name, preset.name);
|
||||
assert_eq!(look.color, [31, 61, 91]);
|
||||
assert_eq!(look.pbr, preset.opaque_look().unwrap().pbr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,11 +10,15 @@ use super::CommandSpec;
|
||||
use crate::params::bad;
|
||||
use crate::{Result, Session};
|
||||
|
||||
mod clear_coat;
|
||||
mod environment;
|
||||
mod jobs;
|
||||
mod textures;
|
||||
mod transmission;
|
||||
|
||||
pub static COMMANDS: &[CommandSpec] = &[
|
||||
CommandSpec::new("render.preflight", "Check Render Request", jobs::preflight).icon("inspect").noundo().doc_only()
|
||||
.params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"),
|
||||
.params("setup?: saved name OR settings?: full RenderSetup; transparent_background?: false; replace?: false; progressive?: false; samples?: 1..4096 (64); max_depth?: 1..16 (8); seed?: u32 (0). raytrace?: {dielectrics: up to eight runtime body overrides, clear_coats: up to 64 runtime opaque coat overrides, textures: bounded body mappings (docs/render-runtime-textures.md)} (see docs/render-transmission.md); lens?: {aperture_radius_mm, focus_distance_mm} (see docs/render-thin-lens.md). environment?: {kind: hdr, path, assume_linear_srgb?} OR {kind: sky, gradient?}, with rotation_rad/intensity (docs/render-environment.md); lighting?: {sun?: {direction,angular_radius_rad,radiance}, environment: [r,g,b], area_lights: [{center,u,v,radiance}]} and tone_map?: reinhard|aces_fitted (docs/render-studio-lighting.md); denoise?: bool (false), native GPU final edge-aware filter (docs/render-denoise.md). Sampling, exposure, transmission, clear-coat, texture, lens, lighting, tone-map and denoise controls require progressive rendering. Validates the installed image host and bounded captured product scene without starting work. Stale geometry is rejected. See docs/render-jobs.md"),
|
||||
CommandSpec::new("render.start", "Render Image", jobs::start).noundo().doc_only().at("RENDER", "IMAGE").icon("render_image")
|
||||
.params("Same request as render.preflight. Captures an immutable product scene and queues a bounded image job. Returns its session-scoped job identity, captured scene_generation and progress. Does not change the design. See docs/render-jobs.md"),
|
||||
CommandSpec::new("render.status", "Render Progress", jobs::status).icon("list").noundo().doc_only()
|
||||
@@ -45,7 +49,8 @@ fn scene(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let bodies: Vec<_> = scene
|
||||
.meshes
|
||||
.iter()
|
||||
.map(|m| json!({"name":m.name,"triangles":m.mesh.triangles.len(),"color":m.color,"opacity":m.opacity,"face_appearances":m.faces.len()}))
|
||||
.enumerate()
|
||||
.map(|(ordinal,m)| json!({"ordinal":ordinal,"name":m.name,"triangles":m.mesh.triangles.len(),"color":m.color,"opacity":m.opacity,"face_appearances":m.faces.len()}))
|
||||
.collect();
|
||||
Ok(
|
||||
json!({"bodies":bodies,"bounds":if scene.bounds.is_empty() { Value::Null } else { json!({"min":scene.bounds.min,"max":scene.bounds.max}) },"overlays":false}),
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Request-only coat overrides on immutable captured geometry; no saved appearance changes.
|
||||
use crate::{Result, params::bad};
|
||||
use serde::Deserialize;
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::{
|
||||
ProductScene,
|
||||
clear_coat::ClearCoat,
|
||||
surface::{OpaqueSurface, srgb8_to_linear},
|
||||
};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Override {
|
||||
body_index: usize,
|
||||
body_name: String,
|
||||
weight: f64,
|
||||
roughness: f64,
|
||||
ior: f64,
|
||||
}
|
||||
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
|
||||
if overrides.len() > 64 {
|
||||
return Err(bad(command, "at most 64 runtime clear-coat overrides are supported"));
|
||||
}
|
||||
let mut ordinals = std::collections::BTreeSet::new();
|
||||
for input in overrides {
|
||||
if input.body_name.is_empty()
|
||||
|| input.body_name.len() > 1024
|
||||
|| input.body_name.chars().any(char::is_control)
|
||||
|| !ordinals.insert(input.body_index)
|
||||
{
|
||||
return Err(bad(command, "clear coats require bounded names and distinct captured body ordinals"));
|
||||
}
|
||||
let coat = ClearCoat::new(input.weight, input.roughness, input.ior).map_err(|e| bad(command, e.to_string()))?;
|
||||
let body = scene
|
||||
.meshes
|
||||
.get_mut(input.body_index)
|
||||
.filter(|m| m.name == input.body_name)
|
||||
.ok_or_else(|| bad(command, "clear-coat body ordinal and exact name must both match render.scene"))?;
|
||||
// A zero-weight override is a true no-op, including legacy appearances.
|
||||
if coat.weight() == 0.0 {
|
||||
continue;
|
||||
}
|
||||
if body.transmission.is_some() || body.faces.iter().any(|f| f.transmission.is_some()) {
|
||||
return Err(bad(command, "clear coat and transmission cannot share a body"));
|
||||
}
|
||||
let surface = |color: [u8; 3], existing: Option<OpaqueSurface>| -> Result<OpaqueSurface> {
|
||||
let [r, g, b] = color.map(srgb8_to_linear);
|
||||
let base =
|
||||
existing.map(Ok).unwrap_or_else(|| OpaqueSurface::new(Vec3::new(r, g, b), 0.0, 0.5, 1.5)).map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok(base.with_clear_coat(coat))
|
||||
};
|
||||
body.surface = Some(surface(body.color, body.surface)?);
|
||||
for face in &mut body.faces {
|
||||
face.surface = Some(surface(face.color, face.surface)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn coat_is_runtime_only_and_validates_exact_body_and_values() {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":2,"body_name":"Housing"})).unwrap();
|
||||
let saved = s.doc.to_json();
|
||||
let original = crate::product_scene::extract(&s).unwrap();
|
||||
let input = json!({"body_index":0,"body_name":"Housing","weight":1,"roughness":0.12,"ior":1.5});
|
||||
let mut scene = original.clone();
|
||||
apply(&mut scene, &[serde_json::from_value(input.clone()).unwrap()], "render.start").unwrap();
|
||||
assert!(scene.meshes[0].surface.unwrap().clear_coat().is_some());
|
||||
assert!(std::sync::Arc::ptr_eq(&scene.meshes[0].mesh, &original.meshes[0].mesh));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
assert!(crate::product_scene::extract(&s).unwrap().meshes[0].surface.is_none_or(|s| s.clear_coat().is_none()));
|
||||
for (key, value) in
|
||||
[("weight", json!(-0.1)), ("roughness", json!(0)), ("ior", json!(4)), ("body_index", json!(1)), ("body_name", json!("Wrong"))]
|
||||
{
|
||||
let mut bad_input = input.clone();
|
||||
bad_input[key] = value;
|
||||
assert!(apply(&mut original.clone(), &[serde_json::from_value(bad_input).unwrap()], "render.start").is_err());
|
||||
}
|
||||
let duplicate = serde_json::from_value::<Vec<Override>>(json!([input, input])).unwrap();
|
||||
assert!(apply(&mut original.clone(), &duplicate, "render.start").is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Runtime environment inputs. Captures bytes through the caller's scoped VFS.
|
||||
use crate::{Result, params::bad};
|
||||
use serde::Deserialize;
|
||||
use solidcraft_render::lighting::{
|
||||
MAX_HDR_BYTES,
|
||||
environment::{EnvironmentResource, EnvironmentSource, SkyGradient},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
fn unit() -> f64 {
|
||||
1.0
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub(super) enum Input {
|
||||
Hdr {
|
||||
path: String,
|
||||
#[serde(default)]
|
||||
rotation_rad: f64,
|
||||
#[serde(default = "unit")]
|
||||
intensity: f64,
|
||||
#[serde(default)]
|
||||
assume_linear_srgb: bool,
|
||||
},
|
||||
Sky {
|
||||
#[serde(default)]
|
||||
gradient: SkyGradient,
|
||||
#[serde(default)]
|
||||
rotation_rad: f64,
|
||||
#[serde(default = "unit")]
|
||||
intensity: f64,
|
||||
},
|
||||
}
|
||||
impl Input {
|
||||
pub(super) fn capture(&self, command: &str) -> Result<EnvironmentResource> {
|
||||
let source = match self {
|
||||
Self::Hdr { path, rotation_rad, intensity, assume_linear_srgb } => {
|
||||
if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) {
|
||||
return Err(bad(command, "bounded HDR path required"));
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(path, MAX_HDR_BYTES).map_err(|e| bad(command, format!("could not capture HDR: {e}")))?;
|
||||
EnvironmentSource::hdr(bytes, *rotation_rad, *intensity, *assume_linear_srgb)
|
||||
}
|
||||
Self::Sky { gradient, rotation_rad, intensity } => EnvironmentSource::sky(*gradient, *rotation_rad, *intensity),
|
||||
}
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok(Arc::new(source))
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,94 @@ fn finish(host: &CpuHost) {
|
||||
panic!("cooperative job did not finish");
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn additional_executor_routes_by_capability_and_survives_document_adoption() {
|
||||
use solidcraft_render::job::{Admission, CancelToken, ImageExecutor, ImageHost, ImageRequest, ImageTask, ImageWork, JobOptions};
|
||||
struct Progressive;
|
||||
impl ImageExecutor for Progressive {
|
||||
fn descriptor(&self) -> solidcraft_render::job::Result<solidcraft_render::ExecutorDescriptor> {
|
||||
let mut descriptor = CpuHost::cooperative().descriptor()?;
|
||||
descriptor.id = solidcraft_render::ExecutorId::new("test-progressive").unwrap();
|
||||
descriptor.profiles[0].features.push(solidcraft_render::RenderFeature::Progressive);
|
||||
descriptor.profiles[0].features.push(solidcraft_render::RenderFeature::Hdri);
|
||||
Ok(descriptor)
|
||||
}
|
||||
fn admit(
|
||||
&self,
|
||||
scene: &solidcraft_render::ProductScene,
|
||||
request: &ImageRequest,
|
||||
options: JobOptions,
|
||||
) -> solidcraft_render::job::Result<Admission> {
|
||||
options.validate()?;
|
||||
// admit_geometry counts source geometry only; this executor renders through the
|
||||
// CPU task, which ignores the environment and textures, so budget its pixels and
|
||||
// PNG with the CPU estimate for the same frame.
|
||||
solidcraft_render::job::admit(scene, &ImageRequest { environment: None, textures: None, ..request.clone() })
|
||||
}
|
||||
fn begin(
|
||||
&self,
|
||||
scene: solidcraft_render::ProductScene,
|
||||
request: ImageRequest,
|
||||
_: JobOptions,
|
||||
cancel: CancelToken,
|
||||
) -> solidcraft_render::job::Result<Box<dyn ImageWork>> {
|
||||
Ok(Box::new(ImageTask::new(scene, request, cancel)?))
|
||||
}
|
||||
}
|
||||
let (mut session, cpu) = session();
|
||||
let additional = ImageHost::with_executor(Arc::new(Progressive)).unwrap();
|
||||
session.register_render_job_host(additional.clone()).unwrap();
|
||||
assert!(
|
||||
session.register_render_job_host(ImageHost::with_executor(Arc::new(Progressive)).unwrap()).is_err(),
|
||||
"duplicate executor IDs must be rejected"
|
||||
);
|
||||
let cpu_id = start(&mut session);
|
||||
let request = json!({"settings":{"width":16,"height":16},"progressive":true,"samples":3});
|
||||
let preflight = session.execute("render.preflight", &request).unwrap();
|
||||
assert_eq!(preflight["resolved"]["executor"], "test-progressive");
|
||||
assert_eq!(additional.admitted_bytes(), 0);
|
||||
let mut studio = request.clone();
|
||||
studio["lighting"] = json!({"environment":[1,1,1],"area_lights":[{"center":[0,0,10],"u":[2,0,0],"v":[0,-2,0],"radiance":[4,4,4]}]});
|
||||
studio["tone_map"] = json!("aces_fitted");
|
||||
let document = session.doc.clone();
|
||||
let studio_reply = session.execute("render.preflight", &studio).unwrap();
|
||||
assert_eq!(studio_reply["options"]["lighting"]["environment"], json!([1.0, 1.0, 1.0]));
|
||||
assert_eq!(studio_reply["options"]["tone_map"], "aces_fitted");
|
||||
assert!(Arc::ptr_eq(&document, &session.doc));
|
||||
studio["environment"] = json!({"kind":"sky","intensity":0.5,"rotation_rad":0.3});
|
||||
let env_reply = session.execute("render.preflight", &studio).unwrap();
|
||||
assert_eq!(env_reply["environment"]["kind"], "original_gradient_sky");
|
||||
assert_eq!(env_reply["environment"]["width"], 512);
|
||||
assert_eq!(env_reply["environment"]["source_sha256"].as_str().unwrap().len(), 64);
|
||||
assert!(env_reply["request"].get("environment").is_none());
|
||||
assert!(Arc::ptr_eq(&document, &session.doc));
|
||||
studio["progressive"] = json!(false);
|
||||
assert!(session.execute("render.preflight", &studio).unwrap_err().to_string().contains("require progressive"));
|
||||
studio["progressive"] = json!(true);
|
||||
studio["lighting"]["area_lights"][0]["u"] = json!([0, 0, 0]);
|
||||
assert!(session.execute("render.preflight", &studio).is_err());
|
||||
let started = session.execute("render.start", &request).unwrap();
|
||||
let id: JobId = serde_json::from_value(started["job"].clone()).unwrap();
|
||||
let status = session.render_job_handle(id).unwrap().wait_timeout(std::time::Duration::from_secs(10)).unwrap();
|
||||
assert_eq!(status.resolved.executor.as_str(), "test-progressive");
|
||||
assert_eq!(status.options.samples, 3);
|
||||
assert_eq!(status.progress.phase, solidcraft_render::job::Phase::Completed);
|
||||
finish(&cpu);
|
||||
assert_eq!(session.execute("render.status", &json!({"job":cpu_id})).unwrap()["frame"]["executor"], "cpu-product");
|
||||
assert!(session.scratch().execute("render.status", &json!({"job":id})).is_err());
|
||||
session.execute("file.new", &json!({})).unwrap();
|
||||
assert_eq!(session.execute("render.preflight", &request).unwrap()["resolved"]["executor"], "test-progressive");
|
||||
assert!(session.execute("render.status", &json!({"job":id})).is_err());
|
||||
for bad in [json!({"samples":0}), json!({"max_depth":17}), json!({"seed":-1}), json!({"samples":4097})] {
|
||||
let mut params = request.clone();
|
||||
for (key, value) in bad.as_object().unwrap() {
|
||||
params[key] = value.clone();
|
||||
}
|
||||
assert!(session.execute("render.start", ¶ms).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_commands_preserve_document_history_and_evaluation() {
|
||||
let (mut s, host) = session();
|
||||
@@ -217,3 +305,13 @@ fn native_start_and_cancel_measure_cached_capture_without_timing_assertions() {
|
||||
);
|
||||
assert!(status.progress.phase.terminal());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_texture_controls_require_progressive_and_leave_document_unchanged() {
|
||||
let (mut s, _) = session();
|
||||
let doc = s.doc.clone();
|
||||
let input = json!({"raytrace":{"textures":[{"body_index":0,"body_name":"No body","frame":{"origin":[0,0,0],"u":[1,0,0],"v":[0,1,0]},"normal_opengl":"not-opened.png"}]}});
|
||||
let error = s.execute("render.preflight", &input).unwrap_err().to_string();
|
||||
assert!(error.contains("require progressive"), "{error}");
|
||||
assert!(Arc::ptr_eq(&doc, &s.doc));
|
||||
}
|
||||
|
||||
@@ -14,14 +14,50 @@ struct Start {
|
||||
settings: Option<RenderSetup>,
|
||||
transparent_background: bool,
|
||||
replace: bool,
|
||||
progressive: bool,
|
||||
samples: Option<u32>,
|
||||
max_depth: Option<u32>,
|
||||
seed: Option<u32>,
|
||||
raytrace: super::transmission::Raytrace,
|
||||
lens: Option<solidcraft_render::job::ThinLens>,
|
||||
lighting: Option<solidcraft_render::job::StudioLighting>,
|
||||
tone_map: Option<solidcraft_render::job::ToneMap>,
|
||||
environment: Option<super::environment::Input>,
|
||||
denoise: bool,
|
||||
}
|
||||
|
||||
fn prepare(s: &Session, params: &Value, command: &str) -> Result<(Start, solidcraft_render::ProductScene, ImageRequest, Value)> {
|
||||
struct Prepared {
|
||||
input: Start,
|
||||
scene: solidcraft_render::ProductScene,
|
||||
request: ImageRequest,
|
||||
reply: Value,
|
||||
executor: solidcraft_render::ExecutorId,
|
||||
options: solidcraft_render::job::JobOptions,
|
||||
}
|
||||
|
||||
fn prepare(s: &Session, params: &Value, command: &str) -> Result<Prepared> {
|
||||
let input: Start = serde_json::from_value(params.clone()).map_err(|e| bad(command, e.to_string()))?;
|
||||
if input.setup.is_some() && input.settings.is_some() {
|
||||
return Err(bad(command, "choose either setup or settings"));
|
||||
}
|
||||
let scene = crate::product_scene::extract(s)?;
|
||||
let mut scene = crate::product_scene::extract(s)?;
|
||||
if !input.progressive
|
||||
&& (!input.raytrace.dielectrics.is_empty()
|
||||
|| !input.raytrace.clear_coats.is_empty()
|
||||
|| !input.raytrace.textures.is_empty()
|
||||
|| input.lens.is_some()
|
||||
|| input.lighting.is_some()
|
||||
|| input.tone_map.is_some()
|
||||
|| input.environment.is_some()
|
||||
|| input.denoise)
|
||||
{
|
||||
return Err(bad(
|
||||
command,
|
||||
"runtime transmission, clear-coat, texture, lens, environment, lighting, tone-map and denoise controls require progressive rendering",
|
||||
));
|
||||
}
|
||||
super::transmission::apply(&mut scene, &input.raytrace.dielectrics, command)?;
|
||||
super::clear_coat::apply(&mut scene, &input.raytrace.clear_coats, command)?;
|
||||
let settings = if let Some(name) = &input.setup {
|
||||
s.doc.render.setups.get(name).cloned().ok_or_else(|| bad(command, "no saved render setup with that name"))?
|
||||
} else if let Some(settings) = &input.settings {
|
||||
@@ -36,12 +72,38 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<(Start, solidcr
|
||||
settings
|
||||
};
|
||||
settings.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
if settings.exposure_ev != 0.0 || settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0 {
|
||||
return Err(bad(command, "exposure and environment controls are not supported by the R4a CPU image executor"));
|
||||
if settings.environment_rotation != 0.0 || settings.environment_intensity != 1.0 {
|
||||
return Err(bad(command, "environment controls are not supported by the installed image executors"));
|
||||
}
|
||||
let host = s.render_job_host().ok_or_else(|| bad(command, "no image host is installed"))?;
|
||||
if !host.is_available() {
|
||||
return Err(bad(command, "image host is unavailable"));
|
||||
if !input.progressive && (input.samples.is_some() || input.max_depth.is_some() || input.seed.is_some() || settings.exposure_ev != 0.0) {
|
||||
return Err(bad(command, "sampling and exposure controls require progressive rendering"));
|
||||
}
|
||||
let mut options = solidcraft_render::job::JobOptions::default();
|
||||
if let Some(samples) = input.samples {
|
||||
options.samples = samples;
|
||||
}
|
||||
if let Some(depth) = input.max_depth {
|
||||
options.max_depth = depth;
|
||||
}
|
||||
if let Some(seed) = input.seed {
|
||||
options.seed = seed;
|
||||
}
|
||||
options.exposure_ev = settings.exposure_ev;
|
||||
options.lens = input.lens;
|
||||
options.lighting = input.lighting;
|
||||
options.tone_map = input.tone_map.unwrap_or_default();
|
||||
options.denoise = input.denoise;
|
||||
options.validate().map_err(job_error)?;
|
||||
let environment = input.environment.as_ref().map(|source| source.capture(command)).transpose()?;
|
||||
let mut features = scene.required_features();
|
||||
if environment.is_some() {
|
||||
features.push(solidcraft_render::RenderFeature::Hdri);
|
||||
}
|
||||
if input.progressive {
|
||||
features.push(solidcraft_render::RenderFeature::Progressive);
|
||||
if scene.has_transmission() {
|
||||
features.push(solidcraft_render::RenderFeature::Refraction);
|
||||
}
|
||||
}
|
||||
let resolved = solidcraft_render::resolve_request(
|
||||
&[s.render_backend()],
|
||||
@@ -52,33 +114,50 @@ fn prepare(s: &Session, params: &Value, command: &str) -> Result<(Start, solidcr
|
||||
width: u64::from(settings.width),
|
||||
height: u64::from(settings.height),
|
||||
format: Some(settings.format),
|
||||
features: scene.required_features(),
|
||||
features,
|
||||
policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| bad(command, e.to_string()))?;
|
||||
if resolved.executor.as_str() != "cpu-product" {
|
||||
return Err(bad(command, "resolved executor is not installed as an image job host"));
|
||||
if options.denoise && resolved.executor.as_str() != "gpu-path" {
|
||||
return Err(bad(command, "denoise requires the native gpu-path executor"));
|
||||
}
|
||||
let host = s.render_jobs.host_for(&resolved.executor).ok_or_else(|| bad(command, "resolved executor is not installed as an image job host"))?;
|
||||
if !host.is_available() {
|
||||
return Err(bad(command, "image host is unavailable"));
|
||||
}
|
||||
let request = ImageRequest {
|
||||
environment,
|
||||
camera: settings.camera,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
background: settings.background,
|
||||
transparent_background: input.transparent_background,
|
||||
textures: super::textures::capture(&scene, &input.raytrace.textures, command)?,
|
||||
};
|
||||
let admission = solidcraft_render::job::admit(&scene, &request).map_err(job_error)?;
|
||||
let reply = json!({"resolved":resolved,"admission":admission,"request":request,"scene_generation":s.revision,"host":host.kind(),"transparency":"sorted_alpha_approximation","refraction":false});
|
||||
Ok((input, scene, request, reply))
|
||||
let admission = host.admit(&scene, &request, options).map_err(job_error)?;
|
||||
let refraction = input.progressive && scene.has_transmission();
|
||||
let reply = json!({"resolved":resolved,"admission":admission,"request":request,"textures":request.textures.as_ref().map(|resources|resources.summary()),"scene_generation":s.revision,"host":host.kind(),"transparency":if refraction {"physical_transmission"} else if input.progressive {"opaque_only"} else {"sorted_alpha_approximation"},"options":options,"environment":request.environment.as_ref().map(|source|source.info()),"refraction":refraction,"dielectric_boundaries": if resolved.executor.as_str() == "gpu-path" {vec!["thin_sheet","closed_box","closed_mesh"]} else {Vec::<&str>::new()}});
|
||||
let mut reply = reply;
|
||||
if options.denoise {
|
||||
reply["denoising"] = json!({
|
||||
"method":"edge_aware_atrous", "passes":3, "steps":[1,2,4],
|
||||
"guides":"deterministic_center_ray_albedo_normal_position_depth",
|
||||
"bypass":"glass, smooth metal/coat, emitter, background and partial coverage first hits",
|
||||
"limitation":"first-hit guides do not reconstruct reflection/refraction or depth-of-field correspondences",
|
||||
"raw_linear_accumulator_preserved":true, "output_stage":"linear_before_tone_map"
|
||||
});
|
||||
}
|
||||
Ok(Prepared { input, scene, request, reply, executor: resolved.executor, options })
|
||||
}
|
||||
|
||||
pub(super) fn preflight(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
prepare(s, params, "render.preflight").map(|(_, _, _, reply)| reply)
|
||||
prepare(s, params, "render.preflight").map(|prepared| prepared.reply)
|
||||
}
|
||||
|
||||
pub(super) fn start(s: &mut Session, params: &Value) -> Result<Value> {
|
||||
let (input, scene, request, _) = prepare(s, params, "render.start")?;
|
||||
let status = s.render_jobs.start(s.revision, scene, request, input.replace).map_err(job_error)?;
|
||||
let p = prepare(s, params, "render.start")?;
|
||||
let status = s.render_jobs.start(s.revision, p.scene, p.request, p.input.replace, &p.executor, p.options).map_err(job_error)?;
|
||||
Ok(json!(status))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//! Bounded immutable file capture; decoding is deferred to the admitted worker.
|
||||
use crate::{Result, params::bad};
|
||||
use serde::Deserialize;
|
||||
use solidcraft_render::{
|
||||
ProductScene,
|
||||
job::textures::{MAX_TEXTURE_BINDINGS, MAX_TEXTURE_SOURCE_BYTES, PlanarFrame, RuntimeTextures, TextureBinding, TextureMap},
|
||||
texture_input::MapRole,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Override {
|
||||
body_index: usize,
|
||||
body_name: String,
|
||||
frame: PlanarFrame,
|
||||
#[serde(default)]
|
||||
base_color_srgb: Option<String>,
|
||||
#[serde(default)]
|
||||
normal_opengl: Option<String>,
|
||||
#[serde(default = "normal_strength")]
|
||||
normal_strength: f64,
|
||||
}
|
||||
fn normal_strength() -> f64 {
|
||||
1.0
|
||||
}
|
||||
fn load(path: &str, role: MapRole, remaining: &mut u64, command: &str) -> Result<TextureMap> {
|
||||
if path.is_empty() || path.len() > 4096 || path.chars().any(char::is_control) {
|
||||
return Err(bad(command, "texture path must be a bounded local path"));
|
||||
}
|
||||
let bytes = solidcraft_io::vfs::read(path, *remaining as usize).map_err(|e| bad(command, format!("could not capture texture: {e}")))?;
|
||||
if bytes.is_empty() {
|
||||
return Err(bad(command, "texture file must not be empty"));
|
||||
}
|
||||
let map = TextureMap::new(bytes, role).map_err(|e| bad(command, e.to_string()))?;
|
||||
*remaining = remaining.checked_sub(map.retained_bytes() as u64).ok_or_else(|| bad(command, "encoded texture capacity exceeds budget"))?;
|
||||
Ok(map)
|
||||
}
|
||||
pub(super) fn capture(scene: &ProductScene, overrides: &[Override], command: &str) -> Result<Option<Arc<RuntimeTextures>>> {
|
||||
if overrides.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if overrides.len() > MAX_TEXTURE_BINDINGS {
|
||||
return Err(bad(command, "at most 16 runtime texture bodies are supported"));
|
||||
}
|
||||
let mut remaining = MAX_TEXTURE_SOURCE_BYTES;
|
||||
let mut bindings = Vec::new();
|
||||
for input in overrides {
|
||||
input.frame.validate().map_err(|e| bad(command, e.to_string()))?;
|
||||
// Refuse stale identity or unsupported optics before touching any file.
|
||||
let body = scene
|
||||
.meshes
|
||||
.get(input.body_index)
|
||||
.filter(|b| b.name == input.body_name)
|
||||
.ok_or_else(|| bad(command, "texture ordinal and name must both match render.scene"))?;
|
||||
if body.transmission.is_some() || body.opacity != 1.0 || body.faces.iter().any(|f| f.transmission.is_some() || f.opacity != 1.0) {
|
||||
return Err(bad(command, "runtime maps support opaque bodies only"));
|
||||
}
|
||||
let base_color = input.base_color_srgb.as_ref().map(|p| load(p, MapRole::BaseColor, &mut remaining, command)).transpose()?;
|
||||
let normal = input.normal_opengl.as_ref().map(|p| load(p, MapRole::NormalOpenGl, &mut remaining, command)).transpose()?;
|
||||
bindings.push(TextureBinding {
|
||||
body_index: input.body_index,
|
||||
body_name: input.body_name.clone(),
|
||||
frame: input.frame,
|
||||
normal_strength: input.normal_strength,
|
||||
base_color,
|
||||
normal,
|
||||
});
|
||||
}
|
||||
let resources = RuntimeTextures::new(bindings).map_err(|e| bad(command, e.to_string()))?;
|
||||
resources.validate_scene(scene).map_err(|e| bad(command, e.to_string()))?;
|
||||
Ok(Some(Arc::new(resources)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
#[test]
|
||||
fn unsupported_or_stale_map_targets_fail_before_file_io() {
|
||||
let scene = ProductScene {
|
||||
bounds: Aabb3::EMPTY,
|
||||
meshes: vec![solidcraft_render::ProductMesh {
|
||||
name: "Glass".into(),
|
||||
mesh: Arc::new(Mesh::default()),
|
||||
color: [255; 3],
|
||||
opacity: 1.,
|
||||
surface: None,
|
||||
transmission: Some(solidcraft_render::transmission::Transmission::new(solidcraft_geom::Vec3::ZERO, Some(1.)).unwrap()),
|
||||
faces: vec![],
|
||||
}],
|
||||
};
|
||||
let value =
|
||||
json!({"body_index":0,"body_name":"Glass","frame":{"origin":[0,0,0],"u":[1,0,0],"v":[0,1,0]},"base_color_srgb":"nonexistent-image.png"});
|
||||
let input: Override = serde_json::from_value(value.clone()).unwrap();
|
||||
assert!(capture(&scene, &[input], "render.start").unwrap_err().to_string().contains("opaque bodies"));
|
||||
let mut stale = value.clone();
|
||||
stale["body_name"] = json!("Stale");
|
||||
let input: Override = serde_json::from_value(stale).unwrap();
|
||||
assert!(capture(&scene, &[input], "render.start").unwrap_err().to_string().contains("ordinal and name"));
|
||||
let mut unknown = value;
|
||||
unknown["metallic_map"] = json!("foo");
|
||||
assert!(serde_json::from_value::<Override>(unknown).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Agreed #1659 runtime wire adapter, coordinated in Mailbox 9075.
|
||||
//! Keep wire details here until the CPU contract is published. Never save them.
|
||||
use crate::{Result, params::bad};
|
||||
use serde::Deserialize;
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::{
|
||||
ProductScene,
|
||||
surface::{OpaqueSurface, srgb8_to_linear},
|
||||
transmission::Transmission,
|
||||
};
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub(super) struct Raytrace {
|
||||
pub dielectrics: Vec<Override>,
|
||||
pub clear_coats: Vec<super::clear_coat::Override>,
|
||||
pub textures: Vec<super::textures::Override>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Override {
|
||||
body_index: usize,
|
||||
body_name: String,
|
||||
ior: f64,
|
||||
absorption_per_mm: [f64; 3],
|
||||
boundary: Boundary,
|
||||
thickness_mm: f64,
|
||||
#[serde(default)]
|
||||
shadow_transmission: Option<bool>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(try_from = "String")]
|
||||
enum Boundary {
|
||||
ThinSheet,
|
||||
ClosedBox,
|
||||
ClosedMesh,
|
||||
}
|
||||
impl TryFrom<String> for Boundary {
|
||||
type Error = &'static str;
|
||||
fn try_from(value: String) -> std::result::Result<Self, Self::Error> {
|
||||
match value.as_str() {
|
||||
"thin_sheet" => Ok(Self::ThinSheet),
|
||||
"closed_box" => Ok(Self::ClosedBox),
|
||||
"closed_mesh" => Ok(Self::ClosedMesh),
|
||||
_ => Err("boundary must be thin_sheet, closed_box or closed_mesh"),
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) fn apply(scene: &mut ProductScene, overrides: &[Override], command: &str) -> Result<()> {
|
||||
if overrides.len() > 8 {
|
||||
return Err(bad(command, "at most eight runtime dielectric overrides are supported"));
|
||||
}
|
||||
let mut ordinals = std::collections::BTreeSet::new();
|
||||
for input in overrides {
|
||||
if input.body_name.is_empty()
|
||||
|| input.body_name.len() > 1024
|
||||
|| input.body_name.chars().any(char::is_control)
|
||||
|| !ordinals.insert(input.body_index)
|
||||
{
|
||||
return Err(bad(command, "dielectrics require bounded names and distinct captured body ordinals"));
|
||||
}
|
||||
let thickness = match input.boundary {
|
||||
Boundary::ThinSheet => Some(input.thickness_mm),
|
||||
_ => None,
|
||||
};
|
||||
if !matches!(input.boundary, Boundary::ThinSheet) && input.thickness_mm != 0.0 {
|
||||
return Err(bad(command, "closed dielectric thickness_mm must be zero; distance comes from geometry"));
|
||||
}
|
||||
let [r, g, b] = input.absorption_per_mm;
|
||||
let mut transmission = Transmission::new(Vec3::new(r, g, b), thickness).map_err(|e| bad(command, e.to_string()))?;
|
||||
if let Some(enabled) = input.shadow_transmission {
|
||||
transmission = transmission.with_shadow_transmission(enabled);
|
||||
}
|
||||
OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, input.ior).map_err(|e| bad(command, e.to_string()))?;
|
||||
let body = scene
|
||||
.meshes
|
||||
.get_mut(input.body_index)
|
||||
.filter(|m| m.name == input.body_name)
|
||||
.ok_or_else(|| bad(command, "dielectric body ordinal and exact name must both match render.scene"))?;
|
||||
if matches!(input.boundary, Boundary::ClosedBox) {
|
||||
certify_box(&body.mesh, command)?;
|
||||
}
|
||||
// Full dielectric override replaces optical metalness in the job snapshot only.
|
||||
let surface = |color: [u8; 3], existing: Option<OpaqueSurface>| {
|
||||
let [r, g, b] = color.map(srgb8_to_linear);
|
||||
OpaqueSurface::new(Vec3::new(r, g, b), 0.0, existing.map_or(0.5, |s| s.roughness()), input.ior).map_err(|e| bad(command, e.to_string()))
|
||||
};
|
||||
body.surface = Some(surface(body.color, body.surface)?);
|
||||
body.transmission = Some(transmission);
|
||||
for face in &mut body.faces {
|
||||
face.surface = Some(surface(face.color, face.surface)?);
|
||||
face.transmission = Some(transmission);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn certify_box(mesh: &solidcraft_geom::Mesh, command: &str) -> Result<()> {
|
||||
let bounds = mesh.bounds();
|
||||
if mesh.triangles.is_empty()
|
||||
|| !bounds.min.is_finite()
|
||||
|| !bounds.max.is_finite()
|
||||
|| bounds.min.x >= bounds.max.x
|
||||
|| bounds.min.y >= bounds.max.y
|
||||
|| bounds.min.z >= bounds.max.z
|
||||
{
|
||||
return Err(bad(command, "closed_box requires a nonempty axis-aligned box"));
|
||||
}
|
||||
for triangle in &mesh.triangles {
|
||||
let points = triangle.map(|i| mesh.positions.get(i as usize).copied());
|
||||
let [Some(a), Some(b), Some(c)] = points else {
|
||||
return Err(bad(command, "invalid closed_box triangle"));
|
||||
};
|
||||
let on_plane = |x: f64, y: f64, z: f64, lo: f64, hi: f64| {
|
||||
[lo, hi].into_iter().any(|plane| [x, y, z].into_iter().all(|v| v.is_finite() && (v - plane).abs() <= 1e-6))
|
||||
};
|
||||
if !(on_plane(a.x, b.x, c.x, bounds.min.x, bounds.max.x)
|
||||
|| on_plane(a.y, b.y, c.y, bounds.min.y, bounds.max.y)
|
||||
|| on_plane(a.z, b.z, c.z, bounds.min.z, bounds.max.z))
|
||||
{
|
||||
return Err(bad(command, "closed_box requires axis-aligned bounding planes; use GPU-only closed_mesh for other closed geometry"));
|
||||
}
|
||||
}
|
||||
// GPU preparation additionally certifies closed, outward two-manifold topology.
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
fn fixture() -> crate::Session {
|
||||
let mut s = crate::Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":2,"body_name":"Glass"})).unwrap();
|
||||
s
|
||||
}
|
||||
fn input() -> serde_json::Value {
|
||||
json!({"body_index":0,"body_name":"Glass","ior":1.5,"absorption_per_mm":[0.1,0.02,0],"boundary":"closed_box","thickness_mm":0})
|
||||
}
|
||||
#[test]
|
||||
fn request_glass_leaves_document_geometry_and_preview_unchanged() {
|
||||
let mut s = fixture();
|
||||
s.execute("appearance.assign", &json!({"bodies":["Glass"],"color":"#c0e0ff","opacity":0.2})).unwrap();
|
||||
let saved = s.doc.to_json();
|
||||
let mut scene = crate::product_scene::extract(&s).unwrap();
|
||||
let mesh = scene.meshes[0].mesh.clone();
|
||||
apply(&mut scene, &[serde_json::from_value(input()).unwrap()], "render.start").unwrap();
|
||||
assert!(scene.has_transmission());
|
||||
assert_eq!(scene.meshes[0].opacity, 0.2);
|
||||
assert!(Arc::ptr_eq(&mesh, &scene.meshes[0].mesh));
|
||||
assert_eq!(scene.meshes[0].surface.unwrap().dielectric_ior(), 1.5);
|
||||
assert_eq!(saved, s.doc.to_json());
|
||||
let reopened = crate::Session::new(solidcraft_doc::Document::from_json(&saved).unwrap());
|
||||
assert!(!crate::product_scene::extract(&reopened).unwrap().has_transmission());
|
||||
assert!(
|
||||
s.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Glass"],"color":"#ffffff",
|
||||
"pbr":{"metallic":0,"roughness":0.1,"dielectric_ior":1.5,"transmission":{}}})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn request_glass_rejects_unknown_fractional_duplicate_and_mismatched_inputs() {
|
||||
let s = fixture();
|
||||
for (key, value) in [
|
||||
("weight", json!(0.5)),
|
||||
("ior", json!(0.9)),
|
||||
("absorption_per_mm", json!([100.01, 0, 0])),
|
||||
("body_index", json!(1)),
|
||||
("body_name", json!("missing")),
|
||||
("thickness_mm", json!(-1)),
|
||||
("boundary", json!({"thin_sheet":null})),
|
||||
("extra", json!(true)),
|
||||
] {
|
||||
let mut value_input = input();
|
||||
value_input[key] = value;
|
||||
let rejected = match serde_json::from_value::<Override>(value_input) {
|
||||
Ok(value) => apply(&mut crate::product_scene::extract(&s).unwrap(), &[value], "render.start").is_err(),
|
||||
Err(_) => true,
|
||||
};
|
||||
assert!(rejected, "{key}");
|
||||
}
|
||||
for count in [2, 9] {
|
||||
let overrides = serde_json::from_value::<Vec<Override>>(json!(vec![input(); count])).unwrap();
|
||||
assert!(apply(&mut crate::product_scene::extract(&s).unwrap(), &overrides, "render.start").is_err());
|
||||
}
|
||||
let mut thin = input();
|
||||
thin["boundary"] = json!("thin_sheet");
|
||||
assert!(apply(&mut crate::product_scene::extract(&s).unwrap(), &[serde_json::from_value(thin).unwrap()], "render.start").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn closed_box_certificate_rejects_deformed_mesh_but_closed_mesh_is_explicit() {
|
||||
let mut scene = crate::product_scene::extract(&fixture()).unwrap();
|
||||
Arc::make_mut(&mut scene.meshes[0].mesh).positions[0] += Vec3::new(0.2, 0.3, 0.4);
|
||||
assert!(apply(&mut scene.clone(), &[serde_json::from_value(input()).unwrap()], "render.start").is_err());
|
||||
let mut value = input();
|
||||
value["boundary"] = json!("closed_mesh");
|
||||
assert!(apply(&mut scene, &[serde_json::from_value(value).unwrap()], "render.start").is_ok());
|
||||
}
|
||||
#[test]
|
||||
fn shadow_flags_follow_boundary_defaults_and_explicit_runtime_overrides() {
|
||||
let s = fixture();
|
||||
let saved = s.doc.to_json();
|
||||
for thin in [false, true] {
|
||||
for flag in [None, Some(false), Some(true)] {
|
||||
let mut value = input();
|
||||
if thin {
|
||||
value["boundary"] = json!("thin_sheet");
|
||||
}
|
||||
if let Some(flag) = flag {
|
||||
value["shadow_transmission"] = json!(flag);
|
||||
}
|
||||
let mut scene = crate::product_scene::extract(&s).unwrap();
|
||||
apply(&mut scene, &[serde_json::from_value(value).unwrap()], "render.start").unwrap();
|
||||
assert_eq!(scene.meshes[0].transmission.unwrap().shadow_transmission(), flag.unwrap_or(thin));
|
||||
assert_eq!(s.doc.to_json(), saved);
|
||||
}
|
||||
}
|
||||
let mut malformed = input();
|
||||
malformed["shadow_transmission"] = json!(0.5);
|
||||
assert!(serde_json::from_value::<Override>(malformed).is_err());
|
||||
}
|
||||
}
|
||||
@@ -507,11 +507,14 @@ impl Session {
|
||||
}
|
||||
if let Some(executor) = &self.render_jobs.descriptor {
|
||||
let mut executor = executor.clone();
|
||||
if self.render_jobs.host.as_ref().is_none_or(|host| !host.is_available()) {
|
||||
executor.availability = render::Availability::Unavailable { reason: "CPU image host is unavailable".into() };
|
||||
}
|
||||
executor.availability = image_host_availability(self.render_jobs.host.as_ref(), &executor.id);
|
||||
backend.executors.push(executor);
|
||||
}
|
||||
for (host, descriptor) in &self.render_jobs.additional {
|
||||
let mut descriptor = descriptor.clone();
|
||||
descriptor.availability = image_host_availability(Some(host), &descriptor.id);
|
||||
backend.executors.push(descriptor);
|
||||
}
|
||||
backend
|
||||
}
|
||||
|
||||
@@ -532,6 +535,7 @@ impl Session {
|
||||
if let Some(executor) = &self.render_jobs.descriptor {
|
||||
backend.executors.push(executor.clone());
|
||||
}
|
||||
backend.executors.extend(self.render_jobs.additional.iter().map(|(_, d)| d.clone()));
|
||||
backend.executors.push(executor.clone());
|
||||
// Resolution validates the whole registry, including duplicate executor IDs.
|
||||
let request = render::RenderRequest {
|
||||
@@ -851,6 +855,7 @@ impl Session {
|
||||
let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?;
|
||||
let host_viewport = self.render_viewport.clone();
|
||||
let host_jobs = self.render_jobs.host.clone();
|
||||
let additional_jobs = self.render_jobs.additional.clone();
|
||||
let live = spec.live.filter(|_| params.get("live").and_then(Value::as_bool) == Some(true));
|
||||
if live.is_none() && spec.reads_model && self.model.is_stale() {
|
||||
self.refresh();
|
||||
@@ -894,6 +899,9 @@ impl Session {
|
||||
if self.render_jobs.host.is_none() && host_jobs.is_some() {
|
||||
self.set_render_job_host(host_jobs)?;
|
||||
}
|
||||
for (host, _) in additional_jobs {
|
||||
self.register_render_job_host(host)?;
|
||||
}
|
||||
if outermost && crash::enabled() {
|
||||
// A failed command leaves the design as it was (restored below).
|
||||
let failed = r.as_ref().err().map(ToString::to_string);
|
||||
@@ -1295,3 +1303,13 @@ fn own_values(sk: &solidcraft_sketch::Sketch) -> std::collections::BTreeMap<Stri
|
||||
}
|
||||
values
|
||||
}
|
||||
|
||||
/// Capabilities are fixed at registration; availability is live device/host state.
|
||||
fn image_host_availability(host: Option<&render::job::ImageHost>, expected: &render::ExecutorId) -> render::Availability {
|
||||
match host.map(render::job::ImageHost::descriptor) {
|
||||
Some(Ok(current)) if current.id == *expected => current.availability,
|
||||
Some(Ok(_)) => render::Availability::Unavailable { reason: "image executor identity changed after registration".into() },
|
||||
Some(Err(error)) => render::Availability::Unavailable { reason: format!("image executor status unavailable: {error}") },
|
||||
None => render::Availability::Unavailable { reason: "image host is unavailable".into() },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ pub fn extract(s: &Session) -> Result<ProductScene> {
|
||||
color: look.color,
|
||||
opacity: look.opacity.clamp(0.0, 1.0),
|
||||
surface: look.opaque_surface().map_err(|e| EngineError::Other(e.to_string()))?,
|
||||
transmission: None,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
@@ -42,6 +43,7 @@ pub fn extract(s: &Session) -> Result<ProductScene> {
|
||||
color,
|
||||
opacity: s.doc.model_body_opacity(body).clamp(0.0, 1.0),
|
||||
surface,
|
||||
transmission: None,
|
||||
faces,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) struct Jobs {
|
||||
generation: u64,
|
||||
pub(crate) host: Option<CpuHost>,
|
||||
pub(crate) descriptor: Option<ExecutorDescriptor>,
|
||||
pub(crate) additional: Vec<(CpuHost, ExecutorDescriptor)>,
|
||||
live: Vec<JobHandle>,
|
||||
history: VecDeque<JobStatus>,
|
||||
last_good: Option<JobHandle>,
|
||||
@@ -37,7 +38,17 @@ pub(crate) struct Jobs {
|
||||
|
||||
impl Default for Jobs {
|
||||
fn default() -> Self {
|
||||
Self { owner: owner(), serial: 0, generation: 0, host: None, descriptor: None, live: Vec::new(), history: VecDeque::new(), last_good: None }
|
||||
Self {
|
||||
owner: owner(),
|
||||
serial: 0,
|
||||
generation: 0,
|
||||
host: None,
|
||||
descriptor: None,
|
||||
additional: Vec::new(),
|
||||
live: Vec::new(),
|
||||
history: VecDeque::new(),
|
||||
last_good: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,10 +61,18 @@ impl Drop for Jobs {
|
||||
}
|
||||
|
||||
impl Jobs {
|
||||
pub(crate) fn host_for(&self, executor: &solidcraft_render::ExecutorId) -> Option<&CpuHost> {
|
||||
if self.descriptor.as_ref().is_some_and(|d| &d.id == executor) {
|
||||
return self.host.as_ref();
|
||||
}
|
||||
self.additional.iter().find(|(_, d)| &d.id == executor).map(|(host, _)| host)
|
||||
}
|
||||
|
||||
pub(crate) fn scratch(&self) -> Self {
|
||||
let mut jobs = Self::default();
|
||||
jobs.host = self.host.clone();
|
||||
jobs.descriptor = self.descriptor.clone();
|
||||
jobs.additional = self.additional.clone();
|
||||
jobs
|
||||
}
|
||||
|
||||
@@ -83,9 +102,11 @@ impl Jobs {
|
||||
scene: solidcraft_render::ProductScene,
|
||||
request: solidcraft_render::job::ImageRequest,
|
||||
replace: bool,
|
||||
executor: &solidcraft_render::ExecutorId,
|
||||
options: solidcraft_render::job::JobOptions,
|
||||
) -> Result<JobStatus, JobError> {
|
||||
self.collect()?;
|
||||
let host = self.host.as_ref().ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "no image host is installed"))?.clone();
|
||||
let host = self.host_for(executor).ok_or_else(|| JobError::new(ErrorCode::HostUnavailable, "resolved image host is not installed"))?.clone();
|
||||
if replace {
|
||||
for job in self.live.drain(..) {
|
||||
let mut status = job.cancel()?;
|
||||
@@ -109,7 +130,7 @@ impl Jobs {
|
||||
}
|
||||
let serial = self.serial.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render job identity exhausted"))?;
|
||||
let generation = self.generation.checked_add(1).ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "render generation exhausted"))?;
|
||||
let handle = host.submit(JobId { session: self.owner, serial, generation }, revision, scene, request)?;
|
||||
let handle = host.submit_with_options(JobId { session: self.owner, serial, generation }, revision, scene, request, options)?;
|
||||
let status = handle.status()?;
|
||||
self.serial = serial;
|
||||
self.generation = generation;
|
||||
@@ -176,12 +197,43 @@ impl crate::Session {
|
||||
)
|
||||
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
let mut jobs = Jobs::default();
|
||||
jobs.additional = self.render_jobs.additional.clone();
|
||||
jobs.host = host;
|
||||
jobs.descriptor = descriptor;
|
||||
self.render_jobs = jobs;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Add an executor without replacing CPU jobs or their ownership. Applications share
|
||||
/// this host across document sessions; descriptor IDs must be unique in the registry.
|
||||
pub fn register_render_job_host(&mut self, host: solidcraft_render::job::ImageHost) -> crate::Result<()> {
|
||||
let descriptor = host.descriptor().map_err(job_error)?;
|
||||
if self.render_jobs.additional.iter().any(|(old, _)| old.same_host(&host)) {
|
||||
return Ok(());
|
||||
}
|
||||
if descriptor.profiles.iter().any(|p| p.operation != solidcraft_render::RenderOperation::Image) {
|
||||
return Err(crate::EngineError::Other("image host must only register image profiles".into()));
|
||||
}
|
||||
let mut registry = self.render_backend();
|
||||
registry.executors.push(descriptor.clone());
|
||||
solidcraft_render::resolve_request(
|
||||
&[registry],
|
||||
&solidcraft_render::RenderRequest {
|
||||
backend: None,
|
||||
mode: solidcraft_render::RenderMode::Preview,
|
||||
operation: solidcraft_render::RenderOperation::Image,
|
||||
width: 1,
|
||||
height: 1,
|
||||
format: Some(solidcraft_render::RenderFormat::Png),
|
||||
features: Vec::new(),
|
||||
policy: Default::default(),
|
||||
},
|
||||
)
|
||||
.map_err(|e| crate::EngineError::Other(e.to_string()))?;
|
||||
self.render_jobs.additional.push((host, descriptor));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn render_job_host(&self) -> Option<&CpuHost> {
|
||||
self.render_jobs.host.as_ref()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
robust = "1.2"
|
||||
solidcraft-geom = { workspace = true }
|
||||
solidcraft-render = { workspace = true }
|
||||
wgpu = { workspace = true }
|
||||
@@ -22,6 +23,7 @@ pollster = { workspace = true }
|
||||
wgpu = { workspace = true, features = ["metal", "vulkan", "dx12", "gles"] }
|
||||
|
||||
[dev-dependencies]
|
||||
image = { workspace = true }
|
||||
solidcraft-kernel = { workspace = true }
|
||||
# Headless pictures of real designs (capture tests).
|
||||
solidcraft-engine = { workspace = true }
|
||||
|
||||
+11
-4
@@ -20,6 +20,8 @@ use solidcraft_geom::tess::{EvalCurve, EvalSurface, TessPlan};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub mod capture;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod path;
|
||||
mod pbr;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub mod select;
|
||||
@@ -65,14 +67,19 @@ fn popped(f: impl std::future::Future<Output = Option<wgpu::Error>>) -> Option<w
|
||||
fn open_headless() -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
|
||||
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 {
|
||||
let (device, queue) = open_device(&adapter)?;
|
||||
Ok((adapter, device, queue))
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn open_device(adapter: &wgpu::Adapter) -> Result<(wgpu::Device, wgpu::Queue)> {
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("solidcraft headless"),
|
||||
required_features: depth_features(&adapter),
|
||||
required_features: depth_features(adapter),
|
||||
required_limits: wgpu::Limits::downlevel_defaults().using_resolution(adapter.limits()),
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(|e| GpuError::Device(e.to_string()))?;
|
||||
Ok((adapter, device, queue))
|
||||
.map_err(|e| GpuError::Device(e.to_string()))
|
||||
}
|
||||
|
||||
/// Most vertices evaluated in one dispatch (bounds the buffers; larger plans go in batches).
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Optional output filtering; never writes the Monte Carlo accumulation buffer.
|
||||
use super::{Context, Result, SHADER, TIMEOUT, Task, checked, placeholder_bind, scopes, warm_up};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub(super) const BYTES_PER_PIXEL: u64 = 80; // three guide vec4s and two colour vec4s
|
||||
const FILTER: &str = include_str!("denoise.wgsl");
|
||||
const GUIDES: &str = include_str!("denoise_guides.wgsl");
|
||||
#[cfg(test)]
|
||||
#[path = "denoise_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
// Shared by every denoised job on a device: the guide pipeline compiles the whole
|
||||
// path shader, so it is built and warmed once rather than per job.
|
||||
pub(super) struct Pipelines {
|
||||
guide: wgpu::ComputePipeline,
|
||||
filter: wgpu::ComputePipeline,
|
||||
filter_layout: wgpu::BindGroupLayout,
|
||||
}
|
||||
|
||||
impl Pipelines {
|
||||
fn new(context: &Context) -> Result<Self> {
|
||||
let device = &context.device;
|
||||
let error_scopes = scopes(device);
|
||||
let guide_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("denoise guides"),
|
||||
source: wgpu::ShaderSource::Wgsl(format!("{SHADER}\n{GUIDES}").into()),
|
||||
});
|
||||
let guide_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("denoise guides"),
|
||||
bind_group_layouts: &[Some(&context.layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let guide = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("denoise guides"),
|
||||
layout: Some(&guide_layout),
|
||||
module: &guide_shader,
|
||||
entry_point: Some("denoise_guides"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let entries: Vec<_> = (0..4)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: if binding == 0 { wgpu::BufferBindingType::Uniform } else { wgpu::BufferBindingType::Storage { read_only: binding != 3 } },
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
let filter_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("denoise filter"), entries: &entries });
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("denoise filter"),
|
||||
bind_group_layouts: &[Some(&filter_layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let shader = device
|
||||
.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("denoise filter"), source: wgpu::ShaderSource::Wgsl(FILTER.into()) });
|
||||
let filter = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("denoise filter"),
|
||||
layout: Some(&layout),
|
||||
module: &shader,
|
||||
entry_point: Some("atrous"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
checked(error_scopes)?;
|
||||
warm_up(device, &context.queue, &guide, &placeholder_bind(device, &context.layout, 7)?, &context.healthy)?;
|
||||
warm_up(device, &context.queue, &filter, &placeholder_bind(device, &filter_layout, 4)?, &context.healthy)?;
|
||||
Ok(Self { guide, filter, filter_layout })
|
||||
}
|
||||
|
||||
fn shared(context: &Context) -> Result<&Self> {
|
||||
if let Some(pipelines) = context.denoise.get() {
|
||||
return Ok(pipelines);
|
||||
}
|
||||
let built = Self::new(context)?;
|
||||
Ok(context.denoise.get_or_init(|| built))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct Resources {
|
||||
guide_pipeline: wgpu::ComputePipeline,
|
||||
guide_bind: wgpu::BindGroup,
|
||||
filter_pipeline: wgpu::ComputePipeline,
|
||||
filter_uniform: wgpu::Buffer,
|
||||
filter_binds: Vec<wgpu::BindGroup>,
|
||||
output: wgpu::Buffer,
|
||||
// Retained for diagnostic tests; bind groups also retain the guide allocation.
|
||||
_guides: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl Resources {
|
||||
pub(super) fn new(
|
||||
context: &Context,
|
||||
path_uniform: &wgpu::Buffer,
|
||||
scene: [&wgpu::Buffer; 5],
|
||||
accumulation: &wgpu::Buffer,
|
||||
size: u64,
|
||||
) -> Result<Self> {
|
||||
let pipelines = Pipelines::shared(context)?;
|
||||
let device = &context.device;
|
||||
let error_scopes = scopes(device);
|
||||
let buffer = |label, bytes| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some(label),
|
||||
size: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
let guides = buffer("denoise first-hit guides", size * 3);
|
||||
let output = buffer("denoise filtered colour", size);
|
||||
let temporary = buffer("denoise intermediate colour", size);
|
||||
let [triangles, nodes, environment, textures, failures] = scene;
|
||||
let entries: Vec<_> = [path_uniform, triangles, nodes, &guides, environment, textures, failures]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(binding, value)| wgpu::BindGroupEntry { binding: binding as u32, resource: value.as_entire_binding() })
|
||||
.collect();
|
||||
let guide_bind =
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("denoise guides"), layout: &context.layout, entries: &entries });
|
||||
let filter_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("denoise parameters"),
|
||||
contents: &[0; 32],
|
||||
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||
});
|
||||
let filter_binds = [(accumulation, &output), (&output, &temporary), (&temporary, &output)]
|
||||
.into_iter()
|
||||
.map(|(source, destination)| {
|
||||
let entries: Vec<_> = [&filter_uniform, &guides, source, destination]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(binding, value)| wgpu::BindGroupEntry { binding: binding as u32, resource: value.as_entire_binding() })
|
||||
.collect();
|
||||
device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("denoise filter"),
|
||||
layout: &pipelines.filter_layout,
|
||||
entries: &entries,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
checked(error_scopes)?;
|
||||
Ok(Self {
|
||||
guide_pipeline: pipelines.guide.clone(),
|
||||
guide_bind,
|
||||
filter_pipeline: pipelines.filter.clone(),
|
||||
filter_uniform,
|
||||
filter_binds,
|
||||
output,
|
||||
_guides: guides,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn output(&self) -> &wgpu::Buffer {
|
||||
&self.output
|
||||
}
|
||||
|
||||
pub(super) fn run(&self, task: &Task) -> Result<()> {
|
||||
let pixels = task.request.width * task.request.height;
|
||||
let mut budget = super::dispatch::Budget::default();
|
||||
let mut offset = 0;
|
||||
while offset < pixels {
|
||||
task.cancel.check()?;
|
||||
let count = budget.count(pixels - offset);
|
||||
let mut parameters = task.parameters(count);
|
||||
if let Some(bytes) = parameters.get_mut(12..16) {
|
||||
bytes.copy_from_slice(&offset.to_le_bytes());
|
||||
}
|
||||
task.context.queue.write_buffer(&task.uniform, 0, ¶meters);
|
||||
let started = std::time::Instant::now();
|
||||
dispatch(&task.context, &self.guide_pipeline, &self.guide_bind, count)?;
|
||||
budget.observe(count, started.elapsed());
|
||||
offset += count;
|
||||
}
|
||||
self.run_filter(task)
|
||||
}
|
||||
|
||||
fn run_filter(&self, task: &Task) -> Result<()> {
|
||||
let pixels = task.request.width * task.request.height;
|
||||
for (pass, bind) in self.filter_binds.iter().enumerate() {
|
||||
let mut budget = super::dispatch::Budget::default();
|
||||
let mut offset = 0;
|
||||
while offset < pixels {
|
||||
task.cancel.check()?;
|
||||
let count = budget.count(pixels - offset);
|
||||
let mut bytes = Vec::with_capacity(32);
|
||||
for word in [task.request.width, task.request.height, 1 << pass, offset, count, task.sample, 0, 0] {
|
||||
bytes.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
task.context.queue.write_buffer(&self.filter_uniform, 0, &bytes);
|
||||
let started = std::time::Instant::now();
|
||||
dispatch(&task.context, &self.filter_pipeline, bind, count)?;
|
||||
budget.observe(count, started.elapsed());
|
||||
offset += count;
|
||||
}
|
||||
}
|
||||
task.cancel.check()
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(context: &Context, pipeline: &wgpu::ComputePipeline, bind: &wgpu::BindGroup, count: u32) -> Result<()> {
|
||||
context.ensure_available()?;
|
||||
let error_scopes = scopes(&context.device);
|
||||
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("denoise tile") });
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("denoise tile"), timestamp_writes: None });
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.dispatch_workgroups(count.div_ceil(64), 1, 1);
|
||||
}
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
checked(error_scopes)?;
|
||||
context.wait(submission, TIMEOUT)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Original three-pass edge-avoiding a-trous filter inspired by Dammertz et al.
|
||||
// Inputs/outputs keep accumulated linear premultiplied radiance and raw alpha.
|
||||
struct FilterParams { image: vec4<u32>, work: vec4<u32> };
|
||||
@group(0) @binding(0) var<uniform> f: FilterParams;
|
||||
@group(0) @binding(1) var<storage,read> guides: array<vec4<f32>>;
|
||||
@group(0) @binding(2) var<storage,read> source: array<vec4<f32>>;
|
||||
@group(0) @binding(3) var<storage,read_write> filtered: array<vec4<f32>>;
|
||||
fn kernel(x:i32)->f32 {
|
||||
if x==0 { return 6.0; }
|
||||
if abs(x)==1 { return 4.0; }
|
||||
return 1.0;
|
||||
}
|
||||
fn max_component(v:vec3<f32>)->f32 { return max(max(v.x,v.y),v.z); }
|
||||
@compute @workgroup_size(64)
|
||||
fn atrous(@builtin(global_invocation_id) gid:vec3<u32>) {
|
||||
let pixel=f.image.w+gid.x;
|
||||
if gid.x>=f.work.x || pixel>=f.image.x*f.image.y { return; }
|
||||
let c=source[pixel];
|
||||
let a=guides[pixel*3u]; let n=guides[pixel*3u+1u]; let p=guides[pixel*3u+2u];
|
||||
if a.w==0.0 || c.w<f32(f.work.y)*0.999 { filtered[pixel]=c; return; }
|
||||
let xy=vec2<i32>(i32(pixel%f.image.x),i32(pixel/f.image.x));
|
||||
var mean=vec3<f32>(0.0); var total=0.0;
|
||||
let samples=f32(f.work.y);
|
||||
let color=c.xyz/samples;
|
||||
for(var y=-2;y<=2;y+=1) { for(var x=-2;x<=2;x+=1) {
|
||||
let q=xy+vec2<i32>(x,y)*i32(f.image.z);
|
||||
if any(q<vec2<i32>(0)) || q.x>=i32(f.image.x) || q.y>=i32(f.image.y) { continue; }
|
||||
let j=u32(q.y)*f.image.x+u32(q.x);
|
||||
let b=guides[j*3u]; let m=guides[j*3u+1u]; let r=guides[j*3u+2u];
|
||||
let v=source[j];
|
||||
if b.w!=a.w || abs(v.w-c.w)>samples*0.01 { continue; }
|
||||
let da=a.xyz-b.xyz; let dp=r.xyz-p.xyz;
|
||||
let plane=abs(dot(dp,n.xyz))+abs(dot(dp,m.xyz));
|
||||
let scale=max(0.05*max(p.w,r.w)*f32(f.image.z),0.000001);
|
||||
let depth=abs(n.w-m.w)/max(8.0*max(p.w,r.w)*f32(f.image.z),0.000001);
|
||||
let dc=max_component(abs(color-v.xyz/samples));
|
||||
let color_scale=0.25+0.5*max(max_component(color),max_component(v.xyz/samples));
|
||||
let weight=kernel(x)*kernel(y)*exp(-dot(da,da)/0.04-plane/scale-depth-dc/color_scale)
|
||||
*pow(max(dot(n.xyz,m.xyz),0.0),32.0);
|
||||
if weight>0.0 {
|
||||
total+=weight;
|
||||
// Online convex averaging avoids overflow of radiance times kernel.
|
||||
mean+=(v.xyz-mean)*(weight/total);
|
||||
}
|
||||
}}
|
||||
// The centre always contributes; retain original coverage without filtering.
|
||||
filtered[pixel]=vec4<f32>(mean,c.w);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Original deterministic first-hit guides. Uses the production intersection and
|
||||
// material/normal texture evaluation, without advancing the path random stream.
|
||||
@compute @workgroup_size(64)
|
||||
fn denoise_guides(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let pixel=u.image.w+gid.x;
|
||||
if gid.x>=u.sampling.z || pixel>=u.image.x*u.image.y { return; }
|
||||
let base=pixel*3u;
|
||||
accumulation[base]=vec4<f32>(0.0);
|
||||
accumulation[base+1u]=vec4<f32>(0.0);
|
||||
accumulation[base+2u]=vec4<f32>(0.0);
|
||||
let xy=vec2<f32>(f32(pixel%u.image.x)+0.5,f32(pixel/u.image.x)+0.5);
|
||||
let uv=vec2<f32>(xy.x/f32(u.image.x)*2.0-1.0,1.0-xy.y/f32(u.image.y)*2.0);
|
||||
let ray=primary_ray(uv,vec2<f32>(0.0));
|
||||
let hit=trace(ray.origin,ray.direction,false);
|
||||
if hit.triangle==0xffffffffu { return; }
|
||||
if u.environment.w>0.0 && emitter_hit_bounded(ray.origin,ray.direction,u.lighting.x).distance<hit.distance { return; }
|
||||
var t=triangle(hit.triangle);
|
||||
let position=surface_point(t,hit.bary);
|
||||
t=texture_surface(t,position);
|
||||
// Single first-hit guides cannot describe refracted or sharp reflected images.
|
||||
if t.base.w>0.0 || (t.parameters.x>0.5 && t.parameters.y<0.25) || (t.coat.x>0.0 && t.coat.y<0.05) { return; }
|
||||
var geometric=unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
|
||||
if dot(geometric,-ray.direction)<0.0 { geometric=-geometric; }
|
||||
var n=unit(t.n0.xyz*hit.bary.x+t.n1.xyz*hit.bary.y+t.n2.xyz*hit.bary.z);
|
||||
if dot(n,geometric)<0.0 { n=-n; }
|
||||
if dot(n,-ray.direction)<=0.0 { n=geometric; }
|
||||
let mapped=texture_normal(t,position,n,geometric);
|
||||
if dot(mapped,-ray.direction)>0.0 { n=mapped; }
|
||||
let depth=dot(position-u.eye.xyz,-u.back.xyz);
|
||||
var footprint=2.0*length(u.up.xyz)/f32(u.image.y);
|
||||
if u.eye.w>0.5 { footprint*=max(depth,0.0)/u.back.w; }
|
||||
accumulation[base]=vec4<f32>(t.base.xyz,t.optics.w);
|
||||
accumulation[base+1u]=vec4<f32>(n,depth);
|
||||
accumulation[base+2u]=vec4<f32>(position,max(footprint,0.000001));
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
use super::super::{Executor, JobOptions, Task, open_headless};
|
||||
use super::*;
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
use solidcraft_render::{
|
||||
Camera, ProductMesh, ProductScene, StandardView,
|
||||
job::{CancelToken, ImageRequest, ImageWork, TaskPoll},
|
||||
surface::OpaqueSurface,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
type Guide = [[f64; 4]; 3];
|
||||
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
|
||||
a.into_iter().zip(b).map(|(x, y)| x * y).sum()
|
||||
}
|
||||
fn diff(a: [f64; 4], b: [f64; 4]) -> [f64; 3] {
|
||||
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
||||
}
|
||||
fn xyz(a: [f64; 4]) -> [f64; 3] {
|
||||
[a[0], a[1], a[2]]
|
||||
}
|
||||
|
||||
// Independent f64 gather oracle: all inputs are averaged linear values, so the
|
||||
// GPU's accumulated-sample scaling is absent from this numerical implementation.
|
||||
fn reference(width: usize, input: &[[f64; 4]], guides: &[Guide], step: usize) -> Vec<[f64; 4]> {
|
||||
let height = input.len() / width;
|
||||
let kernel = [1.0, 4.0, 6.0, 4.0, 1.0];
|
||||
input
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, ¢er)| {
|
||||
let [a, n, p] = guides[i];
|
||||
if a[3] == 0.0 || center[3] < 0.999 {
|
||||
return center;
|
||||
}
|
||||
let mut result = [0.0; 4];
|
||||
let mut total = 0.0;
|
||||
for (ky, &wy) in kernel.iter().enumerate() {
|
||||
for (kx, &wx) in kernel.iter().enumerate() {
|
||||
let x = (i % width) as isize + (kx as isize - 2) * step as isize;
|
||||
let y = (i / width) as isize + (ky as isize - 2) * step as isize;
|
||||
if x < 0 || y < 0 || x >= width as isize || y >= height as isize {
|
||||
continue;
|
||||
}
|
||||
let j = y as usize * width + x as usize;
|
||||
let [b, m, q] = guides[j];
|
||||
let value = input[j];
|
||||
if a[3] != b[3] || (value[3] - center[3]).abs() > 0.01 {
|
||||
continue;
|
||||
}
|
||||
let albedo = diff(a, b);
|
||||
let position = diff(q, p);
|
||||
let plane = dot(position, xyz(n)).abs() + dot(position, xyz(m)).abs();
|
||||
let scale = (0.05 * p[3].max(q[3]) * step as f64).max(1e-6);
|
||||
let depth = (n[3] - m[3]).abs() / (8.0 * p[3].max(q[3]) * step as f64).max(1e-6);
|
||||
let color = diff(center, value);
|
||||
let maximum = |v: [f64; 3]| v.into_iter().fold(0.0, f64::max);
|
||||
let color_scale = 0.25 + 0.5 * maximum(xyz(center)).max(maximum(xyz(value)));
|
||||
let weight = wx
|
||||
* wy
|
||||
* (-dot(albedo, albedo) / 0.04 - plane / scale - depth - maximum(color.map(f64::abs)) / color_scale).exp()
|
||||
* dot(xyz(n), xyz(m)).max(0.0).powi(32);
|
||||
for k in 0..3 {
|
||||
result[k] += value[k] * weight;
|
||||
}
|
||||
total += weight;
|
||||
}
|
||||
}
|
||||
for v in result.iter_mut().take(3) {
|
||||
*v /= total;
|
||||
}
|
||||
result[3] = center[3];
|
||||
result
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
fn guide(x: usize, y: usize) -> Guide {
|
||||
[[0.5, 0.5, 0.5, 1.0], [0.0, 0.0, 1.0, 4.0], [x as f64, y as f64, 0.0, 1.0]]
|
||||
}
|
||||
fn fixture(size: usize) -> (Vec<[f64; 4]>, Vec<Guide>) {
|
||||
let mut pixels = Vec::new();
|
||||
let mut guides = Vec::new();
|
||||
for y in 0..size {
|
||||
for x in 0..size {
|
||||
// Deterministic white noise in [0.4, 0.6]. A pixel-frequency checker would
|
||||
// be invisible to the step-2 and step-4 passes, which only reach same-parity pixels.
|
||||
let hash = ((x * 7919 + y * 104_729) as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) >> 40;
|
||||
let level = 0.4 + 0.2 * hash as f64 / f64::from(1u32 << 24);
|
||||
pixels.push([level, level, level, 1.0]);
|
||||
guides.push(guide(x, y));
|
||||
}
|
||||
}
|
||||
(pixels, guides)
|
||||
}
|
||||
#[test]
|
||||
fn f64_atrous_reduces_flat_noise_preserves_constant_and_coverage() {
|
||||
let (input, guides) = fixture(17);
|
||||
let mut result = input.clone();
|
||||
for step in [1, 2, 4] {
|
||||
result = reference(17, &result, &guides, step);
|
||||
}
|
||||
let variance = |image: &[[f64; 4]]| {
|
||||
let mean = image.iter().map(|p| p[0]).sum::<f64>() / image.len() as f64;
|
||||
image.iter().map(|p| (p[0] - mean).powi(2)).sum::<f64>() / image.len() as f64
|
||||
};
|
||||
println!("noise variance {} -> {}", variance(&input), variance(&result));
|
||||
assert!(variance(&result) < variance(&input) / 4.0, "noise variance {} -> {}", variance(&input), variance(&result));
|
||||
assert_eq!(reference(17, &vec![[0.7, 0.3, 0.1, 0.5]; 289], &guides, 1), vec![[0.7, 0.3, 0.1, 0.5]; 289]);
|
||||
let constant = reference(17, &vec![[0.75, 0.75, 0.75, 1.0]; 289], &guides, 4);
|
||||
assert!(constant.iter().all(|p| p[..3].iter().all(|x| (*x - 0.75).abs() < 1e-14)));
|
||||
}
|
||||
#[test]
|
||||
fn f64_atrous_preserves_body_normal_depth_and_albedo_edges() {
|
||||
for edge in 0..5 {
|
||||
let mut pixels = Vec::new();
|
||||
let mut guides = Vec::new();
|
||||
for y in 0..17 {
|
||||
for x in 0..17 {
|
||||
let mut g = guide(x, y);
|
||||
let c = if x < 8 { 0.0 } else { 1.0 };
|
||||
if x >= 8 {
|
||||
match edge {
|
||||
0 => g[0][3] = 2.0,
|
||||
1 => g[1] = [1.0, 0.0, 0.0, 4.0],
|
||||
2 => g[2][2] = 10.0,
|
||||
4 => g[1][3] = 1004.0,
|
||||
_ => g[0] = [1.0, 1.0, 1.0, 1.0],
|
||||
}
|
||||
}
|
||||
if edge == 3 && x < 8 {
|
||||
g[0] = [0.0, 0.0, 0.0, 1.0];
|
||||
}
|
||||
pixels.push([c, c, c, 1.0]);
|
||||
guides.push(g);
|
||||
}
|
||||
}
|
||||
let mut filtered = pixels.clone();
|
||||
for step in [1, 2, 4] {
|
||||
filtered = reference(17, &filtered, &guides, step);
|
||||
}
|
||||
assert!(filtered.iter().zip(&pixels).all(|(a, b)| (a[0] - b[0]).abs() < 1e-12), "edge {edge}");
|
||||
}
|
||||
}
|
||||
fn context() -> Option<Arc<Context>> {
|
||||
let (_, device, queue) = match open_headless() {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
// The path tracer runs on Vulkan only: where the backends in use leave Vulkan out
|
||||
// (macOS, or `WGPU_BACKEND=dx12`), it is unavailable by design, not missing.
|
||||
let vulkan = crate::select::native_backends().contains(wgpu::Backends::VULKAN);
|
||||
assert!(!vulkan || std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required GPU unavailable: {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
Some(Executor::new(device, queue).unwrap().context)
|
||||
}
|
||||
fn request(size: u32) -> ImageRequest {
|
||||
let mut camera = Camera { distance: 4.0, ..Default::default() };
|
||||
camera.set_view(StandardView::Top);
|
||||
ImageRequest { camera, width: size, height: size, background: [0; 3], transparent_background: false, environment: None, textures: None }
|
||||
}
|
||||
fn plane() -> ProductScene {
|
||||
let mesh = Mesh {
|
||||
positions: vec![Vec3::new(-1.0, -1.0, 0.0), Vec3::new(1.0, -1.0, 0.0), Vec3::new(1.0, 1.0, 0.0), Vec3::new(-1.0, 1.0, 0.0)],
|
||||
normals: vec![Vec3::Z; 4],
|
||||
triangles: vec![[0, 1, 2], [0, 2, 3]],
|
||||
tri_face: vec![0, 0],
|
||||
..Default::default()
|
||||
};
|
||||
ProductScene {
|
||||
bounds: mesh.bounds(),
|
||||
meshes: vec![ProductMesh {
|
||||
name: "original denoise plane".into(),
|
||||
mesh: Arc::new(mesh),
|
||||
color: [255; 3],
|
||||
opacity: 1.0,
|
||||
surface: Some(OpaqueSurface::new(Vec3::new(0.5, 0.5, 0.5), 0.0, 1.0, 1.5).unwrap()),
|
||||
transmission: None,
|
||||
faces: Vec::new(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
fn read_guides(context: &Context, buffer: &wgpu::Buffer) -> Vec<[f32; 4]> {
|
||||
let staging = context.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: buffer.size(),
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = context.device.create_command_encoder(&Default::default());
|
||||
encoder.copy_buffer_to_buffer(buffer, 0, &staging, 0, buffer.size());
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
staging.slice(..).map_async(wgpu::MapMode::Read, move |v| {
|
||||
tx.send(v).unwrap();
|
||||
});
|
||||
context.device.poll(wgpu::PollType::Wait { submission_index: Some(submission), timeout: Some(TIMEOUT) }).unwrap();
|
||||
rx.recv().unwrap().unwrap();
|
||||
let mapped = staging.slice(..).get_mapped_range().unwrap();
|
||||
let values = mapped.as_chunks::<16>().0.iter().map(|v| [0, 4, 8, 12].map(|i| f32::from_le_bytes(v[i..i + 4].try_into().unwrap()))).collect();
|
||||
drop(mapped);
|
||||
staging.unmap();
|
||||
values
|
||||
}
|
||||
#[test]
|
||||
fn denoise_admission_counts_guides_and_ping_pong_only_when_enabled() {
|
||||
let scene = plane();
|
||||
let request = request(32);
|
||||
let plain = super::super::admission(&scene, &request, JobOptions::default()).unwrap();
|
||||
let filtered = super::super::admission(&scene, &request, JobOptions { denoise: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(filtered.pixel_scratch_bytes - plain.pixel_scratch_bytes, 32 * 32 * 80);
|
||||
assert_eq!(filtered.estimated_total_bytes - plain.estimated_total_bytes, 32 * 32 * 80);
|
||||
let large = ImageRequest { width: 2000, height: 2000, ..request.clone() };
|
||||
assert!(super::super::admission(&scene, &large, JobOptions::default()).is_ok());
|
||||
assert_eq!(
|
||||
super::super::admission(&scene, &large, JobOptions { denoise: true, ..Default::default() }).unwrap_err().code,
|
||||
solidcraft_render::job::ErrorCode::LimitExceeded
|
||||
);
|
||||
assert!(!serde_json::from_str::<JobOptions>("{}").unwrap().denoise);
|
||||
assert!(serde_json::from_str::<JobOptions>(r#"{"denoise":"true"}"#).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn gpu_atrous_matches_f64_guides_and_leaves_input_unchanged() {
|
||||
let Some(context) = context() else {
|
||||
return;
|
||||
};
|
||||
let (mut input, mut guides) = fixture(13);
|
||||
for y in 0..13 {
|
||||
for x in 7..13 {
|
||||
guides[y * 13 + x][0][3] = 2.0;
|
||||
input[y * 13 + x][0] += 0.8;
|
||||
}
|
||||
}
|
||||
guides[0][0][3] = 0.0;
|
||||
input[1][3] = 0.5;
|
||||
let bytes: Vec<u8> = input.iter().flatten().flat_map(|x| (*x as f32).to_le_bytes()).collect();
|
||||
let device = &context.device;
|
||||
let make = |label, bytes: &[u8]| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some(label),
|
||||
contents: bytes,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
})
|
||||
};
|
||||
let raw = make("denoise fixture raw", &bytes);
|
||||
let dummy = make("denoise unused geometry", &[0; 64]);
|
||||
let mut task = Task::new(context.clone(), plane(), request(13), JobOptions::default(), CancelToken::default()).unwrap();
|
||||
task.sample = 1;
|
||||
let filter = Resources::new(&context, &task.uniform, [&dummy, &dummy, &dummy, &dummy, &dummy], &raw, bytes.len() as u64).unwrap();
|
||||
let guide_bytes: Vec<u8> = guides.iter().flatten().flatten().flat_map(|x| (*x as f32).to_le_bytes()).collect();
|
||||
context.queue.write_buffer(&filter._guides, 0, &guide_bytes);
|
||||
let before = task.read_linear_buffer(&raw).unwrap();
|
||||
filter.run_filter(&task).unwrap();
|
||||
assert_eq!(before, task.read_linear_buffer(&raw).unwrap());
|
||||
let actual = task.read_linear_buffer(filter.output()).unwrap();
|
||||
let mut expected = input;
|
||||
for step in [1, 2, 4] {
|
||||
expected = reference(13, &expected, &guides, step);
|
||||
}
|
||||
for (a, b) in actual.iter().zip(&expected) {
|
||||
for (x, y) in a.iter().zip(b) {
|
||||
assert!((f64::from(*x) - *y).abs() < 2e-5, "{a:?} vs {b:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn gpu_denoise_default_false_is_identical_and_true_preserves_raw() {
|
||||
let Some(context) = context() else {
|
||||
return;
|
||||
};
|
||||
let mut outputs = Vec::new();
|
||||
let mut pngs = Vec::new();
|
||||
for options in [
|
||||
JobOptions { samples: 4, ..Default::default() },
|
||||
JobOptions { samples: 4, denoise: false, ..Default::default() },
|
||||
JobOptions { samples: 4, denoise: true, ..Default::default() },
|
||||
] {
|
||||
let mut task = Task::new(context.clone(), plane(), request(8), options, CancelToken::default()).unwrap();
|
||||
assert_eq!(task.denoiser.is_some(), options.denoise);
|
||||
while task.sample < 4 {
|
||||
task.complete_sample().unwrap();
|
||||
}
|
||||
let raw = task.read_linear().unwrap();
|
||||
if let Some(filter) = &task.denoiser {
|
||||
filter.run(&task).unwrap();
|
||||
assert_eq!(raw, task.read_linear().unwrap());
|
||||
let filtered = task.read_linear_buffer(filter.output()).unwrap();
|
||||
assert!(filtered.iter().all(|p| p.iter().all(|v| v.is_finite())));
|
||||
let guides = read_guides(&context, &filter._guides);
|
||||
assert_eq!(guides[0], [0.0; 4]);
|
||||
let center = (4 * 8 + 4) * 3;
|
||||
assert_eq!(guides[center], [0.5, 0.5, 0.5, 1.0]);
|
||||
assert_eq!(guides[center + 1], [0.0, 0.0, 1.0, 16.0]);
|
||||
assert!((guides[center + 2][0] - 0.2).abs() < 1e-6);
|
||||
assert!((guides[center + 2][1] + 0.2).abs() < 1e-6);
|
||||
assert!((guides[center + 2][3] - 0.4).abs() < 1e-6);
|
||||
}
|
||||
outputs.push(raw);
|
||||
for _ in 0..100 {
|
||||
if let TaskPoll::Complete(png) = task.step(4096).unwrap() {
|
||||
pngs.push(png);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(outputs[0], outputs[1]);
|
||||
assert_eq!(outputs[0], outputs[2]);
|
||||
assert_eq!(pngs.len(), 3);
|
||||
assert_eq!(pngs[0], pngs[1]);
|
||||
}
|
||||
#[test]
|
||||
fn gpu_denoise_preflight_reports_limits_without_changing_document() {
|
||||
let Some(context) = context() else {
|
||||
return;
|
||||
};
|
||||
let mut session = solidcraft_engine::Session::default();
|
||||
session.set_render_job_host(Some(solidcraft_render::job::CpuHost::cooperative())).unwrap();
|
||||
session.register_render_job_host(solidcraft_render::job::ImageHost::with_executor(Arc::new(Executor { context })).unwrap()).unwrap();
|
||||
session.execute("solid.box", &serde_json::json!({"length":2,"width":2,"height":1})).unwrap();
|
||||
let before = session.doc.to_json();
|
||||
let mut params = serde_json::json!({"progressive":true,"denoise":true,"settings":{"width":8,"height":8}});
|
||||
let reply = session.execute("render.preflight", ¶ms).unwrap();
|
||||
assert_eq!(reply["options"]["denoise"], true);
|
||||
assert_eq!(reply["denoising"]["passes"], 3);
|
||||
assert_eq!(reply["denoising"]["raw_linear_accumulator_preserved"], true);
|
||||
assert_eq!(before, session.doc.to_json());
|
||||
params["progressive"] = serde_json::json!(false);
|
||||
assert!(session.execute("render.preflight", ¶ms).is_err());
|
||||
params["denoise"] = serde_json::json!(false);
|
||||
let reply = session.execute("render.preflight", ¶ms).unwrap();
|
||||
assert!(reply.get("denoising").is_none());
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! Conservative work quanta selected from actual submission completion latency.
|
||||
//! This is a bounded controller, not a guarantee about a driver's scheduling deadline.
|
||||
use std::time::Duration;
|
||||
|
||||
pub(super) const MIN_PATHS: u32 = 64;
|
||||
pub(super) const MAX_PATHS: u32 = 16_384;
|
||||
const TARGET: Duration = Duration::from_millis(20);
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(super) struct Budget {
|
||||
paths: u32,
|
||||
}
|
||||
impl Default for Budget {
|
||||
fn default() -> Self {
|
||||
Self { paths: MIN_PATHS }
|
||||
}
|
||||
}
|
||||
impl Budget {
|
||||
pub(super) fn count(&self, remaining: u32) -> u32 {
|
||||
remaining.min(self.paths)
|
||||
}
|
||||
pub(super) fn observe(&mut self, completed: u32, elapsed: Duration) {
|
||||
if completed == 0 {
|
||||
return;
|
||||
}
|
||||
let ideal = u128::from(completed).saturating_mul(TARGET.as_nanos()) / elapsed.as_nanos().max(1);
|
||||
let bounded = ideal.clamp(u128::from(MIN_PATHS), u128::from(MAX_PATHS));
|
||||
let requested = u32::try_from(bounded).unwrap_or(MIN_PATHS);
|
||||
// Never jump from the first tiny/fast dispatch to a large unmeasured one.
|
||||
self.paths = requested.min(self.paths.saturating_mul(2)).clamp(MIN_PATHS, MAX_PATHS);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn cold_dispatch_starts_small_and_growth_is_bounded() {
|
||||
let mut budget = Budget::default();
|
||||
assert_eq!(budget.count(u32::MAX), 64);
|
||||
for _ in 0..100 {
|
||||
let before = budget.count(u32::MAX);
|
||||
budget.observe(before, Duration::from_nanos(1));
|
||||
let after = budget.count(u32::MAX);
|
||||
assert!(after <= before * 2 && after <= MAX_PATHS);
|
||||
}
|
||||
assert_eq!(budget.count(u32::MAX), MAX_PATHS);
|
||||
budget.observe(MAX_PATHS, Duration::from_secs(10));
|
||||
assert_eq!(budget.count(u32::MAX), MIN_PATHS);
|
||||
assert_eq!(budget.count(17), 17);
|
||||
assert_eq!(budget.count(0), 0);
|
||||
}
|
||||
#[test]
|
||||
fn observed_cost_controls_the_quantum_without_overflow_or_zero_work() {
|
||||
let mut budget = Budget::default();
|
||||
for _ in 0..10 {
|
||||
budget.observe(budget.paths, Duration::from_millis(1));
|
||||
}
|
||||
let before = budget.paths;
|
||||
budget.observe(before, Duration::from_millis(80));
|
||||
assert_eq!(budget.paths, before / 4);
|
||||
let unchanged = budget.paths;
|
||||
budget.observe(0, Duration::ZERO);
|
||||
assert_eq!(budget.paths, unchanged);
|
||||
budget.observe(u32::MAX, Duration::MAX);
|
||||
assert_eq!(budget.paths, MIN_PATHS);
|
||||
budget.observe(u32::MAX, Duration::ZERO);
|
||||
assert_eq!(budget.paths, MIN_PATHS * 2);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,743 @@
|
||||
//! Native progressive product rendering. Software BVH traversal needs no ray-query
|
||||
//! features. Small measured quanta adapt to the device, then check cancellation.
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::{
|
||||
Availability, ExecutorDescriptor, ExecutorId, ProductScene, RenderFeature, RenderFormat, RenderLimits, RenderMode, RenderOperation,
|
||||
RenderProfile, ScratchLayout,
|
||||
job::{
|
||||
Admission, CancelToken, ErrorCode, ImageExecutor, ImageHost, ImageRequest, ImageWork, JobError, JobOptions, Phase, PngTask, Progress, Result,
|
||||
TaskPoll, ToneMap, admit_geometry,
|
||||
},
|
||||
};
|
||||
use std::{
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
mod denoise;
|
||||
mod dispatch;
|
||||
mod scene;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod textures;
|
||||
const SHADER: &str = concat!(include_str!("path.wgsl"), "\n", include_str!("shadows.wgsl"), "\n", include_str!("textures.wgsl"));
|
||||
const MAX_TRIANGLES: u64 = 1_048_576;
|
||||
const PARAMETER_BYTES: usize = 448;
|
||||
// Eight u32 words: count low, pixel, bounce, sample, code, count high, first-writer flag, spare.
|
||||
const FAILURE_BYTES: u64 = 32;
|
||||
// A path that cannot classify its glass medium ends there. Isolated paths at the
|
||||
// limits of f32 geometry are tolerated and logged; more than this fraction of all
|
||||
// paths (any systematic error such as a camera inside glass) fails the job.
|
||||
const FAILURE_FRACTION: f64 = 1e-6;
|
||||
const TIMEOUT: Duration = Duration::from_secs(30);
|
||||
// Software Vulkan drivers can compile a pipeline at its first dispatch, which on a cold
|
||||
// shader cache exceeds the per-dispatch hang timeout. Setup dispatches once with no work
|
||||
// and allows this longer wait, so jobs keep the short one.
|
||||
const SETUP_TIMEOUT: Duration = Duration::from_secs(600);
|
||||
|
||||
fn gpu(e: impl std::fmt::Display) -> JobError {
|
||||
JobError::new(ErrorCode::HostUnavailable, e.to_string())
|
||||
}
|
||||
|
||||
fn scopes(device: &wgpu::Device) -> [wgpu::ErrorScopeGuard; 3] {
|
||||
[wgpu::ErrorFilter::Validation, wgpu::ErrorFilter::OutOfMemory, wgpu::ErrorFilter::Internal].map(|f| device.push_error_scope(f))
|
||||
}
|
||||
fn checked(scopes: [wgpu::ErrorScopeGuard; 3]) -> Result<()> {
|
||||
let mut error = None;
|
||||
for scope in scopes.into_iter().rev() {
|
||||
if let Some(e) = pollster::block_on(scope.pop()) {
|
||||
error = Some(e);
|
||||
}
|
||||
}
|
||||
error.map_or(Ok(()), |e| Err(gpu(e)))
|
||||
}
|
||||
|
||||
/// Drain every scope, in reverse order, even when resource creation returns an error.
|
||||
/// The ordinary cancellation/error path must not drop an array of guards forwards.
|
||||
fn checked_creation<T>(device: &wgpu::Device, create: impl FnOnce() -> Result<T>) -> Result<T> {
|
||||
let error_scopes = scopes(device);
|
||||
let result = create();
|
||||
checked(error_scopes)?;
|
||||
result
|
||||
}
|
||||
|
||||
/// Open and validate a native device before advertising the executor. Failure leaves the
|
||||
/// caller's CPU host usable. Respects SOLIDCRAFT_GPU, including the software adapter policy.
|
||||
pub fn host() -> Result<ImageHost> {
|
||||
let (adapter, device, queue) = open_headless()?;
|
||||
log::info!("gpu-path adapter: {} ({:?})", adapter.get_info().name, adapter.get_info().device_type);
|
||||
ImageHost::with_executor(Arc::new(Executor::new(device, queue)?))
|
||||
}
|
||||
|
||||
/// Open an independent job device on the viewport's selected adapter. In particular,
|
||||
/// software fallback and safe mode must not rediscover a failing hardware driver.
|
||||
/// An explicit incompatible SOLIDCRAFT_GPU policy leaves this executor unavailable.
|
||||
/// The path tracer runs on Vulkan only: a viewport on another backend is reopened on the same
|
||||
/// GPU through Vulkan, and without one the executor is unavailable and images render on the CPU.
|
||||
pub fn host_on_adapter(adapter: &wgpu::Adapter) -> Result<ImageHost> {
|
||||
let policy = crate::select::Policy::from_env().map_err(gpu)?;
|
||||
let info = adapter.get_info();
|
||||
if !policy.admits(crate::select::tier_of(info.device_type)) {
|
||||
return Err(gpu(format!("viewport adapter does not satisfy SOLIDCRAFT_GPU={}", policy.name())));
|
||||
}
|
||||
if info.backend != wgpu::Backend::Vulkan {
|
||||
let (vulkan, _) = crate::select::pick_vulkan(policy, |i| i.vendor == info.vendor && i.device == info.device)
|
||||
.ok_or_else(|| gpu(format!("{VULKAN_ONLY}; the viewport uses {:?}", info.backend)))?;
|
||||
let (device, queue) = open_device(&vulkan)?;
|
||||
log::info!("gpu-path viewport GPU: {} through Vulkan", info.name);
|
||||
return ImageHost::with_executor(Arc::new(Executor::new(device, queue)?));
|
||||
}
|
||||
let (device, queue) = open_device(adapter)?;
|
||||
log::info!("gpu-path viewport adapter: {} ({:?})", info.name, info.device_type);
|
||||
ImageHost::with_executor(Arc::new(Executor::new(device, queue)?))
|
||||
}
|
||||
|
||||
/// Why the executor is unavailable without Vulkan (#3217).
|
||||
const VULKAN_ONLY: &str = "the GPU path tracer needs a Vulkan driver (images render on the CPU without one)";
|
||||
|
||||
// Path resources need six storage buffers (geometry, BVH, accumulation, HDRI,
|
||||
// textures, medium-failure counts). Request that limit only on this independent path device: raster and
|
||||
// tessellation keep their existing four-buffer downlevel requirement.
|
||||
const PATH_STORAGE_BUFFERS: u32 = 6;
|
||||
fn device_limits(supported: wgpu::Limits) -> Result<wgpu::Limits> {
|
||||
if supported.max_storage_buffers_per_shader_stage < PATH_STORAGE_BUFFERS {
|
||||
return Err(gpu("gpu-path requires six storage buffers per shader stage"));
|
||||
}
|
||||
let mut limits = wgpu::Limits::downlevel_defaults().using_resolution(supported);
|
||||
limits.max_storage_buffers_per_shader_stage = PATH_STORAGE_BUFFERS;
|
||||
Ok(limits)
|
||||
}
|
||||
fn open_device(adapter: &wgpu::Adapter) -> Result<(wgpu::Device, wgpu::Queue)> {
|
||||
let required_limits = device_limits(adapter.limits())?;
|
||||
pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("solidcraft progressive path"),
|
||||
required_features: crate::depth_features(adapter),
|
||||
required_limits,
|
||||
..Default::default()
|
||||
}))
|
||||
.map_err(gpu)
|
||||
}
|
||||
fn open_headless() -> Result<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
|
||||
let policy = crate::select::Policy::from_env().map_err(gpu)?;
|
||||
let (adapter, _) = crate::select::pick_vulkan(policy, |_| true)
|
||||
.ok_or_else(|| gpu(format!("{VULKAN_ONLY}; no Vulkan adapter satisfies SOLIDCRAFT_GPU={}", policy.name())))?;
|
||||
let (device, queue) = open_device(&adapter)?;
|
||||
Ok((adapter, device, queue))
|
||||
}
|
||||
|
||||
struct Context {
|
||||
healthy: Arc<AtomicBool>,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
// Built and warmed by the first denoised job, then shared by later ones.
|
||||
denoise: std::sync::OnceLock<denoise::Pipelines>,
|
||||
layout: wgpu::BindGroupLayout,
|
||||
}
|
||||
impl Context {
|
||||
fn ensure_available(&self) -> Result<()> {
|
||||
if self.healthy.load(Ordering::Acquire) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(JobError::new(
|
||||
ErrorCode::HostUnavailable,
|
||||
"GPU path device was lost or failed to complete work; restart the renderer before retrying",
|
||||
))
|
||||
}
|
||||
}
|
||||
fn wait(&self, submission: wgpu::SubmissionIndex, timeout: Duration) -> Result<()> {
|
||||
wait_device(&self.device, &self.healthy, submission, timeout)
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_device(device: &wgpu::Device, healthy: &AtomicBool, submission: wgpu::SubmissionIndex, timeout: Duration) -> Result<()> {
|
||||
if !healthy.load(Ordering::Acquire) {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "GPU path device is unavailable"));
|
||||
}
|
||||
if let Err(error) = device.poll(wgpu::PollType::Wait { submission_index: Some(submission), timeout: Some(timeout) }) {
|
||||
healthy.store(false, Ordering::Release);
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, format!("GPU path completion failed; executor retired: {error}")));
|
||||
}
|
||||
if !healthy.load(Ordering::Acquire) {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "GPU path device was lost while completing work"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
struct Executor {
|
||||
context: Arc<Context>,
|
||||
}
|
||||
impl Executor {
|
||||
fn new(device: wgpu::Device, queue: wgpu::Queue) -> Result<Self> {
|
||||
device_limits(device.limits())?;
|
||||
let healthy = Arc::new(AtomicBool::new(true));
|
||||
let uncaptured = healthy.clone();
|
||||
device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| {
|
||||
uncaptured.store(false, Ordering::Release);
|
||||
log::error!("gpu-path executor retired after uncaptured error: {e}");
|
||||
}));
|
||||
let lost = healthy.clone();
|
||||
device.set_device_lost_callback(move |reason, message| {
|
||||
lost.store(false, Ordering::Release);
|
||||
log::warn!("gpu-path device lost ({reason:?}); executor unavailable: {message}");
|
||||
});
|
||||
let scopes = scopes(&device);
|
||||
let shader =
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("gpu-path"), source: wgpu::ShaderSource::Wgsl(SHADER.into()) });
|
||||
let entries: Vec<_> = (0..7)
|
||||
.map(|binding| wgpu::BindGroupLayoutEntry {
|
||||
binding,
|
||||
visibility: wgpu::ShaderStages::COMPUTE,
|
||||
ty: wgpu::BindingType::Buffer {
|
||||
ty: if binding == 0 {
|
||||
wgpu::BufferBindingType::Uniform
|
||||
} else {
|
||||
wgpu::BufferBindingType::Storage { read_only: binding != 3 && binding != 6 }
|
||||
},
|
||||
has_dynamic_offset: false,
|
||||
min_binding_size: None,
|
||||
},
|
||||
count: None,
|
||||
})
|
||||
.collect();
|
||||
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("gpu-path"), entries: &entries });
|
||||
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("gpu-path"),
|
||||
bind_group_layouts: &[Some(&layout)],
|
||||
immediate_size: 0,
|
||||
});
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("gpu-path"),
|
||||
layout: Some(&pipeline_layout),
|
||||
module: &shader,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
checked(scopes)?;
|
||||
warm_up(&device, &queue, &pipeline, &placeholder_bind(&device, &layout, 7)?, &healthy)?;
|
||||
Ok(Self { context: Arc::new(Context { healthy, device, queue, pipeline, layout, denoise: std::sync::OnceLock::new() }) })
|
||||
}
|
||||
}
|
||||
|
||||
// Small zeroed bindings: binding 0 is a uniform whose zero work count makes every
|
||||
// invocation return at once; the others are storage buffers.
|
||||
fn placeholder_bind(device: &wgpu::Device, layout: &wgpu::BindGroupLayout, count: u32) -> Result<wgpu::BindGroup> {
|
||||
let scopes = scopes(device);
|
||||
let buffers: Vec<_> = (0..count)
|
||||
.map(|binding| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("gpu-path warm-up"),
|
||||
size: if binding == 0 { PARAMETER_BYTES as u64 } else { 256 },
|
||||
usage: if binding == 0 { wgpu::BufferUsages::UNIFORM } else { wgpu::BufferUsages::STORAGE },
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let entries: Vec<_> = buffers
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry { binding: binding as u32, resource: buffer.as_entire_binding() })
|
||||
.collect();
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("gpu-path warm-up"), layout, entries: &entries });
|
||||
checked(scopes)?;
|
||||
Ok(bind)
|
||||
}
|
||||
|
||||
fn warm_up(device: &wgpu::Device, queue: &wgpu::Queue, pipeline: &wgpu::ComputePipeline, bind: &wgpu::BindGroup, healthy: &AtomicBool) -> Result<()> {
|
||||
let scopes = scopes(device);
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("gpu-path warm-up") });
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("gpu-path warm-up"), timestamp_writes: None });
|
||||
pass.set_pipeline(pipeline);
|
||||
pass.set_bind_group(0, bind, &[]);
|
||||
pass.dispatch_workgroups(1, 1, 1);
|
||||
}
|
||||
let submission = queue.submit([encoder.finish()]);
|
||||
checked(scopes)?;
|
||||
wait_device(device, healthy, submission, SETUP_TIMEOUT)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn medium_failures_tolerated(failed: u64, paths: u64) -> bool {
|
||||
failed as f64 <= paths as f64 * FAILURE_FRACTION
|
||||
}
|
||||
|
||||
fn descriptor() -> Result<ExecutorDescriptor> {
|
||||
Ok(ExecutorDescriptor {
|
||||
id: ExecutorId::new("gpu-path").map_err(gpu)?,
|
||||
availability: Availability::Available,
|
||||
profiles: vec![RenderProfile {
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Image,
|
||||
formats: vec![RenderFormat::Png],
|
||||
features: vec![RenderFeature::Pbr, RenderFeature::Progressive, RenderFeature::Refraction, RenderFeature::Hdri],
|
||||
}],
|
||||
limits: RenderLimits::default(),
|
||||
scratch: ScratchLayout { sample_scale: 1, sample_bytes_per_pixel: 16, output_bytes_per_pixel: 16, encoding_bytes_per_pixel: 16 },
|
||||
})
|
||||
}
|
||||
|
||||
fn admission(scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
|
||||
options.validate()?;
|
||||
if let Some(environment) = &request.environment {
|
||||
environment.validate()?;
|
||||
}
|
||||
let camera = request.camera;
|
||||
if options.lens.is_some_and(|lens| lens.aperture_radius_mm > 0.0)
|
||||
&& (camera.distance < 1e-6 || !(1e-6..=1e6).contains(&(camera.half_height() / camera.distance)) || camera.fov == 0.0)
|
||||
{
|
||||
return Err(JobError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"thin-lens depth of field requires a bounded perspective camera (distance >= 0.000001 mm and tan(fov/2) in 0.000001..1000000)",
|
||||
));
|
||||
}
|
||||
if !camera.fov.is_finite()
|
||||
|| !(0.0..std::f64::consts::PI).contains(&camera.fov)
|
||||
|| !camera.half_height().is_finite()
|
||||
|| camera.half_height() > 1e12
|
||||
|| camera.distance > 1e9
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "gpu-path requires a bounded finite camera"));
|
||||
}
|
||||
let mut admission = admit_geometry(scene, request, MAX_TRIANGLES)?;
|
||||
if scene
|
||||
.meshes
|
||||
.iter()
|
||||
.any(|m| (m.opacity != 1.0 && m.transmission.is_none()) || m.faces.iter().any(|f| f.opacity != 1.0 && f.transmission.is_none()))
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::InvalidScene, "gpu-path requires explicit transmission for nonopaque preview appearances"));
|
||||
}
|
||||
// Peak preparation: f64 triangles + nodes + serialized upload. After preparation:
|
||||
// serialized + device geometry + accumulation/readback/RGBA/PNG. These phases don't overlap.
|
||||
let pixels = u64::from(request.width) * u64::from(request.height);
|
||||
let denoise_bytes = pixels * if options.denoise { denoise::BYTES_PER_PIXEL } else { 0 };
|
||||
admission.pixel_scratch_bytes = pixels * 64 + denoise_bytes;
|
||||
let vertex_bytes = scene.meshes.iter().map(|m| m.mesh.positions.len() as u64 * 32).sum::<u64>();
|
||||
let triangle_preparation =
|
||||
(admission.triangles * 400_u64.max(scene::planar_preparation_bytes_per_triangle() as u64)).max(scene::medium_preparation_bytes(scene));
|
||||
admission.preparation_bytes = (triangle_preparation + vertex_bytes * 2 + admission.face_appearances * 8)
|
||||
.max(admission.triangles * 232 + vertex_bytes * 2 + pixels * 32 + denoise_bytes)
|
||||
.max(admission.triangles * 116 + vertex_bytes + admission.pixel_scratch_bytes)
|
||||
+ 1_048_576;
|
||||
admission.preparation_bytes += request.environment.as_ref().map_or(0, |source| source.admitted_bytes());
|
||||
if let Some(textures) = &request.textures {
|
||||
textures.validate_scene(scene)?;
|
||||
admission.preparation_bytes += textures.working_bytes();
|
||||
}
|
||||
admission.estimated_total_bytes = admission.geometry_bytes + admission.preparation_bytes;
|
||||
if admission.estimated_total_bytes > 512 * 1024 * 1024 {
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "gpu-path job exceeds its 512 MiB admission estimate"));
|
||||
}
|
||||
Ok(admission)
|
||||
}
|
||||
|
||||
impl ImageExecutor for Executor {
|
||||
fn descriptor(&self) -> Result<ExecutorDescriptor> {
|
||||
let mut value = descriptor()?;
|
||||
if !self.context.healthy.load(Ordering::Acquire) {
|
||||
value.availability = Availability::Unavailable { reason: "GPU path device was lost or failed to complete work; restart renderer".into() };
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
|
||||
self.context.ensure_available()?;
|
||||
let a = admission(scene, request, options)?;
|
||||
let limit = self.context.device.limits().max_storage_buffer_binding_size;
|
||||
let geometry_bytes = 16 + a.triangles.max(1) * 84 + scene.meshes.iter().map(|m| m.mesh.positions.len() as u64 * 32).sum::<u64>();
|
||||
let texture_bytes = request
|
||||
.textures
|
||||
.as_ref()
|
||||
.map_or(16, |t| t.summary().linear_pixel_bytes + 4 * (5 + scene.meshes.len() as u64 + t.bindings().len() as u64 * 16));
|
||||
let environment_bytes =
|
||||
request.environment.as_ref().map_or(48, |source| 16 + u64::from(source.info().width) * u64::from(source.info().height) * 32);
|
||||
if texture_bytes > limit
|
||||
|| environment_bytes > limit
|
||||
|| geometry_bytes > limit
|
||||
|| u64::from(request.width) * u64::from(request.height) * 16 > limit
|
||||
|| (options.denoise && u64::from(request.width) * u64::from(request.height) * 48 > limit)
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::LimitExceeded, "gpu-path storage buffer exceeds this device's limit"));
|
||||
}
|
||||
Ok(a)
|
||||
}
|
||||
fn begin(&self, scene: ProductScene, request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
|
||||
self.admit(&scene, &request, options)?;
|
||||
Ok(Box::new(Task::new(self.context.clone(), scene, request, options, cancel)?))
|
||||
}
|
||||
}
|
||||
|
||||
/// Build all fallible CPU environment data before opening GPU error scopes.
|
||||
fn prepare_environment(request: &ImageRequest, cancel: &CancelToken) -> Result<Vec<u8>> {
|
||||
cancel.check()?;
|
||||
let mut environment_bytes = Vec::new();
|
||||
if let Some(source) = &request.environment {
|
||||
let prepared = source.prepare(cancel)?;
|
||||
environment_bytes = scene::reserved(16 + prepared.texels().len() * 32)?;
|
||||
let [width, height] = prepared.dimensions();
|
||||
for x in [width, height, width * height, 0] {
|
||||
environment_bytes.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
for (index, texel) in prepared.texels().iter().enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
for x in texel.rgb.into_iter().chain([texel.pdf, texel.threshold]) {
|
||||
environment_bytes.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
environment_bytes.extend_from_slice(&texel.alias.to_le_bytes());
|
||||
environment_bytes.extend_from_slice(&[0; 8]);
|
||||
}
|
||||
} else {
|
||||
environment_bytes.resize(48, 0);
|
||||
}
|
||||
Ok(environment_bytes)
|
||||
}
|
||||
|
||||
struct Task {
|
||||
dispatch_budget: dispatch::Budget,
|
||||
context: Arc<Context>,
|
||||
request: ImageRequest,
|
||||
options: JobOptions,
|
||||
cancel: CancelToken,
|
||||
uniform: wgpu::Buffer,
|
||||
accumulation: wgpu::Buffer,
|
||||
failures: wgpu::Buffer,
|
||||
readback: wgpu::Buffer,
|
||||
bind: wgpu::BindGroup,
|
||||
denoiser: Option<denoise::Resources>,
|
||||
sample: u32,
|
||||
offset: u32,
|
||||
epsilon: f32,
|
||||
png: Option<PngTask>,
|
||||
started: Instant,
|
||||
// Uniform environment is used only by the numerical furnace test, never advertised as HDRI.
|
||||
environment: f32,
|
||||
environment_bottom_only: bool,
|
||||
studio: f32,
|
||||
#[cfg(test)]
|
||||
metrics: (f64, u64, f64),
|
||||
}
|
||||
impl Task {
|
||||
fn new(context: Arc<Context>, scene: ProductScene, mut request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Self> {
|
||||
cancel.check()?;
|
||||
let start = Instant::now();
|
||||
if let Some(resources) = &request.textures {
|
||||
resources.validate_scene(&scene)?;
|
||||
}
|
||||
let texture_upload = textures::flatten(request.textures.as_deref(), scene.meshes.len(), request.camera.target, &cancel)?;
|
||||
request.textures = None; // GPU upload owns decoded pixels; release captured source bytes.
|
||||
let upload = scene::flatten(&scene, request.camera.target, &cancel)?;
|
||||
let bvh_ms = start.elapsed().as_secs_f64() * 1000.0;
|
||||
let mut upload_bytes = (upload.geometry.len() + upload.nodes.len() + texture_upload.len()) as u64;
|
||||
let environment_bytes = prepare_environment(&request, &cancel)?;
|
||||
cancel.check()?;
|
||||
let uploading = Instant::now();
|
||||
drop(scene);
|
||||
let device = &context.device;
|
||||
let (uniform, accumulation, failures, readback, bind, denoiser) = checked_creation(device, || {
|
||||
let make = |label, bytes: &[u8], usage| {
|
||||
device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some(label), contents: bytes, usage })
|
||||
};
|
||||
upload_bytes += environment_bytes.len() as u64;
|
||||
let environment = make("path environment", &environment_bytes, wgpu::BufferUsages::STORAGE);
|
||||
drop(environment_bytes);
|
||||
let triangles = make("path triangles", &upload.geometry, wgpu::BufferUsages::STORAGE);
|
||||
let nodes = make("path BVH", &upload.nodes, wgpu::BufferUsages::STORAGE);
|
||||
let texture_buffer = make("path opaque textures", &texture_upload, wgpu::BufferUsages::STORAGE);
|
||||
let uniform = make("path parameters", &[0; PARAMETER_BYTES], wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST);
|
||||
let size = u64::from(request.width) * u64::from(request.height) * 16;
|
||||
let accumulation = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("path accumulation"),
|
||||
size,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
// Inconsistent-medium path count and the first such path's location (see read_linear).
|
||||
let failures = make(
|
||||
"path medium failures",
|
||||
&[0; FAILURE_BYTES as usize],
|
||||
wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC | wgpu::BufferUsages::COPY_DST,
|
||||
);
|
||||
let readback = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("path readback"),
|
||||
size: size + FAILURE_BYTES,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut entries: Vec<_> = [&uniform, &triangles, &nodes, &accumulation, &environment]
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(binding, buffer)| wgpu::BindGroupEntry { binding: binding as u32, resource: buffer.as_entire_binding() })
|
||||
.collect();
|
||||
entries.push(wgpu::BindGroupEntry { binding: 5, resource: texture_buffer.as_entire_binding() });
|
||||
entries.push(wgpu::BindGroupEntry { binding: 6, resource: failures.as_entire_binding() });
|
||||
let bind = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("gpu-path"), layout: &context.layout, entries: &entries });
|
||||
let denoiser = if options.denoise {
|
||||
Some(denoise::Resources::new(
|
||||
&context,
|
||||
&uniform,
|
||||
[&triangles, &nodes, &environment, &texture_buffer, &failures],
|
||||
&accumulation,
|
||||
size,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((uniform, accumulation, failures, readback, bind, denoiser))
|
||||
})?;
|
||||
let submission = context.queue.submit([]);
|
||||
context.wait(submission, TIMEOUT)?;
|
||||
let upload_ms = uploading.elapsed().as_secs_f64() * 1000.0;
|
||||
log::info!("gpu-path BVH {bvh_ms:.3} ms, upload {upload_bytes} bytes in {upload_ms:.3} ms");
|
||||
cancel.check()?;
|
||||
Ok(Self {
|
||||
dispatch_budget: Default::default(),
|
||||
context,
|
||||
request,
|
||||
options,
|
||||
cancel,
|
||||
uniform,
|
||||
accumulation,
|
||||
failures,
|
||||
readback,
|
||||
bind,
|
||||
denoiser,
|
||||
sample: 0,
|
||||
offset: 0,
|
||||
epsilon: upload.epsilon,
|
||||
png: None,
|
||||
started: Instant::now(),
|
||||
environment: 0.0,
|
||||
environment_bottom_only: false,
|
||||
studio: 1.0,
|
||||
#[cfg(test)]
|
||||
metrics: (bvh_ms, upload_bytes, upload_ms),
|
||||
})
|
||||
}
|
||||
fn parameters(&self, count: u32) -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(PARAMETER_BYTES);
|
||||
for x in [
|
||||
self.request.width,
|
||||
self.request.height,
|
||||
self.sample,
|
||||
self.offset,
|
||||
self.options.seed,
|
||||
self.options.max_depth,
|
||||
count,
|
||||
u32::from(self.environment_bottom_only),
|
||||
] {
|
||||
bytes.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
let camera = self.request.camera;
|
||||
let (right, up, back) = camera.basis();
|
||||
let perspective = camera.fov > 0.0;
|
||||
let eye = back * (camera.distance * if perspective { 1.0 } else { 4.0 });
|
||||
scene::vector(&mut bytes, eye, if perspective { 1.0 } else { 0.0 });
|
||||
scene::vector(
|
||||
&mut bytes,
|
||||
right * camera.half_height() * (f64::from(self.request.width) / f64::from(self.request.height)),
|
||||
self.options.lens.map_or(0.0, |l| l.aperture_radius_mm as f32),
|
||||
);
|
||||
scene::vector(&mut bytes, up * camera.half_height(), self.options.lens.map_or(0.0, |l| l.focus_distance_mm as f32));
|
||||
scene::vector(&mut bytes, back, camera.distance as f32);
|
||||
scene::vector(&mut bytes, Vec3::new(f64::from(self.epsilon), f64::from(self.environment), f64::from(self.studio)), 0.0);
|
||||
let lighting = self.options.lighting.unwrap_or_default();
|
||||
scene::vector(
|
||||
&mut bytes,
|
||||
Vec3::new(lighting.environment[0], lighting.environment[1], lighting.environment[2]),
|
||||
if self.options.lighting.is_some() || self.request.environment.is_some() { 1.0 } else { 0.0 },
|
||||
);
|
||||
scene::vector(&mut bytes, Vec3::ZERO, lighting.area_lights.iter().count() as f32);
|
||||
for light in lighting.area_lights.iter() {
|
||||
let vector = |[x, y, z]: [f64; 3]| Vec3::new(x, y, z);
|
||||
scene::vector(&mut bytes, vector(light.center) - camera.target, light.area() as f32);
|
||||
scene::vector(&mut bytes, vector(light.u), 0.0);
|
||||
scene::vector(&mut bytes, vector(light.v), 0.0);
|
||||
scene::vector(&mut bytes, vector(light.radiance), 0.0);
|
||||
}
|
||||
bytes.resize(400, 0);
|
||||
if let Some(source) = &self.request.environment {
|
||||
let info = source.info();
|
||||
scene::vector(&mut bytes, Vec3::new(info.rotation_rad.cos(), info.rotation_rad.sin(), info.intensity), 1.0);
|
||||
} else {
|
||||
scene::vector(&mut bytes, Vec3::ZERO, 0.0);
|
||||
}
|
||||
if let Some(sun) = lighting.sun {
|
||||
scene::vector(&mut bytes, sun.direction(), sun.angular_radius_rad.cos() as f32);
|
||||
let [r, g, b] = sun.radiance;
|
||||
scene::vector(&mut bytes, Vec3::new(r, g, b), (1.0 / sun.solid_angle()) as f32);
|
||||
} else {
|
||||
bytes.resize(PARAMETER_BYTES, 0);
|
||||
}
|
||||
|
||||
bytes
|
||||
}
|
||||
fn dispatch(&mut self) -> Result<()> {
|
||||
self.cancel.check()?;
|
||||
self.context.ensure_available()?;
|
||||
let dispatch_started = Instant::now();
|
||||
let pixels = self.request.width * self.request.height;
|
||||
let count = self.dispatch_budget.count(pixels - self.offset);
|
||||
let context = &self.context;
|
||||
let scopes = scopes(&context.device);
|
||||
context.queue.write_buffer(&self.uniform, 0, &self.parameters(count));
|
||||
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("path tile") });
|
||||
{
|
||||
let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("path tile"), timestamp_writes: None });
|
||||
pass.set_pipeline(&context.pipeline);
|
||||
pass.set_bind_group(0, &self.bind, &[]);
|
||||
pass.dispatch_workgroups(count.div_ceil(64), 1, 1);
|
||||
}
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
checked(scopes)?;
|
||||
context.wait(submission, TIMEOUT)?;
|
||||
self.dispatch_budget.observe(count, dispatch_started.elapsed());
|
||||
self.cancel.check()?;
|
||||
self.offset += count;
|
||||
if self.offset == pixels {
|
||||
self.sample += 1;
|
||||
self.offset = 0;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(test)]
|
||||
fn complete_sample(&mut self) -> Result<()> {
|
||||
let wanted = self.sample.checked_add(1).ok_or_else(|| gpu("sample counter overflow"))?;
|
||||
let maximum = (self.request.width * self.request.height).div_ceil(dispatch::MIN_PATHS);
|
||||
for _ in 0..maximum {
|
||||
self.dispatch()?;
|
||||
if self.sample == wanted {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(gpu("bounded full-sample test helper did not advance"))
|
||||
}
|
||||
fn read_linear(&self) -> Result<Vec<[f32; 4]>> {
|
||||
self.read_linear_buffer(&self.accumulation)
|
||||
}
|
||||
fn read_linear_buffer(&self, source: &wgpu::Buffer) -> Result<Vec<[f32; 4]>> {
|
||||
self.cancel.check()?;
|
||||
let context = &self.context;
|
||||
let scopes = scopes(&context.device);
|
||||
let mut encoder = context.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("path readback") });
|
||||
encoder.copy_buffer_to_buffer(source, 0, &self.readback, 0, self.accumulation.size());
|
||||
encoder.copy_buffer_to_buffer(&self.failures, 0, &self.readback, self.accumulation.size(), FAILURE_BYTES);
|
||||
let submission = context.queue.submit([encoder.finish()]);
|
||||
checked(scopes)?;
|
||||
let slice = self.readback.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |result| {
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
context.wait(submission, TIMEOUT)?;
|
||||
rx.try_recv().map_err(gpu)?.map_err(gpu)?;
|
||||
let data = slice.get_mapped_range().map_err(gpu)?;
|
||||
let (pixels, failures) = data.split_at(data.len().saturating_sub(FAILURE_BYTES as usize));
|
||||
let word = |i: usize| failures.as_chunks::<4>().0.get(i).map_or(0, |w| u32::from_le_bytes(*w));
|
||||
let failed = u64::from(word(0)) | (u64::from(word(5)) << 32);
|
||||
if failed > 0 {
|
||||
let paths = u64::from(self.request.width) * u64::from(self.request.height) * u64::from(self.sample.max(1));
|
||||
let detail = format!("{failed} of {paths} paths, first at pixel {}, bounce {}, sample {}, code {}", word(1), word(2), word(3), word(4));
|
||||
if !medium_failures_tolerated(failed, paths) {
|
||||
return Err(JobError::new(
|
||||
ErrorCode::InvalidScene,
|
||||
format!(
|
||||
"inconsistent glass medium paths ({detail}): camera must start outside volumes; overlapping solids or more than 16 nested media are unsupported"
|
||||
),
|
||||
));
|
||||
}
|
||||
log::warn!("gpu-path ended {detail} at an unclassifiable glass medium");
|
||||
}
|
||||
let mut values = scene::reserved((self.request.width * self.request.height) as usize)?;
|
||||
for pixel in pixels.as_chunks::<16>().0 {
|
||||
let mut value = [0.0; 4];
|
||||
for (v, bytes) in value.iter_mut().zip(pixel.as_chunks::<4>().0) {
|
||||
*v = f32::from_le_bytes(*bytes) / self.sample as f32;
|
||||
if !v.is_finite() || *v < 0.0 {
|
||||
return Err(JobError::new(ErrorCode::Internal, "nonfinite or negative path radiance"));
|
||||
}
|
||||
}
|
||||
values.push(value);
|
||||
}
|
||||
drop(data);
|
||||
self.readback.unmap();
|
||||
self.cancel.check()?;
|
||||
Ok(values)
|
||||
}
|
||||
}
|
||||
#[derive(Clone, Copy)]
|
||||
struct Display {
|
||||
exposure: f64,
|
||||
tone_map: ToneMap,
|
||||
background: [f64; 3],
|
||||
transparent: bool,
|
||||
}
|
||||
impl Display {
|
||||
fn encode(self, input: &[[f32; 4]], output: &mut [u8], cancel: &CancelToken) -> Result<()> {
|
||||
for (index, (pixel, rgba)) in input.iter().zip(output.as_chunks_mut::<4>().0).enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let alpha = f64::from(pixel[3]).clamp(0.0, 1.0);
|
||||
for ((radiance, background), out) in pixel.iter().take(3).zip(self.background).zip(rgba.iter_mut()) {
|
||||
let c = if alpha > 0.0 { f64::from(*radiance) * self.exposure / alpha } else { 0.0 };
|
||||
let mapped = self.tone_map.map(c);
|
||||
let value = if self.transparent { mapped } else { mapped * alpha + background * (1.0 - alpha) };
|
||||
let encoded = if value <= 0.0031308 { value * 12.92 } else { 1.055 * value.powf(1.0 / 2.4) - 0.055 };
|
||||
*out = (encoded.clamp(0.0, 1.0) * 255.0).round() as u8;
|
||||
}
|
||||
rgba[3] = if self.transparent { (alpha * 255.0).round() as u8 } else { 255 };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
impl ImageWork for Task {
|
||||
fn step(&mut self, units: usize) -> Result<TaskPoll> {
|
||||
self.cancel.check()?;
|
||||
self.context.ensure_available()?;
|
||||
if let Some(png) = &mut self.png {
|
||||
return png.step(units);
|
||||
}
|
||||
if self.sample < self.options.samples {
|
||||
self.dispatch()?;
|
||||
return Ok(TaskPoll::Pending(Progress { phase: Phase::Running, done: u64::from(self.sample), total: u64::from(self.options.samples) }));
|
||||
}
|
||||
log::info!("gpu-path {} spp, {:.3} ms/spp", self.sample, self.started.elapsed().as_secs_f64() * 1000.0 / f64::from(self.sample));
|
||||
let mut linear = self.read_linear()?;
|
||||
if let Some(denoiser) = &self.denoiser {
|
||||
// Diagnose the raw path buffer before filtering: never hide invalid media
|
||||
// or nonfinite radiance by replacing a pixel with neighbouring values.
|
||||
drop(linear);
|
||||
denoiser.run(self)?;
|
||||
linear = self.read_linear_buffer(denoiser.output())?;
|
||||
}
|
||||
let mut rgba = scene::reserved(linear.len() * 4)?;
|
||||
rgba.resize(linear.len() * 4, 0);
|
||||
let display = Display {
|
||||
exposure: 2.0f64.powf(self.options.exposure_ev),
|
||||
tone_map: self.options.tone_map,
|
||||
background: self.request.background.map(solidcraft_render::surface::srgb8_to_linear),
|
||||
transparent: self.request.transparent_background,
|
||||
};
|
||||
// Pixels are independent: encode contiguous chunks on every core.
|
||||
let workers = std::thread::available_parallelism().map_or(1, |n| n.get()).clamp(1, 16);
|
||||
let chunk = linear.len().div_ceil(workers).max(1);
|
||||
let cancel = &self.cancel;
|
||||
std::thread::scope(|scope| {
|
||||
let jobs: Vec<_> = linear
|
||||
.chunks(chunk)
|
||||
.zip(rgba.chunks_mut(chunk * 4))
|
||||
.map(|(input, output)| scope.spawn(move || display.encode(input, output, cancel)))
|
||||
.collect();
|
||||
jobs.into_iter()
|
||||
.try_for_each(|job| job.join().unwrap_or_else(|_| Err(JobError::new(ErrorCode::Internal, "display encoding worker failed"))))
|
||||
})?;
|
||||
self.png = Some(PngTask::new(&self.request, rgba, self.cancel.clone())?);
|
||||
Ok(TaskPoll::Pending(Progress { phase: Phase::Encoding, done: 0, total: u64::from(self.request.height) }))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,239 @@
|
||||
//! Exact connected planar patches of the positions actually uploaded to the GPU.
|
||||
//! This is a source-plane identity, not an angular or distance tolerance. A ray
|
||||
//! leaving a plane cannot meet that same plane again at a positive distance.
|
||||
use super::{Triangle, invalid, medium_root, reserved};
|
||||
use robust::{Coord, Coord3D, orient2d, orient3d};
|
||||
use solidcraft_render::job::{CancelToken, Result};
|
||||
|
||||
#[derive(Eq, PartialEq, Ord, PartialOrd)]
|
||||
struct Edge {
|
||||
body: u32,
|
||||
a: [u32; 3],
|
||||
b: [u32; 3],
|
||||
triangle: usize,
|
||||
}
|
||||
|
||||
pub(super) fn temporary_bytes_per_triangle() -> usize {
|
||||
3 * std::mem::size_of::<Edge>() + std::mem::size_of::<usize>() + std::mem::size_of::<u32>()
|
||||
}
|
||||
|
||||
fn uploaded(t: &Triangle) -> [Coord3D<f64>; 3] {
|
||||
t.p.map(|p| Coord3D { x: f64::from(p.x as f32), y: f64::from(p.y as f32), z: f64::from(p.z as f32) })
|
||||
}
|
||||
|
||||
fn key(p: Coord3D<f64>) -> [u32; 3] {
|
||||
[p.x, p.y, p.z].map(|x| if x == 0.0 { 0 } else { (x as f32).to_bits() })
|
||||
}
|
||||
|
||||
fn nondegenerate(p: [Coord3D<f64>; 3]) -> bool {
|
||||
for axes in [(0, 1), (0, 2), (1, 2)] {
|
||||
let q = p.map(|v| {
|
||||
let values = [v.x, v.y, v.z];
|
||||
Coord { x: values[axes.0], y: values[axes.1] }
|
||||
});
|
||||
if orient2d(q[0], q[1], q[2]) != 0.0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn ids(triangles: &[Triangle], cancel: &CancelToken) -> Result<Vec<u32>> {
|
||||
cancel.check()?;
|
||||
if triangles.len() >= u32::MAX as usize {
|
||||
return Err(invalid("too many triangles for planar patch identities"));
|
||||
}
|
||||
let edge_count = triangles.len().checked_mul(3).ok_or_else(|| invalid("planar edge count overflow"))?;
|
||||
let mut edges = reserved::<Edge>(edge_count)?;
|
||||
let mut parents = reserved::<usize>(triangles.len())?;
|
||||
parents.extend(0..triangles.len());
|
||||
for (index, triangle) in triangles.iter().enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let p = uploaded(triangle);
|
||||
// An f64 triangle can collapse at upload precision. It has no valid plane
|
||||
// and must never connect otherwise unrelated planes into one patch.
|
||||
if !nondegenerate(p) {
|
||||
continue;
|
||||
}
|
||||
let keys = p.map(key);
|
||||
for (a, b) in [(0, 1), (1, 2), (2, 0)] {
|
||||
let (a, b) = if keys[a] <= keys[b] { (keys[a], keys[b]) } else { (keys[b], keys[a]) };
|
||||
edges.push(Edge { body: triangle.medium, a, b, triangle: index });
|
||||
}
|
||||
}
|
||||
cancel.check()?;
|
||||
edges.sort_unstable();
|
||||
cancel.check()?;
|
||||
let mut begin = 0;
|
||||
while begin < edges.len() {
|
||||
if begin % 1024 < 3 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let first = edges.get(begin).ok_or_else(|| invalid("missing planar edge"))?;
|
||||
let mut end = begin + 1;
|
||||
while let Some(edge) = edges.get(end) {
|
||||
if (edge.body, edge.a, edge.b) != (first.body, first.a, first.b) {
|
||||
break;
|
||||
}
|
||||
if end % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
// Only unambiguous two-face adjacency is joined. At nonmanifold edges
|
||||
// leave identities separate rather than inferring a connected surface.
|
||||
if end - begin == 2 {
|
||||
let second = edges.get(begin + 1).ok_or_else(|| invalid("missing planar neighbor"))?;
|
||||
let a = uploaded(triangles.get(first.triangle).ok_or_else(|| invalid("missing planar triangle"))?);
|
||||
let b = uploaded(triangles.get(second.triangle).ok_or_else(|| invalid("missing planar triangle"))?);
|
||||
// Adaptive exact predicates on f32-as-f64 coordinates. Mirrored
|
||||
// winding does not change plane identity; curved facets do.
|
||||
if b.iter().all(|&p| orient3d(a[0], a[1], a[2], p) == 0.0) {
|
||||
let root_a = medium_root(&mut parents, first.triangle)?;
|
||||
let root_b = medium_root(&mut parents, second.triangle)?;
|
||||
let (keep, merge) = (root_a.min(root_b), root_a.max(root_b));
|
||||
*parents.get_mut(merge).ok_or_else(|| invalid("missing planar component"))? = keep;
|
||||
}
|
||||
}
|
||||
begin = end;
|
||||
}
|
||||
let mut result = reserved(triangles.len())?;
|
||||
for index in 0..triangles.len() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
result.push(medium_root(&mut parents, index)? as u32 + 1);
|
||||
}
|
||||
cancel.check()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::surface::OpaqueSurface;
|
||||
|
||||
fn triangle(p: [[f64; 3]; 3], body: u32) -> Triangle {
|
||||
Triangle {
|
||||
p: p.map(|v| Vec3::new(v[0], v[1], v[2])),
|
||||
indices: [0, 1, 2],
|
||||
surface: OpaqueSurface::new(Vec3::ZERO, 0.0, 0.5, 1.5).unwrap(),
|
||||
transmission: None,
|
||||
medium: body,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_connected_planes_join_with_either_winding() {
|
||||
let a = [191.3_f32 as f64, 79.2_f32 as f64, 84.7_f32 as f64];
|
||||
let b = [a[0] + 8.0, a[1], a[2] + 2.0];
|
||||
let c = [a[0] + 8.0, a[1] + 8.0, a[2] + 6.0];
|
||||
let d = [a[0], a[1] + 8.0, a[2] + 4.0];
|
||||
for neighbor in [[a, c, d], [d, c, a]] {
|
||||
let values = ids(&[triangle([a, b, c], 1), triangle(neighbor, 1)], &CancelToken::default()).unwrap();
|
||||
assert_eq!(values[0], values[1]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_parallel_bent_and_different_bodies_stay_separate() {
|
||||
let a = [0.0, 0.0, 0.0];
|
||||
let b = [1.0, 0.0, 0.0];
|
||||
let c = [1.0, 1.0, 0.0];
|
||||
let d = [0.0, 1.0, 0.0];
|
||||
let cases = [
|
||||
(triangle([a, c, d], 2), "different bodies"),
|
||||
(triangle([[2.0, 0.0, 0.0], [3.0, 0.0, 0.0], [2.0, 1.0, 0.0]], 1), "disconnected"),
|
||||
(triangle([a, c, [0.0, 1.0, 1e-20]], 1), "arbitrarily shallow bend"),
|
||||
(triangle([[0.0, 0.0, 1e-20], [1.0, 1.0, 1e-20], [0.0, 1.0, 1e-20]], 1), "near parallel"),
|
||||
];
|
||||
for (neighbor, name) in cases {
|
||||
let values = ids(&[triangle([a, b, c], 1), neighbor], &CancelToken::default()).unwrap();
|
||||
assert_ne!(values[0], values[1], "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouping_uses_upload_precision_and_normalizes_signed_zero() {
|
||||
let values = ids(
|
||||
&[
|
||||
triangle([[0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 1.0]], 1),
|
||||
triangle([[-0.0, -0.0, 1.0 + 1e-10], [1.0, 1.0, 1.0 + 1e-10], [0.0, 1.0, 1.0]], 1),
|
||||
],
|
||||
&CancelToken::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(values[0], values[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_and_empty_preparation_are_bounded() {
|
||||
let cancel = CancelToken::default();
|
||||
assert!(ids(&[], &cancel).unwrap().is_empty());
|
||||
cancel.cancel();
|
||||
assert!(ids(&[], &cancel).is_err());
|
||||
assert!(temporary_bytes_per_triangle() >= 3 * std::mem::size_of::<Edge>() + 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn upload_collapse_never_bridges_planes() {
|
||||
let a = [1.0, 0.0, 0.0];
|
||||
let b = [1.0 + 1e-10, 0.0, 0.0];
|
||||
let c = [1.0, 1.0, 0.0];
|
||||
let values =
|
||||
ids(&[triangle([a, c, [2.0, 0.0, 0.0]], 1), triangle([a, b, c], 1), triangle([a, c, [1.0, 0.0, 1.0]], 1)], &CancelToken::default())
|
||||
.unwrap();
|
||||
assert_ne!(values[0], values[1]);
|
||||
assert_ne!(values[1], values[2]);
|
||||
assert_ne!(values[0], values[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packing_offset_and_ids_follow_bvh_reordering() {
|
||||
use solidcraft_geom::Mesh;
|
||||
use solidcraft_render::{ProductMesh, ProductScene};
|
||||
use std::sync::Arc;
|
||||
let mut mesh = Mesh::default();
|
||||
for z in [5.0, 0.0, 10.0] {
|
||||
let first = mesh.positions.len() as u32;
|
||||
mesh.positions.extend([Vec3::new(0.0, 0.0, z), Vec3::new(1.0, 0.0, z), Vec3::new(1.0, 1.0, z), Vec3::new(0.0, 1.0, z)]);
|
||||
mesh.normals.extend([Vec3::Z; 4]);
|
||||
mesh.triangles.extend([[first, first + 1, first + 2], [first, first + 2, first + 3]]);
|
||||
mesh.tri_face.extend([0, 0]);
|
||||
}
|
||||
let bounds = mesh.bounds();
|
||||
let scene = ProductScene {
|
||||
bounds,
|
||||
meshes: vec![ProductMesh {
|
||||
name: "three separate planes".into(),
|
||||
mesh: Arc::new(mesh),
|
||||
color: [255; 3],
|
||||
opacity: 1.0,
|
||||
surface: None,
|
||||
transmission: None,
|
||||
faces: vec![],
|
||||
}],
|
||||
};
|
||||
let upload = super::super::flatten(&scene, Vec3::ZERO, &CancelToken::default()).unwrap();
|
||||
let word = |index: usize| u32::from_le_bytes(upload.geometry[index * 4..index * 4 + 4].try_into().unwrap()) as usize;
|
||||
assert_eq!(word(0), 6);
|
||||
assert_eq!(word(3), word(2) + 6 * 16);
|
||||
assert_eq!(upload.geometry.len(), 16 + 6 * 84 + 12 * 32);
|
||||
let mut pairs = Vec::new();
|
||||
for index in 0..6 {
|
||||
let vertex = word(4 + index * 4);
|
||||
let z = f32::from_bits(word(word(1) + vertex * 8 + 2) as u32);
|
||||
pairs.push((z, word(word(3) + index)));
|
||||
}
|
||||
for (a, pa) in &pairs {
|
||||
for (b, pb) in &pairs {
|
||||
assert_eq!(a == b, pa == pb);
|
||||
}
|
||||
}
|
||||
assert!(!upload.nodes.is_empty());
|
||||
assert!(upload.epsilon > 0.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,526 @@
|
||||
//! Original median-split BVH. World geometry is rebased around the camera target for f32.
|
||||
#[path = "planar_patches.rs"]
|
||||
mod planar_patches;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::{
|
||||
ProductScene,
|
||||
job::{CancelToken, ErrorCode, JobError, Result},
|
||||
surface::{OpaqueSurface, srgb8_to_linear},
|
||||
transmission::Transmission,
|
||||
};
|
||||
|
||||
pub(super) struct Triangle {
|
||||
pub p: [Vec3; 3],
|
||||
pub indices: [u32; 3],
|
||||
pub surface: OpaqueSurface,
|
||||
pub transmission: Option<Transmission>,
|
||||
pub medium: u32,
|
||||
}
|
||||
type MediumEdge = (usize, usize, i32);
|
||||
pub(super) fn medium_preparation_bytes(scene: &ProductScene) -> u64 {
|
||||
// Validation consumes one body's scratch at a time. Opaque/thin-sheet bodies
|
||||
// allocate none of the weld table: do not charge their triangles for it.
|
||||
let closed = |t: Option<Transmission>| t.is_some_and(|v| v.thin_wall_thickness_mm().is_none());
|
||||
let mut total = 0u64;
|
||||
let mut largest_medium = 0u64;
|
||||
for body in &scene.meshes {
|
||||
let triangles = body.mesh.triangles.len() as u64;
|
||||
total = total.saturating_add(triangles);
|
||||
if closed(body.transmission) || body.faces.iter().any(|face| closed(face.transmission)) {
|
||||
largest_medium = largest_medium.max(triangles);
|
||||
}
|
||||
}
|
||||
// Three representatives per triangle at most. The 320 includes positions,
|
||||
// full eight-slot hash entries, control bytes and worst-case table rounding.
|
||||
let scratch = 3 * (std::mem::size_of::<MediumEdge>() + 320) + std::mem::size_of::<usize>();
|
||||
total.saturating_mul(std::mem::size_of::<Triangle>() as u64).saturating_add(largest_medium.saturating_mul(scratch as u64))
|
||||
}
|
||||
|
||||
pub(super) fn planar_preparation_bytes_per_triangle() -> usize {
|
||||
let common = std::mem::size_of::<Triangle>() + 2 * std::mem::size_of::<Node>();
|
||||
(common + planar_patches::temporary_bytes_per_triangle()).max(common + 84 + 64 + 4)
|
||||
}
|
||||
|
||||
impl Triangle {
|
||||
fn centre(&self, axis: usize) -> f64 {
|
||||
self.p.iter().map(|p| [p.x, p.y, p.z].get(axis).copied().unwrap_or(0.0)).sum::<f64>() / 3.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Node {
|
||||
lo: [f32; 3],
|
||||
hi: [f32; 3],
|
||||
first: u32,
|
||||
count: u32,
|
||||
}
|
||||
|
||||
pub(super) struct Upload {
|
||||
pub geometry: Vec<u8>,
|
||||
pub nodes: Vec<u8>,
|
||||
pub epsilon: f32,
|
||||
}
|
||||
|
||||
fn invalid(message: &str) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidScene, message)
|
||||
}
|
||||
pub(super) fn reserved<T>(count: usize) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "path tracer allocation failed"))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
pub(super) fn flatten(scene: &ProductScene, origin: Vec3, cancel: &CancelToken) -> Result<Upload> {
|
||||
let count = scene.meshes.iter().map(|m| m.mesh.triangles.len()).sum::<usize>();
|
||||
let mut triangles = reserved(count)?;
|
||||
let vertex_count = scene.meshes.iter().map(|m| m.mesh.positions.len()).sum::<usize>();
|
||||
let mut vertices = reserved(vertex_count * 32)?;
|
||||
let mut vertex_offset = 0u32;
|
||||
let mut media_bounds = reserved::<(Vec3, Vec3, bool)>(8)?;
|
||||
for (body_index, body) in scene.meshes.iter().enumerate() {
|
||||
cancel.check()?;
|
||||
let body_start = triangles.len();
|
||||
let mut faces = reserved(body.faces.len())?;
|
||||
faces.extend(body.faces.iter());
|
||||
faces.sort_unstable_by_key(|f| f.face);
|
||||
if faces.windows(2).any(|f| f[0].face == f[1].face) {
|
||||
return Err(invalid("duplicate face appearance"));
|
||||
}
|
||||
let mesh = &body.mesh;
|
||||
if mesh.normals.len() != mesh.positions.len() || mesh.tri_face.len() != mesh.triangles.len() {
|
||||
return Err(invalid("path tracer requires normals and a face ID for every triangle"));
|
||||
}
|
||||
for (index, (position, normal)) in mesh.positions.iter().zip(&mesh.normals).enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let position = *position - origin;
|
||||
if !position.is_finite() || [position.x, position.y, position.z].iter().any(|x| x.abs() > 1e7) {
|
||||
return Err(invalid("path geometry must be finite and within 1e7 mm of the camera target"));
|
||||
}
|
||||
let normal = solidcraft_render::surface::unit_direction(*normal).map_err(|_| invalid("invalid mesh normal"))?;
|
||||
vector(&mut vertices, position, 0.0);
|
||||
vector(&mut vertices, normal, 0.0);
|
||||
}
|
||||
for (index, indices) in mesh.triangles.iter().enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let face = mesh.tri_face.get(index).copied().ok_or_else(|| invalid("missing triangle face"))?;
|
||||
let look = faces.binary_search_by_key(&face, |f| f.face).ok().and_then(|i| faces.get(i));
|
||||
let opacity = look.map_or(body.opacity, |f| f.opacity);
|
||||
let transmission = look.map_or(body.transmission, |f| f.transmission);
|
||||
if opacity != 1.0 && transmission.is_none() {
|
||||
return Err(invalid("gpu-path requires explicit transmission for nonopaque preview appearances"));
|
||||
}
|
||||
let color = look.map_or(body.color, |f| f.color);
|
||||
let surface = look
|
||||
.map_or(body.surface, |f| f.surface)
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| {
|
||||
OpaqueSurface::new(Vec3::new(srgb8_to_linear(color[0]), srgb8_to_linear(color[1]), srgb8_to_linear(color[2])), 0.0, 0.5, 1.5)
|
||||
})
|
||||
.map_err(|e| invalid(&e.to_string()))?;
|
||||
let mut p = [Vec3::ZERO; 3];
|
||||
let mut global_indices = [0u32; 3];
|
||||
for (corner, &vertex) in indices.iter().enumerate() {
|
||||
let position = *mesh.positions.get(vertex as usize).ok_or_else(|| invalid("triangle index out of bounds"))? - origin;
|
||||
if !position.is_finite() || [position.x, position.y, position.z].iter().any(|x| x.abs() > 1e7) {
|
||||
return Err(invalid("path geometry must be finite and within 1e7 mm of the camera target"));
|
||||
}
|
||||
if let Some(v) = p.get_mut(corner) {
|
||||
*v = position;
|
||||
}
|
||||
if let Some(v) = global_indices.get_mut(corner) {
|
||||
*v = vertex.checked_add(vertex_offset).ok_or_else(|| invalid("vertex offset overflow"))?;
|
||||
}
|
||||
}
|
||||
if (p[1] - p[0]).cross(p[2] - p[0]).len() <= 1e-12 {
|
||||
continue;
|
||||
}
|
||||
if transmission.is_some() && surface.clear_coat().is_some() {
|
||||
return Err(invalid("clear coat and transmission cannot share a surface"));
|
||||
}
|
||||
if transmission.is_some() && surface.metallic() != 0.0 {
|
||||
return Err(invalid("transmission requires a nonmetallic surface"));
|
||||
}
|
||||
triangles.push(Triangle { p, indices: global_indices, surface, transmission, medium: body_index as u32 + 1 });
|
||||
}
|
||||
validate_medium(&triangles[body_start..], cancel)?;
|
||||
let body_triangles = &triangles[body_start..];
|
||||
if body_triangles.iter().any(|t| t.transmission.is_some()) {
|
||||
if media_bounds.len() >= 8 {
|
||||
return Err(invalid("at most eight dielectric bodies are supported"));
|
||||
}
|
||||
let closed = body_triangles.iter().any(|t| t.transmission.is_some_and(|v| v.thin_wall_thickness_mm().is_none()));
|
||||
let mut lo = Vec3::new(f64::INFINITY, f64::INFINITY, f64::INFINITY);
|
||||
let mut hi = -lo;
|
||||
for t in body_triangles {
|
||||
for p in t.p {
|
||||
lo = lo.min(p);
|
||||
hi = hi.max(p);
|
||||
}
|
||||
}
|
||||
for (other_lo, other_hi, other_closed) in &media_bounds {
|
||||
if (closed || *other_closed)
|
||||
&& !(hi.x < other_lo.x || lo.x > other_hi.x || hi.y < other_lo.y || lo.y > other_hi.y || hi.z < other_lo.z || lo.z > other_hi.z)
|
||||
{
|
||||
return Err(invalid("nested or overlapping dielectric bounds are unsupported; separate the bodies' bounds"));
|
||||
}
|
||||
}
|
||||
media_bounds.push((lo, hi, closed));
|
||||
}
|
||||
vertex_offset = vertex_offset.checked_add(mesh.positions.len() as u32).ok_or_else(|| invalid("vertex offset overflow"))?;
|
||||
}
|
||||
let mut nodes = reserved(triangles.len().saturating_mul(2).max(1))?;
|
||||
nodes.push(Node::default());
|
||||
build(&mut triangles, 0, &mut nodes, 0, 0, cancel)?;
|
||||
let patches = planar_patches::ids(&triangles, cancel)?;
|
||||
let extent = nodes.first().map_or(1.0, |n| n.lo.iter().zip(n.hi).map(|(a, b)| b - a).fold(0.0f32, f32::max));
|
||||
let mut geometry = reserved(16 + triangles.len().max(1) * 84 + vertices.len())?;
|
||||
let mut materials = reserved(triangles.len().max(1) * 64)?;
|
||||
let vertex_word = 4 + triangles.len().max(1) * 4;
|
||||
let material_word = vertex_word + vertices.len() / 4;
|
||||
let patch_word = material_word + triangles.len().max(1) * 16;
|
||||
for word in [triangles.len() as u32, vertex_word as u32, material_word as u32, patch_word as u32] {
|
||||
geometry.extend_from_slice(&word.to_le_bytes());
|
||||
}
|
||||
for (material, t) in triangles.into_iter().enumerate() {
|
||||
if material % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
for index in t.indices {
|
||||
geometry.extend_from_slice(&index.to_le_bytes());
|
||||
}
|
||||
geometry.extend_from_slice(&(material as u32).to_le_bytes());
|
||||
vector(&mut materials, t.surface.base_color(), if t.transmission.is_some() { 1.0 } else { 0.0 });
|
||||
vector(
|
||||
&mut materials,
|
||||
Vec3::new(t.surface.metallic(), t.surface.roughness(), t.surface.dielectric_ior()),
|
||||
t.transmission.and_then(|t| t.thin_wall_thickness_mm()).unwrap_or(-1.0) as f32,
|
||||
);
|
||||
vector(&mut materials, t.transmission.map_or(Vec3::ZERO, |t| t.extinction()), t.medium as f32);
|
||||
let coat = t.surface.clear_coat().map_or(Vec3::new(0.0, 0.5, 1.5), |c| Vec3::new(c.weight(), c.roughness(), c.ior()));
|
||||
vector(&mut materials, coat, if t.transmission.is_some_and(|t| t.shadow_transmission()) { 1.0 } else { 0.0 });
|
||||
}
|
||||
if geometry.len() == 16 {
|
||||
geometry.resize(32, 0);
|
||||
materials.resize(64, 0);
|
||||
}
|
||||
geometry.extend_from_slice(&vertices);
|
||||
drop(vertices);
|
||||
geometry.extend_from_slice(&materials);
|
||||
drop(materials);
|
||||
if patches.is_empty() {
|
||||
geometry.extend_from_slice(&0u32.to_le_bytes());
|
||||
} else {
|
||||
for (index, patch) in patches.into_iter().enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
geometry.extend_from_slice(&patch.to_le_bytes());
|
||||
}
|
||||
}
|
||||
let mut node_bytes = reserved(nodes.len() * 32)?;
|
||||
for node in nodes {
|
||||
for x in node.lo {
|
||||
node_bytes.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
node_bytes.extend_from_slice(&node.first.to_le_bytes());
|
||||
for x in node.hi {
|
||||
node_bytes.extend_from_slice(&x.to_le_bytes());
|
||||
}
|
||||
node_bytes.extend_from_slice(&node.count.to_le_bytes());
|
||||
}
|
||||
Ok(Upload { geometry, nodes: node_bytes, epsilon: (extent * 1e-7).max(1e-5) })
|
||||
}
|
||||
|
||||
const MEDIUM_WELD_MM: f64 = 1e-6;
|
||||
|
||||
#[derive(Default)]
|
||||
struct MediumBucket {
|
||||
representatives: [usize; 8],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
/// Only topology uses these representatives; every uploaded vertex stays unchanged.
|
||||
/// Checking neighboring cells avoids rounding-boundary cracks in curved CAD seams.
|
||||
struct MediumWeld {
|
||||
cells: HashMap<[i64; 3], MediumBucket>,
|
||||
positions: Vec<Vec3>,
|
||||
limit: usize,
|
||||
}
|
||||
|
||||
impl MediumWeld {
|
||||
fn new(limit: usize) -> Result<Self> {
|
||||
let mut cells = HashMap::new();
|
||||
cells.try_reserve(limit).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "glass topology allocation failed"))?;
|
||||
Ok(Self { cells, positions: reserved(limit)?, limit })
|
||||
}
|
||||
|
||||
fn key(&mut self, point: Vec3) -> Result<usize> {
|
||||
let xyz = [point.x, point.y, point.z];
|
||||
// Leave room for neighboring cells and refuse saturated integer casts.
|
||||
if !xyz.into_iter().all(|v| v.is_finite() && v.abs() <= 1e9) {
|
||||
return Err(invalid("glass topology position exceeds finite coordinate bounds"));
|
||||
}
|
||||
let cell = xyz.map(|v| (v / MEDIUM_WELD_MM).floor() as i64);
|
||||
let mut found: Option<usize> = None;
|
||||
for dx in -1..=1 {
|
||||
for dy in -1..=1 {
|
||||
for dz in -1..=1 {
|
||||
let neighbor = [cell[0] + dx, cell[1] + dy, cell[2] + dz];
|
||||
let Some(bucket) = self.cells.get(&neighbor) else { continue };
|
||||
for &index in bucket.representatives.iter().take(bucket.len) {
|
||||
let other = self.positions.get(index).ok_or_else(|| invalid("missing glass topology representative"))?;
|
||||
let delta = point - *other;
|
||||
if delta.dot(delta) <= MEDIUM_WELD_MM * MEDIUM_WELD_MM && found.is_none_or(|old| index < old) {
|
||||
found = Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(index) = found {
|
||||
return Ok(index);
|
||||
}
|
||||
if self.positions.len() >= self.limit {
|
||||
return Err(invalid("glass topology representative budget exceeded"));
|
||||
}
|
||||
let index = self.positions.len();
|
||||
let bucket = self.cells.entry(cell).or_default();
|
||||
// Split a unit cell into eight half-size cubes: points sharing one are
|
||||
// at most sqrt(3)/2 tolerances apart, so a cell has at most eight reps.
|
||||
let slot = bucket.representatives.get_mut(bucket.len).ok_or_else(|| invalid("glass topology cell budget exceeded"))?;
|
||||
*slot = index;
|
||||
bucket.len += 1;
|
||||
self.positions.push(point);
|
||||
Ok(index)
|
||||
}
|
||||
}
|
||||
|
||||
// Volume transmission needs a consistently oriented closed two-manifold. CAD
|
||||
// tessellation duplicates face-seam vertices; compare their actual Euclidean
|
||||
// separation at the existing one-nanometre-in-mm tolerance, without modifying
|
||||
// uploaded geometry or merging chains beyond a representative's tolerance.
|
||||
fn validate_medium(triangles: &[Triangle], cancel: &CancelToken) -> Result<()> {
|
||||
let Some(first) = triangles.iter().find(|t| t.transmission.is_some_and(|v| v.thin_wall_thickness_mm().is_none())) else {
|
||||
return Ok(());
|
||||
};
|
||||
if triangles.iter().any(|t| t.transmission != first.transmission || t.surface.dielectric_ior() != first.surface.dielectric_ior()) {
|
||||
return Err(invalid("a closed glass body must use one homogeneous transmission medium; use separate bodies or thin walls"));
|
||||
}
|
||||
let vertex_count = triangles.len().checked_mul(3).ok_or_else(|| invalid("glass topology vertex count overflow"))?;
|
||||
let mut edges = reserved::<MediumEdge>(vertex_count)?;
|
||||
let mut weld = MediumWeld::new(vertex_count)?;
|
||||
let mut parents = reserved::<usize>(triangles.len())?;
|
||||
parents.extend(0..triangles.len());
|
||||
let origin = first.p[0];
|
||||
let mut six_volume = 0.0;
|
||||
for (index, triangle) in triangles.iter().enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let keys = [weld.key(triangle.p[0])?, weld.key(triangle.p[1])?, weld.key(triangle.p[2])?];
|
||||
for (a, b) in [(0, 1), (1, 2), (2, 0)] {
|
||||
if keys[a] == keys[b] {
|
||||
return Err(invalid("glass triangle collapses at the 1e-6 mm topology tolerance"));
|
||||
}
|
||||
let ordinal = i32::try_from(index + 1).map_err(|_| invalid("too many glass triangles"))?;
|
||||
edges.push(if keys[a] < keys[b] { (keys[a], keys[b], ordinal) } else { (keys[b], keys[a], -ordinal) });
|
||||
}
|
||||
six_volume += (triangle.p[0] - origin).dot((triangle.p[1] - origin).cross(triangle.p[2] - origin));
|
||||
}
|
||||
edges.sort_unstable();
|
||||
for edge in edges.chunks(2) {
|
||||
let [a, b] = edge else {
|
||||
return Err(invalid("volume glass requires a closed mesh; use thin_wall_thickness_mm for sheets"));
|
||||
};
|
||||
if a.0 != b.0 || a.1 != b.1 || a.2.signum() + b.2.signum() != 0 {
|
||||
return Err(invalid("volume glass requires a closed, consistently outward-oriented mesh"));
|
||||
}
|
||||
let first = medium_root(&mut parents, a.2.unsigned_abs() as usize - 1)?;
|
||||
let second = medium_root(&mut parents, b.2.unsigned_abs() as usize - 1)?;
|
||||
*parents.get_mut(first).ok_or_else(|| invalid("invalid glass component"))? = second;
|
||||
}
|
||||
// Groups with four or more coincident directed edges cannot be a two-manifold.
|
||||
if edges.windows(3).any(|e| e[0].0 == e[2].0 && e[0].1 == e[2].1) {
|
||||
return Err(invalid("nonmanifold glass edge"));
|
||||
}
|
||||
let component = medium_root(&mut parents, 0)?;
|
||||
for index in 0..parents.len() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
if medium_root(&mut parents, index)? != component {
|
||||
return Err(invalid("closed dielectric body must contain exactly one connected shell"));
|
||||
}
|
||||
}
|
||||
if six_volume <= 0.0 || !six_volume.is_finite() {
|
||||
return Err(invalid("glass mesh must enclose positive outward-oriented volume"));
|
||||
}
|
||||
cancel.check()
|
||||
}
|
||||
|
||||
fn medium_root(parents: &mut [usize], mut index: usize) -> Result<usize> {
|
||||
loop {
|
||||
let parent = *parents.get(index).ok_or_else(|| invalid("invalid glass component index"))?;
|
||||
if parent == index {
|
||||
return Ok(index);
|
||||
}
|
||||
let grandparent = *parents.get(parent).ok_or_else(|| invalid("invalid glass component parent"))?;
|
||||
*parents.get_mut(index).ok_or_else(|| invalid("invalid glass component index"))? = grandparent;
|
||||
index = grandparent;
|
||||
}
|
||||
}
|
||||
|
||||
// The shader's traversal stack holds 32 nodes and needs at most depth + 1.
|
||||
const MAX_BVH_DEPTH: usize = 30;
|
||||
const SAH_BINS: usize = 16;
|
||||
type Bounds = ([f32; 3], [f32; 3]);
|
||||
const EMPTY_BOUNDS: Bounds = ([f32::INFINITY; 3], [f32::NEG_INFINITY; 3]);
|
||||
|
||||
fn grow(bounds: &mut Bounds, t: &Triangle) {
|
||||
for p in t.p {
|
||||
for (axis, value) in [p.x as f32, p.y as f32, p.z as f32].into_iter().enumerate() {
|
||||
bounds.0[axis] = bounds.0[axis].min(value);
|
||||
bounds.1[axis] = bounds.1[axis].max(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge(bounds: &mut Bounds, other: &Bounds) {
|
||||
for axis in 0..3 {
|
||||
bounds.0[axis] = bounds.0[axis].min(other.0[axis]);
|
||||
bounds.1[axis] = bounds.1[axis].max(other.1[axis]);
|
||||
}
|
||||
}
|
||||
|
||||
fn half_area(bounds: &Bounds) -> f64 {
|
||||
let [x, y, z] = [0, 1, 2].map(|axis| f64::from((bounds.1[axis] - bounds.0[axis]).max(0.0)));
|
||||
x * y + y * z + z * x
|
||||
}
|
||||
|
||||
// Depth a median split adds below a node of `count` triangles.
|
||||
fn median_levels(mut count: usize) -> usize {
|
||||
let mut levels = 0;
|
||||
while count > 4 {
|
||||
count = count.div_ceil(2);
|
||||
levels += 1;
|
||||
}
|
||||
levels
|
||||
}
|
||||
|
||||
// Binned surface-area heuristic over triangle centres. A split is accepted only
|
||||
// if a median split of its larger side still fits MAX_BVH_DEPTH, so the median
|
||||
// fallback always remains available. Returns the left count after partitioning.
|
||||
fn sah_split(triangles: &mut [Triangle], depth: usize) -> Option<usize> {
|
||||
let mut centre_lo = [f64::INFINITY; 3];
|
||||
let mut centre_hi = [f64::NEG_INFINITY; 3];
|
||||
for t in triangles.iter() {
|
||||
for axis in 0..3 {
|
||||
centre_lo[axis] = centre_lo[axis].min(t.centre(axis));
|
||||
centre_hi[axis] = centre_hi[axis].max(t.centre(axis));
|
||||
}
|
||||
}
|
||||
let bin = |t: &Triangle, axis: usize| {
|
||||
let scaled = (t.centre(axis) - centre_lo[axis]) / (centre_hi[axis] - centre_lo[axis]) * SAH_BINS as f64;
|
||||
(scaled as usize).min(SAH_BINS - 1)
|
||||
};
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
for axis in 0..3 {
|
||||
if centre_hi[axis] - centre_lo[axis] <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut counts = [0usize; SAH_BINS];
|
||||
let mut boxes = [EMPTY_BOUNDS; SAH_BINS];
|
||||
for t in triangles.iter() {
|
||||
let b = bin(t, axis);
|
||||
counts[b] += 1;
|
||||
grow(&mut boxes[b], t);
|
||||
}
|
||||
let mut right_area = [0.0; SAH_BINS];
|
||||
let mut right_count = [0usize; SAH_BINS];
|
||||
let mut accumulated = EMPTY_BOUNDS;
|
||||
let mut count = 0;
|
||||
for b in (1..SAH_BINS).rev() {
|
||||
merge(&mut accumulated, &boxes[b]);
|
||||
count += counts[b];
|
||||
right_area[b] = half_area(&accumulated);
|
||||
right_count[b] = count;
|
||||
}
|
||||
accumulated = EMPTY_BOUNDS;
|
||||
count = 0;
|
||||
for b in 1..SAH_BINS {
|
||||
merge(&mut accumulated, &boxes[b - 1]);
|
||||
count += counts[b - 1];
|
||||
let right = right_count[b];
|
||||
if count == 0 || right == 0 || depth + 1 + median_levels(count.max(right)) > MAX_BVH_DEPTH {
|
||||
continue;
|
||||
}
|
||||
let cost = half_area(&accumulated) * count as f64 + right_area[b] * right as f64;
|
||||
if best.is_none_or(|(lowest, _, _)| cost < lowest) {
|
||||
best = Some((cost, axis, b));
|
||||
}
|
||||
}
|
||||
}
|
||||
let (_, axis, split) = best?;
|
||||
let mut middle = 0;
|
||||
for i in 0..triangles.len() {
|
||||
if bin(&triangles[i], axis) < split {
|
||||
triangles.swap(i, middle);
|
||||
middle += 1;
|
||||
}
|
||||
}
|
||||
Some(middle)
|
||||
}
|
||||
|
||||
fn build(triangles: &mut [Triangle], offset: usize, nodes: &mut Vec<Node>, index: usize, depth: usize, cancel: &CancelToken) -> Result<()> {
|
||||
cancel.check()?;
|
||||
let mut bounds = EMPTY_BOUNDS;
|
||||
for t in triangles.iter() {
|
||||
grow(&mut bounds, t);
|
||||
}
|
||||
let (lo, hi) = bounds;
|
||||
let mut node = Node { lo, hi, first: offset as u32, count: triangles.len() as u32 };
|
||||
if triangles.is_empty() {
|
||||
node = Node { first: u32::MAX, ..Default::default() };
|
||||
}
|
||||
if triangles.len() > 4 {
|
||||
let middle = match sah_split(triangles, depth) {
|
||||
Some(middle) => middle,
|
||||
None => {
|
||||
let axis = (0..3).max_by(|&a, &b| (hi[a] - lo[a]).total_cmp(&(hi[b] - lo[b]))).unwrap_or(0);
|
||||
let middle = triangles.len() / 2;
|
||||
triangles.select_nth_unstable_by(middle, |a, b| a.centre(axis).total_cmp(&b.centre(axis)));
|
||||
middle
|
||||
}
|
||||
};
|
||||
let first = nodes.len();
|
||||
nodes.push(Node::default());
|
||||
nodes.push(Node::default());
|
||||
node.first = first as u32;
|
||||
node.count = 0;
|
||||
let (left, right) = triangles.split_at_mut(middle);
|
||||
build(left, offset, nodes, first, depth + 1, cancel)?;
|
||||
build(right, offset + middle, nodes, first + 1, depth + 1, cancel)?;
|
||||
}
|
||||
*nodes.get_mut(index).ok_or_else(|| invalid("missing BVH node"))? = node;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn vector(bytes: &mut Vec<u8>, v: Vec3, w: f32) {
|
||||
for value in [v.x as f32, v.y as f32, v.z as f32, w] {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scene_weld_tests.rs"]
|
||||
mod weld_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "scene_bvh_tests.rs"]
|
||||
mod bvh_tests;
|
||||
@@ -0,0 +1,62 @@
|
||||
use super::{MAX_BVH_DEPTH, Node, Triangle, build};
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::{job::CancelToken, surface::OpaqueSurface};
|
||||
|
||||
// Triangle centres spread over 60 decades: a sixteen-bin SAH split on the
|
||||
// centre range peels only the few largest triangles off at each level, so an
|
||||
// unbounded build is far deeper than the shader's traversal stack allows.
|
||||
fn decades(count: usize) -> Vec<Triangle> {
|
||||
let surface = OpaqueSurface::new(Vec3::new(0.5, 0.5, 0.5), 0.0, 0.5, 1.5).unwrap();
|
||||
(0..count)
|
||||
.map(|i| {
|
||||
let x = 10f64.powf(-30.0 + 60.0 * i as f64 / count as f64);
|
||||
Triangle {
|
||||
p: [Vec3::new(x, 0., 0.), Vec3::new(x * 1.5, 0., 0.), Vec3::new(x, x, x)],
|
||||
indices: [0, 1, 2].map(|c| (3 * i + c) as u32),
|
||||
surface,
|
||||
transmission: None,
|
||||
medium: 0,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sah_tree_respects_the_shader_stack_depth_and_covers_every_triangle_once() {
|
||||
let count = 2048;
|
||||
let mut triangles = decades(count);
|
||||
let mut nodes = vec![Node::default()];
|
||||
build(&mut triangles, 0, &mut nodes, 0, 0, &CancelToken::default()).unwrap();
|
||||
assert!(nodes.len() < 2 * count, "{} nodes", nodes.len());
|
||||
let mut covered = vec![0; count];
|
||||
let mut deepest = 0;
|
||||
let mut pending = vec![(0usize, 0usize)];
|
||||
while let Some((index, depth)) = pending.pop() {
|
||||
let node = &nodes[index];
|
||||
let (first, size) = (node.first as usize, node.count as usize);
|
||||
deepest = deepest.max(depth);
|
||||
if size == 0 {
|
||||
for child in [first, first + 1] {
|
||||
let inner = &nodes[child];
|
||||
for axis in 0..3 {
|
||||
assert!(node.lo[axis] <= inner.lo[axis] && inner.hi[axis] <= node.hi[axis], "child box escapes its parent");
|
||||
}
|
||||
pending.push((child, depth + 1));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
assert!(size <= 4);
|
||||
for (slot, triangle) in covered[first..first + size].iter_mut().zip(&triangles[first..first + size]) {
|
||||
*slot += 1;
|
||||
for p in triangle.p {
|
||||
for (axis, value) in [p.x as f32, p.y as f32, p.z as f32].into_iter().enumerate() {
|
||||
assert!(node.lo[axis] <= value && value <= node.hi[axis], "triangle escapes its leaf");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(covered.iter().all(|&c| c == 1), "{covered:?}");
|
||||
assert!(deepest <= MAX_BVH_DEPTH, "depth {deepest}");
|
||||
// Covering 2048 triangles in leaves of four needs at least nine levels.
|
||||
assert!(deepest >= 9, "depth {deepest}");
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::{MediumWeld, Triangle, flatten, medium_preparation_bytes};
|
||||
use solidcraft_geom::{Mesh, Vec3};
|
||||
use solidcraft_render::{ProductMesh, ProductScene, job::CancelToken, surface::OpaqueSurface, transmission::Transmission};
|
||||
|
||||
fn seam_tetra(offset: Vec3) -> ProductScene {
|
||||
// Original outward tetrahedron. Only one face has a duplicated CAD seam
|
||||
// endpoint; its measured displacement comes from our revolved tumbler.
|
||||
let origin = Vec3::new(-81.00000000023796, -51.6269330409162, 592.0);
|
||||
let mesh = Mesh {
|
||||
positions: vec![origin, origin + Vec3::X, origin + Vec3::Y, origin + Vec3::Z, origin + offset],
|
||||
normals: vec![Vec3::Z; 5],
|
||||
triangles: vec![[4, 2, 1], [0, 1, 3], [0, 3, 2], [1, 2, 3]],
|
||||
tri_face: vec![0; 4],
|
||||
..Default::default()
|
||||
};
|
||||
let bounds = mesh.bounds();
|
||||
ProductScene {
|
||||
meshes: vec![ProductMesh {
|
||||
name: "original seam tetrahedron".into(),
|
||||
mesh: Arc::new(mesh),
|
||||
color: [255; 3],
|
||||
opacity: 1.0,
|
||||
surface: Some(OpaqueSurface::new(Vec3::ZERO, 0.0, 0.1, 1.5).unwrap()),
|
||||
transmission: Some(Transmission::new(Vec3::ZERO, None).unwrap()),
|
||||
faces: Vec::new(),
|
||||
}],
|
||||
bounds,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closed_cad_seam_across_rounding_cells_keeps_one_nanometre_tolerance() {
|
||||
let delta = Vec3::new(-0.00000054155099, 0.00000031266462, 0.0);
|
||||
assert!(delta.len() < 1e-6);
|
||||
let scene = seam_tetra(delta);
|
||||
assert!(flatten(&scene, Vec3::ZERO, &CancelToken::default()).is_ok());
|
||||
let open = seam_tetra(Vec3::new(1.01e-6, 0.0, 0.0));
|
||||
assert!(flatten(&open, Vec3::ZERO, &CancelToken::default()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_weld_is_deterministic_without_transitive_or_diagonal_expansion() {
|
||||
for _ in 0..8 {
|
||||
let mut weld = MediumWeld::new(8).unwrap();
|
||||
let a = weld.key(Vec3::ZERO).unwrap();
|
||||
assert_eq!(a, weld.key(Vec3::new(0.75e-6, 0.0, 0.0)).unwrap());
|
||||
assert_ne!(a, weld.key(Vec3::new(1.5e-6, 0.0, 0.0)).unwrap());
|
||||
assert_ne!(a, weld.key(Vec3::new(0.9e-6, 0.9e-6, 0.9e-6)).unwrap());
|
||||
assert!(weld.key(Vec3::new(f64::NAN, 0.0, 0.0)).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spatial_weld_preserves_winding_and_connected_component_checks() {
|
||||
let mut inward = seam_tetra(Vec3::ZERO);
|
||||
for tri in &mut Arc::make_mut(&mut inward.meshes[0].mesh).triangles {
|
||||
tri.swap(1, 2);
|
||||
}
|
||||
assert!(flatten(&inward, Vec3::ZERO, &CancelToken::default()).is_err());
|
||||
|
||||
let mut disconnected = seam_tetra(Vec3::ZERO);
|
||||
let mesh = Arc::make_mut(&mut disconnected.meshes[0].mesh);
|
||||
let second = mesh.clone();
|
||||
mesh.positions.extend(second.positions.iter().map(|p| *p + Vec3::new(4.0, 0.0, 0.0)));
|
||||
mesh.normals.extend(&second.normals);
|
||||
mesh.triangles.extend(second.triangles.iter().map(|t| t.map(|i| i + 5)));
|
||||
mesh.tri_face.extend(&second.tri_face);
|
||||
let error = flatten(&disconnected, Vec3::ZERO, &CancelToken::default()).err().unwrap();
|
||||
assert!(error.to_string().contains("one connected shell"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weld_scratch_only_counts_the_largest_closed_medium() {
|
||||
let glass = seam_tetra(Vec3::ZERO);
|
||||
let triangle_bytes = 4 * std::mem::size_of::<Triangle>() as u64;
|
||||
let mut opaque = glass.clone();
|
||||
opaque.meshes[0].transmission = None;
|
||||
assert_eq!(medium_preparation_bytes(&opaque), triangle_bytes);
|
||||
assert!(medium_preparation_bytes(&opaque) <= 4 * 400);
|
||||
opaque.meshes[0].transmission = Some(Transmission::new(Vec3::ZERO, Some(1.0)).unwrap());
|
||||
assert_eq!(medium_preparation_bytes(&opaque), triangle_bytes);
|
||||
let mut two = glass.clone();
|
||||
two.meshes.push(glass.meshes[0].clone());
|
||||
assert_eq!(medium_preparation_bytes(&two), medium_preparation_bytes(&glass) + triangle_bytes);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Bounded straight connection, not refracted caustic transport. Host preparation
|
||||
// certifies disjoint closed media; malformed crossing order is still blocked.
|
||||
struct ShadowResult { transmittance: vec3<f32>, branch_pdf: f32, crossings: u32, solid: bool };
|
||||
fn blocked_shadow() -> ShadowResult {
|
||||
return ShadowResult(vec3<f32>(0.0),0.0,0u,false);
|
||||
}
|
||||
fn shadow_transmittance(origin: vec3<f32>, direction: vec3<f32>, limit: f32) -> ShadowResult {
|
||||
var result = ShadowResult(vec3<f32>(1.0),1.0,0u,false);
|
||||
var o = origin;
|
||||
var o_low = vec3<f32>(0.0);
|
||||
var ray_excluded = 0xffffffffu;
|
||||
var ray_minimum = u.lighting.x;
|
||||
var medium_id = 0.0;
|
||||
var medium_ior = 1.0;
|
||||
var sigma = vec3<f32>(0.0);
|
||||
var entry = origin;
|
||||
var entry_low = vec3<f32>(0.0);
|
||||
// Up to 64 interfaces. If traversal cannot establish visibility, block it.
|
||||
for (var step=0u; step<=64u; step+=1u) {
|
||||
let traversed = dot(precise_point_delta(o,o_low,origin,vec3<f32>(0.0)),direction);
|
||||
let remaining = limit-traversed;
|
||||
// Outside media nothing beyond the endpoint matters. Inside, the
|
||||
// endpoint branch below must still see the farther boundary.
|
||||
let hit = trace_precise_within(o,o_low,direction,false,ray_excluded,ray_minimum,medium_id!=0.0,select(FAR,remaining,medium_id==0.0),remaining);
|
||||
if hit.triangle == 0xffffffffu {
|
||||
if medium_id!=0.0 { return blocked_shadow(); }
|
||||
return result;
|
||||
}
|
||||
if hit.distance>=remaining {
|
||||
// A finite emitter may be inside a certified medium. Attenuate only
|
||||
// to that emitter, never to the body's farther exit surface.
|
||||
if medium_id!=0.0 { result.transmittance*=exp(-sigma*length(ray_segment_precise(o,o_low,entry,entry_low,direction,remaining)))/(medium_ior*medium_ior); }
|
||||
return result;
|
||||
}
|
||||
if step==64u { return blocked_shadow(); }
|
||||
let t = triangle(hit.triangle);
|
||||
if t.base.w<=0.0 || t.coat.w<=0.0 { return blocked_shadow(); }
|
||||
var position=surface_point(t,hit.bary);
|
||||
var position_low=vec3<f32>(0.0);
|
||||
if t.parameters.w<0.0 {
|
||||
let point=surface_point_precise(t,hit.bary);
|
||||
position=point.hi; position_low=point.lo;
|
||||
}
|
||||
let outward=unit(cross(t.p1.xyz-t.p0.xyz,t.p2.xyz-t.p0.xyz));
|
||||
let entering=dot(outward,direction)<0.0;
|
||||
let geometric=select(-outward,outward,entering);
|
||||
var n=unit(t.n0.xyz*hit.bary.x+t.n1.xyz*hit.bary.y+t.n2.xyz*hit.bary.z);
|
||||
if dot(n,geometric)<0.0 { n=-n; }
|
||||
if dot(n,-direction)<=0.0 { n=geometric; }
|
||||
if t.parameters.w>=0.0 {
|
||||
if medium_id!=0.0 { return blocked_shadow(); }
|
||||
// Match the primary path's shading-normal fallback before the sheet sum.
|
||||
var f=dielectric(clamp(dot(n,-direction),0.0,1.0),1.0,t.parameters.z);
|
||||
let refracted=refract(direction,n,1.0/t.parameters.z);
|
||||
if (f.z>=0.0 && dot(refracted,geometric)>=0.0) || dot(reflect(direction,n),geometric)<=0.0 {
|
||||
n=geometric;
|
||||
f=dielectric(clamp(dot(n,-direction),0.0,1.0),1.0,t.parameters.z);
|
||||
}
|
||||
let weights=sheet(t,f);
|
||||
let r=dot(weights.reflection,vec3<f32>(1.0/3.0));
|
||||
let tr=dot(weights.transmission,vec3<f32>(1.0/3.0));
|
||||
if r+tr<=0.0 || tr<=0.0 { return blocked_shadow(); }
|
||||
result.transmittance*=weights.transmission;
|
||||
result.branch_pdf*=tr/(r+tr);
|
||||
} else {
|
||||
result.solid=true;
|
||||
// Reciprocal external-angle Fresnel at each surface. Applying an
|
||||
// inside critical angle to this unrefracted connection would invent TIR.
|
||||
let f=dielectric(clamp(abs(dot(outward,direction)),0.0,1.0),1.0,t.parameters.z);
|
||||
result.transmittance*=1.0-(f.x+f.y)*0.5;
|
||||
if entering {
|
||||
if medium_id!=0.0 { return blocked_shadow(); }
|
||||
medium_id=t.optics.w; medium_ior=t.parameters.z; entry=position; entry_low=position_low; sigma=t.optics.xyz;
|
||||
} else {
|
||||
// The first crossing from the shading point exits a closed body:
|
||||
// the point touches or lies within it (see the path loop).
|
||||
if medium_id==0.0 && step==0u {
|
||||
medium_id=t.optics.w; medium_ior=t.parameters.z; sigma=t.optics.xyz; entry=origin; entry_low=vec3<f32>(0.0);
|
||||
}
|
||||
if medium_id==0.0 || medium_id!=t.optics.w { return blocked_shadow(); }
|
||||
result.transmittance*=exp(-sigma*length(precise_point_delta(position,position_low,entry,entry_low)));
|
||||
medium_id=0.0;
|
||||
}
|
||||
}
|
||||
result.crossings+=1u;
|
||||
if max(max(result.transmittance.x,result.transmittance.y),result.transmittance.z)<=0.0 { return blocked_shadow(); }
|
||||
// Stay on the same line so the finite emitter endpoint remains meaningful.
|
||||
// Absorption uses unshifted entry/exit endpoints, not this numerical offset.
|
||||
o=position+direction*(u.lighting.x*2.0); o_low=vec3<f32>(0.0);
|
||||
ray_excluded=0xffffffffu; ray_minimum=u.lighting.x;
|
||||
if t.parameters.w<0.0 {
|
||||
// Preserve arbitrarily short real corner segments, using the same
|
||||
// producing-plane exclusion as closed primary-path continuation.
|
||||
o=position; o_low=position_low; ray_excluded=hit.triangle; ray_minimum=0.0;
|
||||
}
|
||||
}
|
||||
return blocked_shadow();
|
||||
}
|
||||
|
||||
@compute @workgroup_size(1)
|
||||
fn shadow_reference() {
|
||||
let shadow=shadow_transmittance(u.eye.xyz,u.back.xyz,u.back.w);
|
||||
accumulation[0]=vec4<f32>(shadow.transmittance,1.0);
|
||||
accumulation[1]=vec4<f32>(shadow.branch_pdf,f32(shadow.crossings),select(0.0,1.0,shadow.solid),1.0);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
//! One read-only storage buffer: body lookup, planar records and linear RGBA pixels.
|
||||
use solidcraft_geom::Vec3;
|
||||
use solidcraft_render::job::{
|
||||
CancelToken, ErrorCode, JobError, Result,
|
||||
textures::{RuntimeTextures, TextureMap},
|
||||
};
|
||||
|
||||
fn vector(a: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(a[0], a[1], a[2])
|
||||
}
|
||||
fn write_word(bytes: &mut [u8], word: usize, value: u32) -> Result<()> {
|
||||
bytes
|
||||
.get_mut(word * 4..word * 4 + 4)
|
||||
.ok_or_else(|| JobError::new(ErrorCode::Internal, "texture record outside allocation"))?
|
||||
.copy_from_slice(&value.to_le_bytes());
|
||||
Ok(())
|
||||
}
|
||||
fn pixels(bytes: &mut Vec<u8>, map: Option<&TextureMap>, cancel: &CancelToken) -> Result<(u32, [u32; 2])> {
|
||||
let Some(map) = map else {
|
||||
return Ok((0, [0; 2]));
|
||||
};
|
||||
cancel.check()?;
|
||||
let rgba = map.decode(cancel)?;
|
||||
let offset = bytes.len() / 4;
|
||||
for (index, value) in rgba.into_iter().enumerate() {
|
||||
if index % 4096 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
Ok((offset as u32, map.dimensions()))
|
||||
}
|
||||
pub(super) fn flatten(resources: Option<&RuntimeTextures>, body_count: usize, origin: Vec3, cancel: &CancelToken) -> Result<Vec<u8>> {
|
||||
let Some(resources) = resources else {
|
||||
return Ok(vec![0; 16]);
|
||||
};
|
||||
let header = 4 + body_count + 1;
|
||||
let record_words = resources.bindings().len() * 16;
|
||||
let mut bytes = super::scene::reserved((header + record_words) * 4 + resources.summary().linear_pixel_bytes as usize)?;
|
||||
bytes.resize((header + record_words) * 4, 0);
|
||||
write_word(&mut bytes, 0, (body_count + 1) as u32)?;
|
||||
for (index, binding) in resources.bindings().iter().enumerate() {
|
||||
cancel.check()?;
|
||||
let record = header + index * 16;
|
||||
write_word(&mut bytes, 4 + binding.body_index + 1, record as u32)?;
|
||||
let delta = vector(binding.frame.origin) - origin;
|
||||
if [delta.x, delta.y, delta.z].iter().any(|v| v.abs() > 1e7) {
|
||||
return Err(JobError::new(ErrorCode::InvalidScene, "texture frame must be within 1e7 mm of camera target"));
|
||||
}
|
||||
let u = vector(binding.frame.u);
|
||||
let v = vector(binding.frame.v);
|
||||
for (offset, p) in [(0, delta), (4, u / u.len2()), (8, v / v.len2())] {
|
||||
for (k, c) in [p.x, p.y, p.z].into_iter().enumerate() {
|
||||
write_word(&mut bytes, record + offset + k, (c as f32).to_bits())?;
|
||||
}
|
||||
}
|
||||
let (base, base_size) = pixels(&mut bytes, binding.base_color.as_ref(), cancel)?;
|
||||
let (normal, normal_size) = pixels(&mut bytes, binding.normal.as_ref(), cancel)?;
|
||||
for (offset, value) in [
|
||||
(3, base),
|
||||
(7, base_size[0]),
|
||||
(11, base_size[1]),
|
||||
(12, normal),
|
||||
(13, normal_size[0]),
|
||||
(14, normal_size[1]),
|
||||
(15, (binding.normal_strength as f32).to_bits()),
|
||||
] {
|
||||
write_word(&mut bytes, record + offset, value)?;
|
||||
}
|
||||
}
|
||||
cancel.check()?;
|
||||
Ok(bytes)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
@group(0) @binding(5) var<storage, read> texture_words: array<u32>;
|
||||
fn texture_vector(word:u32)->vec3<f32> {
|
||||
return bitcast<vec3<f32>>(vec3<u32>(texture_words[word],texture_words[word+1u],texture_words[word+2u]));
|
||||
}
|
||||
fn texture_record(t:Triangle)->u32 {
|
||||
let body=u32(t.optics.w);
|
||||
if body>=texture_words[0] {return 0u;}
|
||||
return texture_words[4u+body];
|
||||
}
|
||||
fn texture_pixel(offset:u32,width:u32,x:u32,y:u32)->vec3<f32> {
|
||||
return texture_vector(offset+4u*(y*width+x));
|
||||
}
|
||||
fn texture_sample(offset:u32,size:vec2<u32>,uv:vec2<f32>)->vec3<f32> {
|
||||
let position=fract(uv)*vec2<f32>(size)-vec2<f32>(0.5);
|
||||
let lower=vec2<i32>(floor(position));
|
||||
let first=vec2<u32>((lower+vec2<i32>(size))%vec2<i32>(size));
|
||||
let second=(first+vec2<u32>(1u))%size;
|
||||
let fraction=fract(position);
|
||||
let a=texture_pixel(offset,size.x,first.x,first.y);
|
||||
let b=texture_pixel(offset,size.x,second.x,first.y);
|
||||
let c=texture_pixel(offset,size.x,first.x,second.y);
|
||||
let d=texture_pixel(offset,size.x,second.x,second.y);
|
||||
return mix(mix(a,b,fraction.x),mix(c,d,fraction.x),fraction.y);
|
||||
}
|
||||
fn texture_uv(record:u32,position:vec3<f32>)->vec2<f32> {
|
||||
let delta=position-texture_vector(record);
|
||||
return vec2<f32>(dot(delta,texture_vector(record+4u)),dot(delta,texture_vector(record+8u)));
|
||||
}
|
||||
fn texture_surface(t:Triangle,position:vec3<f32>)->Triangle {
|
||||
let record=texture_record(t);var result=t;
|
||||
if record==0u || texture_words[record+3u]==0u {return result;}
|
||||
let rgb=texture_sample(texture_words[record+3u],vec2<u32>(texture_words[record+7u],texture_words[record+11u]),texture_uv(record,position));
|
||||
// Texture modulates linear reflectance, never emission or scalar alpha.
|
||||
result.base=vec4<f32>(t.base.xyz*rgb,t.base.w);
|
||||
return result;
|
||||
}
|
||||
fn texture_normal(t:Triangle,position:vec3<f32>,n:vec3<f32>,geometric:vec3<f32>)->vec3<f32> {
|
||||
let record=texture_record(t);
|
||||
if record==0u || texture_words[record+12u]==0u {return n;}
|
||||
let rgb=texture_sample(texture_words[record+12u],vec2<u32>(texture_words[record+13u],texture_words[record+14u]),texture_uv(record,position));
|
||||
let u=unit(texture_vector(record+4u));
|
||||
let tangent=u-n*dot(n,u);
|
||||
if dot(tangent,tangent)<1e-12 {return n;}
|
||||
let tangent_unit=unit(tangent);
|
||||
let handedness=select(1.0,-1.0,dot(n,cross(texture_vector(record+4u),texture_vector(record+8u)))<0.0);
|
||||
let strength=bitcast<f32>(texture_words[record+15u]);
|
||||
let local=vec3<f32>((2.0*rgb.xy-vec2<f32>(1.0))*strength,2.0*rgb.z-1.0);
|
||||
if dot(local,local)<1e-12 || local.z<=0.0 {return n;}
|
||||
let mapped=unit(tangent_unit*local.x+cross(n,tangent_unit)*(handedness*local.y)+n*local.z);
|
||||
return select(mapped,n,dot(mapped,geometric)<=0.0);
|
||||
}
|
||||
@compute @workgroup_size(1)
|
||||
fn texture_reference() {
|
||||
let t=triangle(0u);
|
||||
let mapped=texture_surface(t,u.eye.xyz);
|
||||
accumulation[0]=vec4<f32>(mapped.base.xyz,1.0);
|
||||
accumulation[1]=vec4<f32>(texture_normal(t,u.eye.xyz,u.up.xyz,u.back.xyz)+vec3<f32>(2.0),1.0);
|
||||
}
|
||||
@@ -34,9 +34,11 @@ fn vs_pbr(@location(0) p: vec3<f32>, @location(1) n: vec3<f32>, @location(2) bas
|
||||
return o;
|
||||
}
|
||||
|
||||
fn pbr_fresnel(f0: vec3<f32>, cosine: f32) -> vec3<f32> {
|
||||
fn pbr_fresnel(f0: vec3<f32>, cosine: f32, metallic: f32, ior: f32) -> vec3<f32> {
|
||||
let t = 1.0 - cosine;
|
||||
return f0 + (vec3<f32>(1.0) - f0) * (t * t * t * t * t);
|
||||
// Match the shared surface: an index-matched dielectric has no interface.
|
||||
let grazing = select(1.0, metallic, ior == 1.0);
|
||||
return f0 + (vec3<f32>(grazing) - f0) * (t * t * t * t * t);
|
||||
}
|
||||
|
||||
fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>, l: vec3<f32>) -> vec3<f32> {
|
||||
@@ -54,9 +56,9 @@ fn pbr_brdf(base: vec3<f32>, parameters: vec3<f32>, n: vec3<f32>, v: vec3<f32>,
|
||||
let gl = 2.0 / (nl + sqrt(a2 + (1.0 - a2) * nl * nl));
|
||||
let ratio = (parameters.z - 1.0) / (parameters.z + 1.0);
|
||||
let f0 = mix(vec3<f32>(ratio * ratio), base, parameters.x);
|
||||
let specular = pbr_fresnel(f0, vh) * (distribution * 0.25 * gv * gl);
|
||||
let specular = pbr_fresnel(f0, vh, parameters.x, parameters.z) * (distribution * 0.25 * gv * gl);
|
||||
let diffuse = base * ((1.0 - parameters.x) / 3.141592653589793);
|
||||
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl)) * diffuse;
|
||||
return specular + (vec3<f32>(1.0) - pbr_fresnel(f0, nv, parameters.x, parameters.z)) * (vec3<f32>(1.0) - pbr_fresnel(f0, nl, parameters.x, parameters.z)) * diffuse;
|
||||
}
|
||||
|
||||
@fragment
|
||||
|
||||
@@ -91,7 +91,7 @@ impl Policy {
|
||||
}
|
||||
}
|
||||
|
||||
fn admits(self, tier: Tier) -> bool {
|
||||
pub(crate) fn admits(self, tier: Tier) -> bool {
|
||||
match self {
|
||||
Policy::Auto => true,
|
||||
Policy::Hardware => tier == Tier::Hardware,
|
||||
@@ -139,7 +139,7 @@ pub fn native_backends() -> wgpu::Backends {
|
||||
}
|
||||
}
|
||||
|
||||
fn tier_of(t: wgpu::DeviceType) -> Tier {
|
||||
pub(crate) 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,
|
||||
@@ -201,6 +201,28 @@ pub fn survey(policy: Policy) -> Survey {
|
||||
Survey { backends: format!("{:?}", native_backends()), adapters: found, chosen, lands_on }
|
||||
}
|
||||
|
||||
/// The Vulkan adapter `policy` picks among those `keep` accepts, with its report; `None` where
|
||||
/// the native backends (or `WGPU_BACKEND`) leave out Vulkan, as on macOS. The GPU path tracer
|
||||
/// runs on Vulkan only (#3217): D3D12 was never faster in our measurements and needs minutes to
|
||||
/// compile its shader at every start, where Vulkan keeps compiled pipelines between runs.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn pick_vulkan(policy: Policy, keep: impl Fn(&wgpu::AdapterInfo) -> bool) -> Option<(wgpu::Adapter, AdapterReport)> {
|
||||
if !native_backends().contains(wgpu::Backends::VULKAN) {
|
||||
return None;
|
||||
}
|
||||
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle_from_env();
|
||||
desc.backends = wgpu::Backends::VULKAN;
|
||||
let mut all = pollster::block_on(wgpu::Instance::new(desc).enumerate_adapters(wgpu::Backends::VULKAN));
|
||||
all.sort_by_key(|a| std::cmp::Reverse(rank(a.get_info().device_type)));
|
||||
all.into_iter()
|
||||
.filter(|a| keep(&a.get_info()))
|
||||
.map(|a| {
|
||||
let r = report(&a.get_info());
|
||||
(a, r)
|
||||
})
|
||||
.find(|(_, r)| policy.admits(r.tier))
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
|
||||
+96
-22
@@ -1,11 +1,12 @@
|
||||
//! Portable scalar libraries; no renderer, network, external resources or design links.
|
||||
//! Portable material libraries; no network, external resources or design links.
|
||||
use crate::{IoError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_doc::{Document, UserMaterial, appearance::Look, valid_density};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub const FORMAT: &str = "solidcraft-library/1";
|
||||
pub const FORMAT: &str = "solidcraft-library/2";
|
||||
pub const V1_FORMAT: &str = "solidcraft-library/1";
|
||||
pub const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
pub const MAX_ENTRIES: usize = 1024;
|
||||
/// Bound source inspection even when many assignments reuse the same definition.
|
||||
@@ -51,47 +52,57 @@ impl Provenance {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScalarLook {
|
||||
pub struct LibraryLook {
|
||||
pub name: String,
|
||||
pub color: [u8; 3],
|
||||
pub opacity: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub pbr: Option<solidcraft_doc::pbr_appearance::PbrParameters>,
|
||||
}
|
||||
impl ScalarLook {
|
||||
impl LibraryLook {
|
||||
pub fn from_look(look: &Look) -> Result<Self> {
|
||||
if look.pbr.is_some() {
|
||||
return Err(invalid("scalar catalog v1 cannot preserve PBR parameters; export refused"));
|
||||
}
|
||||
Ok(Self { name: look.name.clone(), color: look.color, opacity: look.opacity })
|
||||
let out = Self { name: look.name.clone(), color: look.color, opacity: look.opacity, pbr: look.pbr };
|
||||
out.validate()?;
|
||||
Ok(out)
|
||||
}
|
||||
pub fn to_look(&self) -> Look {
|
||||
Look { name: self.name.clone(), color: self.color, opacity: self.opacity, pbr: None }
|
||||
Look { name: self.name.clone(), color: self.color, opacity: self.opacity, pbr: self.pbr }
|
||||
}
|
||||
fn validate(&self) -> Result<()> {
|
||||
if !text(&self.name, 128) || !self.opacity.is_finite() || !(0.0..=1.0).contains(&self.opacity) {
|
||||
return Err(invalid("library appearance needs a 1–128 byte name and finite opacity 0–1"));
|
||||
}
|
||||
self.to_look().opaque_surface().map_err(|e| invalid(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ScalarMaterial {
|
||||
pub struct LibraryMaterial {
|
||||
pub name: String,
|
||||
pub density_g_cm3: f64,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub appearance: Option<ScalarLook>,
|
||||
pub appearance: Option<LibraryLook>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub physical_card: Option<solidcraft_doc::physical_material::ValidatedCard>,
|
||||
}
|
||||
impl ScalarMaterial {
|
||||
impl LibraryMaterial {
|
||||
pub fn from_material(material: &UserMaterial) -> Result<Self> {
|
||||
Ok(Self {
|
||||
name: material.name.clone(),
|
||||
density_g_cm3: material.density,
|
||||
appearance: material.appearance.as_ref().map(ScalarLook::from_look).transpose()?,
|
||||
appearance: material.appearance.as_ref().map(LibraryLook::from_look).transpose()?,
|
||||
physical_card: material.physical_card.clone(),
|
||||
})
|
||||
}
|
||||
pub fn to_material(&self, name: String) -> UserMaterial {
|
||||
UserMaterial { name, density: self.density_g_cm3, appearance: self.appearance.as_ref().map(ScalarLook::to_look) }
|
||||
UserMaterial {
|
||||
name,
|
||||
density: self.density_g_cm3,
|
||||
appearance: self.appearance.as_ref().map(LibraryLook::to_look),
|
||||
physical_card: self.physical_card.clone(),
|
||||
}
|
||||
}
|
||||
fn validate(&self) -> Result<()> {
|
||||
if !text(&self.name, 128) || !valid_density(self.density_g_cm3) {
|
||||
@@ -107,10 +118,16 @@ impl ScalarMaterial {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", content = "value", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum Definition {
|
||||
Appearance(ScalarLook),
|
||||
Material(ScalarMaterial),
|
||||
Appearance(LibraryLook),
|
||||
Material(LibraryMaterial),
|
||||
}
|
||||
impl Definition {
|
||||
fn requires_v2(&self) -> bool {
|
||||
match self {
|
||||
Self::Appearance(look) => look.pbr.is_some(),
|
||||
Self::Material(material) => material.physical_card.is_some() || material.appearance.as_ref().is_some_and(|l| l.pbr.is_some()),
|
||||
}
|
||||
}
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
Self::Appearance(l) => &l.name,
|
||||
@@ -140,7 +157,8 @@ pub struct Entry {
|
||||
pub definition: Definition,
|
||||
}
|
||||
impl Entry {
|
||||
/// Revision is SHA-256 of compact serde JSON `[provenance, definition]` in this v1 contract.
|
||||
/// SHA-256 of compact serde JSON `[provenance, definition]`. Absent v2 fields
|
||||
/// are omitted, preserving the exact v1 scalar writer contract.
|
||||
pub fn content_revision(&self) -> Result<String> {
|
||||
self.provenance.validate()?;
|
||||
self.definition.validate()?;
|
||||
@@ -207,11 +225,15 @@ fn bounded_entries<'de, D: serde::Deserializer<'de>>(deserializer: D) -> std::re
|
||||
}
|
||||
impl Catalog {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.format != FORMAT || !identifier(&self.id) || !text(&self.name, 128) || self.entries.len() > MAX_ENTRIES {
|
||||
return Err(invalid("unsupported or invalid scalar library (v1, 1024 entries, 128 byte name)"));
|
||||
if !matches!(self.format.as_str(), FORMAT | V1_FORMAT) || !identifier(&self.id) || !text(&self.name, 128) || self.entries.len() > MAX_ENTRIES
|
||||
{
|
||||
return Err(invalid("unsupported or invalid library (v1/v2, 1024 entries, 128 byte name)"));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for entry in &self.entries {
|
||||
if self.format == V1_FORMAT && entry.definition.requires_v2() {
|
||||
return Err(invalid("catalog v1 cannot preserve PBR or engineering cards; export refused"));
|
||||
}
|
||||
entry.validate(&self.id)?;
|
||||
if !ids.insert(&entry.id) {
|
||||
return Err(invalid("duplicate library entry identity"));
|
||||
@@ -223,18 +245,53 @@ impl Catalog {
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(invalid("scalar library exceeds 4 MiB"));
|
||||
}
|
||||
// Keep the v1 wire schema strict, including newly recognized null fields.
|
||||
// This bounded raw preflight is not arbitrary JSON canonicalization.
|
||||
let raw: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| invalid(format!("invalid library: {e}")))?;
|
||||
if raw.get("format").and_then(serde_json::Value::as_str) == Some(V1_FORMAT)
|
||||
&& let Some(entries) = raw.get("entries").and_then(serde_json::Value::as_array)
|
||||
{
|
||||
if entries.len() > MAX_ENTRIES {
|
||||
return Err(invalid("library exceeds 1024 entries"));
|
||||
}
|
||||
for entry in entries {
|
||||
if let Some(value) = entry.get("definition").and_then(|d| d.get("value")) {
|
||||
let has = |v: &serde_json::Value, key: &str| v.as_object().is_some_and(|o| o.contains_key(key));
|
||||
if has(value, "pbr") || has(value, "physical_card") || value.get("appearance").is_some_and(|a| has(a, "pbr")) {
|
||||
return Err(invalid("v1 definitions cannot contain v2 fields"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
drop(raw);
|
||||
let catalog: Self = serde_json::from_slice(bytes).map_err(|e| invalid(format!("invalid scalar library: {e}")))?;
|
||||
catalog.validate()?;
|
||||
Ok(catalog)
|
||||
}
|
||||
pub fn encode(&self) -> Result<Vec<u8>> {
|
||||
self.validate()?;
|
||||
let mut budget = 4096usize;
|
||||
for entry in &self.entries {
|
||||
budget = budget.saturating_add(entry_encoded_bound(entry)?);
|
||||
if budget > MAX_BYTES {
|
||||
return Err(invalid("library exceeds 4 MiB encoded admission budget"));
|
||||
}
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(self).map_err(|e| invalid(e.to_string()))?;
|
||||
if bytes.len() > MAX_BYTES {
|
||||
return Err(invalid("scalar library exceeds 4 MiB"));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
/// Change only the envelope version; reject any lossy downgrade.
|
||||
pub fn in_format(mut self, format: &str) -> Result<Self> {
|
||||
if !matches!(format, FORMAT | V1_FORMAT) {
|
||||
return Err(invalid("unsupported library format"));
|
||||
}
|
||||
self.format = format.into();
|
||||
self.validate()?;
|
||||
Ok(self)
|
||||
}
|
||||
pub fn read(path: &str) -> Result<Self> {
|
||||
// Check the VFS size first as well: its browser read clones the stored bytes.
|
||||
if crate::vfs::len(path).map_err(|e| invalid(e.to_string()))? > MAX_BYTES as u64 {
|
||||
@@ -258,6 +315,7 @@ impl Catalog {
|
||||
return Err(invalid("library export exceeds 100000 source references"));
|
||||
}
|
||||
let mut entries = BTreeMap::new();
|
||||
let mut budget = 4096usize;
|
||||
let mut insert = |definition| -> Result<()> {
|
||||
let entry = Entry::new(id, definition, provenance.clone())?;
|
||||
if let Some(previous) = entries.get(&entry.id) {
|
||||
@@ -268,17 +326,21 @@ impl Catalog {
|
||||
if entries.len() >= MAX_ENTRIES {
|
||||
return Err(invalid("scalar library exceeds 1024 unique entries"));
|
||||
}
|
||||
budget = budget.saturating_add(entry_encoded_bound(&entry)?);
|
||||
if budget > MAX_BYTES {
|
||||
return Err(invalid("library exceeds 4 MiB encoded admission budget"));
|
||||
}
|
||||
entries.insert(entry.id.clone(), entry);
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
for look in doc.appearances.bodies.values().chain(doc.appearances.components.values()).chain(doc.appearances.faces.iter().map(|f| &f.look)) {
|
||||
insert(Definition::Appearance(ScalarLook::from_look(look)?))?;
|
||||
insert(Definition::Appearance(LibraryLook::from_look(look)?))?;
|
||||
}
|
||||
for material in &doc.user_materials {
|
||||
insert(Definition::Material(ScalarMaterial::from_material(material)?))?;
|
||||
insert(Definition::Material(LibraryMaterial::from_material(material)?))?;
|
||||
if let Some(look) = &material.appearance {
|
||||
insert(Definition::Appearance(ScalarLook::from_look(look)?))?;
|
||||
insert(Definition::Appearance(LibraryLook::from_look(look)?))?;
|
||||
}
|
||||
}
|
||||
let out = Self { format: FORMAT.into(), id: id.into(), name: name.into(), entries: entries.into_values().collect() };
|
||||
@@ -287,6 +349,18 @@ impl Catalog {
|
||||
}
|
||||
}
|
||||
|
||||
/// Bound retained unique definitions and the final pretty writer before a full
|
||||
/// catalog allocation. Each admitted entry is bounded independently; reserve
|
||||
/// envelope text, four spaces per nested line, separators and a final newline.
|
||||
fn entry_encoded_bound(entry: &Entry) -> Result<usize> {
|
||||
let bytes = serde_json::to_vec_pretty(entry).map_err(|e| invalid(e.to_string()))?;
|
||||
let lines = bytes.iter().filter(|b| **b == b'\n').count().saturating_add(1);
|
||||
Ok(bytes.len().saturating_add(lines.saturating_mul(4)).saturating_add(2))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "catalog_tests.rs"]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
#[path = "catalog_v2_tests.rs"]
|
||||
mod v2_tests;
|
||||
|
||||
@@ -4,7 +4,7 @@ use serde_json::json;
|
||||
fn catalog() -> Catalog {
|
||||
let entry = Entry::new(
|
||||
"user.test",
|
||||
Definition::Appearance(ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }),
|
||||
Definition::Appearance(LibraryLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8, pbr: None }),
|
||||
Provenance::default(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -34,7 +34,7 @@ fn strict_schema_rejects_unknown_kinds_fields_versions_and_bad_identity() {
|
||||
for changed in [
|
||||
{
|
||||
let mut v = value.clone();
|
||||
v["format"] = json!("solidcraft-library/2");
|
||||
v["format"] = json!("solidcraft-library/99");
|
||||
v
|
||||
},
|
||||
{
|
||||
@@ -96,14 +96,17 @@ fn scalar_validation_refuses_nonfinite_density_opacity_counts_strings_and_oversi
|
||||
assert!(
|
||||
Entry::new(
|
||||
"user.test",
|
||||
Definition::Material(ScalarMaterial { name: "Timber".into(), density_g_cm3: density, appearance: None }),
|
||||
Definition::Material(LibraryMaterial { name: "Timber".into(), density_g_cm3: density, appearance: None, physical_card: None }),
|
||||
Provenance::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
for name in ["".into(), "x".repeat(129), "bad\nname".into(), " leading".into()] {
|
||||
assert!(Entry::new("user.test", Definition::Appearance(ScalarLook { name, color: [1, 2, 3], opacity: 1.0 }), Provenance::default()).is_err());
|
||||
assert!(
|
||||
Entry::new("user.test", Definition::Appearance(LibraryLook { name, color: [1, 2, 3], opacity: 1.0, pbr: None }), Provenance::default())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(Catalog::decode(&vec![b' '; MAX_BYTES + 1]).is_err());
|
||||
let source = Provenance { source_url: Some("x".repeat(2049)), ..Default::default() };
|
||||
@@ -112,10 +115,10 @@ fn scalar_validation_refuses_nonfinite_density_opacity_counts_strings_and_oversi
|
||||
#[test]
|
||||
fn design_export_deduplicates_exact_looks_and_keeps_physical_properties_distinct() {
|
||||
let mut doc = Document::default();
|
||||
let look = ScalarLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8 }.to_look();
|
||||
let look = LibraryLook { name: "Warm oak colour".into(), color: [180, 120, 70], opacity: 0.8, pbr: None }.to_look();
|
||||
doc.appearances.bodies.insert("Body1".into(), look.clone());
|
||||
doc.appearances.bodies.insert("Body2".into(), look.clone());
|
||||
doc.user_materials.push(UserMaterial { name: "Timber density".into(), density: 0.7, appearance: Some(look) });
|
||||
doc.user_materials.push(UserMaterial { name: "Timber density".into(), density: 0.7, appearance: Some(look), physical_card: None });
|
||||
let library = Catalog::from_document(&doc, "user.test", "Test", Provenance::default()).unwrap();
|
||||
assert_eq!(library.entries.len(), 2);
|
||||
assert!(library.entries.iter().any(|e| matches!(&e.definition,Definition::Material(m) if m.density_g_cm3==0.7)));
|
||||
@@ -127,7 +130,7 @@ fn design_export_deduplicates_exact_looks_and_keeps_physical_properties_distinct
|
||||
#[test]
|
||||
fn scalar_export_refuses_pbr_in_every_design_owned_appearance_location() {
|
||||
use solidcraft_doc::{appearance::FaceLook, pbr_appearance::PbrParameters};
|
||||
let mut look = ScalarLook { name: "Original PBR specimen".into(), color: [180, 120, 70], opacity: 1.0 }.to_look();
|
||||
let mut look = LibraryLook { name: "Original PBR specimen".into(), color: [180, 120, 70], opacity: 1.0, pbr: None }.to_look();
|
||||
look.pbr = Some(PbrParameters::new(1.0, 0.3, 1.5).unwrap());
|
||||
for location in 0..4 {
|
||||
let mut doc = Document::default();
|
||||
@@ -146,10 +149,16 @@ fn scalar_export_refuses_pbr_in_every_design_owned_appearance_location() {
|
||||
strict_name: false,
|
||||
certificate: None,
|
||||
}),
|
||||
_ => doc.user_materials.push(UserMaterial { name: "Original material".into(), density: 1.2, appearance: Some(look.clone()) }),
|
||||
_ => doc.user_materials.push(UserMaterial {
|
||||
name: "Original material".into(),
|
||||
density: 1.2,
|
||||
appearance: Some(look.clone()),
|
||||
physical_card: None,
|
||||
}),
|
||||
}
|
||||
let before = doc.clone();
|
||||
let error = Catalog::from_document(&doc, "user.test", "Test", Provenance::default()).unwrap_err();
|
||||
let rich = Catalog::from_document(&doc, "user.test", "Test", Provenance::default()).unwrap();
|
||||
let error = rich.in_format(V1_FORMAT).unwrap_err();
|
||||
assert!(error.to_string().contains("cannot preserve PBR"));
|
||||
assert_eq!(doc, before);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
use super::*;
|
||||
use serde_json::{Value, json};
|
||||
use solidcraft_doc::{pbr_appearance::PbrParameters, physical_material::ValidatedCard};
|
||||
|
||||
// Exact bytes and revisions captured from the pre-v2 writer at 75e66eea.
|
||||
const V1: &str = r#"{
|
||||
"format": "solidcraft-library/1",
|
||||
"id": "user.test",
|
||||
"name": "Original v1 fixture",
|
||||
"entries": [
|
||||
{
|
||||
"id": "user.test/appearance-c17adc43c1eb8c7d",
|
||||
"revision": "c17adc43c1eb8c7d6badd7e7821150509bf513eb29c75a90c4027f46d9b49437",
|
||||
"provenance": {
|
||||
"license": "LicenseRef-User-Provided"
|
||||
},
|
||||
"definition": {
|
||||
"kind": "appearance",
|
||||
"value": {
|
||||
"name": "Original scalar study",
|
||||
"color": [
|
||||
180,
|
||||
120,
|
||||
70
|
||||
],
|
||||
"opacity": 0.8
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
fn card() -> ValidatedCard {
|
||||
ValidatedCard::from_value(&json!({"schema_version":1,"designation":"Original synthetic card","family":"other",
|
||||
"sources":[{"id":"fixture","title":"Original invented constants","kind":"illustrative"}],
|
||||
"properties":[{"source_id":"fixture","data":{"kind":"elasticity","model":{
|
||||
"symmetry":"isotropic","young_modulus_pa":12.25e9,"poisson_ratio":0.25}}}]}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn rich() -> Catalog {
|
||||
let mut doc = Document::default();
|
||||
let mut look = Look::custom([180, 120, 70], 1.0);
|
||||
look.name = "Original opaque study".into();
|
||||
look.pbr = Some(PbrParameters::new(1.0, 0.3, 1.5).unwrap());
|
||||
doc.appearances.bodies.insert("Body1".into(), look.clone());
|
||||
doc.user_materials.push(UserMaterial { name: "Original candidate".into(), density: 0.8, appearance: Some(look), physical_card: Some(card()) });
|
||||
Catalog::from_document(&doc, "user.test", "Original v2 fixture", Default::default()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_v1_bytes_revisions_and_scalar_material_canonical_fields_are_preserved() {
|
||||
let old = Catalog::decode(V1.as_bytes()).unwrap();
|
||||
assert_eq!(old.encode().unwrap(), V1.as_bytes());
|
||||
let upgraded = old.clone().in_format(FORMAT).unwrap();
|
||||
assert_eq!(upgraded.entries, old.entries);
|
||||
assert_eq!(upgraded.encode().unwrap(), V1.replacen(V1_FORMAT, FORMAT, 1).as_bytes());
|
||||
let material = LibraryMaterial {
|
||||
name: "Original scalar material".into(),
|
||||
density_g_cm3: 0.7,
|
||||
appearance: Some(LibraryLook { name: "Original scalar material".into(), color: [180, 120, 70], opacity: 1.0, pbr: None }),
|
||||
physical_card: None,
|
||||
};
|
||||
let provenance = Provenance {
|
||||
license: "MIT OR Apache-2.0".into(),
|
||||
author: Some("SolidCraft contributors".into()),
|
||||
source_url: Some("https://example.invalid/original".into()),
|
||||
};
|
||||
let entry = Entry::new("user.test", Definition::Material(material), provenance).unwrap();
|
||||
assert_eq!(entry.revision, "3dc003a875463baef587b85b9883f227cb546178317271c60078989bdb86c245");
|
||||
assert_eq!(entry.id, "user.test/material-3dc003a875463bae");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_roundtrip_keeps_optics_cards_and_revisions_cover_richer_data() {
|
||||
let original = rich();
|
||||
let copy = Catalog::decode(&original.encode().unwrap()).unwrap();
|
||||
assert_eq!(copy, original);
|
||||
assert!(copy.clone().in_format(V1_FORMAT).is_err());
|
||||
let mut changed = copy.clone();
|
||||
for entry in &mut changed.entries {
|
||||
match &mut entry.definition {
|
||||
Definition::Appearance(l) => l.pbr = Some(PbrParameters::new(0., 0.8, 1.4).unwrap()),
|
||||
Definition::Material(m) => {
|
||||
assert_eq!(m.to_material(m.name.clone()).physical_card, Some(card()));
|
||||
let mut v = serde_json::to_value(card()).unwrap();
|
||||
v["designation"] = json!("Changed synthetic designation");
|
||||
m.physical_card = Some(ValidatedCard::from_value(&v).unwrap());
|
||||
}
|
||||
}
|
||||
assert_ne!(entry.content_revision().unwrap(), entry.revision);
|
||||
}
|
||||
assert!(changed.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_versions_unknown_fields_invalid_optics_and_hostile_cards_refuse() {
|
||||
let legacy: Value = serde_json::from_str(V1).unwrap();
|
||||
for value in [Value::Null, json!({"metallic":0,"roughness":0.3,"dielectric_ior":1.5})] {
|
||||
let mut bad = legacy.clone();
|
||||
bad["entries"][0]["definition"]["value"]["pbr"] = value;
|
||||
assert!(Catalog::decode(&serde_json::to_vec(&bad).unwrap()).is_err());
|
||||
}
|
||||
let v = serde_json::to_value(rich()).unwrap();
|
||||
let appearance = v["entries"].as_array().unwrap().iter().position(|e| e["definition"]["kind"] == "appearance").unwrap();
|
||||
let material = v["entries"].as_array().unwrap().iter().position(|e| e["definition"]["kind"] == "material").unwrap();
|
||||
for (path, value) in [("opacity", json!(0.5)), ("pbr", json!({"metallic":0,"roughness":0,"dielectric_ior":1.5})), ("unknown", json!(true))] {
|
||||
let mut bad = v.clone();
|
||||
bad["entries"][appearance]["definition"]["value"][path] = value;
|
||||
assert!(Catalog::decode(&serde_json::to_vec(&bad).unwrap()).is_err());
|
||||
}
|
||||
let mut bad = v.clone();
|
||||
bad["entries"][material]["definition"]["value"]["physical_card"]["sources"] = json!(vec![json!({}); 33]);
|
||||
assert!(Catalog::decode(&serde_json::to_vec(&bad).unwrap()).is_err());
|
||||
let mut downgraded = v.clone();
|
||||
downgraded["format"] = json!(V1_FORMAT);
|
||||
assert!(Catalog::decode(&serde_json::to_vec(&downgraded).unwrap()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rich_export_bounds_retained_definitions_before_whole_catalog_encoding() {
|
||||
let mut v = serde_json::to_value(card()).unwrap();
|
||||
v["properties"] = json!([]);
|
||||
v["sources"] = json!(
|
||||
(0..32)
|
||||
.map(|i| json!({"id":format!("s{i}"),"title":"Original synthetic metadata","kind":"illustrative","reference":"x".repeat(2048)}))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
let record = ValidatedCard::from_value(&v).unwrap();
|
||||
let mut doc = Document::default();
|
||||
for i in 0..64 {
|
||||
doc.user_materials.push(UserMaterial { name: format!("Candidate {i}"), density: 0.8, appearance: None, physical_card: Some(record.clone()) });
|
||||
}
|
||||
assert!(Catalog::from_document(&doc, "user.test", "Original large fixture", Default::default()).unwrap_err().to_string().contains("4 MiB"));
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
//! Original anisotropic GGX reflection with separable Smith masking and Schlick Fresnel.
|
||||
//!
|
||||
//! Mathematics: Heitz, JCGT 7(4), 2018, equations 1 and 2,
|
||||
//! <https://jcgt.org/published/0007/04/01/>. No external implementation adapted.
|
||||
//! The explicit legacy numerical controls use the **NDF**:
|
||||
//! `p(h) = D(h) * n.h`, then `p(l) = p(h) / (4 * v.h)`.
|
||||
//! Rejected reflections are null events, never resampled or renormalized.
|
||||
//! The separate `sample_visible_reflection` API uses a matched GGX VNDF and PDF.
|
||||
//! Its original spherical-cap construction follows Dupuy and Benyoub (2023),
|
||||
//! <https://arxiv.org/abs/2306.05044>; see docs/render-vndf-prototype.md.
|
||||
//!
|
||||
//! This numerical module does not install a renderer, persist a material or alter
|
||||
//! engineering axes. The visual frame's normal may be a shading normal, but the
|
||||
//! original geometric normal is required separately to reject below-surface rays.
|
||||
//! Consumers retain geometric normals for ray spawning and optical boundaries.
|
||||
//! No shading-normal energy correction, multiple scattering or transmission is modeled.
|
||||
|
||||
use std::f64::consts::{PI, TAU};
|
||||
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::surface::{MIN_ROUGHNESS, OpaqueSurface, SurfaceError, unit_direction};
|
||||
|
||||
/// Admission or finite-arithmetic failure in the anisotropic numerical contract.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum AnisotropicError {
|
||||
/// An existing opaque-surface parameter or direction was invalid.
|
||||
Surface(SurfaceError),
|
||||
/// A visual frame was not orthogonal, or opposed its geometric normal.
|
||||
Frame,
|
||||
/// A variate was not finite in the half-open interval [0, 1).
|
||||
Variates,
|
||||
/// A computed density, response or sample weight was not finite/nonnegative.
|
||||
Arithmetic,
|
||||
}
|
||||
|
||||
impl From<SurfaceError> for AnisotropicError {
|
||||
fn from(value: SurfaceError) -> Self {
|
||||
Self::Surface(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for AnisotropicError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Surface(e) => std::fmt::Display::fmt(e, f),
|
||||
Self::Frame => f.write_str("visual frame must be orthogonal and face the geometric normal"),
|
||||
Self::Variates => f.write_str("anisotropic variates must be finite in [0, 1)"),
|
||||
Self::Arithmetic => f.write_str("anisotropic result must be finite and nonnegative"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for AnisotropicError {}
|
||||
|
||||
type Result<T> = std::result::Result<T, AnisotropicError>;
|
||||
|
||||
/// Right-handed orthonormal visual axes; independent of engineering material axes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VisualFrame {
|
||||
normal: Vec3,
|
||||
tangent: Vec3,
|
||||
bitangent: Vec3,
|
||||
}
|
||||
|
||||
impl VisualFrame {
|
||||
/// Normalize finite nonzero orthogonal inputs. An arbitrary oblique tangent is
|
||||
/// refused rather than silently choosing a different brushed direction.
|
||||
/// Roundoff within 1e-10 of perpendicular is removed by orthogonalization.
|
||||
pub fn new(normal: Vec3, tangent: Vec3) -> Result<Self> {
|
||||
let normal = unit_direction(normal)?;
|
||||
let tangent = unit_direction(tangent)?;
|
||||
let dot = normal.dot(tangent);
|
||||
if dot.abs() > 1e-10 {
|
||||
return Err(AnisotropicError::Frame);
|
||||
}
|
||||
let tangent = unit_direction(tangent - normal * dot)?;
|
||||
let bitangent = unit_direction(normal.cross(tangent))?;
|
||||
Ok(Self { normal, tangent, bitangent })
|
||||
}
|
||||
|
||||
/// Shading normal, never a replacement for an original geometric normal.
|
||||
pub fn normal(self) -> Vec3 {
|
||||
self.normal
|
||||
}
|
||||
|
||||
/// Visual X axis, associated with the first roughness.
|
||||
pub fn tangent(self) -> Vec3 {
|
||||
self.tangent
|
||||
}
|
||||
|
||||
/// Visual Y axis, associated with the second roughness.
|
||||
pub fn bitangent(self) -> Vec3 {
|
||||
self.bitangent
|
||||
}
|
||||
|
||||
fn local(self, direction: Vec3) -> Vec3 {
|
||||
Vec3::new(self.tangent.dot(direction), self.bitangent.dot(direction), self.normal.dot(direction))
|
||||
}
|
||||
|
||||
fn world(self, local: Vec3) -> Vec3 {
|
||||
self.tangent * local.x + self.bitangent * local.y + self.normal * local.z
|
||||
}
|
||||
|
||||
fn geometric(self, normal: Vec3) -> Result<Vec3> {
|
||||
let normal = unit_direction(normal)?;
|
||||
if normal.dot(self.normal) <= 0.0 {
|
||||
return Err(AnisotropicError::Frame);
|
||||
}
|
||||
Ok(normal)
|
||||
}
|
||||
}
|
||||
|
||||
/// A bounded opaque response with two perceptual roughness axes in [0.045, 1].
|
||||
/// Alpha is roughness squared, matching [`OpaqueSurface`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct AnisotropicSurface {
|
||||
base: OpaqueSurface,
|
||||
roughness_y: f64,
|
||||
}
|
||||
|
||||
/// One reflected **specular-only** sample. Fields are in world coordinates.
|
||||
/// Its producing method identifies the NDF or visible-normal proposal.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ReflectionSample {
|
||||
/// Outgoing direction, pointing away from the surface.
|
||||
pub direction: Vec3,
|
||||
/// Unconditional density per steradian, without renormalizing null events.
|
||||
pub pdf: f64,
|
||||
/// `specular_brdf * shading_cosine / pdf`, in linear RGB.
|
||||
/// Diffuse response is excluded; its sampling strategy belongs to the consumer.
|
||||
pub weight: Vec3,
|
||||
}
|
||||
|
||||
impl AnisotropicSurface {
|
||||
/// Linear-sRGB reflectance, metal fraction and IOR use the opaque contract.
|
||||
/// Perfect delta mirrors and out-of-range roughness are refused, not clamped.
|
||||
pub fn new(base_color: Vec3, metallic: f64, roughness_x: f64, roughness_y: f64, dielectric_ior: f64) -> Result<Self> {
|
||||
let base = OpaqueSurface::new(base_color, metallic, roughness_x, dielectric_ior)?;
|
||||
if !roughness_y.is_finite() || !(MIN_ROUGHNESS..=1.0).contains(&roughness_y) {
|
||||
return Err(SurfaceError::Roughness.into());
|
||||
}
|
||||
Ok(Self { base, roughness_y })
|
||||
}
|
||||
|
||||
/// Admitted perceptual roughness along visual X and Y.
|
||||
pub fn roughness(self) -> [f64; 2] {
|
||||
[self.base.roughness(), self.roughness_y]
|
||||
}
|
||||
|
||||
fn alpha(self) -> [f64; 2] {
|
||||
[self.base.roughness().powi(2), self.roughness_y.powi(2)]
|
||||
}
|
||||
|
||||
fn fresnel(self, cosine: f64) -> Vec3 {
|
||||
self.base.specular_fresnel(cosine)
|
||||
}
|
||||
|
||||
fn distribution_local(self, h: Vec3) -> f64 {
|
||||
if h.z <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let [ax, ay] = self.alpha();
|
||||
let q = (h.x / ax).powi(2) + (h.y / ay).powi(2) + h.z * h.z;
|
||||
1.0 / (PI * ax * ay * q * q)
|
||||
}
|
||||
|
||||
// Smith G1 / cosine, avoiding division by tiny cosines and tan(theta).
|
||||
fn visibility(self, w: Vec3) -> f64 {
|
||||
let [ax, ay] = self.alpha();
|
||||
2.0 / (w.z + (w.z * w.z + (ax * w.x).powi(2) + (ay * w.y).powi(2)).sqrt())
|
||||
}
|
||||
|
||||
/// GGX normal distribution D, per steradian of microfacet normal.
|
||||
/// This is not a normalized probability density: [`Self::normal_pdf`] is.
|
||||
pub fn distribution(self, frame: VisualFrame, half: Vec3) -> Result<f64> {
|
||||
finite_scalar(self.distribution_local(frame.local(unit_direction(half)?)))
|
||||
}
|
||||
|
||||
/// Projected NDF density `D(h) * n.h` on the upper shading hemisphere.
|
||||
/// Its integral over that hemisphere is one.
|
||||
pub fn normal_pdf(self, frame: VisualFrame, half: Vec3) -> Result<f64> {
|
||||
let h = frame.local(unit_direction(half)?);
|
||||
finite_scalar(self.distribution_local(h) * h.z.max(0.0))
|
||||
}
|
||||
|
||||
/// Sample the projected NDF by stretching a radial GGX slope distribution.
|
||||
/// The inverse radial CDF is `r = sqrt(u/(1-u))`; homogeneous coordinates
|
||||
/// `(alpha_x*sqrt(u)*cos(phi), alpha_y*sqrt(u)*sin(phi), sqrt(1-u))`
|
||||
/// avoid a large intermediate radius. No heap, rejection loop or RNG is used.
|
||||
pub fn sample_normal(self, frame: VisualFrame, variates: [f64; 2]) -> Result<Vec3> {
|
||||
let [u, azimuth] = variates;
|
||||
if !variates.into_iter().all(|v| v.is_finite() && (0.0..1.0).contains(&v)) {
|
||||
return Err(AnisotropicError::Variates);
|
||||
}
|
||||
let [ax, ay] = self.alpha();
|
||||
let phi = TAU * azimuth;
|
||||
let radial = u.sqrt();
|
||||
let h = unit_direction(Vec3::new(ax * radial * phi.cos(), ay * radial * phi.sin(), (1.0 - u).sqrt()))?;
|
||||
Ok(unit_direction(frame.world(h))?)
|
||||
}
|
||||
|
||||
/// One view-conditioned visible-normal attempt. The legacy `sample_normal`
|
||||
/// remains an explicitly projected-NDF utility, not this production proposal.
|
||||
/// An upper-hemisphere view is required; no retries or probability clamps.
|
||||
pub fn sample_visible_normal(self, frame: VisualFrame, view: Vec3, variates: [f64; 2]) -> Result<Option<Vec3>> {
|
||||
if !variates.into_iter().all(|u| u.is_finite() && (0.0..1.0).contains(&u)) {
|
||||
return Err(AnisotropicError::Variates);
|
||||
}
|
||||
let v = frame.local(unit_direction(view)?);
|
||||
if v.z <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let [ax, ay] = self.alpha();
|
||||
let stretched = unit_direction(Vec3::new(ax * v.x, ay * v.y, v.z))?;
|
||||
// The reflected unit-roughness hemisphere is uniform on z in [-v.z,1].
|
||||
// Factor the radial square, avoiding a negative 1-z*z from cancellation.
|
||||
let z = 1.0 - variates[0] * (1.0 + stretched.z);
|
||||
let radius = ((1.0 - z) * (1.0 + z)).sqrt();
|
||||
let phi = TAU * variates[1];
|
||||
// Directly retain (v+c).z; adding cap.z to v.z loses this positive
|
||||
// component near the lower cap boundary. Normalization is homogeneous,
|
||||
// so apply the covector stretch before the single local normalization.
|
||||
let height = (1.0 - variates[0]) * (1.0 + stretched.z);
|
||||
let h = unit_direction(Vec3::new(ax * (stretched.x + radius * phi.cos()), ay * (stretched.y + radius * phi.sin()), height))?;
|
||||
Ok(Some(unit_direction(frame.world(h))?))
|
||||
}
|
||||
|
||||
/// Visible-normal density D(h)*G1(v)*max(v.h,0)/(n.v), per steradian.
|
||||
/// This is distinct from the projected-NDF `normal_pdf` utility.
|
||||
pub fn visible_normal_pdf(self, frame: VisualFrame, view: Vec3, half: Vec3) -> Result<f64> {
|
||||
let v = frame.local(unit_direction(view)?);
|
||||
let h = frame.local(unit_direction(half)?);
|
||||
let vh = v.dot(h);
|
||||
if v.z <= 0.0 || h.z <= 0.0 || vh <= 0.0 {
|
||||
return Ok(0.0);
|
||||
}
|
||||
finite_scalar(self.distribution_local(h) * self.visibility(v) * vh)
|
||||
}
|
||||
|
||||
/// Matched unconditional VNDF reflected-direction density; null attempts are
|
||||
/// not renormalized. Reflection's Jacobian cancels the positive v.h factor.
|
||||
pub fn visible_reflection_pdf(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<f64> {
|
||||
let Some((v, _, h)) = self.directions(frame, geometric, view, light)? else { return Ok(0.0) };
|
||||
finite_scalar(self.distribution_local(h) * self.visibility(v) * 0.25)
|
||||
}
|
||||
|
||||
/// One VNDF specular attempt using the unchanged separable-Smith BSDF.
|
||||
/// Weight f*cos/pdf reduces to F(v.h)*G1(l), without dividing by tiny n.v
|
||||
/// or v.h. Geometric/shading rejection remains a zero event counted in R.
|
||||
pub fn sample_visible_reflection(self, frame: VisualFrame, geometric: Vec3, view: Vec3, variates: [f64; 2]) -> Result<Option<ReflectionSample>> {
|
||||
let geometric_unit = frame.geometric(geometric)?;
|
||||
let view_unit = unit_direction(view)?;
|
||||
let Some(half) = self.sample_visible_normal(frame, view, variates)? else { return Ok(None) };
|
||||
if geometric_unit.dot(view_unit) <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let vh = view_unit.dot(half);
|
||||
if vh <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let light = unit_direction(half * (2.0 * vh) - view_unit)?;
|
||||
let Some((v, l, h)) = self.directions(frame, geometric, view, light)? else { return Ok(None) };
|
||||
let pdf = finite_scalar(self.distribution_local(h) * self.visibility(v) * 0.25)?;
|
||||
if pdf <= 0.0 {
|
||||
return Err(AnisotropicError::Arithmetic);
|
||||
}
|
||||
let weight = finite_rgb(self.fresnel(v.dot(h)) * (l.z * self.visibility(l)))?;
|
||||
Ok(Some(ReflectionSample { direction: light, pdf, weight }))
|
||||
}
|
||||
|
||||
fn directions(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<Option<(Vec3, Vec3, Vec3)>> {
|
||||
let geometric = frame.geometric(geometric)?;
|
||||
let view = unit_direction(view)?;
|
||||
let light = unit_direction(light)?;
|
||||
let (v, l) = (frame.local(view), frame.local(light));
|
||||
if v.z <= 0.0 || l.z <= 0.0 || geometric.dot(view) <= 0.0 || geometric.dot(light) <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let h = unit_direction(view + light)?;
|
||||
Ok(Some((v, l, frame.local(h))))
|
||||
}
|
||||
|
||||
fn specular_local(self, v: Vec3, l: Vec3, h: Vec3) -> Result<Vec3> {
|
||||
finite_rgb(self.fresnel(v.dot(h)) * (self.distribution_local(h) * 0.25 * self.visibility(v) * self.visibility(l)))
|
||||
}
|
||||
|
||||
/// Specular component only, with independent geometric hemisphere rejection.
|
||||
/// All input directions point away from the surface.
|
||||
pub fn specular_brdf(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<Vec3> {
|
||||
let Some((v, l, h)) = self.directions(frame, geometric, view, light)? else { return Ok(Vec3::ZERO) };
|
||||
self.specular_local(v, l, h)
|
||||
}
|
||||
|
||||
/// Full opaque BRDF, including the same symmetric attenuated Lambert term as
|
||||
/// [`OpaqueSurface`]. No radiance, cosine, exposure or display transform included.
|
||||
/// Equal roughness axes delegate to the existing isotropic numerical contract.
|
||||
pub fn brdf(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<Vec3> {
|
||||
let Some((v, l, h)) = self.directions(frame, geometric, view, light)? else { return Ok(Vec3::ZERO) };
|
||||
if self.base.roughness() == self.roughness_y {
|
||||
return Ok(self.base.brdf(frame.normal, view, light)?);
|
||||
}
|
||||
let specular = self.specular_local(v, l, h)?;
|
||||
let fv = self.fresnel(v.z);
|
||||
let fl = self.fresnel(l.z);
|
||||
let diffuse = self.base.base_color() * ((1.0 - self.base.metallic()) / PI);
|
||||
finite_rgb(
|
||||
specular
|
||||
+ Vec3::new(
|
||||
(1.0 - fv.x) * (1.0 - fl.x) * diffuse.x,
|
||||
(1.0 - fv.y) * (1.0 - fl.y) * diffuse.y,
|
||||
(1.0 - fv.z) * (1.0 - fl.z) * diffuse.z,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/// Unconditional reflected-direction density for the specular NDF sampler.
|
||||
/// Integrates to the acceptance probability, not necessarily one: invalid
|
||||
/// microfacets and below-geometric/shading-hemisphere reflections are null events.
|
||||
/// A mixture with a diffuse or environment sampler must use its own mixture PDF.
|
||||
pub fn reflection_pdf(self, frame: VisualFrame, geometric: Vec3, view: Vec3, light: Vec3) -> Result<f64> {
|
||||
let Some((v, _, h)) = self.directions(frame, geometric, view, light)? else { return Ok(0.0) };
|
||||
finite_scalar(self.distribution_local(h) * h.z.max(0.0) / (4.0 * v.dot(h).max(0.0)))
|
||||
}
|
||||
|
||||
/// One specular NDF attempt. `None` is a zero-contribution event and must
|
||||
/// still count towards the consumer's sample denominator. Never retry until
|
||||
/// success. Ray origins/visibility use the consumer's original geometric data.
|
||||
pub fn sample_reflection(self, frame: VisualFrame, geometric: Vec3, view: Vec3, variates: [f64; 2]) -> Result<Option<ReflectionSample>> {
|
||||
let geometric = frame.geometric(geometric)?;
|
||||
let view = unit_direction(view)?;
|
||||
let half = self.sample_normal(frame, variates)?;
|
||||
let v = frame.local(view);
|
||||
if v.z <= 0.0 || geometric.dot(view) <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let vh = view.dot(half);
|
||||
if vh <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let light = unit_direction(half * (2.0 * vh) - view)?;
|
||||
let l = frame.local(light);
|
||||
if l.z <= 0.0 || geometric.dot(light) <= 0.0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let h = frame.local(half);
|
||||
let pdf = self.reflection_pdf(frame, geometric, view, light)?;
|
||||
if pdf <= 0.0 {
|
||||
return Err(AnisotropicError::Arithmetic);
|
||||
}
|
||||
// Cancel D algebraically in f_spec * cos / pdf. No tiny-cosine division.
|
||||
let weight = finite_rgb(self.fresnel(vh) * (self.visibility(v) * (self.visibility(l) * l.z) * vh / h.z))?;
|
||||
Ok(Some(ReflectionSample { direction: light, pdf, weight }))
|
||||
}
|
||||
}
|
||||
|
||||
fn finite_scalar(value: f64) -> Result<f64> {
|
||||
if value.is_finite() && value >= 0.0 { Ok(value) } else { Err(AnisotropicError::Arithmetic) }
|
||||
}
|
||||
|
||||
fn finite_rgb(value: Vec3) -> Result<Vec3> {
|
||||
if value.is_finite() && [value.x, value.y, value.z].into_iter().all(|c| c >= 0.0) { Ok(value) } else { Err(AnisotropicError::Arithmetic) }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "anisotropic_surface_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "anisotropic_vndf_tests.rs"]
|
||||
mod vndf_tests;
|
||||
@@ -0,0 +1,277 @@
|
||||
use super::*;
|
||||
|
||||
fn frame() -> VisualFrame {
|
||||
VisualFrame::new(Vec3::Z, Vec3::X).unwrap()
|
||||
}
|
||||
|
||||
fn surface(rx: f64, ry: f64) -> AnisotropicSurface {
|
||||
AnisotropicSurface::new(Vec3::new(0.8, 0.5, 0.2), 1.0, rx, ry, 1.5).unwrap()
|
||||
}
|
||||
|
||||
fn close(a: f64, b: f64, tolerance: f64) {
|
||||
assert!((a - b).abs() <= tolerance * (1.0 + a.abs().max(b.abs())), "{a} != {b}");
|
||||
}
|
||||
|
||||
fn close_rgb(a: Vec3, b: Vec3, tolerance: f64) {
|
||||
close(a.x, b.x, tolerance);
|
||||
close(a.y, b.y, tolerance);
|
||||
close(a.z, b.z, tolerance);
|
||||
}
|
||||
|
||||
fn hemisphere(mu: f64, phi: f64) -> Vec3 {
|
||||
let radius = (1.0 - mu * mu).sqrt();
|
||||
Vec3::new(radius * phi.cos(), radius * phi.sin(), mu)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constructor_rejects_hostile_parameters_on_both_axes() {
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 0.0, MIN_ROUGHNESS.next_down(), 1.01] {
|
||||
assert!(AnisotropicSurface::new(Vec3::X, 1.0, bad, 0.5, 1.5).is_err());
|
||||
assert!(AnisotropicSurface::new(Vec3::X, 1.0, 0.5, bad, 1.5).is_err());
|
||||
}
|
||||
for bad in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
|
||||
assert!(AnisotropicSurface::new(Vec3::new(bad, 0.0, 0.0), 1.0, 0.5, 0.8, 1.5).is_err());
|
||||
assert!(AnisotropicSurface::new(Vec3::X, bad, 0.5, 0.8, 1.5).is_err());
|
||||
}
|
||||
for bad in [f64::NAN, 0.99, 3.01] {
|
||||
assert!(AnisotropicSurface::new(Vec3::X, 1.0, 0.5, 0.8, bad).is_err());
|
||||
}
|
||||
assert_eq!(surface(MIN_ROUGHNESS, 1.0).roughness(), [MIN_ROUGHNESS, 1.0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_is_right_handed_and_requires_an_explicit_orthogonal_axis() {
|
||||
let f = VisualFrame::new(Vec3::new(0.0, 0.0, 1e308), Vec3::new(1e-300, 0.0, 0.0)).unwrap();
|
||||
assert_eq!(f.normal(), Vec3::Z);
|
||||
assert_eq!(f.tangent(), Vec3::X);
|
||||
assert_eq!(f.bitangent(), Vec3::Y);
|
||||
for (n, t) in [(Vec3::ZERO, Vec3::X), (Vec3::Z, Vec3::Z), (Vec3::Z, Vec3::X + Vec3::Z), (Vec3::Z, Vec3::new(f64::NAN, 0.0, 0.0))] {
|
||||
assert!(VisualFrame::new(n, t).is_err());
|
||||
}
|
||||
let tilted = VisualFrame::new(Vec3::new(0.3, 0.4, 1.0), Vec3::new(0.8, -0.6, 0.0)).unwrap();
|
||||
close(tilted.tangent().dot(tilted.normal()), 0.0, 1e-14);
|
||||
close_rgb(tilted.tangent().cross(tilted.bitangent()), tilted.normal(), 1e-14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_incidence_matches_independent_ggx_peak() {
|
||||
let s = surface(0.3, 0.7);
|
||||
let d = 1.0 / (PI * 0.3_f64.powi(2) * 0.7_f64.powi(2));
|
||||
close(s.distribution(frame(), Vec3::Z).unwrap(), d, 1e-14);
|
||||
close(s.normal_pdf(frame(), Vec3::Z).unwrap(), d, 1e-14);
|
||||
close_rgb(s.brdf(frame(), Vec3::Z, Vec3::Z, Vec3::Z).unwrap(), Vec3::new(0.8, 0.5, 0.2) * (d / 4.0), 1e-14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isotropic_limit_matches_existing_opaque_contract() {
|
||||
for r in [MIN_ROUGHNESS, 0.3, 0.7, 1.0] {
|
||||
for metallic in [0.0, 0.5, 1.0] {
|
||||
let color = Vec3::new(0.8, 0.5, 0.2);
|
||||
let a = AnisotropicSurface::new(color, metallic, r, r, 1.5).unwrap();
|
||||
let opaque = OpaqueSurface::new(color, metallic, r, 1.5).unwrap();
|
||||
for (v, l) in [(Vec3::Z, Vec3::Z), (Vec3::new(0.4, 0.2, 0.8), Vec3::new(-0.2, 0.7, 0.6))] {
|
||||
assert_eq!(a.brdf(frame(), Vec3::Z, v, l).unwrap(), opaque.brdf(Vec3::Z, v, l).unwrap());
|
||||
if metallic == 1.0 {
|
||||
close_rgb(a.specular_brdf(frame(), Vec3::Z, v, l).unwrap(), opaque.brdf(Vec3::Z, v, l).unwrap(), 1e-10);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reciprocal_for_unequal_axes_and_mixed_metal_fraction() {
|
||||
for metallic in [0.0, 0.4, 1.0] {
|
||||
let s = AnisotropicSurface::new(Vec3::new(0.8, 0.5, 0.2), metallic, 0.2, 0.9, 2.0).unwrap();
|
||||
for (v, l) in [(Vec3::new(0.4, 0.1, 1.0), Vec3::new(-0.3, 0.2, 0.8)), (Vec3::new(1.0, 0.0, 1e-300), Vec3::new(-1.0, 0.0, 1e-300))] {
|
||||
close_rgb(s.brdf(frame(), Vec3::Z, v, l).unwrap(), s.brdf(frame(), Vec3::Z, l, v).unwrap(), 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ninety_degree_visual_rotation_swaps_the_brushed_response() {
|
||||
let s = surface(0.25, 0.8);
|
||||
let swapped = surface(0.8, 0.25);
|
||||
let rotated = VisualFrame::new(Vec3::Z, Vec3::Y).unwrap();
|
||||
let light = Vec3::new(0.6, 0.0, 0.8);
|
||||
let initial = s.brdf(frame(), Vec3::Z, Vec3::Z, light).unwrap();
|
||||
let turned = s.brdf(rotated, Vec3::Z, Vec3::Z, light).unwrap();
|
||||
assert!((initial - turned).len() > 0.01);
|
||||
close_rgb(turned, swapped.brdf(frame(), Vec3::Z, Vec3::Z, light).unwrap(), 1e-13);
|
||||
let h = s.sample_normal(frame(), [0.3, 0.2]).unwrap();
|
||||
let rotated_h = s.sample_normal(rotated, [0.3, 0.2]).unwrap();
|
||||
close_rgb(rotated_h, Vec3::new(-h.y, h.x, h.z), 1e-14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn geometric_and_shading_hemispheres_are_independent() {
|
||||
let shading = VisualFrame::new(Vec3::new(0.8, 0.0, 0.6), Vec3::Y).unwrap();
|
||||
let light = Vec3::new(0.99, 0.0, -0.1);
|
||||
let s = surface(0.5, 0.8);
|
||||
assert!(shading.normal().dot(light) > 0.0);
|
||||
assert_eq!(s.brdf(shading, Vec3::Z, Vec3::Z, light).unwrap(), Vec3::ZERO);
|
||||
assert_eq!(s.reflection_pdf(shading, Vec3::Z, Vec3::Z, light).unwrap(), 0.0);
|
||||
assert!(s.brdf(frame(), -Vec3::Z, Vec3::Z, Vec3::Z).is_err());
|
||||
assert_eq!(s.brdf(frame(), Vec3::Z, Vec3::Z, Vec3::X).unwrap(), Vec3::ZERO);
|
||||
assert_eq!(s.brdf(frame(), Vec3::Z, -Vec3::Z, Vec3::Z).unwrap(), Vec3::ZERO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grazing_and_extreme_finite_direction_magnitudes_remain_finite() {
|
||||
for (rx, ry) in [(MIN_ROUGHNESS, 1.0), (1.0, MIN_ROUGHNESS), (MIN_ROUGHNESS, MIN_ROUGHNESS)] {
|
||||
let s = surface(rx, ry);
|
||||
for (v, l) in [(Vec3::new(1.0, 0.0, 1e-300), Vec3::new(-1.0, 0.0, 1e-300)), (Vec3::new(1e308, 1e308, 1e308), Vec3::new(-1e-300, 0.0, 1e-300))]
|
||||
{
|
||||
let b = s.brdf(frame(), Vec3::Z, v, l).unwrap();
|
||||
assert!(b.is_finite() && b.x >= 0.0 && b.y >= 0.0 && b.z >= 0.0);
|
||||
}
|
||||
for u in [0.0, f64::from_bits(1), 0.5, 1.0_f64.next_down()] {
|
||||
let h = s.sample_normal(frame(), [u, 0.5]).unwrap();
|
||||
assert!(h.is_finite() && h.z > 0.0);
|
||||
assert!(s.normal_pdf(frame(), h).unwrap().is_finite());
|
||||
}
|
||||
let view = Vec3::new(1.0, 0.0, 1e-300);
|
||||
let sample = s.sample_reflection(frame(), Vec3::Z, view, [0.0, 0.0]).unwrap().unwrap();
|
||||
assert!(sample.pdf.is_finite() && sample.pdf > 0.0 && sample.weight.is_finite());
|
||||
// A mathematical density beyond f64 is a typed refusal, never clamped.
|
||||
assert_eq!(s.sample_reflection(frame(), Vec3::Z, Vec3::new(1.0, 0.0, f64::from_bits(1)), [0.0, 0.0]), Err(AnisotropicError::Arithmetic));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_directions_and_variates_fail_without_clamping() {
|
||||
let s = surface(0.5, 0.8);
|
||||
for bad in [Vec3::ZERO, Vec3::new(f64::NAN, 0.0, 1.0), Vec3::new(f64::INFINITY, 0.0, 1.0)] {
|
||||
assert!(s.brdf(frame(), Vec3::Z, bad, Vec3::Z).is_err());
|
||||
assert!(s.brdf(frame(), bad, Vec3::Z, Vec3::Z).is_err());
|
||||
assert!(s.brdf(frame(), Vec3::Z, Vec3::Z, bad).is_err());
|
||||
assert!(s.normal_pdf(frame(), bad).is_err());
|
||||
}
|
||||
for bad in [-0.1, 1.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [bad, 0.5]).is_err());
|
||||
assert!(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [0.5, bad]).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sampled_slopes_follow_the_independent_inverse_radial_cdf() {
|
||||
let s = surface(0.3, 0.7);
|
||||
for u in [0.0, 0.01, 0.3, 0.8, 0.9999] {
|
||||
for azimuth in [0.0, 0.2, 0.5, 0.8] {
|
||||
let h = s.sample_normal(frame(), [u, azimuth]).unwrap();
|
||||
let sx = h.x / (0.3_f64.powi(2) * h.z);
|
||||
let sy = h.y / (0.7_f64.powi(2) * h.z);
|
||||
let radius2 = sx * sx + sy * sy;
|
||||
close(radius2 / (1.0 + radius2), u, 1e-13);
|
||||
close_rgb(h, s.sample_normal(frame(), [u, azimuth]).unwrap(), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflection_samples_match_pdf_and_specular_throughput() {
|
||||
let s = surface(0.4, 0.8);
|
||||
let view = unit_direction(Vec3::new(0.2, 0.3, 1.0)).unwrap();
|
||||
let mut accepted = 0;
|
||||
let mut null = 0;
|
||||
for i in 0..32 {
|
||||
for j in 0..32 {
|
||||
let uv = [(i as f64 + 0.5) / 32.0, (j as f64 + 0.5) / 32.0];
|
||||
match s.sample_reflection(frame(), Vec3::Z, view, uv).unwrap() {
|
||||
Some(sample) => {
|
||||
accepted += 1;
|
||||
close(sample.pdf, s.reflection_pdf(frame(), Vec3::Z, view, sample.direction).unwrap(), 1e-13);
|
||||
let expected = s.specular_brdf(frame(), Vec3::Z, view, sample.direction).unwrap() * (sample.direction.z / sample.pdf);
|
||||
close_rgb(sample.weight, expected, 1e-10);
|
||||
}
|
||||
None => null += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(accepted > 0 && null > 0);
|
||||
assert_eq!(accepted + null, 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_roughness_normal_view_has_half_null_mass_and_uniform_reflection_pdf() {
|
||||
let s = surface(1.0, 1.0);
|
||||
assert!(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [0.75, 0.3]).unwrap().is_none());
|
||||
let sample = s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [0.25, 0.3]).unwrap().unwrap();
|
||||
close(sample.pdf, 1.0 / (4.0 * PI), 1e-14);
|
||||
let mut accepted = 0;
|
||||
for i in 0..1024 {
|
||||
accepted += usize::from(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [(i as f64 + 0.5) / 1024.0, 0.3]).unwrap().is_some());
|
||||
}
|
||||
assert_eq!(accepted, 512);
|
||||
// p(l)=1/(4*pi) over the upper hemisphere: valid mass exactly1/2.
|
||||
close(s.reflection_pdf(frame(), Vec3::Z, Vec3::Z, Vec3::Z).unwrap() * (2.0 * PI), 0.5, 1e-14);
|
||||
assert_eq!(s.reflection_pdf(frame(), Vec3::Z, Vec3::Z, -Vec3::Z).unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invisible_microfacets_are_null_and_weights_exclude_the_diffuse_substrate() {
|
||||
let s = AnisotropicSurface::new(Vec3::new(0.8, 0.5, 0.2), 0.0, 1.0, 1.0, 1.5).unwrap();
|
||||
let view = unit_direction(Vec3::new(0.99, 0.0, 0.1)).unwrap();
|
||||
assert!(s.sample_reflection(frame(), Vec3::Z, view, [0.9, 0.5]).unwrap().is_none());
|
||||
let sample = s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [0.1, 0.2]).unwrap().unwrap();
|
||||
let scale = sample.direction.z / sample.pdf;
|
||||
let specular = s.specular_brdf(frame(), Vec3::Z, Vec3::Z, sample.direction).unwrap() * scale;
|
||||
let total = s.brdf(frame(), Vec3::Z, Vec3::Z, sample.direction).unwrap() * scale;
|
||||
close_rgb(sample.weight, specular, 1e-13);
|
||||
assert!(total.x > sample.weight.x + 0.1);
|
||||
assert_eq!(s.normal_pdf(frame(), -Vec3::Z).unwrap(), 0.0);
|
||||
assert_eq!(s.normal_pdf(frame(), Vec3::X).unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quadrature_distinguishes_normal_pdf_mass_from_valid_reflection_mass() {
|
||||
// Finite midpoint grids resolve these broad lobes only. Narrowest admitted
|
||||
// roughness is tested analytically and with inverse-CDF samples elsewhere.
|
||||
const N: usize = 256;
|
||||
for (rx, ry) in [(1.0, 1.0), (0.6, 0.9)] {
|
||||
let s = surface(rx, ry);
|
||||
let mut normal_mass = 0.0;
|
||||
let mut valid_mass = 0.0;
|
||||
let mut accepted = 0;
|
||||
for i in 0..N {
|
||||
for j in 0..N {
|
||||
let u = (i as f64 + 0.5) / N as f64;
|
||||
let azimuth = (j as f64 + 0.5) / N as f64;
|
||||
let l = hemisphere(u, TAU * azimuth);
|
||||
normal_mass += s.normal_pdf(frame(), l).unwrap();
|
||||
valid_mass += s.reflection_pdf(frame(), Vec3::Z, Vec3::Z, l).unwrap();
|
||||
accepted += usize::from(s.sample_reflection(frame(), Vec3::Z, Vec3::Z, [u, azimuth]).unwrap().is_some());
|
||||
}
|
||||
}
|
||||
let domega = TAU / (N * N) as f64;
|
||||
close(normal_mass * domega, 1.0, 0.001);
|
||||
close(valid_mass * domega, accepted as f64 / (N * N) as f64, 0.003);
|
||||
assert!(valid_mass * domega < 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_lobe_importance_estimate_agrees_with_integrated_energy() {
|
||||
// White metal, normal view, deterministic256² grids. A scoped consistency
|
||||
// check, not universal energy certification or a variance/convergence claim.
|
||||
const N: usize = 256;
|
||||
let s = AnisotropicSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, 0.6, 0.9, 1.5).unwrap();
|
||||
let mut integrated = Vec3::ZERO;
|
||||
let mut sampled = Vec3::ZERO;
|
||||
for i in 0..N {
|
||||
for j in 0..N {
|
||||
let uv = [(i as f64 + 0.5) / N as f64, (j as f64 + 0.5) / N as f64];
|
||||
let light = hemisphere(uv[0], TAU * uv[1]);
|
||||
integrated += s.specular_brdf(frame(), Vec3::Z, Vec3::Z, light).unwrap() * light.z;
|
||||
if let Some(sample) = s.sample_reflection(frame(), Vec3::Z, Vec3::Z, uv).unwrap() {
|
||||
sampled += sample.weight;
|
||||
}
|
||||
}
|
||||
}
|
||||
integrated = integrated * (TAU / (N * N) as f64);
|
||||
sampled = sampled / (N * N) as f64; // Includes ALL null attempts.
|
||||
assert!(integrated.x > 0.0 && integrated.x <= 1.001);
|
||||
assert!(sampled.x > 0.0 && sampled.x <= 1.001);
|
||||
close_rgb(integrated, sampled, 0.003);
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
//! VNDF proposal tests, written but not executed. Legacy NDF assertions stay intact.
|
||||
use super::*;
|
||||
|
||||
fn frame() -> VisualFrame {
|
||||
VisualFrame::new(Vec3::Z, Vec3::X).unwrap()
|
||||
}
|
||||
fn white(rx: f64, ry: f64) -> AnisotropicSurface {
|
||||
AnisotropicSurface::new(Vec3::new(1.0, 1.0, 1.0), 1.0, rx, ry, 1.5).unwrap()
|
||||
}
|
||||
fn close(a: f64, b: f64, tolerance: f64) {
|
||||
assert!((a - b).abs() <= tolerance * (1.0 + a.abs().max(b.abs())), "{a} != {b}");
|
||||
}
|
||||
fn rotate(v: Vec3) -> Vec3 {
|
||||
Vec3::new(-v.y, v.x, v.z)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vndf_normal_incidence_has_analytic_mean_second_moment_and_half_null_mass() {
|
||||
let surface = white(1.0, 1.0);
|
||||
let (mut sum, mut second, mut nulls) = (0.0, 0.0, 0);
|
||||
const N: usize = 4096;
|
||||
for i in 0..N {
|
||||
let u = (i as f64 + 0.5) / N as f64;
|
||||
let value = match surface.sample_visible_reflection(frame(), Vec3::Z, Vec3::Z, [u, 0.3]).unwrap() {
|
||||
Some(s) => {
|
||||
close(s.direction.z, 1.0 - 2.0 * u, 1e-13);
|
||||
close(s.pdf, 1.0 / (4.0 * PI), 1e-13);
|
||||
close(s.weight.x, 2.0 * s.direction.z / (1.0 + s.direction.z), 1e-13);
|
||||
s.weight.x
|
||||
}
|
||||
None => {
|
||||
nulls += 1;
|
||||
0.0
|
||||
}
|
||||
};
|
||||
sum += value;
|
||||
second += value * value;
|
||||
}
|
||||
assert_eq!(nulls, N / 2);
|
||||
// Independent integrals of (1-2u)/(1-u) and its square on [0,1/2].
|
||||
close(sum / N as f64, 1.0 - 2.0_f64.ln(), 1e-6);
|
||||
close(second / N as f64, 3.0 - 4.0 * 2.0_f64.ln(), 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_roughness_visible_cap_has_analytic_grazing_pdf_and_acceptance_mass() {
|
||||
const N: usize = 2048;
|
||||
let s = white(1.0, 1.0);
|
||||
for cosine in [1.0_f64, 0.2, 1e-12] {
|
||||
let view = Vec3::new(((1.0 - cosine) * (1.0 + cosine)).sqrt(), 0.0, cosine);
|
||||
let mut accepted = 0;
|
||||
for i in 0..N {
|
||||
let u = (i as f64 + 0.5) / N as f64;
|
||||
if let Some(sample) = s.sample_visible_reflection(frame(), Vec3::Z, view, [u, 0.37]).unwrap() {
|
||||
accepted += 1;
|
||||
close(sample.pdf, 1.0 / (2.0 * PI * (1.0 + cosine)), 1e-10);
|
||||
close(sample.weight.x, 2.0 * sample.direction.z / (1.0 + sample.direction.z), 1e-10);
|
||||
}
|
||||
}
|
||||
close(accepted as f64 / N as f64, 1.0 / (1.0 + cosine), 1.0 / N as f64);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anisotropic_visible_samples_match_unchanged_specular_bsdf_and_pdf() {
|
||||
let s = AnisotropicSurface::new(Vec3::new(0.8, 0.5, 0.2), 0.4, 0.4, 0.8, 1.5).unwrap();
|
||||
let v = unit_direction(Vec3::new(0.2, 0.3, 1.0)).unwrap();
|
||||
let (mut accepted, mut nulls) = (0, 0);
|
||||
for i in 0..32 {
|
||||
for j in 0..32 {
|
||||
let uv = [(i as f64 + 0.5) / 32.0, (j as f64 + 0.5) / 32.0];
|
||||
match s.sample_visible_reflection(frame(), Vec3::Z, v, uv).unwrap() {
|
||||
Some(sample) => {
|
||||
accepted += 1;
|
||||
let pdf = s.visible_reflection_pdf(frame(), Vec3::Z, v, sample.direction).unwrap();
|
||||
close(sample.pdf, pdf, 1e-13);
|
||||
let expected = s.specular_brdf(frame(), Vec3::Z, v, sample.direction).unwrap() * (sample.direction.z / pdf);
|
||||
assert!((sample.weight - expected).len() < 1e-10);
|
||||
assert!([sample.weight.x, sample.weight.y, sample.weight.z].into_iter().all(|w| (0.0..=1.0 + 1e-12).contains(&w)));
|
||||
}
|
||||
None => nulls += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(accepted > 0 && nulls > 0);
|
||||
assert_eq!(accepted + nulls, 1024);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anisotropic_visible_mapping_is_covariant_under_visual_frame_rotation() {
|
||||
let s = white(0.2, 0.8);
|
||||
let v = unit_direction(Vec3::new(0.4, 0.2, 1.0)).unwrap();
|
||||
let turned = VisualFrame::new(Vec3::Z, Vec3::Y).unwrap();
|
||||
for u in [[0.0, 0.0], [0.2, 0.3], [0.8, 0.7], [1.0_f64.next_down(), 0.5]] {
|
||||
let a = s.sample_visible_normal(frame(), v, u).unwrap().unwrap();
|
||||
let b = s.sample_visible_normal(turned, rotate(v), u).unwrap().unwrap();
|
||||
assert!((rotate(a) - b).len() < 1e-12);
|
||||
close(s.visible_normal_pdf(frame(), v, a).unwrap(), s.visible_normal_pdf(turned, rotate(v), b).unwrap(), 1e-11);
|
||||
let a = s.sample_visible_reflection(frame(), Vec3::Z, v, u).unwrap();
|
||||
let b = s.sample_visible_reflection(turned, Vec3::Z, rotate(v), u).unwrap();
|
||||
match (a, b) {
|
||||
(Some(a), Some(b)) => {
|
||||
assert!((rotate(a.direction) - b.direction).len() < 1e-12);
|
||||
assert!((a.weight - b.weight).len() < 1e-12);
|
||||
close(a.pdf, b.pdf, 1e-11);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("rotation changed null support"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_mapping_retains_finite_open_endpoints_and_grazing_limits() {
|
||||
for (rx, ry) in [(MIN_ROUGHNESS, 1.0), (1.0, MIN_ROUGHNESS), (MIN_ROUGHNESS, MIN_ROUGHNESS), (1.0, 1.0)] {
|
||||
let s = white(rx, ry);
|
||||
for view in [Vec3::Z, Vec3::new(1.0, 0.0, 1e-300), Vec3::new(1.0, 0.0, f64::from_bits(1))] {
|
||||
for u in [0.0, f64::from_bits(1), 0.5, 1.0_f64.next_down()] {
|
||||
for phi in [0.0, 0.5, 1.0_f64.next_down()] {
|
||||
let h = s.sample_visible_normal(frame(), view, [u, phi]).unwrap().unwrap();
|
||||
assert!(h.is_finite() && h.z > 0.0);
|
||||
close(h.len(), 1.0, 1e-13);
|
||||
assert!(s.visible_normal_pdf(frame(), view, h).unwrap().is_finite());
|
||||
if let Some(sample) = s.sample_visible_reflection(frame(), Vec3::Z, view, [u, phi]).unwrap() {
|
||||
assert!(sample.pdf.is_finite() && sample.pdf > 0.0 && sample.weight.is_finite());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// This endpoint was an overflowing NDF-density control; VNDF cancels
|
||||
// the tiny view cosine analytically instead of clamping that old PDF.
|
||||
let sample = s.sample_visible_reflection(frame(), Vec3::Z, Vec3::new(1.0, 0.0, f64::from_bits(1)), [0.0, 0.0]).unwrap().unwrap();
|
||||
assert!(sample.pdf.is_finite() && sample.weight.is_finite());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_density_has_correct_support_and_rejects_hostile_variates() {
|
||||
let s = white(0.4, 0.8);
|
||||
for bad in [-0.1, 1.0, f64::NAN, f64::INFINITY] {
|
||||
assert!(s.sample_visible_normal(frame(), Vec3::Z, [bad, 0.2]).is_err());
|
||||
assert!(s.sample_visible_reflection(frame(), Vec3::Z, Vec3::Z, [0.2, bad]).is_err());
|
||||
}
|
||||
assert!(s.sample_visible_normal(frame(), Vec3::ZERO, [0.2, 0.2]).is_err());
|
||||
assert_eq!(s.visible_normal_pdf(frame(), Vec3::Z, -Vec3::Z).unwrap(), 0.0);
|
||||
assert_eq!(s.visible_normal_pdf(frame(), Vec3::Z, Vec3::X).unwrap(), 0.0);
|
||||
assert_eq!(s.visible_normal_pdf(frame(), Vec3::new(1.0, 0.0, 0.01), Vec3::new(-1.0, 0.0, 0.1)).unwrap(), 0.0);
|
||||
assert_eq!(s.visible_reflection_pdf(frame(), Vec3::Z, Vec3::Z, -Vec3::Z).unwrap(), 0.0);
|
||||
assert!(s.sample_visible_reflection(frame(), Vec3::Z, -Vec3::Z, [0.2, 0.2]).unwrap().is_none());
|
||||
let tilted = VisualFrame::new(Vec3::new(0.8, 0.0, 0.6), Vec3::Y).unwrap();
|
||||
assert_eq!(s.visible_reflection_pdf(tilted, Vec3::Z, Vec3::Z, Vec3::new(0.99, 0.0, -0.1)).unwrap(), 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unit_roughness_visible_normal_density_integrates_to_one() {
|
||||
const N: usize = 256;
|
||||
let s = white(1.0, 1.0);
|
||||
for view in [Vec3::Z, unit_direction(Vec3::new(1.0, 0.0, 0.2)).unwrap()] {
|
||||
let mut mass = 0.0;
|
||||
for i in 0..N {
|
||||
let z = (i as f64 + 0.5) / N as f64;
|
||||
let radius = ((1.0 - z) * (1.0 + z)).sqrt();
|
||||
for j in 0..N {
|
||||
let phi = TAU * (j as f64 + 0.5) / N as f64;
|
||||
mass += s.visible_normal_pdf(frame(), view, Vec3::new(radius * phi.cos(), radius * phi.sin(), z)).unwrap();
|
||||
}
|
||||
}
|
||||
close(mass * TAU / (N * N) as f64, 1.0, 0.002);
|
||||
}
|
||||
}
|
||||
|
||||
fn oblique_rotation(v: Vec3) -> Vec3 {
|
||||
// Original Rodrigues construction about an axis with all three components.
|
||||
let axis = unit_direction(Vec3::new(1.0, 2.0, 3.0)).unwrap();
|
||||
let (sine, cosine) = 0.71_f64.sin_cos();
|
||||
v * cosine + axis.cross(v) * sine + axis * (axis.dot(v) * (1.0 - cosine))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visible_samples_and_densities_follow_an_arbitrarily_tilted_visual_frame() {
|
||||
let s = white(0.65, 0.9);
|
||||
let tilted = VisualFrame::new(oblique_rotation(Vec3::Z), oblique_rotation(Vec3::X)).unwrap();
|
||||
let v = unit_direction(Vec3::new(0.3, -0.2, 1.0)).unwrap();
|
||||
let geometric = unit_direction(Vec3::new(0.1, 0.0, 1.0)).unwrap();
|
||||
for uv in [[0.0, 0.0], [0.1, 0.3], [0.5, 0.7], [0.9, 0.2]] {
|
||||
let h = s.sample_visible_normal(frame(), v, uv).unwrap().unwrap();
|
||||
let rotated_h = s.sample_visible_normal(tilted, oblique_rotation(v), uv).unwrap().unwrap();
|
||||
assert!((oblique_rotation(h) - rotated_h).len() < 1e-11);
|
||||
close(s.visible_normal_pdf(frame(), v, h).unwrap(), s.visible_normal_pdf(tilted, oblique_rotation(v), rotated_h).unwrap(), 1e-10);
|
||||
match (
|
||||
s.sample_visible_reflection(frame(), geometric, v, uv).unwrap(),
|
||||
s.sample_visible_reflection(tilted, oblique_rotation(geometric), oblique_rotation(v), uv).unwrap(),
|
||||
) {
|
||||
(Some(a), Some(b)) => {
|
||||
assert!((oblique_rotation(a.direction) - b.direction).len() < 1e-10);
|
||||
assert!((a.weight - b.weight).len() < 1e-10);
|
||||
close(a.pdf, b.pdf, 1e-10);
|
||||
}
|
||||
(None, None) => {}
|
||||
_ => panic!("tilting changed support away from a hemisphere boundary"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilted_grazing_and_cap_opposition_are_finite_with_explicit_null_support() {
|
||||
let tilted = VisualFrame::new(oblique_rotation(Vec3::Z), oblique_rotation(Vec3::X)).unwrap();
|
||||
// A resolved 1e-6/1e-9 elevation, rather than an unrepresentable subnormal
|
||||
// added to O(1) world components. The existing frame-Z subnormal test stays.
|
||||
for elevation in [1e-6, 1e-9] {
|
||||
let local_view = unit_direction(Vec3::new(1.0, 0.0, elevation)).unwrap();
|
||||
let view = oblique_rotation(local_view);
|
||||
for (rx, ry) in [(0.65, 0.9), (0.9, 0.65), (MIN_ROUGHNESS, 1.0)] {
|
||||
let s = white(rx, ry);
|
||||
for radial in [0.0, 0.5, 1.0_f64.next_down()] {
|
||||
// Azimuth 1/2 opposes the stretched view's positive X component;
|
||||
// radial→1 approaches the lower cap boundary (w+c cancellation).
|
||||
for azimuth in [0.5_f64.next_down(), 0.5, 0.5_f64.next_up()] {
|
||||
let uv = [radial, azimuth];
|
||||
let h = s.sample_visible_normal(tilted, view, uv).unwrap().unwrap();
|
||||
assert!(h.is_finite());
|
||||
close(h.len(), 1.0, 1e-13);
|
||||
let pdf_h = s.visible_normal_pdf(tilted, view, h).unwrap();
|
||||
assert!(pdf_h.is_finite() && pdf_h >= 0.0);
|
||||
if let Some(sample) = s.sample_visible_reflection(tilted, tilted.normal(), view, uv).unwrap() {
|
||||
assert!(sample.direction.dot(tilted.normal()) > 0.0);
|
||||
assert!(sample.pdf.is_finite() && sample.pdf > 0.0 && sample.weight.is_finite());
|
||||
close(sample.pdf, s.visible_reflection_pdf(tilted, tilted.normal(), view, sample.direction).unwrap(), 1e-12);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Both sides of the shading horizon have explicit support. This
|
||||
// avoids inferring a sign from an exactly tangent, rounded dot.
|
||||
assert!(s.sample_visible_normal(tilted, oblique_rotation(Vec3::new(1.0, 0.0, -elevation)), [0.2, 0.3]).unwrap().is_none());
|
||||
assert_eq!(s.visible_normal_pdf(tilted, oblique_rotation(Vec3::new(1.0, 0.0, -elevation)), tilted.normal()).unwrap(), 0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn independent_visible_density(rx: f64, ry: f64, v: Vec3, h: Vec3) -> f64 {
|
||||
if v.z <= 0.0 || h.z <= 0.0 || v.dot(h) <= 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let (ax, ay) = (rx * rx, ry * ry);
|
||||
let q = (h.x / ax).powi(2) + (h.y / ay).powi(2) + h.z * h.z;
|
||||
let distribution = 1.0 / (PI * ax * ay * q * q);
|
||||
let masking_over_cosine = 2.0 / (v.z + (v.z * v.z + (ax * v.x).powi(2) + (ay * v.y).powi(2)).sqrt());
|
||||
distribution * masking_over_cosine * v.dot(h)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unequal_axes_normal_view_has_analytic_acceptance_and_unconditional_pdf_mass() {
|
||||
const N: usize = 256;
|
||||
for (rx, ry) in [(0.65_f64, 0.9_f64), (0.9, 0.65)] {
|
||||
let s = white(rx, ry);
|
||||
let (ax, ay) = (rx * rx, ry * ry);
|
||||
// At v=n, tan²(theta_h)=u/(1-u)*(ax² cos²(phi)+ay² sin²(phi)).
|
||||
// l.z>0 iff u<1/(1+ax² cos²(phi)+ay² sin²(phi)); integrating phi
|
||||
// gives 1/sqrt((1+ax²)*(1+ay²)), not a density normalized to success.
|
||||
let acceptance = 1.0 / ((1.0 + ax * ax) * (1.0 + ay * ay)).sqrt();
|
||||
let (mut accepted, mut nulls, mut pdf_mass, mut normal_mass) = (0, 0, 0.0, 0.0);
|
||||
for i in 0..N {
|
||||
let u = (i as f64 + 0.5) / N as f64;
|
||||
let radius = ((1.0 - u) * (1.0 + u)).sqrt();
|
||||
for j in 0..N {
|
||||
let t = (j as f64 + 0.5) / N as f64;
|
||||
let phi = TAU * t;
|
||||
let direction = Vec3::new(radius * phi.cos(), radius * phi.sin(), u);
|
||||
let half = unit_direction(Vec3::Z + direction).unwrap();
|
||||
let expected_pdf = independent_visible_density(rx, ry, Vec3::Z, half) / (4.0 * half.z);
|
||||
close(s.visible_reflection_pdf(frame(), Vec3::Z, Vec3::Z, direction).unwrap(), expected_pdf, 1e-12);
|
||||
pdf_mass += expected_pdf;
|
||||
normal_mass += independent_visible_density(rx, ry, Vec3::Z, direction);
|
||||
if s.sample_visible_reflection(frame(), Vec3::Z, Vec3::Z, [u, t]).unwrap().is_some() {
|
||||
accepted += 1;
|
||||
} else {
|
||||
nulls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(accepted + nulls, N * N);
|
||||
assert!(nulls > 0);
|
||||
// Midpoint radial acceptance has <=1/N counting error per azimuth;
|
||||
// smooth-azimuth and solid-angle quadrature remain separate checks.
|
||||
close(accepted as f64 / (N * N) as f64, acceptance, 1.0 / N as f64);
|
||||
close(pdf_mass * TAU / (N * N) as f64, acceptance, 0.002);
|
||||
close(normal_mass * TAU / (N * N) as f64, 1.0, 0.002);
|
||||
assert!(pdf_mass * TAU / ((N * N) as f64) < 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unequal_axes_grazing_pdf_mass_matches_attempts_and_geometric_rejection() {
|
||||
const N: usize = 256;
|
||||
let s = white(0.65, 0.9);
|
||||
let view = unit_direction(Vec3::new(1.0, 0.2, 0.1)).unwrap();
|
||||
let geometric = unit_direction(Vec3::new(-0.05, 0.0, 1.0)).unwrap();
|
||||
let (mut accepted, mut nulls, mut mass, mut normal_mass) = (0, 0, 0.0, 0.0);
|
||||
for i in 0..N {
|
||||
let u = (i as f64 + 0.5) / N as f64;
|
||||
let radius = ((1.0 - u) * (1.0 + u)).sqrt();
|
||||
for j in 0..N {
|
||||
let t = (j as f64 + 0.5) / N as f64;
|
||||
let phi = TAU * t;
|
||||
let light = Vec3::new(radius * phi.cos(), radius * phi.sin(), u);
|
||||
let half = unit_direction(view + light).unwrap();
|
||||
let expected = if geometric.dot(light) > 0.0 { independent_visible_density(0.65, 0.9, view, half) / (4.0 * view.dot(half)) } else { 0.0 };
|
||||
close(s.visible_reflection_pdf(frame(), geometric, view, light).unwrap(), expected, 1e-11);
|
||||
mass += expected;
|
||||
normal_mass += independent_visible_density(0.65, 0.9, view, light);
|
||||
if s.sample_visible_reflection(frame(), geometric, view, [u, t]).unwrap().is_some() {
|
||||
accepted += 1;
|
||||
} else {
|
||||
nulls += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(accepted + nulls, N * N);
|
||||
assert!(accepted > 0 && nulls > 0);
|
||||
let integrated = mass * TAU / (N * N) as f64;
|
||||
// A discontinuous geometric-hemisphere cut and two independently parameterized
|
||||
// midpoint grids have a finite 0.01 agreement gate; no universal error bound.
|
||||
close(integrated, accepted as f64 / (N * N) as f64, 0.01);
|
||||
close(normal_mass * TAU / (N * N) as f64, 1.0, 0.002);
|
||||
assert!(integrated > 0.0 && integrated < 1.0);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! Original single-scattering GGX dielectric coat over the opaque surface oracle.
|
||||
//! The substrate is attenuated at entry and exit; internal inter-layer scattering
|
||||
//! and refraction of substrate directions are omitted. This is a bounded preview
|
||||
//! model, not a full layered random walk. See docs/render-clear-coat.md.
|
||||
use crate::surface::{MIN_ROUGHNESS, OpaqueSurface, SurfaceError, unit_direction};
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::f64::consts::PI;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ClearCoat {
|
||||
weight: f64,
|
||||
roughness: f64,
|
||||
ior: f64,
|
||||
}
|
||||
|
||||
impl ClearCoat {
|
||||
pub fn new(weight: f64, roughness: f64, ior: f64) -> Result<Self, SurfaceError> {
|
||||
if !weight.is_finite() || !(0.0..=1.0).contains(&weight) {
|
||||
return Err(SurfaceError::CoatWeight);
|
||||
}
|
||||
if !roughness.is_finite() || !(MIN_ROUGHNESS..=1.0).contains(&roughness) {
|
||||
return Err(SurfaceError::Roughness);
|
||||
}
|
||||
if !ior.is_finite() || !(1.0..=3.0).contains(&ior) {
|
||||
return Err(SurfaceError::DielectricIor);
|
||||
}
|
||||
Ok(Self { weight, roughness, ior })
|
||||
}
|
||||
pub fn weight(self) -> f64 {
|
||||
self.weight
|
||||
}
|
||||
pub fn roughness(self) -> f64 {
|
||||
self.roughness
|
||||
}
|
||||
pub fn ior(self) -> f64 {
|
||||
self.ior
|
||||
}
|
||||
fn fresnel(self, c: f64) -> f64 {
|
||||
let ratio = (self.ior - 1.0) / (self.ior + 1.0);
|
||||
let f0 = ratio * ratio;
|
||||
f0 + (1.0 - f0) * (1.0 - c).powi(5)
|
||||
}
|
||||
/// Upper bound on coat directional reflectance: over the upper hemisphere,
|
||||
/// v.h >= nv/sqrt(2*(1+sqrt(1-nv^2))). Schlick decreases with v.h and
|
||||
/// the unit-Fresnel single-scattering Smith GGX lobe reflects at most one.
|
||||
fn energy_bound(self, cosine: f64) -> f64 {
|
||||
self.fresnel(cosine / (2.0 * (1.0 + (1.0 - cosine * cosine).max(0.0).sqrt())).sqrt())
|
||||
}
|
||||
pub(crate) fn layer(self, base: Vec3, nv: f64, nl: f64, nh: f64, vh: f64) -> Vec3 {
|
||||
let a2 = self.roughness.powi(4);
|
||||
let denom = (1.0 - nh * nh) + nh * nh * a2;
|
||||
let d = a2 / (PI * denom * denom);
|
||||
let visibility = |c: f64| 2.0 / (c + (a2 + (1.0 - a2) * c * c).sqrt());
|
||||
let coat = self.weight * self.fresnel(vh) * d * 0.25 * visibility(nv) * visibility(nl);
|
||||
let attenuation = (1.0 - self.weight * self.energy_bound(nv)) * (1.0 - self.weight * self.energy_bound(nl));
|
||||
base * attenuation + Vec3::new(coat, coat, coat)
|
||||
}
|
||||
}
|
||||
|
||||
/// Conditional opaque-lobe PDF, including the coat and base mixture. Rejected
|
||||
/// below-surface microfacet samples retain their mass; never renormalize them.
|
||||
pub fn surface_pdf(surface: OpaqueSurface, normal: Vec3, view: Vec3, light: Vec3) -> Result<f64, SurfaceError> {
|
||||
let n = unit_direction(normal)?;
|
||||
let v = unit_direction(view)?;
|
||||
let l = unit_direction(light)?;
|
||||
let nl = n.dot(l);
|
||||
if nl <= 0.0 || n.dot(v) <= 0.0 {
|
||||
return Ok(0.0);
|
||||
}
|
||||
let h = unit_direction(v + l)?;
|
||||
let nh = n.dot(h).clamp(0.0, 1.0);
|
||||
let vh = v.dot(h).max(1e-20);
|
||||
let ggx = |roughness: f64| {
|
||||
let a2 = roughness.powi(4);
|
||||
let d = (1.0 - nh * nh) + nh * nh * a2;
|
||||
a2 / (PI * d * d) * nh / (4.0 * vh)
|
||||
};
|
||||
let base = 0.5 * nl / PI + 0.5 * ggx(surface.roughness());
|
||||
Ok(surface.clear_coat().map_or(base, |coat| {
|
||||
let p = coat.weight() * 0.5;
|
||||
(1.0 - p) * base + p * ggx(coat.roughness())
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn admission_zero_coat_and_reciprocity() {
|
||||
for bad in [f64::NAN, f64::INFINITY, -0.1, 1.1] {
|
||||
assert!(ClearCoat::new(bad, 0.2, 1.5).is_err());
|
||||
}
|
||||
for bad in [0.0, 0.044, 1.1, f64::NAN] {
|
||||
assert!(ClearCoat::new(1.0, bad, 1.5).is_err());
|
||||
}
|
||||
for bad in [0.99, 3.01, f64::NAN] {
|
||||
assert!(ClearCoat::new(1.0, 0.2, bad).is_err());
|
||||
}
|
||||
let base = OpaqueSurface::new(Vec3::new(0.01, 0.02, 0.03), 0.0, 0.3, 1.5).unwrap();
|
||||
assert_eq!(base, base.with_clear_coat(ClearCoat::new(0.0, 0.1, 1.5).unwrap()));
|
||||
let coated = base.with_clear_coat(ClearCoat::new(1.0, 0.12, 1.5).unwrap());
|
||||
let v = Vec3::new(0.5, 0.0, 0.8);
|
||||
let l = Vec3::new(-0.3, 0.2, 0.6);
|
||||
assert!((coated.brdf(Vec3::Z, v, l).unwrap() - coated.brdf(Vec3::Z, l, v).unwrap()).len() < 1e-12);
|
||||
assert!(coated.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap().x > base.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap().x);
|
||||
// Normal incidence: D=1/(pi*roughness^4), G=1, Fresnel=.04.
|
||||
let expected = base.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap() * (1.0 - ClearCoat::new(1.0, 0.12, 1.5).unwrap().energy_bound(1.0)).powi(2)
|
||||
+ Vec3::new(1.0, 1.0, 1.0) * (0.04 / (4.0 * PI * 0.12_f64.powi(4)));
|
||||
assert!((coated.brdf(Vec3::Z, Vec3::Z, Vec3::Z).unwrap() - expected).len() < 1e-10);
|
||||
}
|
||||
#[test]
|
||||
fn layered_white_furnace_and_pdf_quadrature() {
|
||||
// Deterministic quadrature resolves these lobes; it is not a proof for
|
||||
// arbitrary roughness. Narrow coat lobes have separate GPU oracle checks.
|
||||
for roughness in [0.25, 0.5, 1.0] {
|
||||
for metallic in [0.0, 1.0] {
|
||||
for cosine in [0.2_f64, 1.0] {
|
||||
let s = OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), metallic, 0.5, 1.5)
|
||||
.unwrap()
|
||||
.with_clear_coat(ClearCoat::new(1.0, roughness, 1.5).unwrap());
|
||||
let v = Vec3::new((1.0 - cosine * cosine).sqrt(), 0.0, cosine);
|
||||
let mut energy = 0.0;
|
||||
let mut probability = 0.0;
|
||||
for i in 0..256 {
|
||||
let mu = (f64::from(i) + 0.5) / 256.0;
|
||||
let r = (1.0 - mu * mu).sqrt();
|
||||
for j in 0..256 {
|
||||
let phi = 2.0 * PI * (f64::from(j) + 0.5) / 256.0;
|
||||
let l = Vec3::new(r * phi.cos(), r * phi.sin(), mu);
|
||||
energy += s.brdf(Vec3::Z, v, l).unwrap().x * mu;
|
||||
probability += surface_pdf(s, Vec3::Z, v, l).unwrap();
|
||||
}
|
||||
}
|
||||
energy *= 2.0 * PI / 65536.0;
|
||||
probability *= 2.0 * PI / 65536.0;
|
||||
assert!(energy <= 1.025, "roughness={roughness} metal={metallic} cosine={cosine} energy={energy}");
|
||||
assert!((0.2..=1.005).contains(&probability), "{probability}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conservative_bound_and_layer_furnace_include_shiny_bases_and_grazing() {
|
||||
const COUNT: u32 = 4096;
|
||||
let mut maximum = 0.0_f64;
|
||||
for base_roughness in [0.045, 0.12, 0.5, 1.0] {
|
||||
for coat_roughness in [0.12, 0.5, 1.0] {
|
||||
for weight in [0.25, 1.0] {
|
||||
for ior in [1.0, 1.5, 3.0] {
|
||||
for cosine in [0.02_f64, 0.2, 1.0] {
|
||||
for metallic in [0.0, 1.0] {
|
||||
let coat = ClearCoat::new(weight, coat_roughness, ior).unwrap();
|
||||
let surface =
|
||||
OpaqueSurface::new(Vec3::new(1.0, 1.0, 1.0), metallic, base_roughness, 1.5).unwrap().with_clear_coat(coat);
|
||||
let v = Vec3::new((1.0 - cosine * cosine).sqrt(), 0.0, cosine);
|
||||
let p = weight * 0.5;
|
||||
let mut energy = 0.0;
|
||||
// Integrate each proposal with its mixture probability.
|
||||
// NDF importance quadrature resolves sharp lobes, unlike
|
||||
// a uniform hemisphere grid at roughness 0.045.
|
||||
for (component, probability) in [(0, (1.0 - p) * 0.5), (1, (1.0 - p) * 0.5), (2, p)] {
|
||||
for i in 0..COUNT {
|
||||
let x = (f64::from(i) + 0.5) / f64::from(COUNT);
|
||||
let phi = 2.0 * PI * f64::from(i.reverse_bits()) / 4294967296.0;
|
||||
let l = if component == 0 {
|
||||
Vec3::new(x.sqrt() * phi.cos(), x.sqrt() * phi.sin(), (1.0 - x).sqrt())
|
||||
} else {
|
||||
let r = if component == 1 { base_roughness } else { coat_roughness };
|
||||
let c = ((1.0 - x) / (1.0 + (r.powi(4) - 1.0) * x)).sqrt();
|
||||
let radius = (1.0 - c * c).sqrt();
|
||||
let h = Vec3::new(radius * phi.cos(), radius * phi.sin(), c);
|
||||
h * (2.0 * v.dot(h)) - v
|
||||
};
|
||||
if l.z <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let h = unit_direction(v + l).unwrap();
|
||||
assert!(coat.fresnel(v.dot(h).clamp(0.0, 1.0)) <= coat.energy_bound(cosine) + 1e-12);
|
||||
let pdf = surface_pdf(surface, Vec3::Z, v, l).unwrap();
|
||||
energy += probability * surface.brdf(Vec3::Z, v, l).unwrap().x * l.z / pdf / f64::from(COUNT);
|
||||
}
|
||||
}
|
||||
maximum = maximum.max(energy);
|
||||
assert!(
|
||||
energy.is_finite() && energy <= 1.003,
|
||||
"base={base_roughness}, coat={coat_roughness}, w={weight}, ior={ior}, nv={cosine}, m={metallic}: {energy}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
eprintln!("maximum conservative layered furnace reflectance: {maximum}");
|
||||
for cosine in [0.0, 1e-300, 1e-20, 0.02, 1.0] {
|
||||
let bound = ClearCoat::new(1.0, 0.12, 1.5).unwrap().energy_bound(cosine);
|
||||
assert!(bound.is_finite() && (0.0..=1.0).contains(&bound));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Original MIT OR Apache-2.0 analytic fixtures for the shared CPU/GPU dielectric contract.
|
||||
//! Provenance: elementary Fresnel s/p equations, Snell law and Beer-Lambert attenuation.
|
||||
//! No external assets or renderer outputs. Tolerances are absolute f64 errors stated per assertion.
|
||||
//! Import this test module with the optical math module; only the API adapter needs changing.
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn oblique_polarizations_and_snell_match_independent_angle_equations() {
|
||||
let brewster = 1.5_f64.atan();
|
||||
for angle in [0.3, brewster - 1e-4, brewster, brewster + 1e-4, 1.2] {
|
||||
let transmitted_angle = (angle.sin() / 1.5).asin();
|
||||
let expected_s = ((angle - transmitted_angle).sin() / (angle + transmitted_angle).sin()).powi(2);
|
||||
let expected_p = ((angle - transmitted_angle).tan() / (angle + transmitted_angle).tan()).powi(2);
|
||||
let actual = fresnel(angle.cos(), 1.0, 1.5).unwrap();
|
||||
assert!((actual.s - expected_s).abs() < 1e-14);
|
||||
assert!((actual.p - expected_p).abs() < 1e-14);
|
||||
let incident = Vec3::new(angle.sin(), 0.0, -angle.cos());
|
||||
let outgoing = refract(incident, Vec3::Z, 1.0, 1.5).unwrap().unwrap();
|
||||
assert!((outgoing.x - transmitted_angle.sin()).abs() < 1e-14);
|
||||
assert!((outgoing.z + transmitted_angle.cos()).abs() < 1e-14);
|
||||
let reversed = refract(-outgoing, -Vec3::Z, 1.5, 1.0).unwrap().unwrap();
|
||||
assert!((reversed + incident).len() < 1e-13);
|
||||
}
|
||||
let critical = (1.0_f64 / 1.5).asin();
|
||||
assert!(fresnel((critical - 1e-5).cos(), 1.5, 1.0).unwrap().transmitted_cosine.is_some());
|
||||
assert!(fresnel((critical + 1e-5).cos(), 1.5, 1.0).unwrap().transmitted_cosine.is_none());
|
||||
assert!(refract(Vec3::new(0.9, 0.0, -(1.0_f64 - 0.81).sqrt()), Vec3::Z, 1.5, 1.0).unwrap().is_none());
|
||||
let zero_sheet = Transmission::new(Vec3::new(100.0, 100.0, 100.0), Some(0.0)).unwrap();
|
||||
let (r, t) = slab(zero_sheet, 0.0, 1.0, 1.0, 1.5).unwrap();
|
||||
assert!((r.x + t.x - 1.0).abs() < 1e-14);
|
||||
assert!((t.x - 0.96_f64.powi(2) / (1.0 - 0.04_f64.powi(2))).abs() < 1e-14);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_fresnel_brewster_critical_and_index_matched_limits() {
|
||||
for n in [1.0_f64, 1.33, 1.5, 3.0] {
|
||||
assert!((fresnel(1.0, 1.0, n).unwrap().reflectance() - ((n - 1.0) / (n + 1.0)).powi(2)).abs() < 1e-15);
|
||||
assert!(fresnel(n.atan().cos(), 1.0, n).unwrap().p < 1e-30);
|
||||
}
|
||||
assert_eq!(fresnel(0.5, 1.5, 1.0).unwrap().transmitted_cosine, None);
|
||||
assert_eq!(fresnel(0.0, 1.0, 1.0).unwrap().reflectance(), 0.0);
|
||||
assert_eq!(fresnel(0.0, 1.0, 1.5).unwrap().reflectance(), 1.0);
|
||||
for cosine in [0.01, 0.3, 0.9, 1.0] {
|
||||
let f = fresnel(cosine, 1.0, 1.5).unwrap();
|
||||
let reverse = fresnel(f.transmitted_cosine.unwrap(), 1.5, 1.0).unwrap();
|
||||
assert!((f.reflectance() - reverse.reflectance()).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn beer_lambert_slab_and_lossless_energy() {
|
||||
let t = Transmission::new(Vec3::new(-0.5_f64.ln(), -0.8_f64.ln(), 0.0) / 10.0, None).unwrap();
|
||||
assert!((t.attenuation(20.0).unwrap() - Vec3::new(0.25, 0.64, 1.0)).len() < 1e-15);
|
||||
let (r, through) = slab(t, 10.0, 1.0, 1.0, 1.5).unwrap();
|
||||
assert!((through.x - 0.96_f64.powi(2) * 0.5 / (1.0 - 0.04_f64.powi(2) * 0.25)).abs() < 1e-15);
|
||||
assert!((r.z + through.z - 1.0).abs() < 1e-15);
|
||||
assert!(r.x + through.x < 1.0);
|
||||
let clear = Transmission::new(Vec3::ZERO, None).unwrap();
|
||||
for cosine in [0.001, 0.1, 0.5, 1.0] {
|
||||
let (r, t) = slab(clear, 1.0, cosine, 1.0, 1.5).unwrap();
|
||||
assert!((r.x + t.x - 1.0).abs() < 1e-13);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_optics_are_rejected_without_clamping() {
|
||||
for v in [f64::NAN, f64::INFINITY, -1.0, 100.01] {
|
||||
assert!(Transmission::new(Vec3::new(v, 0.0, 0.0), None).is_err());
|
||||
}
|
||||
for v in [f64::NAN, f64::INFINITY, -1.0, 10000.01] {
|
||||
assert!(Transmission::new(Vec3::ZERO, Some(v)).is_err());
|
||||
}
|
||||
assert!(Transmission::new(Vec3::ZERO, Some(0.0)).is_ok());
|
||||
assert!(fresnel(f64::NAN, 1.0, 1.5).is_err());
|
||||
assert!(fresnel(0.5, f64::INFINITY, 1.5).is_err());
|
||||
assert!(refract(Vec3::ZERO, Vec3::Z, 1.0, 1.5).is_err());
|
||||
assert!(refract(Vec3::Z, Vec3::Z, 1.0, 1.5).is_err());
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! Bounded linear RGB statistics, independent of rendering and display transforms.
|
||||
//! Samples use the renderer's linear-sRGB primaries; source HDR conversion is a
|
||||
//! caller prerequisite. Display-sRGB primary backgrounds need a separate coverage
|
||||
//! policy before camera samples can be mixed; this module does not invent one.
|
||||
//!
|
||||
//! The caller reserves `required_bytes` in its EXISTING job/host lease before
|
||||
//! construction, and supplies a charge callback debiting that job's cumulative
|
||||
//! work budget. This module creates no pool, thread, cache or cancellation token.
|
||||
//! Fallible allocation checks actual capacity against that same admission.
|
||||
//!
|
||||
//! Samples for each pixel must arrive in the same order for bitwise reproducible
|
||||
//! results. Splitting contiguous updates into smaller calls preserves that order;
|
||||
//! arbitrary floating-point reductions or reordered samples are not equivalent.
|
||||
//!
|
||||
//! `complete_pass` borrows the film immutably and refuses uneven/empty counts.
|
||||
//! Its iterator exposes linear means only; the consumer applies exposure, tone
|
||||
//! mapping and encoding ONCE, in bounded cancellable chunks. Publishing requires
|
||||
//! a fully resolved owned snapshot with its own host lease, generation check and
|
||||
//! cancellation/publication lock. Neither a partial update nor this borrowed view
|
||||
//! is a publishable frame. Progressive host/UI wiring is a separate integration.
|
||||
|
||||
use crate::job::{CancelToken, ErrorCode, JobError, Result};
|
||||
|
||||
pub const MAX_DIMENSION: u32 = 16_384;
|
||||
pub const MAX_PIXELS: usize = 16_777_216;
|
||||
pub const MAX_SAMPLES: u32 = 1_048_576;
|
||||
pub const MAX_UPDATE: usize = 256;
|
||||
pub const MAX_BYTES: u64 = 384 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
struct Pixel {
|
||||
mean: [f64; 3],
|
||||
m2: [f64; 3],
|
||||
count: u32,
|
||||
}
|
||||
|
||||
/// Unbiased sample variance, zero by convention before two samples.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct PixelStatistics {
|
||||
pub mean: [f64; 3],
|
||||
pub variance: [f64; 3],
|
||||
pub count: u32,
|
||||
}
|
||||
|
||||
/// Storage stays private so callers cannot bypass finite/count invariants.
|
||||
pub struct FilmAccumulator {
|
||||
width: u32,
|
||||
height: u32,
|
||||
sample_limit: u32,
|
||||
pixels: Vec<Pixel>,
|
||||
}
|
||||
|
||||
/// An immutable uniform pass; not an owned or publishable preview image.
|
||||
pub struct CompletePass<'a> {
|
||||
film: &'a FilmAccumulator,
|
||||
samples_per_pixel: u32,
|
||||
}
|
||||
|
||||
fn invalid(message: &'static str) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidRequest, message)
|
||||
}
|
||||
fn limit(message: &'static str) -> JobError {
|
||||
JobError::new(ErrorCode::LimitExceeded, message)
|
||||
}
|
||||
|
||||
fn dimensions(width: u32, height: u32, sample_limit: u32) -> Result<usize> {
|
||||
if width == 0 || height == 0 || width > MAX_DIMENSION || height > MAX_DIMENSION || sample_limit == 0 || sample_limit > MAX_SAMPLES {
|
||||
return Err(invalid("invalid film dimensions or sample limit"));
|
||||
}
|
||||
let pixels = u64::from(width).checked_mul(u64::from(height)).ok_or_else(|| limit("film dimensions overflow"))?;
|
||||
usize::try_from(pixels).ok().filter(|n| *n <= MAX_PIXELS).ok_or_else(|| limit("film pixel limit exceeded"))
|
||||
}
|
||||
|
||||
fn bytes_for_capacity(capacity: usize) -> Result<u64> {
|
||||
let bytes = u64::try_from(capacity)
|
||||
.ok()
|
||||
.and_then(|n| n.checked_mul(std::mem::size_of::<Pixel>() as u64))
|
||||
.and_then(|n| n.checked_add(std::mem::size_of::<FilmAccumulator>() as u64))
|
||||
.and_then(|n| n.checked_add(std::mem::size_of::<[Pixel; MAX_UPDATE]>() as u64))
|
||||
.filter(|n| *n <= MAX_BYTES)
|
||||
.ok_or_else(|| limit("film storage limit exceeded"))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
impl Pixel {
|
||||
fn updated(self, rgb: [f64; 3], sample_limit: u32) -> Result<Self> {
|
||||
if rgb.iter().any(|c| !c.is_finite() || *c < 0.0) {
|
||||
return Err(invalid("film samples must be finite nonnegative linear RGB"));
|
||||
}
|
||||
let count = self.count.checked_add(1).filter(|n| *n <= sample_limit).ok_or_else(|| limit("film sample limit exceeded"))?;
|
||||
let mut result = self;
|
||||
for ((mean, m2), value) in result.mean.iter_mut().zip(result.m2.iter_mut()).zip(rgb) {
|
||||
let delta = value - *mean;
|
||||
*mean += delta / f64::from(count);
|
||||
*m2 += delta * (value - *mean);
|
||||
if !mean.is_finite() || !m2.is_finite() || *mean < 0.0 || *m2 < 0.0 {
|
||||
return Err(invalid("film statistics exceed finite numeric range"));
|
||||
}
|
||||
}
|
||||
result.count = count;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn statistics(self) -> PixelStatistics {
|
||||
let denominator = f64::from(self.count.saturating_sub(1).max(1));
|
||||
PixelStatistics { mean: self.mean, variance: self.m2.map(|m2| m2 / denominator), count: self.count }
|
||||
}
|
||||
}
|
||||
|
||||
impl FilmAccumulator {
|
||||
/// Required host-admitted storage, including the film object and the bounded
|
||||
/// atomic-update stack scratch used while this film is alive. Allocator
|
||||
/// bookkeeping is outside this byte contract; actual Vec capacity is checked.
|
||||
pub fn required_bytes(width: u32, height: u32, sample_limit: u32) -> Result<u64> {
|
||||
bytes_for_capacity(dimensions(width, height, sample_limit)?)
|
||||
}
|
||||
|
||||
/// `admitted_bytes` is an existing host lease amount, not a second pool.
|
||||
/// The caller must preserve that lease for this object's entire lifetime.
|
||||
pub fn new(
|
||||
width: u32,
|
||||
height: u32,
|
||||
sample_limit: u32,
|
||||
admitted_bytes: u64,
|
||||
cancel: &CancelToken,
|
||||
charge: &mut impl FnMut(u64) -> Result<()>,
|
||||
) -> Result<Self> {
|
||||
cancel.check()?;
|
||||
let count = dimensions(width, height, sample_limit)?;
|
||||
let required = bytes_for_capacity(count)?;
|
||||
if admitted_bytes < required || admitted_bytes > MAX_BYTES {
|
||||
return Err(limit("film is not covered by caller admission"));
|
||||
}
|
||||
let mut pixels = Vec::new();
|
||||
pixels.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "could not reserve film storage"))?;
|
||||
if bytes_for_capacity(pixels.capacity())? > admitted_bytes {
|
||||
return Err(limit("actual film capacity exceeds caller admission"));
|
||||
}
|
||||
for _ in 0..count {
|
||||
cancel.check()?;
|
||||
charge(1)?;
|
||||
pixels.push(Pixel::default());
|
||||
}
|
||||
cancel.check()?;
|
||||
Ok(Self { width, height, sample_limit, pixels })
|
||||
}
|
||||
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
(self.width, self.height)
|
||||
}
|
||||
|
||||
/// Actual retained capacity plus object and maximum update scratch allowance.
|
||||
pub fn allocated_bytes(&self) -> Result<u64> {
|
||||
bytes_for_capacity(self.pixels.capacity())
|
||||
}
|
||||
|
||||
/// For diagnostics/variance decisions, not partially published image output.
|
||||
pub fn pixel_statistics(&self, index: usize) -> Result<PixelStatistics> {
|
||||
self.pixels.get(index).copied().map(Pixel::statistics).ok_or_else(|| invalid("film pixel index out of range"))
|
||||
}
|
||||
|
||||
/// One sample per consecutive pixel, at most MAX_UPDATE. Validate the whole
|
||||
/// update before mutation. Validation/commit work is charged up front; failed
|
||||
/// work/cancel/input checks leave the film unchanged (budget stays consumed).
|
||||
/// After the last cancellation check, bounded commit is the linearization
|
||||
/// point: cancellation during commit is observed by the caller before publish.
|
||||
pub fn add_range(&mut self, start: usize, samples: &[[f64; 3]], cancel: &CancelToken, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<()> {
|
||||
cancel.check()?;
|
||||
if samples.is_empty() || samples.len() > MAX_UPDATE {
|
||||
return Err(invalid("film update must contain 1..256 samples"));
|
||||
}
|
||||
let end = start.checked_add(samples.len()).ok_or_else(|| invalid("film update range overflow"))?;
|
||||
let pixels = self.pixels.get(start..end).ok_or_else(|| invalid("film update range out of bounds"))?;
|
||||
// Fixed bounded stack scratch gives atomic commit without a second heap
|
||||
// buffer or recomputation that might fail after earlier pixels mutated.
|
||||
let mut staged = [Pixel::default(); MAX_UPDATE];
|
||||
for ((pixel, sample), target) in pixels.iter().zip(samples).zip(staged.iter_mut()) {
|
||||
cancel.check()?;
|
||||
charge(2)?;
|
||||
*target = pixel.updated(*sample, self.sample_limit)?;
|
||||
}
|
||||
cancel.check()?;
|
||||
let target = self.pixels.get_mut(start..end).ok_or_else(|| invalid("film update range changed"))?;
|
||||
target.copy_from_slice(staged.get(..samples.len()).ok_or_else(|| invalid("film staging range invalid"))?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// No allocation and no display transform. Each checked pixel charges one
|
||||
/// unit to the same job and checks its cancellation token.
|
||||
pub fn complete_pass<'a>(&'a self, cancel: &CancelToken, charge: &mut impl FnMut(u64) -> Result<()>) -> Result<CompletePass<'a>> {
|
||||
cancel.check()?;
|
||||
let count = self.pixels.first().map(|p| p.count).ok_or_else(|| invalid("film has no pixels"))?;
|
||||
if count == 0 {
|
||||
return Err(invalid("film has no complete pass"));
|
||||
}
|
||||
for pixel in &self.pixels {
|
||||
cancel.check()?;
|
||||
charge(1)?;
|
||||
if pixel.count != count {
|
||||
return Err(invalid("film pass is incomplete"));
|
||||
}
|
||||
}
|
||||
cancel.check()?;
|
||||
Ok(CompletePass { film: self, samples_per_pixel: count })
|
||||
}
|
||||
}
|
||||
|
||||
impl CompletePass<'_> {
|
||||
pub fn samples_per_pixel(&self) -> u32 {
|
||||
self.samples_per_pixel
|
||||
}
|
||||
|
||||
pub fn dimensions(&self) -> (u32, u32) {
|
||||
self.film.dimensions()
|
||||
}
|
||||
|
||||
/// Borrowed immutable linear means. Resolving/copying/encoding and its work
|
||||
/// accounting/cancellation remain caller-owned; no output buffer is allocated.
|
||||
pub fn pixels(&self) -> impl ExactSizeIterator<Item = [f64; 3]> + '_ {
|
||||
self.film.pixels.iter().map(|p| p.mean)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "film_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,191 @@
|
||||
use super::*;
|
||||
|
||||
fn film(width: u32, height: u32, limit: u32) -> FilmAccumulator {
|
||||
let bytes = FilmAccumulator::required_bytes(width, height, limit).unwrap();
|
||||
FilmAccumulator::new(width, height, limit, bytes, &CancelToken::default(), &mut |_| Ok(())).unwrap()
|
||||
}
|
||||
|
||||
fn add(film: &mut FilmAccumulator, start: usize, values: &[[f64; 3]]) {
|
||||
film.add_range(start, values, &CancelToken::default(), &mut |_| Ok(())).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_and_alternating_samples_have_analytic_statistics() {
|
||||
let mut film = film(2, 1, 4);
|
||||
for value in [0.0, 2.0, 0.0, 2.0] {
|
||||
add(&mut film, 0, &[[7.0, 3.0, 1.0], [value; 3]]);
|
||||
}
|
||||
assert_eq!(film.pixel_statistics(0).unwrap(), PixelStatistics { mean: [7.0, 3.0, 1.0], variance: [0.0; 3], count: 4 });
|
||||
let statistics = film.pixel_statistics(1).unwrap();
|
||||
assert_eq!(statistics.mean, [1.0; 3]);
|
||||
assert_eq!(statistics.variance, [4.0 / 3.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn complete_pass_refuses_empty_or_uneven_counts_and_keeps_linear_values() {
|
||||
let mut film = film(2, 1, 3);
|
||||
assert!(film.complete_pass(&CancelToken::default(), &mut |_| Ok(())).is_err());
|
||||
add(&mut film, 0, &[[20.0, 2.0, 0.0]]);
|
||||
assert!(film.complete_pass(&CancelToken::default(), &mut |_| Ok(())).is_err());
|
||||
add(&mut film, 1, &[[4.0, 8.0, 16.0]]);
|
||||
let pass = film.complete_pass(&CancelToken::default(), &mut |_| Ok(())).unwrap();
|
||||
assert_eq!(pass.samples_per_pixel(), 1);
|
||||
assert_eq!(pass.dimensions(), (2, 1));
|
||||
assert_eq!(pass.pixels().collect::<Vec<_>>(), vec![[20.0, 2.0, 0.0], [4.0, 8.0, 16.0]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sample_at_end_leaves_entire_update_unchanged() {
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0] {
|
||||
let mut film = film(2, 1, 3);
|
||||
add(&mut film, 0, &[[1.0; 3], [2.0; 3]]);
|
||||
let before = film.pixels.clone();
|
||||
assert_eq!(film.add_range(0, &[[3.0; 3], [bad; 3]], &CancelToken::default(), &mut |_| Ok(())).unwrap_err().code, ErrorCode::InvalidRequest);
|
||||
assert_eq!(film.pixels, before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_limit_and_numeric_overflow_refuse_atomically() {
|
||||
let mut limited = film(2, 1, 1);
|
||||
add(&mut limited, 1, &[[1.0; 3]]);
|
||||
let before = limited.pixels.clone();
|
||||
assert_eq!(limited.add_range(0, &[[1.0; 3]; 2], &CancelToken::default(), &mut |_| Ok(())).unwrap_err().code, ErrorCode::LimitExceeded);
|
||||
assert_eq!(limited.pixels, before);
|
||||
let mut extreme = film(2, 1, 3);
|
||||
add(&mut extreme, 0, &[[1.0; 3], [f64::MAX; 3]]);
|
||||
let before = extreme.pixels.clone();
|
||||
assert!(extreme.add_range(0, &[[2.0; 3], [0.0; 3]], &CancelToken::default(), &mut |_| Ok(())).is_err());
|
||||
assert_eq!(extreme.pixels, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dimensions_bytes_and_actual_capacity_are_checked() {
|
||||
for (width, height, samples) in [(0, 1, 1), (1, 0, 1), (u32::MAX, u32::MAX, 1), (1, 1, 0), (1, 1, MAX_SAMPLES + 1)] {
|
||||
assert!(FilmAccumulator::required_bytes(width, height, samples).is_err());
|
||||
}
|
||||
assert!(FilmAccumulator::required_bytes(MAX_DIMENSION, MAX_DIMENSION, 1).is_err());
|
||||
assert!(bytes_for_capacity(usize::MAX).is_err());
|
||||
let bytes = FilmAccumulator::required_bytes(2, 2, 1).unwrap();
|
||||
assert!(FilmAccumulator::new(2, 2, 1, bytes - 1, &CancelToken::default(), &mut |_| Ok(())).is_err());
|
||||
let film = film(2, 2, 1);
|
||||
assert!(film.allocated_bytes().unwrap() <= bytes);
|
||||
assert_eq!(film.allocated_bytes().unwrap(), bytes_for_capacity(film.pixels.capacity()).unwrap());
|
||||
assert!(film.allocated_bytes().unwrap() >= (std::mem::size_of::<Pixel>() * (4 + MAX_UPDATE)) as u64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ranges_and_oversized_updates_preserve_state() {
|
||||
let mut film = film(2, 1, 1);
|
||||
for (start, values) in [(0, Vec::new()), (usize::MAX, vec![[1.0; 3]]), (2, vec![[1.0; 3]]), (0, vec![[1.0; 3]; MAX_UPDATE + 1])] {
|
||||
assert!(film.add_range(start, &values, &CancelToken::default(), &mut |_| Ok(())).is_err());
|
||||
assert!(film.pixels.iter().all(|p| *p == Pixel::default()));
|
||||
}
|
||||
assert!(film.pixel_statistics(2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_partitions_preserve_bitwise_results_in_sample_order() {
|
||||
let mut whole = film(4, 1, 3);
|
||||
let mut partitioned = film(4, 1, 3);
|
||||
for values in [
|
||||
[[1.0; 3], [1e12; 3], [0.25; 3], [4.0; 3]],
|
||||
[[2.0; 3], [1e12 + 1.0; 3], [0.5; 3], [2.0; 3]],
|
||||
[[4.0; 3], [1e12 - 1.0; 3], [0.75; 3], [0.0; 3]],
|
||||
] {
|
||||
add(&mut whole, 0, &values);
|
||||
add(&mut partitioned, 0, &values[..1]);
|
||||
add(&mut partitioned, 1, &values[1..3]);
|
||||
add(&mut partitioned, 3, &values[3..]);
|
||||
}
|
||||
assert_eq!(whole.pixels, partitioned.pixels);
|
||||
assert_eq!(whole.pixel_statistics(1).unwrap().variance, [1.0; 3]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_and_work_failure_preserve_all_pixels() {
|
||||
let mut film = film(2, 1, 3);
|
||||
let cancel = CancelToken::default();
|
||||
let observer = cancel.clone();
|
||||
assert_eq!(
|
||||
film.add_range(0, &[[1.0; 3]; 2], &cancel, &mut |_| {
|
||||
observer.cancel();
|
||||
Ok(())
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ErrorCode::Cancelled
|
||||
);
|
||||
assert!(film.pixels.iter().all(|p| *p == Pixel::default()));
|
||||
let mut charged = 0;
|
||||
assert_eq!(
|
||||
film.add_range(0, &[[1.0; 3]; 2], &CancelToken::default(), &mut |units| {
|
||||
charged += units;
|
||||
if charged > 2 { Err(limit("job work exhausted")) } else { Ok(()) }
|
||||
})
|
||||
.unwrap_err()
|
||||
.code,
|
||||
ErrorCode::LimitExceeded
|
||||
);
|
||||
assert_eq!(charged, 4);
|
||||
assert!(film.pixels.iter().all(|p| *p == Pixel::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_and_complete_pass_share_caller_work_and_cancel() {
|
||||
let bytes = FilmAccumulator::required_bytes(2, 1, 1).unwrap();
|
||||
let cancel = CancelToken::default();
|
||||
cancel.cancel();
|
||||
assert!(FilmAccumulator::new(2, 1, 1, bytes, &cancel, &mut |_| panic!("cancelled construction charged work")).is_err());
|
||||
let mut charged = 0;
|
||||
let mut film = FilmAccumulator::new(2, 1, 1, bytes, &CancelToken::default(), &mut |n| {
|
||||
charged += n;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(charged, 2);
|
||||
add(&mut film, 0, &[[1.0; 3]; 2]);
|
||||
assert!(film.complete_pass(&CancelToken::default(), &mut |_| Err(limit("job exhausted"))).is_err());
|
||||
let pass = film
|
||||
.complete_pass(&CancelToken::default(), &mut |n| {
|
||||
charged += n;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(charged, 4);
|
||||
assert_eq!(pass.samples_per_pixel(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sample_counts_reach_4096_and_the_declared_cap_without_truncation() {
|
||||
for samples in [4096, MAX_SAMPLES] {
|
||||
let mut film = film(1, 1, samples);
|
||||
let cancel = CancelToken::default();
|
||||
let mut charged = 0u64;
|
||||
for _ in 0..samples {
|
||||
film.add_range(0, &[[7.0, 3.0, 1.0]], &cancel, &mut |n| {
|
||||
charged += n;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
assert_eq!(charged, u64::from(samples) * 2);
|
||||
let expected = PixelStatistics { mean: [7.0, 3.0, 1.0], variance: [0.0; 3], count: samples };
|
||||
assert_eq!(film.pixel_statistics(0).unwrap(), expected);
|
||||
assert_eq!(film.complete_pass(&cancel, &mut |_| Ok(())).unwrap().samples_per_pixel(), samples);
|
||||
assert_eq!(film.add_range(0, &[[1.0; 3]], &cancel, &mut |_| Ok(())).unwrap_err().code, ErrorCode::LimitExceeded);
|
||||
assert_eq!(film.pixel_statistics(0).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn construction_stops_on_exhausted_caller_work() {
|
||||
let bytes = FilmAccumulator::required_bytes(4, 1, 1).unwrap();
|
||||
let mut charged = 0u64;
|
||||
let result = FilmAccumulator::new(4, 1, 1, bytes, &CancelToken::default(), &mut |n| {
|
||||
charged += n;
|
||||
if charged > 2 { Err(limit("existing job budget exhausted")) } else { Ok(()) }
|
||||
});
|
||||
assert!(matches!(result, Err(JobError { code: ErrorCode::LimitExceeded, .. })));
|
||||
assert_eq!(charged, 3);
|
||||
}
|
||||
+129
-42
@@ -1,4 +1,4 @@
|
||||
//! Caller-owned CPU execution host. One worker, two bounded queued inputs; browser pumping
|
||||
//! Caller-owned image execution host. One worker, two bounded queued inputs; browser pumping
|
||||
//! uses the same incremental task without threads. Shared ownership never includes CAD state.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
@@ -12,8 +12,8 @@ use std::sync::{
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{
|
||||
Admission, AlphaConvention, CancelToken, ColourEncoding, ErrorCode, FrameInfo, ImageRequest, ImageTask, JobError, JobId, Phase, Progress, Result,
|
||||
TaskPoll, admit,
|
||||
Admission, AlphaConvention, CancelToken, ColourEncoding, ErrorCode, FrameInfo, ImageExecutor, ImageRequest, ImageTask, ImageWork, JobError,
|
||||
JobId, JobOptions, Phase, Progress, Result, TaskPoll, admit,
|
||||
};
|
||||
use crate::{
|
||||
Availability, ExecutorDescriptor, ExecutorId, ProductScene, RenderFormat, RenderLimits, RenderMode, RenderOperation, RenderProfile, ScratchLayout,
|
||||
@@ -48,7 +48,7 @@ impl Budget {
|
||||
let next = used
|
||||
.checked_add(bytes)
|
||||
.filter(|n| *n <= MEMORY_LIMIT)
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "CPU image host's 512 MiB admission pool is full"))?;
|
||||
.ok_or_else(|| JobError::new(ErrorCode::LimitExceeded, "image host's 512 MiB admission pool is full"))?;
|
||||
match self.used.compare_exchange_weak(used, next, Ordering::AcqRel, Ordering::Acquire) {
|
||||
Ok(_) => break,
|
||||
Err(current) => used = current,
|
||||
@@ -90,7 +90,10 @@ pub struct JobStatus {
|
||||
pub job: JobId,
|
||||
pub scene_generation: u64,
|
||||
pub request: ImageRequest,
|
||||
pub environment: Option<crate::lighting::environment::EnvironmentInfo>,
|
||||
pub resolved: crate::ResolvedRequest,
|
||||
pub options: JobOptions,
|
||||
pub textures: Option<super::textures::TextureSummary>,
|
||||
pub progress: Progress,
|
||||
pub error: Option<JobError>,
|
||||
pub frame: Option<FrameInfo>,
|
||||
@@ -102,11 +105,14 @@ struct Data {
|
||||
frame: Option<Arc<RenderFrame>>,
|
||||
}
|
||||
struct Record {
|
||||
textures: Option<super::textures::TextureSummary>,
|
||||
id: JobId,
|
||||
scene_generation: u64,
|
||||
request: ImageRequest,
|
||||
environment: Option<crate::lighting::environment::EnvironmentInfo>,
|
||||
resolved: crate::ResolvedRequest,
|
||||
cancel: CancelToken,
|
||||
options: JobOptions,
|
||||
data: Mutex<Data>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
done: Condvar,
|
||||
@@ -132,7 +138,10 @@ impl JobHandle {
|
||||
job: self.0.id,
|
||||
scene_generation: self.0.scene_generation,
|
||||
request: self.0.request.clone(),
|
||||
environment: self.0.environment.clone(),
|
||||
resolved: self.0.resolved.clone(),
|
||||
options: self.0.options,
|
||||
textures: self.0.textures.clone(),
|
||||
progress: data.progress,
|
||||
error: data.error.clone(),
|
||||
frame: data.frame.as_ref().map(|f| f.info.clone()),
|
||||
@@ -176,12 +185,16 @@ impl JobHandle {
|
||||
}
|
||||
|
||||
struct Work {
|
||||
request: ImageRequest,
|
||||
executor: Arc<dyn ImageExecutor>,
|
||||
scene: ProductScene,
|
||||
handle: JobHandle,
|
||||
reservation: Reservation,
|
||||
}
|
||||
struct Running {
|
||||
task: ImageTask,
|
||||
/// Taken and dropped as the frame completes, before the reservation shrinks to the PNG: the
|
||||
/// task owns its scratch (pixels, device buffers), which the full reservation pays for.
|
||||
task: Option<Box<dyn ImageWork>>,
|
||||
handle: JobHandle,
|
||||
reservation: Option<Reservation>,
|
||||
}
|
||||
@@ -214,9 +227,12 @@ impl Running {
|
||||
{
|
||||
data.progress.phase = Phase::Preparing;
|
||||
}
|
||||
let task = ImageTask::new(work.scene, work.handle.0.request.clone(), work.handle.0.cancel.clone());
|
||||
let task = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
work.executor.begin(work.scene, work.request, work.handle.0.options, work.handle.0.cancel.clone())
|
||||
}))
|
||||
.unwrap_or_else(|_| Err(JobError::new(ErrorCode::Internal, "image executor failed while preparing the job")));
|
||||
match task {
|
||||
Ok(task) => Some(Self { task, handle: work.handle, reservation: Some(work.reservation) }),
|
||||
Ok(task) => Some(Self { task: Some(task), handle: work.handle, reservation: Some(work.reservation) }),
|
||||
Err(e) => {
|
||||
terminal(&work.handle, e);
|
||||
None
|
||||
@@ -225,7 +241,8 @@ impl Running {
|
||||
}
|
||||
|
||||
fn advance(&mut self, units: usize) -> Result<bool> {
|
||||
let result = self.task.step(units)?;
|
||||
let task = self.task.as_mut().ok_or_else(|| JobError::new(ErrorCode::Internal, "render task already finished"))?;
|
||||
let result = task.step(units)?;
|
||||
let mut data = self.handle.0.data.lock().map_err(|_| JobError::new(ErrorCode::Internal, "render status lock poisoned"))?;
|
||||
// The final publication check is under the same lock as cancellation.
|
||||
if self.handle.0.cancel.is_cancelled() {
|
||||
@@ -240,6 +257,8 @@ impl Running {
|
||||
Ok(true)
|
||||
}
|
||||
TaskPoll::Complete(png) => {
|
||||
// The task's scratch goes before its charge does (still under the publication lock).
|
||||
drop(self.task.take());
|
||||
let mut reservation = self.reservation.take().ok_or_else(|| JobError::new(ErrorCode::Internal, "missing render reservation"))?;
|
||||
reservation.shrink(png.capacity() as u64 + 1024)?;
|
||||
let info = FrameInfo {
|
||||
@@ -269,7 +288,7 @@ impl Running {
|
||||
false
|
||||
}
|
||||
Err(_) => {
|
||||
terminal(&self.handle, JobError::new(ErrorCode::Internal, "CPU image host encountered an internal error"));
|
||||
terminal(&self.handle, JobError::new(ErrorCode::Internal, "image host encountered an internal error"));
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -286,6 +305,7 @@ struct Shared {
|
||||
}
|
||||
|
||||
struct Owner {
|
||||
executor: Arc<dyn ImageExecutor>,
|
||||
shared: Arc<Shared>,
|
||||
kind: HostKind,
|
||||
running: Mutex<Option<Running>>,
|
||||
@@ -320,9 +340,12 @@ impl Drop for Owner {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CpuHost(Arc<Owner>);
|
||||
pub struct ImageHost(Arc<Owner>);
|
||||
|
||||
impl CpuHost {
|
||||
/// Backwards-compatible CPU host constructors. Additional executors use `ImageHost::with_executor`.
|
||||
pub type CpuHost = ImageHost;
|
||||
|
||||
impl ImageHost {
|
||||
fn shared() -> Arc<Shared> {
|
||||
Arc::new(Shared {
|
||||
queue: Mutex::new(VecDeque::new()),
|
||||
@@ -335,7 +358,13 @@ impl CpuHost {
|
||||
}
|
||||
|
||||
pub fn cooperative() -> Self {
|
||||
Self::cooperative_with(Arc::new(CpuExecutor))
|
||||
}
|
||||
|
||||
/// A cooperative host over `executor` (pumped by its caller).
|
||||
fn cooperative_with(executor: Arc<dyn ImageExecutor>) -> Self {
|
||||
Self(Arc::new(Owner {
|
||||
executor,
|
||||
shared: Self::shared(),
|
||||
kind: HostKind::Cooperative,
|
||||
running: Mutex::new(None),
|
||||
@@ -351,16 +380,31 @@ impl CpuHost {
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let shared = Self::shared();
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("solidcraft-render".into())
|
||||
.spawn(move || native_worker(worker_shared))
|
||||
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, format!("could not start CPU image worker: {e}")))?;
|
||||
Ok(Self(Arc::new(Owner { shared, kind: HostKind::NativeWorker, running: Mutex::new(None), _worker: Some(worker) })))
|
||||
Self::with_executor(Arc::new(CpuExecutor))
|
||||
}
|
||||
}
|
||||
|
||||
/// Start an isolated bounded native worker. GPU discovery occurs before registration.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn with_executor(executor: Arc<dyn ImageExecutor>) -> Result<Self> {
|
||||
let descriptor = executor.descriptor()?;
|
||||
if descriptor.profiles.len() != 1
|
||||
|| descriptor
|
||||
.profiles
|
||||
.iter()
|
||||
.any(|p| p.mode != RenderMode::Preview || p.operation != RenderOperation::Image || p.formats != [RenderFormat::Png])
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "image worker currently accepts one Preview/Image/PNG profile"));
|
||||
}
|
||||
let shared = Self::shared();
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("solidcraft-render".into())
|
||||
.spawn(move || native_worker(worker_shared))
|
||||
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, format!("could not start image worker: {e}")))?;
|
||||
Ok(Self(Arc::new(Owner { executor, shared, kind: HostKind::NativeWorker, running: Mutex::new(None), _worker: Some(worker) })))
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> HostKind {
|
||||
self.0.kind
|
||||
}
|
||||
@@ -376,31 +420,38 @@ impl CpuHost {
|
||||
}
|
||||
|
||||
pub fn descriptor(&self) -> Result<ExecutorDescriptor> {
|
||||
Ok(ExecutorDescriptor {
|
||||
id: ExecutorId::new("cpu-product").map_err(|e| JobError::new(ErrorCode::Internal, e.to_string()))?,
|
||||
availability: if self.0.shared.stop.load(Ordering::Acquire) {
|
||||
Availability::Unavailable { reason: "CPU image host has stopped".into() }
|
||||
} else {
|
||||
Availability::Available
|
||||
},
|
||||
profiles: vec![RenderProfile {
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Image,
|
||||
formats: vec![RenderFormat::Png],
|
||||
features: vec![crate::RenderFeature::Pbr],
|
||||
}],
|
||||
limits: RenderLimits::default(),
|
||||
scratch: ScratchLayout { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 },
|
||||
})
|
||||
let mut descriptor = self.0.executor.descriptor()?;
|
||||
if !self.is_available() {
|
||||
descriptor.availability = Availability::Unavailable { reason: "image host has stopped".into() };
|
||||
}
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
pub fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
|
||||
options.validate()?;
|
||||
self.0.executor.admit(scene, request, options)
|
||||
}
|
||||
|
||||
pub fn submit(&self, id: JobId, scene_generation: u64, scene: ProductScene, request: ImageRequest) -> Result<JobHandle> {
|
||||
self.submit_with_options(id, scene_generation, scene, request, JobOptions::default())
|
||||
}
|
||||
|
||||
pub fn submit_with_options(
|
||||
&self,
|
||||
id: JobId,
|
||||
scene_generation: u64,
|
||||
scene: ProductScene,
|
||||
request: ImageRequest,
|
||||
options: JobOptions,
|
||||
) -> Result<JobHandle> {
|
||||
if id.session == 0 || id.serial == 0 || id.generation == 0 {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid render job identity"));
|
||||
}
|
||||
let Admission { estimated_total_bytes, .. } = admit(&scene, &request)?;
|
||||
let Admission { estimated_total_bytes, .. } = self.admit(&scene, &request, options)?;
|
||||
let mut backend = crate::builtin_backend();
|
||||
backend.executors.push(self.descriptor()?);
|
||||
let descriptor = self.descriptor()?;
|
||||
let executor = descriptor.id.clone();
|
||||
backend.executors = vec![descriptor];
|
||||
let resolved = crate::resolve_request(
|
||||
&[backend],
|
||||
&crate::RenderRequest {
|
||||
@@ -415,29 +466,37 @@ impl CpuHost {
|
||||
},
|
||||
)
|
||||
.map_err(|e| JobError::new(ErrorCode::HostUnavailable, e.to_string()))?;
|
||||
if resolved.executor.as_str() != "cpu-product" {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU product executor is unavailable"));
|
||||
if resolved.executor != executor {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "image executor is unavailable"));
|
||||
}
|
||||
let mut queue = self.0.shared.queue.lock().map_err(|_| JobError::new(ErrorCode::HostUnavailable, "render queue lock poisoned"))?;
|
||||
if self.0.shared.stop.load(Ordering::Acquire) {
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "CPU image host has stopped"));
|
||||
return Err(JobError::new(ErrorCode::HostUnavailable, "image host has stopped"));
|
||||
}
|
||||
queue.retain(|work| !work.handle.0.cancel.is_cancelled());
|
||||
if queue.len() >= QUEUE_LIMIT {
|
||||
return Err(JobError::new(ErrorCode::QueueFull, "CPU image queue is full"));
|
||||
return Err(JobError::new(ErrorCode::QueueFull, "image queue is full"));
|
||||
}
|
||||
let reservation = self.0.shared.budget.reserve(estimated_total_bytes)?;
|
||||
// Keep large captured sources in admitted work only. Status/retained frames
|
||||
// must not keep resource bytes alive after the reservation is shrunk.
|
||||
let environment = request.environment.as_ref().map(|source| source.info().clone());
|
||||
let public_request = ImageRequest { textures: None, environment: None, ..request.clone() };
|
||||
let textures = request.textures.as_ref().map(|t| t.summary());
|
||||
let handle = JobHandle(Arc::new(Record {
|
||||
textures,
|
||||
id,
|
||||
scene_generation,
|
||||
request,
|
||||
request: public_request,
|
||||
environment,
|
||||
resolved,
|
||||
cancel: CancelToken::default(),
|
||||
options,
|
||||
data: Mutex::new(Data { progress: Progress { phase: Phase::Queued, done: 0, total: 1 }, error: None, frame: None }),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
done: Condvar::new(),
|
||||
}));
|
||||
queue.push_back(Work { scene, handle: handle.clone(), reservation });
|
||||
queue.push_back(Work { request, executor: self.0.executor.clone(), scene, handle: handle.clone(), reservation });
|
||||
drop(queue);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.0.shared.wake.notify_one();
|
||||
@@ -514,3 +573,31 @@ fn native_worker(shared: Arc<Shared>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CpuExecutor;
|
||||
impl ImageExecutor for CpuExecutor {
|
||||
fn descriptor(&self) -> Result<ExecutorDescriptor> {
|
||||
Ok(ExecutorDescriptor {
|
||||
id: ExecutorId::new("cpu-product").map_err(|e| JobError::new(ErrorCode::Internal, e.to_string()))?,
|
||||
availability: Availability::Available,
|
||||
profiles: vec![RenderProfile {
|
||||
mode: RenderMode::Preview,
|
||||
operation: RenderOperation::Image,
|
||||
formats: vec![RenderFormat::Png],
|
||||
features: vec![crate::RenderFeature::Pbr],
|
||||
}],
|
||||
limits: RenderLimits::default(),
|
||||
scratch: ScratchLayout { sample_scale: 2, sample_bytes_per_pixel: 8, output_bytes_per_pixel: 8, encoding_bytes_per_pixel: 8 },
|
||||
})
|
||||
}
|
||||
|
||||
fn admit(&self, scene: &ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission> {
|
||||
if options != JobOptions::default() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU product does not support sampling or exposure controls"));
|
||||
}
|
||||
admit(scene, request)
|
||||
}
|
||||
fn begin(&self, scene: ProductScene, request: ImageRequest, _options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
|
||||
Ok(Box::new(ImageTask::new(scene, request, cancel)?))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@ use super::*;
|
||||
use solidcraft_geom::{Aabb3, Mesh};
|
||||
|
||||
fn request() -> ImageRequest {
|
||||
ImageRequest { camera: Default::default(), width: 16, height: 16, background: [40, 80, 120], transparent_background: false }
|
||||
ImageRequest {
|
||||
textures: None,
|
||||
environment: None,
|
||||
camera: Default::default(),
|
||||
width: 16,
|
||||
height: 16,
|
||||
background: [40, 80, 120],
|
||||
transparent_background: false,
|
||||
}
|
||||
}
|
||||
fn empty() -> ProductScene {
|
||||
ProductScene { meshes: Vec::new(), bounds: Aabb3::EMPTY }
|
||||
@@ -77,6 +85,7 @@ fn malformed_preparation_fails_the_job_without_stopping_the_host() {
|
||||
color: [0; 3],
|
||||
opacity: 1.0,
|
||||
surface: None,
|
||||
transmission: None,
|
||||
faces: Vec::new(),
|
||||
}],
|
||||
};
|
||||
@@ -129,3 +138,192 @@ fn native_job_finishes_without_any_poll_or_ui_pump() {
|
||||
assert_eq!(status.progress.phase, Phase::Completed);
|
||||
assert_eq!(job.frame().unwrap().unwrap().info.scene_generation, 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_payload_is_owned_by_admitted_work_not_retained_status() {
|
||||
struct ResourceExecutor;
|
||||
impl ImageExecutor for ResourceExecutor {
|
||||
fn descriptor(&self) -> Result<ExecutorDescriptor> {
|
||||
CpuExecutor.descriptor()
|
||||
}
|
||||
fn admit(&self, scene: &ProductScene, request: &ImageRequest, _: JobOptions) -> Result<Admission> {
|
||||
let mut admission = super::super::admit_geometry(scene, request, 262_144)?;
|
||||
admission.estimated_total_bytes += request.environment.as_ref().map_or(0, |source| source.admitted_bytes());
|
||||
Ok(admission)
|
||||
}
|
||||
fn begin(&self, scene: ProductScene, mut request: ImageRequest, _: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
|
||||
request.environment = None;
|
||||
Ok(Box::new(ImageTask::new(scene, request, cancel)?))
|
||||
}
|
||||
}
|
||||
for cancelled in [false, true] {
|
||||
let host = ImageHost(Arc::new(Owner {
|
||||
executor: Arc::new(ResourceExecutor),
|
||||
shared: ImageHost::shared(),
|
||||
kind: HostKind::Cooperative,
|
||||
running: Mutex::new(None),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_worker: None,
|
||||
}));
|
||||
let source = Arc::new(crate::lighting::environment::EnvironmentSource::sky(Default::default(), 0.0, 1.0).unwrap());
|
||||
let weak = Arc::downgrade(&source);
|
||||
let mut input = request();
|
||||
input.environment = Some(source);
|
||||
let job = host.submit(id(1, 1), 1, empty(), input).unwrap();
|
||||
let status = job.status().unwrap();
|
||||
assert!(status.request.environment.is_none());
|
||||
assert_eq!(status.environment.as_ref().unwrap().kind, "original_gradient_sky");
|
||||
assert!(weak.upgrade().is_some(), "queuedwork owns source");
|
||||
if cancelled {
|
||||
job.cancel().unwrap();
|
||||
}
|
||||
drain(&host);
|
||||
assert!(weak.upgrade().is_none(), "completed/cancelled work releases captured source despite retained status/job");
|
||||
assert_eq!(job.status().unwrap().progress.phase, if cancelled { Phase::Cancelled } else { Phase::Completed });
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texture_payload_is_owned_by_admitted_work_not_retained_status() {
|
||||
struct ResourceExecutor;
|
||||
impl ImageExecutor for ResourceExecutor {
|
||||
fn descriptor(&self) -> Result<ExecutorDescriptor> {
|
||||
CpuExecutor.descriptor()
|
||||
}
|
||||
fn admit(&self, scene: &ProductScene, request: &ImageRequest, _: JobOptions) -> Result<Admission> {
|
||||
let mut admission = super::super::admit_geometry(scene, request, 262_144)?;
|
||||
admission.estimated_total_bytes += request.textures.as_ref().map_or(0, |source| source.working_bytes());
|
||||
Ok(admission)
|
||||
}
|
||||
fn begin(&self, scene: ProductScene, mut request: ImageRequest, _: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>> {
|
||||
request.textures = None;
|
||||
Ok(Box::new(ImageTask::new(scene, request, cancel)?))
|
||||
}
|
||||
}
|
||||
for cancelled in [false, true] {
|
||||
let host = ImageHost(Arc::new(Owner {
|
||||
executor: Arc::new(ResourceExecutor),
|
||||
shared: ImageHost::shared(),
|
||||
kind: HostKind::Cooperative,
|
||||
running: Mutex::new(None),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
_worker: None,
|
||||
}));
|
||||
use crate::job::textures::{PlanarFrame, RuntimeTextures, TextureBinding, TextureMap};
|
||||
use image::ImageEncoder;
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes).write_image(&[128, 128, 255, 255], 1, 1, image::ExtendedColorType::Rgba8).unwrap();
|
||||
let source = Arc::new(
|
||||
RuntimeTextures::new(vec![TextureBinding {
|
||||
body_index: 0,
|
||||
body_name: "unused test resource".into(),
|
||||
frame: PlanarFrame { origin: [0.; 3], u: [1., 0., 0.], v: [0., 1., 0.] },
|
||||
normal_strength: 1.,
|
||||
base_color: Some(TextureMap::new(bytes, crate::texture_input::MapRole::BaseColor).unwrap()),
|
||||
normal: None,
|
||||
}])
|
||||
.unwrap(),
|
||||
);
|
||||
let weak = Arc::downgrade(&source);
|
||||
let mut input = request();
|
||||
input.textures = Some(source);
|
||||
let job = host.submit(id(1, 1), 1, empty(), input).unwrap();
|
||||
let status = job.status().unwrap();
|
||||
assert!(status.request.textures.is_none());
|
||||
assert_eq!(status.textures.as_ref().unwrap().bodies, 1);
|
||||
assert!(weak.upgrade().is_some(), "queuedwork owns source");
|
||||
if cancelled {
|
||||
job.cancel().unwrap();
|
||||
}
|
||||
drain(&host);
|
||||
assert!(weak.upgrade().is_none(), "completed/cancelled work releases captured source despite retained status/job");
|
||||
assert_eq!(job.status().unwrap().progress.phase, if cancelled { Phase::Cancelled } else { Phase::Completed });
|
||||
}
|
||||
}
|
||||
/// An executor whose task reports, as it is dropped, how much of the pool is admitted, and
|
||||
/// completes after `steps` steps (or never, cancelled on its last one with `cancel_last`).
|
||||
struct DropProbe {
|
||||
admitted_at_drop: Arc<AtomicU64>,
|
||||
budget: Arc<std::sync::OnceLock<Arc<Budget>>>,
|
||||
estimated: u64,
|
||||
}
|
||||
struct ProbeTask {
|
||||
admitted_at_drop: Arc<AtomicU64>,
|
||||
budget: Arc<std::sync::OnceLock<Arc<Budget>>>,
|
||||
steps: u32,
|
||||
}
|
||||
impl Drop for ProbeTask {
|
||||
fn drop(&mut self) {
|
||||
let used = self.budget.get().map_or(u64::MAX, |b| b.used.load(Ordering::Acquire));
|
||||
self.admitted_at_drop.store(used, Ordering::Release);
|
||||
}
|
||||
}
|
||||
impl ImageWork for ProbeTask {
|
||||
fn step(&mut self, _units: usize) -> Result<TaskPoll> {
|
||||
self.steps += 1;
|
||||
if self.steps < 3 {
|
||||
return Ok(TaskPoll::Pending(Progress { phase: Phase::Running, done: u64::from(self.steps), total: 3 }));
|
||||
}
|
||||
Ok(TaskPoll::Complete(vec![0u8; 100]))
|
||||
}
|
||||
}
|
||||
impl ImageExecutor for DropProbe {
|
||||
fn descriptor(&self) -> Result<ExecutorDescriptor> {
|
||||
CpuExecutor.descriptor()
|
||||
}
|
||||
fn admit(&self, _: &ProductScene, _: &ImageRequest, _: JobOptions) -> Result<Admission> {
|
||||
Ok(Admission {
|
||||
triangles: 0,
|
||||
face_appearances: 0,
|
||||
geometry_bytes: 0,
|
||||
pixel_scratch_bytes: self.estimated,
|
||||
preparation_bytes: 0,
|
||||
estimated_total_bytes: self.estimated,
|
||||
encoded_limit: 4096,
|
||||
})
|
||||
}
|
||||
fn begin(&self, _: ProductScene, _: ImageRequest, _: JobOptions, _: CancelToken) -> Result<Box<dyn ImageWork>> {
|
||||
Ok(Box::new(ProbeTask { admitted_at_drop: self.admitted_at_drop.clone(), budget: self.budget.clone(), steps: 0 }))
|
||||
}
|
||||
}
|
||||
|
||||
fn probe_host(estimated: u64) -> (CpuHost, Arc<AtomicU64>) {
|
||||
let admitted_at_drop = Arc::new(AtomicU64::new(0));
|
||||
let budget = Arc::new(std::sync::OnceLock::new());
|
||||
let host = CpuHost::cooperative_with(Arc::new(DropProbe { admitted_at_drop: admitted_at_drop.clone(), budget: budget.clone(), estimated }));
|
||||
let _ = budget.set(host.0.shared.budget.clone());
|
||||
(host, admitted_at_drop)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_completed_task_is_dropped_while_its_full_reservation_still_pays_for_it() {
|
||||
let estimated = 1 << 20;
|
||||
let (host, admitted_at_drop) = probe_host(estimated);
|
||||
let job = host.submit(id(1, 1), 1, empty(), request()).unwrap();
|
||||
drain(&host);
|
||||
assert_eq!(job.status().unwrap().progress.phase, Phase::Completed);
|
||||
// While the task (and its scratch) was being dropped, its whole charge was still held.
|
||||
assert_eq!(admitted_at_drop.load(Ordering::Acquire), estimated);
|
||||
// Afterwards only the PNG's lease remains, and the frame keeps it.
|
||||
let frame = job.frame().unwrap().unwrap();
|
||||
assert!(host.admitted_bytes() > 0 && host.admitted_bytes() < estimated, "{}", host.admitted_bytes());
|
||||
drop(job);
|
||||
drop(frame);
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cancel_on_the_final_step_publishes_no_frame_and_drops_the_task() {
|
||||
let estimated = 1 << 20;
|
||||
let (host, admitted_at_drop) = probe_host(estimated);
|
||||
let job = host.submit(id(1, 1), 1, empty(), request()).unwrap();
|
||||
// Two pending steps, then cancel before the step that would complete.
|
||||
host.pump(1).unwrap();
|
||||
host.pump(1).unwrap();
|
||||
job.cancel().unwrap();
|
||||
drain(&host);
|
||||
assert_eq!(job.status().unwrap().progress.phase, Phase::Cancelled);
|
||||
assert!(job.frame().unwrap().is_none());
|
||||
assert_eq!(admitted_at_drop.load(Ordering::Acquire), estimated, "the task went before its charge");
|
||||
assert_eq!(host.admitted_bytes(), 0);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,35 @@ fn add_bytes(sum: &mut u64, count: usize, size: usize) -> Result<()> {
|
||||
|
||||
/// Conservative admission before allocating pixels or projected-work lists. Source Arcs are
|
||||
/// counted even when another owner already holds them. Allocator and codec internals are estimates.
|
||||
/// Includes the CPU raster work estimate and its 384 MiB per-job check.
|
||||
pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission> {
|
||||
if request.environment.is_some() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "CPU product does not support environment maps"));
|
||||
}
|
||||
if request.textures.is_some() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "runtime textures require the progressive GPU executor"));
|
||||
}
|
||||
admit_raster(admit_source(scene, request, MAX_TRIANGLES)?)
|
||||
}
|
||||
|
||||
/// Common camera, dimension and source admission for registered executors, without any CPU
|
||||
/// raster estimate: the returned admission counts retained source geometry only
|
||||
/// (`pixel_scratch_bytes` and `preparation_bytes` are zero). Executors budget their own
|
||||
/// preparation and pixel storage, apply their own job limit, and validate triangle contents
|
||||
/// before uploading anything to a device.
|
||||
pub fn admit_geometry(scene: &ProductScene, request: &ImageRequest, max_triangles: u64) -> Result<Admission> {
|
||||
Ok(admit_source(scene, request, max_triangles)?.admission)
|
||||
}
|
||||
|
||||
/// A validated source with its geometry accounting, and the builtin raster scratch estimate
|
||||
/// for the requested dimensions (used only by the CPU estimate).
|
||||
struct Source {
|
||||
admission: Admission,
|
||||
bodies: u64,
|
||||
raster_scratch_bytes: u64,
|
||||
}
|
||||
|
||||
fn admit_source(scene: &ProductScene, request: &ImageRequest, max_triangles: u64) -> Result<Source> {
|
||||
if !request.camera.is_valid() {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid image camera"));
|
||||
}
|
||||
@@ -77,8 +105,8 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission>
|
||||
triangles = triangles.checked_add(m.triangles.len() as u64).ok_or_else(|| limit("triangle count overflow"))?;
|
||||
faces = faces.checked_add(body.faces.len() as u64).ok_or_else(|| limit("appearance count overflow"))?;
|
||||
polylines = polylines.checked_add(m.edges.len() as u64 + m.edge_faces.len() as u64).ok_or_else(|| limit("edge count overflow"))?;
|
||||
if triangles > MAX_TRIANGLES || faces > MAX_TRIANGLES || polylines > MAX_TRIANGLES {
|
||||
return Err(limit("product image exceeds the 262144 triangle/appearance/polyline admission cap"));
|
||||
if triangles > max_triangles || faces > max_triangles || polylines > max_triangles {
|
||||
return Err(limit("product image exceeds its triangle/appearance/polyline admission cap"));
|
||||
}
|
||||
add_bytes(&mut geometry, m.positions.capacity(), std::mem::size_of::<Vec3>())?;
|
||||
add_bytes(&mut geometry, m.normals.capacity(), std::mem::size_of::<Vec3>())?;
|
||||
@@ -97,36 +125,51 @@ pub fn admit(scene: &ProductScene, request: &ImageRequest) -> Result<Admission>
|
||||
for count in [m.seams.capacity(), m.smooth_edges.capacity(), m.sharp_edges.capacity()] {
|
||||
add_bytes(&mut geometry, count, 1)?;
|
||||
}
|
||||
if body.surface.is_some() && body.opacity != 1.0 {
|
||||
if body.surface.is_some() && body.transmission.is_none() && body.opacity != 1.0 {
|
||||
return Err(invalid("opaque PBR requires opacity 1"));
|
||||
}
|
||||
if !body.opacity.is_finite() || !(0.0..=1.0).contains(&body.opacity) {
|
||||
return Err(invalid("invalid body opacity"));
|
||||
}
|
||||
}
|
||||
let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
|
||||
Ok(Source {
|
||||
admission: Admission {
|
||||
triangles,
|
||||
face_appearances: faces,
|
||||
geometry_bytes: geometry,
|
||||
pixel_scratch_bytes: 0,
|
||||
preparation_bytes: 0,
|
||||
estimated_total_bytes: geometry,
|
||||
encoded_limit,
|
||||
},
|
||||
bodies: scene.meshes.len() as u64,
|
||||
raster_scratch_bytes: resolved.estimated_scratch_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
/// The CPU raster scratch/preparation estimate and its 384 MiB per-job check.
|
||||
fn admit_raster(source: Source) -> Result<Admission> {
|
||||
let mut a = source.admission;
|
||||
// Three bounded 16-byte lists, a conservative map-node allowance, and codec overhead.
|
||||
let preparation = triangles
|
||||
let preparation = a
|
||||
.triangles
|
||||
.checked_mul(48)
|
||||
.and_then(|n| faces.checked_mul(160).and_then(|f| n.checked_add(f)))
|
||||
.and_then(|n| n.checked_add(scene.meshes.len() as u64 * 128 + 262_144))
|
||||
.and_then(|n| a.face_appearances.checked_mul(160).and_then(|f| n.checked_add(f)))
|
||||
.and_then(|n| n.checked_add(source.bodies * 128 + 262_144))
|
||||
.ok_or_else(|| limit("work list size overflow"))?;
|
||||
let total = geometry
|
||||
let total = a
|
||||
.geometry_bytes
|
||||
.checked_add(preparation)
|
||||
.and_then(|n| n.checked_add(resolved.estimated_scratch_bytes))
|
||||
.and_then(|n| n.checked_add(source.raster_scratch_bytes))
|
||||
.ok_or_else(|| limit("image memory overflow"))?;
|
||||
if total > MAX_JOB_BYTES {
|
||||
return Err(limit("product job exceeds its 384 MiB admission estimate"));
|
||||
}
|
||||
let encoded_limit = u64::from(request.width) * u64::from(request.height) * 8 + 65_536;
|
||||
Ok(Admission {
|
||||
triangles,
|
||||
face_appearances: faces,
|
||||
geometry_bytes: geometry,
|
||||
pixel_scratch_bytes: resolved.estimated_scratch_bytes,
|
||||
preparation_bytes: preparation,
|
||||
estimated_total_bytes: total,
|
||||
encoded_limit,
|
||||
})
|
||||
a.pixel_scratch_bytes = source.raster_scratch_bytes;
|
||||
a.preparation_bytes = preparation;
|
||||
a.estimated_total_bytes = total;
|
||||
Ok(a)
|
||||
}
|
||||
|
||||
fn reserve<T>(count: usize) -> Result<Vec<T>> {
|
||||
@@ -380,7 +423,11 @@ impl ImageTask {
|
||||
return Err(invalid("non-finite product triangle"));
|
||||
}
|
||||
let look = self.looks.get(draw.body as usize).and_then(|looks| mesh.tri_face.get(draw.triangle as usize).and_then(|face| looks.get(face)));
|
||||
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface), |look| (look.color, look.opacity, look.surface));
|
||||
// Transmission is physical optical data, never a request to change CPU preview.
|
||||
// Keep the existing scalar sorted-alpha shading for glass previews.
|
||||
let (colour, opacity, surface) = look.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |look| {
|
||||
(look.color, look.opacity, look.surface.filter(|_| look.transmission.is_none()))
|
||||
});
|
||||
if surface.is_some() && opacity != 1.0 {
|
||||
return Err(invalid("opaque PBR requires opacity 1"));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ use solidcraft_geom::{Aabb3, Mesh};
|
||||
fn request() -> ImageRequest {
|
||||
let mut camera = Camera { distance: 50.0, ..Default::default() };
|
||||
camera.set_view(StandardView::Front);
|
||||
ImageRequest { camera, width: 64, height: 64, background: [20, 40, 60], transparent_background: true }
|
||||
ImageRequest { textures: None, environment: None, camera, width: 64, height: 64, background: [20, 40, 60], transparent_background: true }
|
||||
}
|
||||
|
||||
fn square(name: &str, colour: [u8; 3], opacity: f64, offset: Vec3) -> ProductMesh {
|
||||
@@ -21,7 +21,7 @@ fn square(name: &str, colour: [u8; 3], opacity: f64, offset: Vec3) -> ProductMes
|
||||
tri_face: vec![0, 1],
|
||||
..Default::default()
|
||||
};
|
||||
ProductMesh { name: name.into(), mesh: Arc::new(mesh), color: colour, opacity, surface: None, faces: Vec::new() }
|
||||
ProductMesh { name: name.into(), mesh: Arc::new(mesh), color: colour, opacity, surface: None, transmission: None, faces: Vec::new() }
|
||||
}
|
||||
|
||||
fn scene(meshes: Vec<ProductMesh>) -> ProductScene {
|
||||
@@ -68,6 +68,17 @@ fn glass_is_sorted_back_to_front_independently_of_body_order() {
|
||||
assert!(p[2] > p[0] + 30, "the near blue layer contributes more: {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_transmission_keeps_the_exact_scalar_cpu_preview() {
|
||||
let original = square("Glass", [80, 180, 140], 0.25, Vec3::ZERO);
|
||||
let mut glass = original.clone();
|
||||
glass.surface = Some(crate::surface::OpaqueSurface::new(Vec3::new(0.2, 0.4, 0.3), 0.0, 0.1, 1.5).unwrap());
|
||||
glass.transmission = Some(crate::transmission::Transmission::new(Vec3::new(-0.7_f64.ln(), -0.9_f64.ln(), 0.0) / 10.0, None).unwrap());
|
||||
let expected = finish(ImageTask::new(scene(vec![original]), request(), CancelToken::default()).unwrap());
|
||||
let actual = finish(ImageTask::new(scene(vec![glass]), request(), CancelToken::default()).unwrap());
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancellation_is_observed_during_preparation_sort_pixels_and_encoding() {
|
||||
for stage in [Stage::Looks, Stage::Prepare, Stage::Sort, Stage::Clear, Stage::Glass, Stage::Downsample, Stage::Encode] {
|
||||
@@ -116,7 +127,7 @@ fn invalid_sizes_indices_opacity_and_large_work_lists_fail_without_a_frame() {
|
||||
if bad == 0 {
|
||||
Arc::make_mut(&mut body.mesh).triangles[0][0] = u32::MAX;
|
||||
} else {
|
||||
body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None });
|
||||
body.faces.push(FaceAppearance { face: 0, color: [0; 3], opacity: f64::NAN, surface: None, transmission: None });
|
||||
}
|
||||
let mut task = ImageTask::new(scene(vec![body]), request(), CancelToken::default()).unwrap();
|
||||
let error = loop {
|
||||
@@ -145,3 +156,25 @@ fn png_output_budget_and_cancellation_fail_without_partial_publication() {
|
||||
assert!(writer.flush().is_err());
|
||||
assert_eq!(*bytes.lock().unwrap(), b"PNG");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registered_executor_geometry_admission_excludes_the_cpu_raster_estimate() {
|
||||
// A library-bounded source: about 100 MiB of face appearances on a small mesh at the
|
||||
// largest image. Its CPU raster estimate is above 384 MiB at a registered executor's cap.
|
||||
let count = (100 * 1024 * 1024 / std::mem::size_of::<FaceAppearance>()).min(1_000_000);
|
||||
let mut body = square("appearances", [200; 3], 1.0, Vec3::ZERO);
|
||||
body.faces = vec![FaceAppearance { face: 0, color: [200; 3], opacity: 1.0, surface: None, transmission: None }; count];
|
||||
let source = scene(vec![body]);
|
||||
let mut r = request();
|
||||
r.width = 2048;
|
||||
r.height = 2048;
|
||||
let cpu = admit_raster(admit_source(&source, &r, 1_048_576).unwrap()).unwrap_err();
|
||||
assert_eq!(cpu.code, ErrorCode::LimitExceeded);
|
||||
assert!(cpu.message.contains("384 MiB"), "{}", cpu.message);
|
||||
let geometry = admit_geometry(&source, &r, 1_048_576).unwrap();
|
||||
assert_eq!((geometry.pixel_scratch_bytes, geometry.preparation_bytes), (0, 0));
|
||||
assert_eq!(geometry.estimated_total_bytes, geometry.geometry_bytes);
|
||||
assert_eq!(geometry.face_appearances, count as u64);
|
||||
// The CPU host itself keeps rejecting the source.
|
||||
assert_eq!(admit(&source, &r).unwrap_err().code, ErrorCode::LimitExceeded);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
//! Runtime thin-lens camera settings. Focus is an axial distance from the eye,
|
||||
//! not distance along an off-axis ray. No document or camera-navigation changes.
|
||||
use super::{ErrorCode, JobError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ThinLens {
|
||||
pub aperture_radius_mm: f64,
|
||||
pub focus_distance_mm: f64,
|
||||
}
|
||||
|
||||
impl ThinLens {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
if !self.aperture_radius_mm.is_finite()
|
||||
|| !(0.0..=1e7).contains(&self.aperture_radius_mm)
|
||||
|| !self.focus_distance_mm.is_finite()
|
||||
|| !(1e-6..=1e9).contains(&self.focus_distance_mm)
|
||||
{
|
||||
return Err(JobError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"lens aperture radius must be 0..10000000 mm and axial focus distance 0.000001..1000000000 mm",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn lens_admission_rejects_nonfinite_and_unbounded_values() {
|
||||
for aperture in [f64::NAN, f64::INFINITY, -1.0, 1e7 + 1.0] {
|
||||
assert!(ThinLens { aperture_radius_mm: aperture, focus_distance_mm: 10.0 }.validate().is_err());
|
||||
}
|
||||
for focus in [f64::NAN, f64::INFINITY, 0.0, -1.0, 1e9 + 1.0] {
|
||||
assert!(ThinLens { aperture_radius_mm: 1.0, focus_distance_mm: focus }.validate().is_err());
|
||||
}
|
||||
assert!(ThinLens { aperture_radius_mm: 0.0, focus_distance_mm: 1.0 }.validate().is_ok());
|
||||
assert!(serde_json::from_str::<ThinLens>(r#"{"aperture_radius_mm":1,"focus_distance_mm":10,"f_stop":2}"#).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Runtime rectangular emitters and constant environment radiance. Coordinates are
|
||||
//! world-space millimetres; radiance is linear RGB. No saved-document contract.
|
||||
use super::{ErrorCode, JobError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
pub const MAX_AREA_LIGHTS: usize = 4;
|
||||
|
||||
/// A one-sided rectangle: `center + u*s + v*t`, with s,t in [-1,1].
|
||||
/// Its emitting normal is `u cross v`; u and v are orthogonal half-edges.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AreaLight {
|
||||
pub center: [f64; 3],
|
||||
pub u: [f64; 3],
|
||||
pub v: [f64; 3],
|
||||
pub radiance: [f64; 3],
|
||||
}
|
||||
|
||||
fn vector(a: [f64; 3]) -> Vec3 {
|
||||
let [x, y, z] = a;
|
||||
Vec3::new(x, y, z)
|
||||
}
|
||||
|
||||
impl AreaLight {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
let u = vector(self.u);
|
||||
let v = vector(self.v);
|
||||
let ul = u.len();
|
||||
let vl = v.len();
|
||||
if self.center.into_iter().any(|x| !x.is_finite() || x.abs() > 1e12)
|
||||
|| self.u.into_iter().chain(self.v).any(|x| !x.is_finite())
|
||||
|| !(1e-4..=1e6).contains(&ul)
|
||||
|| !(1e-4..=1e6).contains(&vl)
|
||||
|| u.dot(v).abs() > 1e-6 * ul * vl
|
||||
|| self.radiance.into_iter().any(|x| !x.is_finite() || !(0.0..=1e6).contains(&x))
|
||||
{
|
||||
return Err(JobError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"area light requires finite center, orthogonal half-edges of 0.0001..1000000 mm and radiance 0..1000000",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn area(self) -> f64 {
|
||||
4.0 * vector(self.u).cross(vector(self.v)).len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Uniform-radiance finite angular disk at infinity. Direction points toward the sun.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Sun {
|
||||
pub direction: [f64; 3],
|
||||
pub angular_radius_rad: f64,
|
||||
pub radiance: [f64; 3],
|
||||
}
|
||||
impl Sun {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
if self.direction.iter().any(|x| !x.is_finite())
|
||||
|| !(1e-8..=1e8).contains(&vector(self.direction).len())
|
||||
|| !self.angular_radius_rad.is_finite()
|
||||
|| !(0.001..=std::f64::consts::FRAC_PI_2).contains(&self.angular_radius_rad)
|
||||
|| self.radiance.iter().any(|x| !x.is_finite() || !(0.0..=1e8).contains(x))
|
||||
{
|
||||
return Err(JobError::new(
|
||||
ErrorCode::InvalidRequest,
|
||||
"sun requires finite nonzero direction, radius 0.001..pi/2 radians and radiance 0..100000000",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn solid_angle(self) -> f64 {
|
||||
4.0 * std::f64::consts::PI * (0.5 * self.angular_radius_rad).sin().powi(2)
|
||||
}
|
||||
pub fn direction(self) -> Vec3 {
|
||||
let d = vector(self.direction);
|
||||
d / d.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// A small copyable light list, serialized as an ordinary bounded JSON array.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
||||
pub struct AreaLights {
|
||||
entries: [AreaLight; MAX_AREA_LIGHTS],
|
||||
len: usize,
|
||||
}
|
||||
|
||||
impl AreaLights {
|
||||
pub fn new(lights: &[AreaLight]) -> Result<Self> {
|
||||
if lights.len() > MAX_AREA_LIGHTS {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "at most four area lights are supported"));
|
||||
}
|
||||
let mut result = Self::default();
|
||||
for (slot, light) in result.entries.iter_mut().zip(lights) {
|
||||
light.validate()?;
|
||||
*slot = *light;
|
||||
}
|
||||
result.len = lights.len();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn iter(&self) -> impl Iterator<Item = &AreaLight> {
|
||||
self.entries.iter().take(self.len)
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for AreaLights {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
|
||||
serializer.collect_seq(self.iter())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for AreaLights {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
struct Visitor;
|
||||
impl<'de> serde::de::Visitor<'de> for Visitor {
|
||||
type Value = AreaLights;
|
||||
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("an array of at most four rectangular area lights")
|
||||
}
|
||||
fn visit_seq<A: serde::de::SeqAccess<'de>>(self, mut seq: A) -> std::result::Result<Self::Value, A::Error> {
|
||||
let mut result = AreaLights::default();
|
||||
while let Some(light) = seq.next_element::<AreaLight>()? {
|
||||
let slot =
|
||||
result.entries.get_mut(result.len).ok_or_else(|| serde::de::Error::custom("at most four area lights are supported"))?;
|
||||
light.validate().map_err(serde::de::Error::custom)?;
|
||||
*slot = light;
|
||||
result.len += 1;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
deserializer.deserialize_seq(Visitor)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct StudioLighting {
|
||||
pub sun: Option<Sun>,
|
||||
pub environment: [f64; 3],
|
||||
pub area_lights: AreaLights,
|
||||
}
|
||||
|
||||
impl StudioLighting {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
if let Some(sun) = self.sun {
|
||||
sun.validate()?;
|
||||
}
|
||||
if self.environment.into_iter().any(|x| !x.is_finite() || !(0.0..=1e6).contains(&x)) {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "environment radiance must be finite and 0..1000000"));
|
||||
}
|
||||
for light in self.area_lights.iter() {
|
||||
light.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Output-only transform, after linear accumulation and exposure, before sRGB.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToneMap {
|
||||
#[default]
|
||||
Reinhard,
|
||||
AcesFitted,
|
||||
}
|
||||
|
||||
impl ToneMap {
|
||||
pub fn map(self, linear: f64) -> f64 {
|
||||
let x = linear.max(0.0);
|
||||
match self {
|
||||
Self::Reinhard => x / (1.0 + x),
|
||||
// Narkowicz (2016), published rational approximation to the ACES RRT/ODT.
|
||||
// This is a per-channel fit, not the full ACES colour-management pipeline.
|
||||
// https://knarkowicz.wordpress.com/2016/01/06/aces-filmic-tone-mapping-curve/
|
||||
Self::AcesFitted => ((x * (2.51 * x + 0.03)) / (x * (2.43 * x + 0.59) + 0.14)).clamp(0.0, 1.0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn light() -> AreaLight {
|
||||
AreaLight { center: [0.0, 0.0, 4.0], u: [2.0, 0.0, 0.0], v: [0.0, -3.0, 0.0], radiance: [5.0; 3] }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_lights_validate_geometry_and_round_trip() {
|
||||
let lights = AreaLights::new(&[light(); 4]).unwrap();
|
||||
assert_eq!(light().area(), 24.0);
|
||||
assert_eq!(serde_json::from_str::<AreaLights>(&serde_json::to_string(&lights).unwrap()).unwrap(), lights);
|
||||
assert!(AreaLights::new(&[light(); 5]).is_err());
|
||||
assert!(serde_json::from_str::<AreaLights>(&serde_json::to_string(&vec![light(); 5]).unwrap()).is_err());
|
||||
for bad in [
|
||||
AreaLight { u: [0.0; 3], ..light() },
|
||||
AreaLight { v: [1.0, 1.0, 0.0], ..light() },
|
||||
AreaLight { radiance: [-1.0; 3], ..light() },
|
||||
AreaLight { center: [f64::NAN; 3], ..light() },
|
||||
] {
|
||||
assert!(bad.validate().is_err());
|
||||
}
|
||||
assert!(StudioLighting { environment: [f64::INFINITY; 3], ..Default::default() }.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_sun_validates_and_integrates_its_solid_angle() {
|
||||
let sun = Sun { direction: [0.0, 0.0, 2.0], angular_radius_rad: 0.2, radiance: [3.0; 3] };
|
||||
sun.validate().unwrap();
|
||||
assert_eq!(sun.direction(), Vec3::Z);
|
||||
assert!((sun.solid_angle() - 2.0 * std::f64::consts::PI * (1.0 - 0.2_f64.cos())).abs() < 1e-14);
|
||||
for radius in [0.0, 0.00001, f64::NAN, 2.0] {
|
||||
assert!(Sun { angular_radius_rad: radius, ..sun }.validate().is_err());
|
||||
}
|
||||
assert!(Sun { direction: [0.0; 3], ..sun }.validate().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filmic_is_bounded_monotone_and_preserves_black() {
|
||||
assert_eq!(ToneMap::default(), ToneMap::Reinhard);
|
||||
assert_eq!(ToneMap::AcesFitted.map(0.0), 0.0);
|
||||
assert!((ToneMap::AcesFitted.map(1.0) - 0.8037974683544302).abs() < 1e-14);
|
||||
let mut previous = 0.0;
|
||||
for i in 0..1000 {
|
||||
let value = ToneMap::AcesFitted.map(f64::from(i) / 100.0);
|
||||
assert!((previous..=1.0).contains(&value));
|
||||
previous = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
//! CPU product-image work and its device-independent, session-scoped protocol.
|
||||
//! Product-image work and its device-independent, session-scoped protocol.
|
||||
//! Hosts own execution; documents and history never contain these values.
|
||||
|
||||
use std::fmt;
|
||||
@@ -13,8 +13,73 @@ use crate::{BackendId, Camera, ExecutorId};
|
||||
|
||||
mod host;
|
||||
mod image;
|
||||
pub use host::{CpuHost, HostKind, JobHandle, JobStatus, RenderFrame};
|
||||
pub use image::{Admission, ImageTask, TaskPoll, admit};
|
||||
mod lens;
|
||||
mod lighting;
|
||||
mod png;
|
||||
pub mod textures;
|
||||
pub use host::{CpuHost, HostKind, ImageHost, JobHandle, JobStatus, RenderFrame};
|
||||
pub use image::{Admission, ImageTask, TaskPoll, admit, admit_geometry};
|
||||
pub use lens::ThinLens;
|
||||
pub use lighting::{AreaLight, AreaLights, MAX_AREA_LIGHTS, StudioLighting, Sun, ToneMap};
|
||||
pub use png::PngTask;
|
||||
|
||||
/// Runtime sampling intent, separate from saved documents and device resources.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct JobOptions {
|
||||
pub samples: u32,
|
||||
pub max_depth: u32,
|
||||
pub seed: u32,
|
||||
pub exposure_ev: f64,
|
||||
pub lens: Option<ThinLens>,
|
||||
pub lighting: Option<StudioLighting>,
|
||||
pub tone_map: ToneMap,
|
||||
/// Optional final spatial filter. Raw progressive accumulation remains unchanged.
|
||||
pub denoise: bool,
|
||||
}
|
||||
|
||||
impl Default for JobOptions {
|
||||
fn default() -> Self {
|
||||
Self { samples: 64, max_depth: 8, seed: 0, exposure_ev: 0.0, lens: None, lighting: None, tone_map: ToneMap::default(), denoise: false }
|
||||
}
|
||||
}
|
||||
|
||||
impl JobOptions {
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if let Some(lighting) = self.lighting {
|
||||
lighting.validate()?;
|
||||
}
|
||||
if let Some(lens) = self.lens {
|
||||
lens.validate()?;
|
||||
}
|
||||
if !(1..=4096).contains(&self.samples)
|
||||
|| !(1..=16).contains(&self.max_depth)
|
||||
|| !self.exposure_ev.is_finite()
|
||||
|| !(-32.0..=32.0).contains(&self.exposure_ev)
|
||||
{
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "samples must be 1..4096, depth 1..16 and exposure -32..32 EV"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// One bounded quantum of image work. Implementations check cancellation between quanta.
|
||||
pub trait ImageWork: Send {
|
||||
fn step(&mut self, units: usize) -> Result<TaskPoll>;
|
||||
}
|
||||
impl ImageWork for ImageTask {
|
||||
fn step(&mut self, units: usize) -> Result<TaskPoll> {
|
||||
self.step(units)
|
||||
}
|
||||
}
|
||||
|
||||
/// Device-independent executor boundary. The host owns admission leases and publication;
|
||||
/// an executor owns computation only. GPU implementations live above the render crate.
|
||||
pub trait ImageExecutor: Send + Sync {
|
||||
fn descriptor(&self) -> Result<crate::ExecutorDescriptor>;
|
||||
fn admit(&self, scene: &crate::ProductScene, request: &ImageRequest, options: JobOptions) -> Result<Admission>;
|
||||
fn begin(&self, scene: crate::ProductScene, request: ImageRequest, options: JobOptions, cancel: CancelToken) -> Result<Box<dyn ImageWork>>;
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -108,11 +173,16 @@ impl CancelToken {
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ImageRequest {
|
||||
/// Captured runtime resources; never persisted or accepted as serialized pixel arrays.
|
||||
#[serde(skip)]
|
||||
pub environment: Option<Arc<crate::lighting::environment::EnvironmentSource>>,
|
||||
pub camera: Camera,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub background: [u8; 3],
|
||||
pub transparent_background: bool,
|
||||
#[serde(skip)]
|
||||
pub textures: Option<Arc<textures::RuntimeTextures>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Bounded PNG encoding shared by native image executors.
|
||||
use super::{CancelToken, ErrorCode, ImageRequest, ImageWork, JobError, Phase, Progress, Result, TaskPoll};
|
||||
use std::{
|
||||
io::Write,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
struct Output {
|
||||
bytes: Arc<Mutex<Vec<u8>>>,
|
||||
limit: usize,
|
||||
cancel: CancelToken,
|
||||
}
|
||||
impl Write for Output {
|
||||
fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(std::io::Error::other("render cancelled"));
|
||||
}
|
||||
let mut out = self.bytes.lock().map_err(|_| std::io::Error::other("PNG lock poisoned"))?;
|
||||
if out.len().checked_add(bytes.len()).is_none_or(|n| n > self.limit) {
|
||||
return Err(std::io::Error::other("PNG byte limit exceeded"));
|
||||
}
|
||||
out.try_reserve_exact(bytes.len()).map_err(|_| std::io::Error::other("PNG allocation failed"))?;
|
||||
out.extend_from_slice(bytes);
|
||||
Ok(bytes.len())
|
||||
}
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns admitted RGBA bytes and yields after at most four scanlines.
|
||||
pub struct PngTask {
|
||||
rgba: Vec<u8>,
|
||||
row: usize,
|
||||
width: usize,
|
||||
height: usize,
|
||||
bytes: Arc<Mutex<Vec<u8>>>,
|
||||
writer: Option<png::StreamWriter<'static, Output>>,
|
||||
cancel: CancelToken,
|
||||
}
|
||||
impl PngTask {
|
||||
pub fn new(request: &ImageRequest, rgba: Vec<u8>, cancel: CancelToken) -> Result<Self> {
|
||||
let pixels = u64::from(request.width) * u64::from(request.height);
|
||||
if !(1..=4096).contains(&request.width) || !(1..=4096).contains(&request.height) || pixels > 4_194_304 || rgba.len() as u64 != pixels * 4 {
|
||||
return Err(JobError::new(ErrorCode::InvalidRequest, "invalid PNG dimensions or RGBA length"));
|
||||
}
|
||||
cancel.check()?;
|
||||
let bytes = Arc::new(Mutex::new(Vec::new()));
|
||||
let output = Output { bytes: bytes.clone(), limit: (pixels * 8 + 65_536) as usize, cancel: cancel.clone() };
|
||||
let mut encoder = png::Encoder::new(output, request.width, request.height);
|
||||
encoder.set_color(png::ColorType::Rgba);
|
||||
encoder.set_depth(png::BitDepth::Eight);
|
||||
encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual);
|
||||
let writer = encoder.write_header().and_then(|w| w.into_stream_writer()).map_err(encoding)?;
|
||||
Ok(Self { rgba, row: 0, width: request.width as usize, height: request.height as usize, bytes, writer: Some(writer), cancel })
|
||||
}
|
||||
}
|
||||
fn encoding(e: impl std::fmt::Display) -> JobError {
|
||||
JobError::new(ErrorCode::EncodingFailed, e.to_string())
|
||||
}
|
||||
impl ImageWork for PngTask {
|
||||
fn step(&mut self, _units: usize) -> Result<TaskPoll> {
|
||||
self.cancel.check()?;
|
||||
for _ in 0..4 {
|
||||
if self.row == self.height {
|
||||
break;
|
||||
}
|
||||
let start = self.row * self.width * 4;
|
||||
let row = self.rgba.get(start..start + self.width * 4).ok_or_else(|| encoding("missing PNG row"))?;
|
||||
self.writer.as_mut().ok_or_else(|| encoding("PNG already completed"))?.write_all(row).map_err(encoding)?;
|
||||
self.row += 1;
|
||||
}
|
||||
if self.row != self.height {
|
||||
return Ok(TaskPoll::Pending(Progress { phase: Phase::Encoding, done: self.row as u64, total: self.height as u64 }));
|
||||
}
|
||||
self.writer.take().ok_or_else(|| encoding("PNG already completed"))?.finish().map_err(encoding)?;
|
||||
self.cancel.check()?;
|
||||
let mut bytes = self.bytes.lock().map_err(|_| encoding("PNG lock poisoned"))?;
|
||||
Ok(TaskPoll::Complete(std::mem::take(&mut *bytes)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Immutable request-only planar opaque textures. Codecs run on the image worker.
|
||||
use super::{CancelToken, ErrorCode, JobError, Result};
|
||||
use crate::{
|
||||
ProductScene,
|
||||
surface::{srgb8_to_linear, unit_direction},
|
||||
texture_input::{MapRole, TextureSource},
|
||||
texture_sampling::TextureView,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
pub const MAX_TEXTURE_BINDINGS: usize = 16;
|
||||
pub const MAX_TEXTURE_PIXELS_BYTES: u64 = 32 * 1024 * 1024;
|
||||
pub const MAX_TEXTURE_SOURCE_BYTES: u64 = 16 * 1024 * 1024;
|
||||
pub const MAX_PLANAR_REPEAT: f64 = 128.0;
|
||||
|
||||
fn invalid(message: impl Into<String>) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidRequest, message)
|
||||
}
|
||||
fn v(a: [f64; 3]) -> Vec3 {
|
||||
Vec3::new(a[0], a[1], a[2])
|
||||
}
|
||||
|
||||
/// `u` and `v` span one full repeat in world-space millimetres. Image v increases
|
||||
/// downward; normal-map green points along the caller's positive v direction.
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlanarFrame {
|
||||
pub origin: [f64; 3],
|
||||
pub u: [f64; 3],
|
||||
pub v: [f64; 3],
|
||||
}
|
||||
impl PlanarFrame {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
let u = v(self.u);
|
||||
let vv = v(self.v);
|
||||
let ul = u.len();
|
||||
let vl = vv.len();
|
||||
if self.origin.into_iter().any(|c| !c.is_finite() || c.abs() > 1e12)
|
||||
|| !u.is_finite()
|
||||
|| !vv.is_finite()
|
||||
|| !(0.001..=1e6).contains(&ul)
|
||||
|| !(0.001..=1e6).contains(&vl)
|
||||
|| u.dot(vv).abs() > 1e-6 * ul * vl
|
||||
{
|
||||
return Err(invalid("planar texture frame requires finite origin and orthogonal u/v repeat vectors of 0.001..1000000 mm"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn uv(self, point: Vec3) -> Result<[f64; 2]> {
|
||||
self.validate()?;
|
||||
let d = point - v(self.origin);
|
||||
let u = v(self.u);
|
||||
let vv = v(self.v);
|
||||
let uv = [d.dot(u) / u.len2(), d.dot(vv) / vv.len2()];
|
||||
if uv.iter().any(|c| !c.is_finite() || c.abs() > MAX_PLANAR_REPEAT) {
|
||||
return Err(invalid("planar UV exceeds the GPU precision bound of 128 repeats"));
|
||||
}
|
||||
Ok(uv)
|
||||
}
|
||||
pub fn mapped_normal(self, normal: Vec3, geometric: Vec3, rgb: [f64; 3], strength: f64) -> Result<Vec3> {
|
||||
self.validate()?;
|
||||
if !strength.is_finite() || !(0.0..=1.0).contains(&strength) || rgb.iter().any(|c| !c.is_finite() || !(0.0..=1.0).contains(c)) {
|
||||
return Err(invalid("invalid tangent normal input"));
|
||||
}
|
||||
let n = unit_direction(normal).map_err(|e| invalid(e.to_string()))?;
|
||||
let g = unit_direction(geometric).map_err(|e| invalid(e.to_string()))?;
|
||||
let u = unit_direction(v(self.u)).map_err(|e| invalid(e.to_string()))?;
|
||||
let tangent = u - n * n.dot(u);
|
||||
if tangent.len2() < 1e-12 {
|
||||
return Ok(n);
|
||||
}
|
||||
let tangent = unit_direction(tangent).map_err(|e| invalid(e.to_string()))?;
|
||||
let handedness = if n.dot(v(self.u).cross(v(self.v))) < 0.0 { -1.0 } else { 1.0 };
|
||||
let local = Vec3::new((2.0 * rgb[0] - 1.0) * strength, (2.0 * rgb[1] - 1.0) * strength, 2.0 * rgb[2] - 1.0);
|
||||
if local.len2() < 1e-12 || local.z <= 0.0 {
|
||||
return Ok(n);
|
||||
}
|
||||
let mapped =
|
||||
unit_direction(tangent * local.x + n.cross(tangent) * (handedness * local.y) + n * local.z).map_err(|e| invalid(e.to_string()))?;
|
||||
Ok(if mapped.dot(g) <= 0.0 { n } else { mapped })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TextureMap {
|
||||
source: TextureSource,
|
||||
retained_bytes: usize,
|
||||
}
|
||||
impl std::fmt::Debug for TextureMap {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("TextureMap").field("info", self.source.info()).finish()
|
||||
}
|
||||
}
|
||||
impl TextureMap {
|
||||
pub fn new(bytes: Vec<u8>, role: MapRole) -> Result<Self> {
|
||||
if !matches!(role, MapRole::BaseColor | MapRole::NormalOpenGl) {
|
||||
return Err(invalid("only sRGB base color and OpenGL tangent normal maps are supported"));
|
||||
}
|
||||
let retained_bytes = bytes.capacity();
|
||||
let source = TextureSource::new(bytes, role).map_err(|e| invalid(e.to_string()))?;
|
||||
if source.info().width > 1024 || source.info().height > 1024 {
|
||||
return Err(invalid("runtime texture maps are limited to 1024x1024"));
|
||||
}
|
||||
Ok(Self { source, retained_bytes })
|
||||
}
|
||||
pub fn retained_bytes(&self) -> usize {
|
||||
self.retained_bytes
|
||||
}
|
||||
pub fn dimensions(&self) -> [u32; 2] {
|
||||
[self.source.info().width, self.source.info().height]
|
||||
}
|
||||
pub fn role(&self) -> MapRole {
|
||||
self.source.info().role
|
||||
}
|
||||
pub fn decode(&self, cancel: &CancelToken) -> Result<Vec<f32>> {
|
||||
cancel.check()?;
|
||||
let decoded = self.source.decode(|| cancel.is_cancelled()).map_err(|e| {
|
||||
if cancel.is_cancelled() { JobError::new(ErrorCode::Cancelled, "texture decoding cancelled") } else { invalid(e.to_string()) }
|
||||
})?;
|
||||
let mut rgba = Vec::new();
|
||||
rgba.try_reserve_exact(decoded.rgba().len()).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "texture pixel allocation failed"))?;
|
||||
for (index, pixel) in decoded.rgba().as_chunks::<4>().0.iter().enumerate() {
|
||||
if index % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
for (channel, value) in pixel.iter().enumerate() {
|
||||
let linear = if self.role() == MapRole::BaseColor && channel < 3 { srgb8_to_linear(*value) } else { f64::from(*value) / 255.0 };
|
||||
rgba.push(linear as f32);
|
||||
}
|
||||
}
|
||||
Ok(rgba)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TextureBinding {
|
||||
pub body_index: usize,
|
||||
pub body_name: String,
|
||||
pub frame: PlanarFrame,
|
||||
pub normal_strength: f64,
|
||||
pub base_color: Option<TextureMap>,
|
||||
pub normal: Option<TextureMap>,
|
||||
}
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RuntimeTextures {
|
||||
bindings: Vec<TextureBinding>,
|
||||
source_bytes: u64,
|
||||
pixel_bytes: u64,
|
||||
}
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct TextureSummary {
|
||||
pub bodies: usize,
|
||||
pub encoded_bytes: u64,
|
||||
pub linear_pixel_bytes: u64,
|
||||
}
|
||||
impl RuntimeTextures {
|
||||
pub fn new(bindings: Vec<TextureBinding>) -> Result<Self> {
|
||||
if bindings.is_empty() || bindings.len() > MAX_TEXTURE_BINDINGS {
|
||||
return Err(invalid("runtime textures require 1..16 distinct body bindings"));
|
||||
}
|
||||
let mut seen = std::collections::BTreeSet::new();
|
||||
let mut source_bytes = 0;
|
||||
let mut pixel_bytes = 0;
|
||||
for binding in &bindings {
|
||||
binding.frame.validate()?;
|
||||
if binding.body_index >= 16384
|
||||
|| binding.body_name.is_empty()
|
||||
|| binding.body_name.len() > 1024
|
||||
|| binding.body_name.chars().any(char::is_control)
|
||||
|| !seen.insert(binding.body_index)
|
||||
|| !binding.normal_strength.is_finite()
|
||||
|| !(0.0..=1.0).contains(&binding.normal_strength)
|
||||
|| (binding.base_color.is_none() && binding.normal.is_none())
|
||||
{
|
||||
return Err(invalid("invalid or duplicate runtime texture body binding"));
|
||||
}
|
||||
if binding.base_color.as_ref().is_some_and(|m| m.role() != MapRole::BaseColor)
|
||||
|| binding.normal.as_ref().is_some_and(|m| m.role() != MapRole::NormalOpenGl)
|
||||
{
|
||||
return Err(invalid("texture role does not match its binding"));
|
||||
}
|
||||
for map in [&binding.base_color, &binding.normal].into_iter().flatten() {
|
||||
source_bytes += map.retained_bytes as u64;
|
||||
pixel_bytes += map.source.info().rgba_bytes * 4;
|
||||
}
|
||||
}
|
||||
if source_bytes > MAX_TEXTURE_SOURCE_BYTES || pixel_bytes > MAX_TEXTURE_PIXELS_BYTES {
|
||||
return Err(invalid("runtime textures exceed 16 MiB encoded or 32 MiB linear pixel budget"));
|
||||
}
|
||||
Ok(Self { bindings, source_bytes, pixel_bytes })
|
||||
}
|
||||
pub fn bindings(&self) -> &[TextureBinding] {
|
||||
&self.bindings
|
||||
}
|
||||
pub fn summary(&self) -> TextureSummary {
|
||||
TextureSummary { bodies: self.bindings.len(), encoded_bytes: self.source_bytes, linear_pixel_bytes: self.pixel_bytes }
|
||||
}
|
||||
/// Source snapshots, codec scratch, decoded bytes, staging and device buffer,
|
||||
/// plus lookup records. Conservatively includes overlapping preparation.
|
||||
pub fn working_bytes(&self) -> u64 {
|
||||
self.source_bytes + self.pixel_bytes * 4 + 64 * 1024 * 1024 + 1024 * 1024
|
||||
}
|
||||
pub fn validate_scene(&self, scene: &ProductScene) -> Result<()> {
|
||||
for binding in &self.bindings {
|
||||
let body = scene
|
||||
.meshes
|
||||
.get(binding.body_index)
|
||||
.filter(|body| body.name == binding.body_name)
|
||||
.ok_or_else(|| invalid("texture ordinal and exact body name must match render.scene"))?;
|
||||
if body.opacity != 1.0 || body.transmission.is_some() || body.faces.iter().any(|f| f.opacity != 1.0 || f.transmission.is_some()) {
|
||||
return Err(invalid("texture maps currently support opaque bodies only; glass and alpha maps are unsupported"));
|
||||
}
|
||||
let bounds = body.mesh.bounds();
|
||||
for x in [bounds.min.x, bounds.max.x] {
|
||||
for y in [bounds.min.y, bounds.max.y] {
|
||||
for z in [bounds.min.z, bounds.max.z] {
|
||||
binding.frame.uv(Vec3::new(x, y, z))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Independent f64 bilinear reference over the worker's normalized pixels.
|
||||
pub fn sample_map(rgba: &[f32], dimensions: [u32; 2], uv: [f64; 2]) -> Result<[f64; 3]> {
|
||||
TextureView::new(rgba, dimensions).and_then(|v| v.sample_repeat_opaque(uv)).map_err(|e| invalid(e.to_string()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use image::ImageEncoder;
|
||||
fn png(width: u32, height: u32, pixels: &[u8]) -> Vec<u8> {
|
||||
let mut bytes = Vec::new();
|
||||
image::codecs::png::PngEncoder::new(&mut bytes).write_image(pixels, width, height, image::ExtendedColorType::Rgba8).unwrap();
|
||||
bytes
|
||||
}
|
||||
fn frame() -> PlanarFrame {
|
||||
PlanarFrame { origin: [0.; 3], u: [2., 0., 0.], v: [0., 4., 0.] }
|
||||
}
|
||||
fn binding() -> TextureBinding {
|
||||
TextureBinding {
|
||||
body_index: 0,
|
||||
body_name: "opaque".into(),
|
||||
frame: frame(),
|
||||
normal_strength: 1.,
|
||||
base_color: Some(TextureMap::new(png(1, 1, &[128, 64, 255, 0]), MapRole::BaseColor).unwrap()),
|
||||
normal: None,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn explicit_color_semantics_and_repeat_filter_ignore_alpha() {
|
||||
let color = TextureMap::new(png(2, 1, &[128, 64, 255, 0, 255, 0, 128, 255]), MapRole::BaseColor).unwrap();
|
||||
let data = TextureMap::new(png(1, 1, &[128, 64, 255, 0]), MapRole::NormalOpenGl).unwrap();
|
||||
let c = color.decode(&CancelToken::default()).unwrap();
|
||||
let n = data.decode(&CancelToken::default()).unwrap();
|
||||
assert!((f64::from(c[0]) - srgb8_to_linear(128)).abs() < 1e-7);
|
||||
assert!((n[0] - 128. / 255.).abs() < 1e-7);
|
||||
let a = sample_map(&c, [2, 1], [0.25, 0.5]).unwrap();
|
||||
let b = sample_map(&c, [2, 1], [-0.75, 100.5]).unwrap();
|
||||
assert_eq!(a, b);
|
||||
assert!((a[0] - srgb8_to_linear(128)).abs() < 1e-7);
|
||||
let seam = sample_map(&c, [2, 1], [0., 0.]).unwrap();
|
||||
assert!((seam[0] - (1. + a[0]) / 2.).abs() < 1e-7);
|
||||
let cancel = CancelToken::default();
|
||||
cancel.cancel();
|
||||
assert_eq!(color.decode(&cancel).unwrap_err().code, ErrorCode::Cancelled);
|
||||
}
|
||||
#[test]
|
||||
fn tangent_frame_preserves_handedness_and_falls_back_safely() {
|
||||
let f = frame();
|
||||
assert_eq!(f.uv(Vec3::new(-1., 2., 900.)).unwrap(), [-0.5, 0.5]);
|
||||
let normal = f.mapped_normal(Vec3::Z, Vec3::Z, [0.5, 1., 1.], 1.).unwrap();
|
||||
assert!((normal - Vec3::new(0., 1., 1.).normalized().unwrap()).len() < 1e-12);
|
||||
let mirrored = PlanarFrame { v: [0., -4., 0.], ..f }.mapped_normal(Vec3::Z, Vec3::Z, [0.5, 1., 1.], 1.).unwrap();
|
||||
assert!((mirrored - Vec3::new(0., -1., 1.).normalized().unwrap()).len() < 1e-12);
|
||||
for rgb in [[0.5; 3], [0., 0., 0.], [1., 1., 0.5]] {
|
||||
assert_eq!(f.mapped_normal(Vec3::Z, Vec3::Z, rgb, 1.).unwrap(), Vec3::Z);
|
||||
}
|
||||
assert_eq!(f.mapped_normal(Vec3::X, Vec3::X, [1.; 3], 1.).unwrap(), Vec3::X);
|
||||
assert!(PlanarFrame { u: [0.; 3], ..f }.validate().is_err());
|
||||
assert!(PlanarFrame { v: [1., 4., 0.], ..f }.validate().is_err());
|
||||
assert!(f.uv(Vec3::new(258., 0., 0.)).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn bounded_resources_reject_duplicate_roles_and_encoded_capacity() {
|
||||
let b = binding();
|
||||
let t = RuntimeTextures::new(vec![b.clone()]).unwrap();
|
||||
assert_eq!(t.summary().linear_pixel_bytes, 16);
|
||||
assert!(RuntimeTextures::new(vec![b.clone(), b]).is_err());
|
||||
let mut wrong = binding();
|
||||
wrong.normal = wrong.base_color.take();
|
||||
assert!(RuntimeTextures::new(vec![wrong]).is_err());
|
||||
let mut bytes = png(1, 1, &[0; 4]);
|
||||
bytes.reserve_exact(16 * 1024 * 1024);
|
||||
assert!(TextureMap::new(bytes, MapRole::BaseColor).is_err());
|
||||
assert!(TextureMap::new(png(1, 1, &[0; 4]), MapRole::Roughness).is_err());
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,16 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod analysis;
|
||||
pub mod anisotropic_surface;
|
||||
mod backend;
|
||||
mod camera;
|
||||
pub mod clear_coat;
|
||||
mod curve_display;
|
||||
pub mod film;
|
||||
mod gif;
|
||||
pub mod job;
|
||||
pub mod lighting;
|
||||
pub mod mapping;
|
||||
mod product;
|
||||
mod raster;
|
||||
mod scene;
|
||||
@@ -20,6 +24,7 @@ pub mod texture_input;
|
||||
pub mod texture_interpolation;
|
||||
pub mod texture_lod;
|
||||
pub mod texture_sampling;
|
||||
pub mod transmission;
|
||||
|
||||
pub use analysis::{AnalysisColors, AnalysisMode, SceneAnalysis};
|
||||
pub use backend::{
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
//! Captured runtime environment input and original exact-solid-angle importance tables.
|
||||
//! The HDR decoder retains source primaries: admission below makes interpretation explicit.
|
||||
use super::{HdrDecodeTask, HdrSource, HdrTaskPoll};
|
||||
use crate::job::{CancelToken, ErrorCode, JobError, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use solidcraft_geom::Vec3;
|
||||
use std::{
|
||||
f64::consts::{PI, TAU},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
fn invalid(message: impl Into<String>) -> JobError {
|
||||
JobError::new(ErrorCode::InvalidRequest, message)
|
||||
}
|
||||
fn reserve<T>(count: usize) -> Result<Vec<T>> {
|
||||
let mut v = Vec::new();
|
||||
v.try_reserve_exact(count).map_err(|_| JobError::new(ErrorCode::AllocationFailed, "environment allocation failed"))?;
|
||||
Ok(v)
|
||||
}
|
||||
|
||||
/// Original artistic gradient, not a calibrated atmosphere. No imported image assets.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct SkyGradient {
|
||||
pub zenith: [f32; 3],
|
||||
pub horizon: [f32; 3],
|
||||
pub ground: [f32; 3],
|
||||
}
|
||||
impl Default for SkyGradient {
|
||||
fn default() -> Self {
|
||||
Self { zenith: [0.16, 0.32, 0.7], horizon: [0.8, 0.9, 1.0], ground: [0.12, 0.1, 0.08] }
|
||||
}
|
||||
}
|
||||
impl SkyGradient {
|
||||
pub fn validate(self) -> Result<()> {
|
||||
if self.zenith.into_iter().chain(self.horizon).chain(self.ground).any(|v| !v.is_finite() || !(0.0..=1e6).contains(&v)) {
|
||||
return Err(invalid("sky radiance must be finite and within 0..1000000"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn evaluate(self, z: f64) -> [f32; 3] {
|
||||
let endpoint = if z >= 0.0 { self.zenith } else { self.ground };
|
||||
let blend = z.abs().sqrt() as f32;
|
||||
std::array::from_fn(|i| self.horizon[i] * (1.0 - blend) + endpoint[i] * blend)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct EnvironmentInfo {
|
||||
pub kind: &'static str,
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub encoded_bytes: u64,
|
||||
pub source_sha256: String,
|
||||
pub color_policy: &'static str,
|
||||
pub rotation_rad: f64,
|
||||
pub intensity: f64,
|
||||
}
|
||||
#[derive(Clone)]
|
||||
enum Source {
|
||||
Hdr(HdrSource),
|
||||
Sky(SkyGradient),
|
||||
}
|
||||
/// Immutable captured input. Preparation happens in the image worker, with cancellation.
|
||||
#[derive(Clone)]
|
||||
pub struct EnvironmentSource {
|
||||
source: Source,
|
||||
info: EnvironmentInfo,
|
||||
retained_bytes: u64,
|
||||
}
|
||||
impl std::fmt::Debug for EnvironmentSource {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("EnvironmentSource").field("info", &self.info).finish()
|
||||
}
|
||||
}
|
||||
impl EnvironmentSource {
|
||||
pub fn hdr(bytes: Vec<u8>, rotation_rad: f64, intensity: f64, assume_linear_srgb: bool) -> Result<Self> {
|
||||
let retained_bytes = bytes.capacity() as u64;
|
||||
let source = HdrSource::new(bytes).map_err(|e| invalid(e.to_string()))?;
|
||||
let primaries = [0.64, 0.33, 0.30, 0.60, 0.15, 0.06, 0.3127, 0.3290];
|
||||
let color_policy = match source.info().primaries {
|
||||
Some(actual) if actual.iter().zip(primaries).all(|(a, b)| (a - b).abs() < 1e-5) => "declared_linear_srgb_d65",
|
||||
Some(_) => return Err(invalid("HDR primaries must be sRGB/D65; convert the source before rendering")),
|
||||
None if assume_linear_srgb => "explicit_assumed_linear_srgb_d65",
|
||||
None => {
|
||||
return Err(invalid("HDR primaries are unspecified: explicitly set assume_linear_srgb or supply a converted source with PRIMARIES"));
|
||||
}
|
||||
};
|
||||
let info = EnvironmentInfo {
|
||||
kind: "radiance_hdr",
|
||||
width: source.info().width,
|
||||
height: source.info().height,
|
||||
encoded_bytes: source.info().encoded_bytes,
|
||||
source_sha256: format!("{:x}", Sha256::digest(source.encoded_bytes())),
|
||||
color_policy,
|
||||
rotation_rad,
|
||||
intensity,
|
||||
};
|
||||
let value = Self { source: Source::Hdr(source), info, retained_bytes };
|
||||
value.validate()?;
|
||||
Ok(value)
|
||||
}
|
||||
pub fn sky(sky: SkyGradient, rotation_rad: f64, intensity: f64) -> Result<Self> {
|
||||
sky.validate()?;
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"solidcraft.original-gradient-sky/1");
|
||||
for v in sky.zenith.into_iter().chain(sky.horizon).chain(sky.ground) {
|
||||
hash.update(v.to_le_bytes());
|
||||
}
|
||||
let value = Self {
|
||||
source: Source::Sky(sky),
|
||||
retained_bytes: 0,
|
||||
info: EnvironmentInfo {
|
||||
kind: "original_gradient_sky",
|
||||
width: 512,
|
||||
height: 256,
|
||||
encoded_bytes: 0,
|
||||
source_sha256: format!("{:x}", hash.finalize()),
|
||||
color_policy: "authored_linear_srgb_d65",
|
||||
rotation_rad,
|
||||
intensity,
|
||||
},
|
||||
};
|
||||
value.validate()?;
|
||||
Ok(value)
|
||||
}
|
||||
pub fn info(&self) -> &EnvironmentInfo {
|
||||
&self.info
|
||||
}
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if !self.info.rotation_rad.is_finite()
|
||||
|| self.info.rotation_rad.abs() > 1e6
|
||||
|| !self.info.intensity.is_finite()
|
||||
|| !(0.0..=100.0).contains(&self.info.intensity)
|
||||
{
|
||||
return Err(invalid("environment rotation must be finite within +/-1000000 radians and intensity 0..100"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
/// Conservative additional peak: encoded bytes, decode/table/alias work, staging and GPU storage.
|
||||
pub fn admitted_bytes(&self) -> u64 {
|
||||
self.retained_bytes + u64::from(self.info.width) * u64::from(self.info.height) * 160 + 65536
|
||||
}
|
||||
pub fn prepare(&self, cancel: &CancelToken) -> Result<PreparedEnvironment> {
|
||||
self.validate()?;
|
||||
cancel.check()?;
|
||||
let pixels = match &self.source {
|
||||
Source::Hdr(source) => {
|
||||
let mut task = HdrDecodeTask::new(source.clone()).map_err(|e| invalid(e.to_string()))?;
|
||||
let decoded = loop {
|
||||
cancel.check()?;
|
||||
match task.step(16384, || cancel.is_cancelled()).map_err(|e| {
|
||||
if cancel.is_cancelled() {
|
||||
JobError::new(ErrorCode::Cancelled, "environment preparation cancelled")
|
||||
} else {
|
||||
invalid(e.to_string())
|
||||
}
|
||||
})? {
|
||||
HdrTaskPoll::Pending(_) => {}
|
||||
HdrTaskPoll::Complete(decoded) => break decoded,
|
||||
}
|
||||
};
|
||||
let mut out = reserve(decoded.pixels().len())?;
|
||||
out.extend_from_slice(decoded.pixels());
|
||||
out
|
||||
}
|
||||
Source::Sky(sky) => {
|
||||
let count = (self.info.width * self.info.height) as usize;
|
||||
let mut out = reserve(count)?;
|
||||
for y in 0..self.info.height {
|
||||
cancel.check()?;
|
||||
let z = (PI * (f64::from(y) + 0.5) / f64::from(self.info.height)).cos();
|
||||
for _ in 0..self.info.width {
|
||||
out.push(sky.evaluate(z));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
};
|
||||
PreparedEnvironment::new(self.info.width, self.info.height, pixels, cancel)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct EnvironmentTexel {
|
||||
pub rgb: [f32; 3],
|
||||
/// Density per steradian, not probability per texel.
|
||||
pub pdf: f32,
|
||||
pub threshold: f32,
|
||||
pub alias: u32,
|
||||
}
|
||||
pub struct PreparedEnvironment {
|
||||
width: u32,
|
||||
height: u32,
|
||||
texels: Vec<EnvironmentTexel>,
|
||||
}
|
||||
impl PreparedEnvironment {
|
||||
fn new(width: u32, height: u32, pixels: Vec<[f32; 3]>, cancel: &CancelToken) -> Result<Self> {
|
||||
let count = (width * height) as usize;
|
||||
if count != pixels.len() || count == 0 {
|
||||
return Err(invalid("invalid environment storage"));
|
||||
}
|
||||
let mut masses = reserve(count)?;
|
||||
let mut total = 0.0;
|
||||
for (i, rgb) in pixels.iter().enumerate() {
|
||||
if i % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let mass = (0.2126 * f64::from(rgb[0]) + 0.7152 * f64::from(rgb[1]) + 0.0722 * f64::from(rgb[2]))
|
||||
* row_solid_angle(width, height, i / width as usize);
|
||||
total += mass;
|
||||
masses.push(mass);
|
||||
}
|
||||
if total == 0.0 {
|
||||
for (i, mass) in masses.iter_mut().enumerate() {
|
||||
*mass = row_solid_angle(width, height, i / width as usize);
|
||||
}
|
||||
total = 4.0 * PI;
|
||||
}
|
||||
let mut small = reserve(count)?;
|
||||
let mut large = reserve(count)?;
|
||||
let mut texels = reserve(count)?;
|
||||
for (i, (rgb, mass)) in pixels.into_iter().zip(masses.iter_mut()).enumerate() {
|
||||
if i % 1024 == 0 {
|
||||
cancel.check()?;
|
||||
}
|
||||
let probability = *mass / total;
|
||||
texels.push(EnvironmentTexel {
|
||||
rgb,
|
||||
pdf: (probability / row_solid_angle(width, height, i / width as usize)) as f32,
|
||||
threshold: 1.0,
|
||||
alias: i as u32,
|
||||
});
|
||||
*mass = probability * count as f64;
|
||||
if *mass < 1.0 {
|
||||
small.push(i);
|
||||
} else {
|
||||
large.push(i);
|
||||
}
|
||||
}
|
||||
while !small.is_empty() && !large.is_empty() {
|
||||
cancel.check()?;
|
||||
let low = small.pop().ok_or_else(|| invalid("invalid alias table"))?;
|
||||
let high = large.pop().ok_or_else(|| invalid("invalid alias table"))?;
|
||||
let value = *masses.get(low).ok_or_else(|| invalid("invalid alias mass"))?;
|
||||
let entry = texels.get_mut(low).ok_or_else(|| invalid("invalid alias index"))?;
|
||||
entry.threshold = value as f32;
|
||||
entry.alias = high as u32;
|
||||
let remaining = masses.get_mut(high).ok_or_else(|| invalid("invalid alias mass"))?;
|
||||
*remaining = (*remaining + value - 1.0).max(0.0);
|
||||
if *remaining < 1.0 {
|
||||
small.push(high);
|
||||
} else {
|
||||
large.push(high);
|
||||
}
|
||||
}
|
||||
cancel.check()?;
|
||||
Ok(Self { width, height, texels })
|
||||
}
|
||||
pub fn dimensions(&self) -> [u32; 2] {
|
||||
[self.width, self.height]
|
||||
}
|
||||
pub fn texels(&self) -> &[EnvironmentTexel] {
|
||||
&self.texels
|
||||
}
|
||||
/// CPU oracle of the same original piecewise-constant sphere mapping as WGSL.
|
||||
pub fn sample(&self, choice: f64, coin: f64, azimuth: f64, vertical: f64) -> Result<(Vec3, [f32; 3], f32)> {
|
||||
if [choice, coin, azimuth, vertical].into_iter().any(|v| !v.is_finite() || !(0.0..1.0).contains(&v)) {
|
||||
return Err(invalid("environment samples must be in [0,1)"));
|
||||
}
|
||||
let scaled = choice * self.texels.len() as f64;
|
||||
let index = scaled.floor() as usize;
|
||||
let entry = self.texels.get(index).ok_or_else(|| invalid("environment sample index"))?;
|
||||
let index = if coin < f64::from(entry.threshold) { index } else { entry.alias as usize };
|
||||
let selected = self.texels.get(index).ok_or_else(|| invalid("environment alias index"))?;
|
||||
let row = index / self.width as usize;
|
||||
let column = index % self.width as usize;
|
||||
let top = (PI * row as f64 / f64::from(self.height)).cos();
|
||||
let bottom = (PI * (row + 1) as f64 / f64::from(self.height)).cos();
|
||||
let z = top + (bottom - top) * vertical;
|
||||
let phi = TAU * (column as f64 + azimuth) / f64::from(self.width);
|
||||
let radius = (1.0 - z * z).max(0.0).sqrt();
|
||||
Ok((Vec3::new(radius * phi.cos(), radius * phi.sin(), z), selected.rgb, selected.pdf))
|
||||
}
|
||||
pub fn evaluate(&self, direction: Vec3) -> Result<([f32; 3], f32)> {
|
||||
let d = crate::surface::unit_direction(direction).map_err(|_| invalid("invalid environment direction"))?;
|
||||
let column = ((d.y.atan2(d.x).rem_euclid(TAU) / TAU * f64::from(self.width)) as usize).min(self.width as usize - 1);
|
||||
let row = ((d.z.clamp(-1.0, 1.0).acos() / PI * f64::from(self.height)) as usize).min(self.height as usize - 1);
|
||||
let texel = self.texels.get(row * self.width as usize + column).ok_or_else(|| invalid("environment lookup index"))?;
|
||||
Ok((texel.rgb, texel.pdf))
|
||||
}
|
||||
}
|
||||
fn row_solid_angle(width: u32, height: u32, row: usize) -> f64 {
|
||||
// Stable difference of cosines at polar rows.
|
||||
let half = PI / (2.0 * f64::from(height));
|
||||
(TAU / f64::from(width)) * 2.0 * (PI * (row as f64 + 0.5) / f64::from(height)).sin() * half.sin()
|
||||
}
|
||||
|
||||
/// Shared captured source, cheap to clone into queued jobs.
|
||||
pub type EnvironmentResource = Arc<EnvironmentSource>;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,104 @@
|
||||
use super::*;
|
||||
|
||||
fn hdr(extra: &str, pixels: &[[u8; 4]]) -> Vec<u8> {
|
||||
let mut bytes = format!("#?RADIANCE\nFORMAT=32-bit_rle_rgbe\n{extra}\n-Y 2 +X 4\n").into_bytes();
|
||||
for pixel in pixels {
|
||||
bytes.extend_from_slice(pixel);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
#[test]
|
||||
fn captured_hdr_requires_explicit_color_policy_and_prepares_without_clipping() {
|
||||
let bytes = hdr("", &[[128, 64, 32, 130]; 8]);
|
||||
assert!(EnvironmentSource::hdr(bytes.clone(), 0.0, 1.0, false).is_err());
|
||||
let source = EnvironmentSource::hdr(bytes, 0.3, 2.0, true).unwrap();
|
||||
assert_eq!(source.info().source_sha256.len(), 64);
|
||||
assert_eq!(source.info().color_policy, "explicit_assumed_linear_srgb_d65");
|
||||
let prepared = source.prepare(&CancelToken::default()).unwrap();
|
||||
assert_eq!(prepared.dimensions(), [4, 2]);
|
||||
assert!(prepared.texels().iter().all(|t| t.rgb == [2.0, 1.0, 0.5]));
|
||||
let wrong = hdr("PRIMARIES=0.67 0.33 0.21 0.71 0.14 0.08 0.31 0.33\n", &[[128, 128, 128, 129]; 8]);
|
||||
assert!(EnvironmentSource::hdr(wrong, 0.0, 1.0, true).is_err());
|
||||
let declared = hdr("PRIMARIES=0.64 0.33 0.30 0.60 0.15 0.06 0.3127 0.3290\n", &[[128, 128, 128, 129]; 8]);
|
||||
assert!(EnvironmentSource::hdr(declared, 0.0, 1.0, false).is_ok());
|
||||
assert!(EnvironmentSource::sky(SkyGradient::default(), f64::NAN, 1.0).is_err());
|
||||
assert!(EnvironmentSource::sky(SkyGradient::default(), 0.0, 101.0).is_err());
|
||||
let token = CancelToken::default();
|
||||
token.cancel();
|
||||
assert_eq!(source.prepare(&token).err().unwrap().code, ErrorCode::Cancelled);
|
||||
}
|
||||
#[test]
|
||||
fn exact_solid_angle_distribution_handles_poles_black_and_white_maps() {
|
||||
for color in [[0.0; 3], [1.0; 3]] {
|
||||
let map = PreparedEnvironment::new(64, 32, vec![color; 2048], &CancelToken::default()).unwrap();
|
||||
let total: f64 = map.texels().iter().enumerate().map(|(i, t)| f64::from(t.pdf) * row_solid_angle(64, 32, i / 64)).sum();
|
||||
assert!((total - 1.0).abs() < 1e-7);
|
||||
for t in map.texels() {
|
||||
assert!((f64::from(t.pdf) - 1.0 / (4.0 * PI)).abs() < 1e-8);
|
||||
}
|
||||
for d in [Vec3::Z, -Vec3::Z, Vec3::X, Vec3::new(1.0, -1e-12, 0.0)] {
|
||||
let (value, pdf) = map.evaluate(d).unwrap();
|
||||
assert_eq!(value, color);
|
||||
assert!((f64::from(pdf) - 1.0 / (4.0 * PI)).abs() < 1e-8);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn alias_samples_match_unequal_texel_probabilities_and_lookup_pdf() {
|
||||
let mut pixels = vec![[0.0; 3]; 32];
|
||||
pixels[2] = [1.0; 3];
|
||||
pixels[11] = [4.0; 3];
|
||||
pixels[29] = [2.0; 3];
|
||||
let map = PreparedEnvironment::new(8, 4, pixels, &CancelToken::default()).unwrap();
|
||||
let mut counts = [0usize; 32];
|
||||
let mut rng = 17u64;
|
||||
let mut random = || {
|
||||
rng = rng.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
(rng >> 11) as f64 / (1u64 << 53) as f64
|
||||
};
|
||||
let count = 200_000;
|
||||
for _ in 0..count {
|
||||
let (direction, rgb, pdf) = map.sample(random(), random(), random(), random()).unwrap();
|
||||
let (actual, actual_pdf) = map.evaluate(direction).unwrap();
|
||||
assert_eq!(rgb, actual);
|
||||
assert_eq!(pdf, actual_pdf);
|
||||
let index = if rgb == [1.0; 3] {
|
||||
2
|
||||
} else if rgb == [4.0; 3] {
|
||||
11
|
||||
} else {
|
||||
assert_eq!(rgb, [2.0; 3]);
|
||||
29
|
||||
};
|
||||
counts[index] += 1;
|
||||
}
|
||||
for i in [2, 11, 29] {
|
||||
let expected = f64::from(map.texels()[i].pdf) * row_solid_angle(8, 4, i / 8);
|
||||
assert!((counts[i] as f64 / f64::from(count) - expected).abs() < 0.004);
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn original_sky_is_bounded_cancellable_and_reproducible() {
|
||||
let source = EnvironmentSource::sky(SkyGradient::default(), 0.0, 1.0).unwrap();
|
||||
let a = source.prepare(&CancelToken::default()).unwrap();
|
||||
assert_eq!(a.dimensions(), [512, 256]);
|
||||
let (zenith, _) = a.evaluate(Vec3::Z).unwrap();
|
||||
let (horizon, _) = a.evaluate(Vec3::X).unwrap();
|
||||
let (ground, _) = a.evaluate(-Vec3::Z).unwrap();
|
||||
assert!(zenith[2] > zenith[0]);
|
||||
assert!(horizon[0] > zenith[0]);
|
||||
assert!(ground[0] > ground[2]);
|
||||
assert!(source.admitted_bytes() > a.texels().len() as u64 * 64);
|
||||
assert!(EnvironmentSource::sky(SkyGradient { zenith: [f32::INFINITY; 3], ..Default::default() }, 0.0, 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hdr_admission_counts_retained_capacity_not_just_file_length() {
|
||||
let bytes = hdr("", &[[128, 128, 128, 129]; 8]);
|
||||
let mut overallocated = Vec::with_capacity(super::super::MAX_HDR_BYTES);
|
||||
overallocated.extend_from_slice(&bytes);
|
||||
let retained = overallocated.capacity() as u64;
|
||||
let source = EnvironmentSource::hdr(overallocated, 0.0, 1.0, true).unwrap();
|
||||
assert_eq!(source.info().encoded_bytes, bytes.len() as u64);
|
||||
assert_eq!(source.admitted_bytes(), retained + 8 * 160 + 65536);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Device-independent, bounded linear HDR input preparation.
|
||||
|
||||
pub mod environment;
|
||||
mod hdr;
|
||||
mod task;
|
||||
pub use hdr::{HdrInfo, HdrSource, MAX_HDR_BYTES, MAX_HDR_HEIGHT, MAX_HDR_PIXELS, MAX_HDR_WIDTH};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Definition-local visual coordinates, independent of geometry and engineering axes.
|
||||
//! This numerical foundation installs no texture sampler, shader or persistent record.
|
||||
|
||||
use solidcraft_geom::{Aabb3, Vec3};
|
||||
|
||||
/// Admission bounds, not a GPU precision or engineering applicability guarantee.
|
||||
pub const MAX_LOCAL_MM: f64 = 1e9;
|
||||
pub const MIN_REPEAT_MM: f64 = 1e-3;
|
||||
pub const MAX_REPEAT_MM: f64 = 1e6;
|
||||
pub const MAX_REPEAT_COORDINATE: f64 = 1e6;
|
||||
/// Absolute tolerance for unit-length squared, orthogonality and handedness checks.
|
||||
pub const FRAME_TOLERANCE: f64 = 1e-10;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum MappingError {
|
||||
Position,
|
||||
Frame,
|
||||
RepeatSize,
|
||||
Coordinates,
|
||||
Bounds,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MappingError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Position => "visual mapping positions must be finite and within +/-1e9 mm",
|
||||
Self::Frame => "visual mapping axes must form a right-handed orthonormal frame",
|
||||
Self::RepeatSize => "visual repeat sizes must be finite and within [0.001, 1e6] mm",
|
||||
Self::Coordinates => "visual repeat coordinates exceed the +/-1e6 admission limit",
|
||||
Self::Bounds => "grain suggestion needs finite ordered local bounds with a positive extent",
|
||||
})
|
||||
}
|
||||
}
|
||||
impl std::error::Error for MappingError {}
|
||||
|
||||
/// A one-time visual suggestion; not a material-local mechanical L/R/T assignment.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum GrainAxis {
|
||||
X,
|
||||
Y,
|
||||
Z,
|
||||
}
|
||||
|
||||
impl GrainAxis {
|
||||
/// First axis is the visual grain direction. The other two complete a right-handed frame.
|
||||
pub fn axes(self) -> [Vec3; 3] {
|
||||
match self {
|
||||
Self::X => [Vec3::X, Vec3::Y, Vec3::Z],
|
||||
Self::Y => [Vec3::Y, Vec3::Z, Vec3::X],
|
||||
Self::Z => [Vec3::Z, Vec3::X, Vec3::Y],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggest from definition-local bounds only, with exact ties resolved X, then Y, then Z.
|
||||
/// The caller stores an accepted frame once; this function never observes a later rebuild.
|
||||
pub fn suggest_grain_axis(local_bounds: Aabb3) -> Result<GrainAxis, MappingError> {
|
||||
if !bounded_position(local_bounds.min) || !bounded_position(local_bounds.max) || local_bounds.is_empty() {
|
||||
return Err(MappingError::Bounds);
|
||||
}
|
||||
let extent = local_bounds.max - local_bounds.min;
|
||||
if extent.x.max(extent.y).max(extent.z) <= 0.0 {
|
||||
return Err(MappingError::Bounds);
|
||||
}
|
||||
if extent.x >= extent.y && extent.x >= extent.z {
|
||||
Ok(GrainAxis::X)
|
||||
} else if extent.y >= extent.z {
|
||||
Ok(GrainAxis::Y)
|
||||
} else {
|
||||
Ok(GrainAxis::Z)
|
||||
}
|
||||
}
|
||||
|
||||
/// Immutable admitted visual frame. Fields cannot be changed without readmission.
|
||||
/// No serde implementation: eventual document/catalog readers must use `new`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct VisualMapping {
|
||||
origin_mm: Vec3,
|
||||
axes: [Vec3; 3],
|
||||
repeat_mm: Vec3,
|
||||
}
|
||||
|
||||
impl VisualMapping {
|
||||
/// Axes are unit directions in definition-local space, in grain/across/depth order.
|
||||
/// Repeat sizes are distances along those directions, not body-bound fractions.
|
||||
/// Small floating-point frame error is admitted within `FRAME_TOLERANCE`; no skew,
|
||||
/// reflection, rescaling, orthogonalization or replacement frame is manufactured.
|
||||
pub fn new(origin_mm: Vec3, axes: [Vec3; 3], repeat_mm: Vec3) -> Result<Self, MappingError> {
|
||||
if !bounded_position(origin_mm) {
|
||||
return Err(MappingError::Position);
|
||||
}
|
||||
let [x, y, z] = axes;
|
||||
if !axes.into_iter().all(|v| v.is_finite() && (v.len2() - 1.0).abs() <= FRAME_TOLERANCE)
|
||||
|| x.dot(y).abs() > FRAME_TOLERANCE
|
||||
|| x.dot(z).abs() > FRAME_TOLERANCE
|
||||
|| y.dot(z).abs() > FRAME_TOLERANCE
|
||||
|| (x.cross(y).dot(z) - 1.0).abs() > FRAME_TOLERANCE
|
||||
{
|
||||
return Err(MappingError::Frame);
|
||||
}
|
||||
if ![repeat_mm.x, repeat_mm.y, repeat_mm.z].into_iter().all(|v| v.is_finite() && (MIN_REPEAT_MM..=MAX_REPEAT_MM).contains(&v)) {
|
||||
return Err(MappingError::RepeatSize);
|
||||
}
|
||||
Ok(Self { origin_mm, axes, repeat_mm })
|
||||
}
|
||||
|
||||
pub fn origin_mm(self) -> Vec3 {
|
||||
self.origin_mm
|
||||
}
|
||||
pub fn axes(self) -> [Vec3; 3] {
|
||||
self.axes
|
||||
}
|
||||
pub fn repeat_mm(self) -> Vec3 {
|
||||
self.repeat_mm
|
||||
}
|
||||
|
||||
/// Unwrapped signed repeat coordinates, calculated in f64. No clamping or UV seam.
|
||||
/// The host must first invert the selected instance placement into definition-local
|
||||
/// space. World-space bounding boxes and camera/mesh positions are not inputs here.
|
||||
/// Samplers choose wrapping/filtering; GPU consumers need their own precision budget.
|
||||
pub fn coordinates(self, local_point_mm: Vec3) -> Result<Vec3, MappingError> {
|
||||
if !bounded_position(local_point_mm) {
|
||||
return Err(MappingError::Position);
|
||||
}
|
||||
let delta = local_point_mm - self.origin_mm;
|
||||
let [x, y, z] = self.axes;
|
||||
let result = Vec3::new(delta.dot(x) / self.repeat_mm.x, delta.dot(y) / self.repeat_mm.y, delta.dot(z) / self.repeat_mm.z);
|
||||
if !result.is_finite() || [result.x, result.y, result.z].into_iter().any(|v| v.abs() > MAX_REPEAT_COORDINATE) {
|
||||
return Err(MappingError::Coordinates);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn bounded_position(value: Vec3) -> bool {
|
||||
value.is_finite() && [value.x, value.y, value.z].into_iter().all(|v| v.abs() <= MAX_LOCAL_MM)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mapping_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,112 @@
|
||||
use super::*;
|
||||
|
||||
fn close(actual: Vec3, expected: Vec3) {
|
||||
assert!((actual - expected).len() < 1e-10, "{actual:?} != {expected:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn physical_repeats_translation_negative_coordinates_and_manual_rotation() {
|
||||
// +90 degrees around local Z: grain +Y, across -X, depth +Z.
|
||||
let origin = Vec3::new(10.0, -20.0, 30.0);
|
||||
let mapping = VisualMapping::new(origin, [Vec3::Y, -Vec3::X, Vec3::Z], Vec3::new(100.0, 20.0, 5.0)).unwrap();
|
||||
close(mapping.coordinates(origin).unwrap(), Vec3::ZERO);
|
||||
close(mapping.coordinates(origin + Vec3::new(-10.0, 200.0, -15.0)).unwrap(), Vec3::new(2.0, 0.5, -3.0));
|
||||
// One repeat is a physical distance, independent of a body's dimensions.
|
||||
close(mapping.coordinates(origin + Vec3::Y * 100.0).unwrap(), Vec3::X);
|
||||
assert_eq!(mapping.origin_mm(), origin);
|
||||
assert_eq!(mapping.axes(), [Vec3::Y, -Vec3::X, Vec3::Z]);
|
||||
assert_eq!(mapping.repeat_mm(), Vec3::new(100.0, 20.0, 5.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oblique_frame_has_analytic_coordinates_without_automatic_orthogonalization() {
|
||||
let a = std::f64::consts::FRAC_1_SQRT_2;
|
||||
let axes = [Vec3::new(a, a, 0.0), Vec3::new(-a, a, 0.0), Vec3::Z];
|
||||
let mapping = VisualMapping::new(Vec3::ZERO, axes, Vec3::new(2.0, 3.0, 4.0)).unwrap();
|
||||
close(mapping.coordinates(Vec3::new(2.0 * a, 2.0 * a, 0.0)).unwrap(), Vec3::X);
|
||||
close(mapping.coordinates(Vec3::new(-3.0 * a, 3.0 * a, 4.0)).unwrap(), Vec3::new(0.0, 1.0, 1.0));
|
||||
assert_eq!(mapping.axes(), axes);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_local_axis_uses_xyz_ties_and_accepts_planar_bounds() {
|
||||
for (extent, expected) in [
|
||||
(Vec3::new(3.0, 3.0, 3.0), GrainAxis::X),
|
||||
(Vec3::new(2.0, 3.0, 3.0), GrainAxis::Y),
|
||||
(Vec3::new(1.0, 2.0, 3.0), GrainAxis::Z),
|
||||
(Vec3::new(0.0, 3.0, 0.0), GrainAxis::Y),
|
||||
] {
|
||||
let min = Vec3::new(10.0, -4.0, 2.0);
|
||||
let axis = suggest_grain_axis(Aabb3 { min, max: min + extent }).unwrap();
|
||||
assert_eq!(axis, expected);
|
||||
assert!(VisualMapping::new(Vec3::ZERO, axis.axes(), Vec3::new(1.0, 1.0, 1.0)).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_frame_does_not_flip_after_a_different_resize_suggestion() {
|
||||
let first = suggest_grain_axis(Aabb3 { min: Vec3::ZERO, max: Vec3::new(100.0, 20.0, 10.0) }).unwrap();
|
||||
let mapping = VisualMapping::new(Vec3::ZERO, first.axes(), Vec3::new(10.0, 10.0, 10.0)).unwrap();
|
||||
let point = Vec3::new(25.0, 15.0, 5.0);
|
||||
let before = mapping.coordinates(point).unwrap();
|
||||
assert_eq!(suggest_grain_axis(Aabb3 { min: Vec3::ZERO, max: Vec3::new(100.0, 200.0, 10.0) }).unwrap(), GrainAxis::Y);
|
||||
assert_eq!(mapping.coordinates(point).unwrap(), before);
|
||||
close(before, Vec3::new(2.5, 1.5, 0.5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rigid_instances_share_definition_coordinates_after_explicit_inverse_placement() {
|
||||
let mapping = VisualMapping::new(Vec3::new(1.0, 2.0, 3.0), GrainAxis::Z.axes(), Vec3::new(5.0, 10.0, 20.0)).unwrap();
|
||||
let definition_point = Vec3::new(21.0, -8.0, 13.0);
|
||||
let expected = Vec3::new(2.0, 2.0, -0.5);
|
||||
close(mapping.coordinates(definition_point).unwrap(), expected);
|
||||
// Independent analytic instance transform: (x,y,z) -> (-y,x,z) + translation.
|
||||
let translation = Vec3::new(-300.0, 70.0, 12.0);
|
||||
let world = Vec3::new(-definition_point.y, definition_point.x, definition_point.z) + translation;
|
||||
let shifted = world - translation;
|
||||
let inverted = Vec3::new(shifted.y, -shifted.x, shifted.z);
|
||||
close(mapping.coordinates(inverted).unwrap(), expected);
|
||||
// Passing world points directly is deliberately not corrected by the utility.
|
||||
assert_ne!(mapping.coordinates(world).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_frames_refuse_instead_of_repairing_or_reflecting() {
|
||||
for axes in [
|
||||
[Vec3::ZERO, Vec3::Y, Vec3::Z],
|
||||
[Vec3::X * 2.0, Vec3::Y, Vec3::Z],
|
||||
[Vec3::X, Vec3::X, Vec3::Z],
|
||||
[Vec3::X, Vec3::Y, -Vec3::Z],
|
||||
[Vec3::X, Vec3::new(0.01, 1.0, 0.0), Vec3::Z],
|
||||
[Vec3::new(f64::NAN, 0.0, 0.0), Vec3::Y, Vec3::Z],
|
||||
[Vec3::new(f64::MAX, 0.0, 0.0), Vec3::Y, Vec3::Z],
|
||||
] {
|
||||
assert_eq!(VisualMapping::new(Vec3::ZERO, axes, Vec3::new(1.0, 1.0, 1.0)), Err(MappingError::Frame));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_positions_sizes_bounds_and_excessive_coordinates_refuse() {
|
||||
let mapping = VisualMapping::new(Vec3::ZERO, GrainAxis::X.axes(), Vec3::new(1.0, 1.0, 1.0)).unwrap();
|
||||
for value in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, f64::MAX, MAX_LOCAL_MM * 2.0] {
|
||||
let point = Vec3::new(value, 0.0, 0.0);
|
||||
assert_eq!(VisualMapping::new(point, GrainAxis::X.axes(), Vec3::new(1.0, 1.0, 1.0)), Err(MappingError::Position));
|
||||
assert_eq!(mapping.coordinates(point), Err(MappingError::Position));
|
||||
assert_eq!(suggest_grain_axis(Aabb3 { min: Vec3::ZERO, max: point }), Err(MappingError::Bounds));
|
||||
}
|
||||
for value in [0.0, -1.0, f64::NAN, f64::INFINITY, f64::MIN_POSITIVE, MIN_REPEAT_MM * 0.5, MAX_REPEAT_MM * 2.0] {
|
||||
assert_eq!(VisualMapping::new(Vec3::ZERO, GrainAxis::X.axes(), Vec3::new(1.0, value, 1.0)), Err(MappingError::RepeatSize));
|
||||
}
|
||||
for bounds in [Aabb3::EMPTY, Aabb3 { min: Vec3::X, max: Vec3::ZERO }, Aabb3 { min: Vec3::ZERO, max: Vec3::ZERO }] {
|
||||
assert_eq!(suggest_grain_axis(bounds), Err(MappingError::Bounds));
|
||||
}
|
||||
assert_eq!(mapping.coordinates(Vec3::new(MAX_REPEAT_COORDINATE + 1.0, 0.0, 0.0)), Err(MappingError::Coordinates));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn admitted_boundaries_remain_finite() {
|
||||
let mapping = VisualMapping::new(Vec3::new(MAX_LOCAL_MM, 0.0, 0.0), GrainAxis::X.axes(), Vec3::new(MAX_REPEAT_MM, MIN_REPEAT_MM, 1.0)).unwrap();
|
||||
close(mapping.coordinates(Vec3::new(-MAX_LOCAL_MM, 0.0, 0.0)).unwrap(), Vec3::new(-2000.0, 0.0, 0.0));
|
||||
let tiny = VisualMapping::new(Vec3::ZERO, GrainAxis::X.axes(), Vec3::new(MIN_REPEAT_MM, 1.0, 1.0)).unwrap();
|
||||
close(tiny.coordinates(Vec3::new(1000.0, 0.0, 0.0)).unwrap(), Vec3::new(MAX_REPEAT_COORDINATE, 0.0, 0.0));
|
||||
}
|
||||
@@ -11,6 +11,7 @@ pub struct FaceAppearance {
|
||||
pub color: [u8; 3],
|
||||
pub opacity: f64,
|
||||
pub surface: Option<crate::surface::OpaqueSurface>,
|
||||
pub transmission: Option<crate::transmission::Transmission>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -22,6 +23,7 @@ pub struct ProductMesh {
|
||||
pub color: [u8; 3],
|
||||
pub opacity: f64,
|
||||
pub surface: Option<crate::surface::OpaqueSurface>,
|
||||
pub transmission: Option<crate::transmission::Transmission>,
|
||||
pub faces: Vec<FaceAppearance>,
|
||||
}
|
||||
|
||||
@@ -32,6 +34,9 @@ pub struct ProductScene {
|
||||
}
|
||||
|
||||
impl ProductScene {
|
||||
pub fn has_transmission(&self) -> bool {
|
||||
self.meshes.iter().any(|m| m.transmission.is_some() || m.faces.iter().any(|f| f.transmission.is_some()))
|
||||
}
|
||||
pub fn required_features(&self) -> Vec<crate::RenderFeature> {
|
||||
if self.meshes.iter().any(|m| m.surface.is_some() || m.faces.iter().any(|f| f.surface.is_some())) {
|
||||
vec![crate::RenderFeature::Pbr]
|
||||
|
||||
@@ -16,12 +16,14 @@ pub struct OpaqueSurface {
|
||||
metallic: f64,
|
||||
roughness: f64,
|
||||
dielectric_ior: f64,
|
||||
clear_coat: Option<crate::clear_coat::ClearCoat>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum SurfaceError {
|
||||
BaseColor,
|
||||
Metallic,
|
||||
CoatWeight,
|
||||
Roughness,
|
||||
DielectricIor,
|
||||
Direction,
|
||||
@@ -32,6 +34,7 @@ impl std::fmt::Display for SurfaceError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::BaseColor => "linear base color channels must be finite and in [0, 1]",
|
||||
Self::CoatWeight => "clear-coat weight must be finite and in [0, 1]",
|
||||
Self::Metallic => "metallic fraction must be finite and in [0, 1]",
|
||||
Self::Roughness => "perceptual roughness must be finite and in [0.045, 1]",
|
||||
Self::DielectricIor => "dielectric IOR must be finite and in [1, 3]",
|
||||
@@ -58,7 +61,16 @@ impl OpaqueSurface {
|
||||
if !dielectric_ior.is_finite() || !(1.0..=3.0).contains(&dielectric_ior) {
|
||||
return Err(SurfaceError::DielectricIor);
|
||||
}
|
||||
Ok(Self { base_color, metallic, roughness, dielectric_ior })
|
||||
Ok(Self { base_color, metallic, roughness, dielectric_ior, clear_coat: None })
|
||||
}
|
||||
|
||||
/// Runtime optical layer; never serialized into a document appearance.
|
||||
pub fn with_clear_coat(mut self, coat: crate::clear_coat::ClearCoat) -> Self {
|
||||
self.clear_coat = if coat.weight() == 0.0 { None } else { Some(coat) };
|
||||
self
|
||||
}
|
||||
pub fn clear_coat(self) -> Option<crate::clear_coat::ClearCoat> {
|
||||
self.clear_coat
|
||||
}
|
||||
|
||||
pub fn base_color(self) -> Vec3 {
|
||||
@@ -79,6 +91,14 @@ impl OpaqueSurface {
|
||||
Vec3::new(ratio * ratio, ratio * ratio, ratio * ratio).lerp(self.base_color, self.metallic)
|
||||
}
|
||||
|
||||
/// Schlick approximation with the exact index-matched dielectric limit.
|
||||
/// A conductor retains its own grazing response, including in a metal mix.
|
||||
pub(crate) fn specular_fresnel(self, cosine: f64) -> Vec3 {
|
||||
let f0 = self.normal_reflectance();
|
||||
let grazing = if self.dielectric_ior == 1.0 { self.metallic } else { 1.0 };
|
||||
f0 + (Vec3::new(grazing, grazing, grazing) - f0) * (1.0 - cosine.clamp(0.0, 1.0)).powi(5)
|
||||
}
|
||||
|
||||
/// BRDF in inverse steradians. Directions point away from the surface.
|
||||
/// No incident radiance, cosine weight, exposure or display transfer is included.
|
||||
/// Back-facing/tangent directions return zero; malformed directions are errors.
|
||||
@@ -102,19 +122,19 @@ impl OpaqueSurface {
|
||||
// G1 / cos(theta) has a finite grazing limit: no epsilon cosine clamp.
|
||||
let visibility = |cos: f64| 2.0 / (cos + (a2 + (1.0 - a2) * cos * cos).sqrt());
|
||||
let specular = distribution * 0.25 * visibility(nv) * visibility(nl);
|
||||
let f0 = self.normal_reflectance();
|
||||
let fresnel = |cos: f64| f0 + (Vec3::new(1.0, 1.0, 1.0) - f0) * (1.0 - cos).powi(5);
|
||||
let fresnel = |cos: f64| self.specular_fresnel(cos);
|
||||
let f = fresnel(vh);
|
||||
// Symmetric entry/exit attenuation avoids adding a full Lambert substrate
|
||||
// to grazing reflection. Internal reflection / multi-scattering is omitted.
|
||||
let fv = fresnel(nv);
|
||||
let fl = fresnel(nl);
|
||||
let diffuse = self.base_color * ((1.0 - self.metallic) / PI);
|
||||
Ok(Vec3::new(
|
||||
let base = Vec3::new(
|
||||
f.x * specular + (1.0 - fv.x) * (1.0 - fl.x) * diffuse.x,
|
||||
f.y * specular + (1.0 - fv.y) * (1.0 - fl.y) * diffuse.y,
|
||||
f.z * specular + (1.0 - fv.z) * (1.0 - fl.z) * diffuse.z,
|
||||
))
|
||||
);
|
||||
Ok(self.clear_coat.map_or(base, |coat| coat.layer(base, nv, nl, nh, vh)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +248,31 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn index_matched_dielectric_is_exact_lambert_at_oblique_and_grazing_angles() {
|
||||
let color = Vec3::new(0.75, 0.35, 0.02);
|
||||
for roughness in [MIN_ROUGHNESS, 0.25, 1.0] {
|
||||
let s = OpaqueSurface::new(color, 0.0, roughness, 1.0).unwrap();
|
||||
for cosine in [0.0, 1e-12, 0.02, 0.5, 1.0] {
|
||||
assert_eq!(s.specular_fresnel(cosine), Vec3::ZERO);
|
||||
}
|
||||
for nv in [1e-8_f64, 0.02, 0.5, 1.0] {
|
||||
for nl in [1e-8_f64, 0.2, 0.7, 1.0] {
|
||||
let v = Vec3::new((1.0 - nv * nv).sqrt(), 0.0, nv);
|
||||
let l = Vec3::new(0.0, (1.0 - nl * nl).sqrt(), nl);
|
||||
assert!((s.brdf(Vec3::Z, v, l).unwrap() - color / PI).len() < 1e-14);
|
||||
}
|
||||
}
|
||||
}
|
||||
for metallic in [0.0, 0.25, 0.5, 1.0] {
|
||||
let s = OpaqueSurface::new(color, metallic, 0.5, 1.0).unwrap();
|
||||
for c in [0.0_f64, 0.2, 0.9, 1.0] {
|
||||
let conductor = color + (Vec3::new(1., 1., 1.) - color) * (1.0 - c).powi(5);
|
||||
assert!((s.specular_fresnel(c) - conductor * metallic).len() < 1e-14);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn srgb_conversion_includes_the_linear_toe() {
|
||||
assert_eq!(srgb8_to_linear(0), 0.0);
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Bounded optical data and original f64 dielectric transport oracles. Units are mm.
|
||||
use solidcraft_geom::Vec3;
|
||||
|
||||
use crate::surface::{SurfaceError, unit_direction};
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Transmission {
|
||||
absorption_per_mm: Vec3,
|
||||
thin_wall_thickness_mm: Option<f64>,
|
||||
shadow_transmission: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TransmissionError {
|
||||
Absorption,
|
||||
Distance,
|
||||
Thickness,
|
||||
Angle,
|
||||
Ior,
|
||||
Direction,
|
||||
}
|
||||
impl std::fmt::Display for TransmissionError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(match self {
|
||||
Self::Absorption => "absorption_per_mm must be finite RGB coefficients in [0, 100] per mm",
|
||||
Self::Distance => "geometric absorption distance must be finite and nonnegative",
|
||||
Self::Thickness => "thin-sheet thickness must be finite and in [0, 10000] mm",
|
||||
Self::Angle => "incident cosine must be finite and in [0, 1]",
|
||||
Self::Ior => "medium IOR must be finite and in [1, 3]",
|
||||
Self::Direction => "incident direction must face the finite nonzero interface normal",
|
||||
})
|
||||
}
|
||||
}
|
||||
impl std::error::Error for TransmissionError {}
|
||||
|
||||
impl Transmission {
|
||||
pub fn new(absorption_per_mm: Vec3, thin_wall_thickness_mm: Option<f64>) -> Result<Self, TransmissionError> {
|
||||
if !absorption_per_mm.is_finite()
|
||||
|| ![absorption_per_mm.x, absorption_per_mm.y, absorption_per_mm.z].into_iter().all(|v| (0.0..=100.0).contains(&v))
|
||||
{
|
||||
return Err(TransmissionError::Absorption);
|
||||
}
|
||||
if thin_wall_thickness_mm.is_some_and(|v| !v.is_finite() || !(0.0..=10000.0).contains(&v)) {
|
||||
return Err(TransmissionError::Thickness);
|
||||
}
|
||||
Ok(Self { absorption_per_mm, thin_wall_thickness_mm, shadow_transmission: thin_wall_thickness_mm.is_some() })
|
||||
}
|
||||
/// Thin sheets enable straight shadow transmission by default; solids opt in.
|
||||
pub fn with_shadow_transmission(mut self, enabled: bool) -> Self {
|
||||
self.shadow_transmission = enabled;
|
||||
self
|
||||
}
|
||||
pub fn shadow_transmission(self) -> bool {
|
||||
self.shadow_transmission
|
||||
}
|
||||
pub fn thin_wall_thickness_mm(self) -> Option<f64> {
|
||||
self.thin_wall_thickness_mm
|
||||
}
|
||||
pub fn extinction(self) -> Vec3 {
|
||||
self.absorption_per_mm
|
||||
}
|
||||
pub fn attenuation(self, distance_mm: f64) -> Result<Vec3, TransmissionError> {
|
||||
if !distance_mm.is_finite() || distance_mm < 0.0 {
|
||||
return Err(TransmissionError::Distance);
|
||||
}
|
||||
let s = self.extinction();
|
||||
Ok(Vec3::new((-s.x * distance_mm).exp(), (-s.y * distance_mm).exp(), (-s.z * distance_mm).exp()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Fresnel {
|
||||
pub s: f64,
|
||||
pub p: f64,
|
||||
/// None at total internal reflection.
|
||||
pub transmitted_cosine: Option<f64>,
|
||||
}
|
||||
impl Fresnel {
|
||||
pub fn reflectance(self) -> f64 {
|
||||
(self.s + self.p) * 0.5
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact unpolarized power reflectance at a lossless dielectric interface.
|
||||
pub fn fresnel(cosine: f64, incident_ior: f64, transmitted_ior: f64) -> Result<Fresnel, TransmissionError> {
|
||||
if !cosine.is_finite() || !(0.0..=1.0).contains(&cosine) {
|
||||
return Err(TransmissionError::Angle);
|
||||
}
|
||||
if [incident_ior, transmitted_ior].into_iter().any(|v| !v.is_finite() || !(1.0..=3.0).contains(&v)) {
|
||||
return Err(TransmissionError::Ior);
|
||||
}
|
||||
if incident_ior == transmitted_ior {
|
||||
return Ok(Fresnel { s: 0.0, p: 0.0, transmitted_cosine: Some(cosine) });
|
||||
}
|
||||
let eta = incident_ior / transmitted_ior;
|
||||
let sin2 = eta * eta * (1.0 - cosine * cosine).max(0.0);
|
||||
if sin2 >= 1.0 {
|
||||
return Ok(Fresnel { s: 1.0, p: 1.0, transmitted_cosine: None });
|
||||
}
|
||||
let ct = (1.0 - sin2).sqrt();
|
||||
let rs = (incident_ior * cosine - transmitted_ior * ct) / (incident_ior * cosine + transmitted_ior * ct);
|
||||
let rp = (transmitted_ior * cosine - incident_ior * ct) / (transmitted_ior * cosine + incident_ior * ct);
|
||||
Ok(Fresnel { s: rs * rs, p: rp * rp, transmitted_cosine: Some(ct) })
|
||||
}
|
||||
|
||||
/// Incident direction points toward the surface; normal points into the incident medium.
|
||||
pub fn refract(incident: Vec3, normal: Vec3, incident_ior: f64, transmitted_ior: f64) -> Result<Option<Vec3>, TransmissionError> {
|
||||
let direction_error = |_: SurfaceError| TransmissionError::Direction;
|
||||
let d = unit_direction(incident).map_err(direction_error)?;
|
||||
let n = unit_direction(normal).map_err(direction_error)?;
|
||||
let cosine = -d.dot(n);
|
||||
if cosine < 0.0 {
|
||||
return Err(TransmissionError::Direction);
|
||||
}
|
||||
let f = fresnel(cosine.min(1.0), incident_ior, transmitted_ior)?;
|
||||
Ok(f.transmitted_cosine.map(|ct| {
|
||||
let eta = incident_ior / transmitted_ior;
|
||||
d * eta + n * (eta * cosine - ct)
|
||||
}))
|
||||
}
|
||||
|
||||
/// Incoherent parallel slab, including all repeated internal reflections. The two
|
||||
/// external media have the same IOR. Returns linear RGB (reflectance, transmittance).
|
||||
pub fn slab(
|
||||
transmission: Transmission,
|
||||
thickness_mm: f64,
|
||||
cosine: f64,
|
||||
outside_ior: f64,
|
||||
inside_ior: f64,
|
||||
) -> Result<(Vec3, Vec3), TransmissionError> {
|
||||
if !thickness_mm.is_finite() || !(0.0..=10000.0).contains(&thickness_mm) {
|
||||
return Err(TransmissionError::Thickness);
|
||||
}
|
||||
let f = fresnel(cosine, outside_ior, inside_ior)?;
|
||||
let Some(ct) = f.transmitted_cosine.filter(|v| *v > 0.0) else {
|
||||
return Ok((Vec3::new(1.0, 1.0, 1.0), Vec3::ZERO));
|
||||
};
|
||||
let a = transmission.attenuation(thickness_mm / ct)?;
|
||||
let channel = |r: f64, a: f64| {
|
||||
let denominator = 1.0 - r * r * a * a;
|
||||
if denominator <= 0.0 {
|
||||
return (1.0, 0.0);
|
||||
}
|
||||
(r + (1.0 - r).powi(2) * r * a * a / denominator, (1.0 - r).powi(2) * a / denominator)
|
||||
};
|
||||
// A parallel slab preserves polarization. Sum each geometric series before
|
||||
// averaging s/p, rather than repeatedly applying the unpolarized mean.
|
||||
let average = |a: f64| {
|
||||
let (rs, ts) = channel(f.s, a);
|
||||
let (rp, tp) = channel(f.p, a);
|
||||
((rs + rp) * 0.5, (ts + tp) * 0.5)
|
||||
};
|
||||
let (rx, tx) = average(a.x);
|
||||
let (ry, ty) = average(a.y);
|
||||
let (rz, tz) = average(a.z);
|
||||
Ok((Vec3::new(rx, ry, rz), Vec3::new(tx, ty, tz)))
|
||||
}
|
||||
|
||||
/// Explicit straight-through solid shadow approximation. Both interface losses
|
||||
/// use reciprocal external-angle Fresnel; `distance_mm` is the actual geometric
|
||||
/// entry/exit interval. This omits refraction and repeated internal reflections.
|
||||
pub fn solid_shadow(transmission: Transmission, distance_mm: f64, entry_cosine: f64, exit_cosine: f64, ior: f64) -> Result<Vec3, TransmissionError> {
|
||||
let entry = fresnel(entry_cosine, 1.0, ior)?.reflectance();
|
||||
let exit = fresnel(exit_cosine, 1.0, ior)?.reflectance();
|
||||
Ok(transmission.attenuation(distance_mm)? * ((1.0 - entry) * (1.0 - exit)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shadow_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn defaults_and_reciprocal_solid_shadow_match_analytic_beer() {
|
||||
let sigma = Vec3::new(-0.5_f64.ln(), -0.8_f64.ln(), 0.0);
|
||||
let solid = Transmission::new(sigma, None).unwrap();
|
||||
let sheet = Transmission::new(sigma, Some(1.0)).unwrap();
|
||||
assert!(!solid.shadow_transmission());
|
||||
assert!(sheet.shadow_transmission());
|
||||
assert!(solid.with_shadow_transmission(true).shadow_transmission());
|
||||
assert!(!sheet.with_shadow_transmission(false).shadow_transmission());
|
||||
assert!((solid_shadow(solid, 1.0, 1.0, 1.0, 1.5).unwrap() - Vec3::new(0.5, 0.8, 1.0) * 0.96 * 0.96).len() < 1e-14);
|
||||
assert_eq!(solid_shadow(solid, 1.0, 0.2, 0.8, 1.5).unwrap(), solid_shadow(solid, 1.0, 0.8, 0.2, 1.5).unwrap());
|
||||
for ior in [1.0, 1.5, 3.0] {
|
||||
for cosine in [0.0, 0.02, 0.5, 1.0] {
|
||||
let shadow = solid_shadow(solid, 1.0, cosine, cosine, ior).unwrap();
|
||||
assert!(shadow.is_finite() && shadow.x >= 0.0 && shadow.z <= 1.0);
|
||||
}
|
||||
}
|
||||
assert!(solid_shadow(solid, f64::NAN, 1.0, 1.0, 1.5).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dielectric_oracle_tests.rs"]
|
||||
mod tests;
|
||||
@@ -60,6 +60,7 @@ pub fn library(app: &mut SolveApp) -> Vec<Look> {
|
||||
let list = v.get("appearances").or(v.get("library")).unwrap_or(&v).as_array()?.clone();
|
||||
let looks: Vec<Look> = list
|
||||
.iter()
|
||||
.filter(|entry| entry.get("available").and_then(Value::as_bool) != Some(false))
|
||||
.filter_map(|x| {
|
||||
Some(Look {
|
||||
name: x.get("name")?.as_str()?.to_string(),
|
||||
@@ -160,7 +161,7 @@ pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
}
|
||||
|
||||
/// The command giving `targets` (bodies or faces) a look.
|
||||
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str>) -> Option<Value> {
|
||||
fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str>, pbr: Option<PbrParameters>) -> Option<Value> {
|
||||
let bodies: Vec<String> = targets.iter().filter_map(|x| if let Sel::Body { name } = x { Some(name.clone()) } else { None }).collect();
|
||||
let faces: Vec<Value> = targets
|
||||
.iter()
|
||||
@@ -184,8 +185,9 @@ fn apply_params(targets: &[Sel], color: [u8; 3], opacity: f64, look: Option<&str
|
||||
}
|
||||
}
|
||||
p["opacity"] = json!(opacity);
|
||||
p["pbr"] = json!(pbr);
|
||||
if let Some(n) = look {
|
||||
p["appearance"] = json!(n);
|
||||
p["name"] = json!(n);
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
@@ -205,15 +207,12 @@ pub fn commands(k: &Ap, inputs: &[SelInput]) -> Vec<(String, Value)> {
|
||||
}
|
||||
|
||||
pub(super) fn selected_params(k: &Ap, items: &[Sel]) -> Option<Value> {
|
||||
let mut p = if let Some(component) = &k.component {
|
||||
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "appearance": k.look}))
|
||||
// Every path applies its complete owned look, never re-resolves its label.
|
||||
if let Some(component) = &k.component {
|
||||
Some(json!({"components": [component], "color": hex(k.color), "opacity": k.opacity, "name": k.look, "pbr": k.pbr}))
|
||||
} else {
|
||||
apply_params(items, k.color, k.opacity, k.look.as_deref())
|
||||
}?;
|
||||
if let Some(pbr) = k.pbr {
|
||||
p["pbr"] = json!(pbr);
|
||||
apply_params(items, k.color, k.opacity, k.look.as_deref(), k.pbr)
|
||||
}
|
||||
Some(p)
|
||||
}
|
||||
|
||||
/// Removing an override restores the inherited component or material appearance.
|
||||
@@ -221,6 +220,7 @@ fn clear_params(k: &Ap, items: &[Sel]) -> Option<Value> {
|
||||
let mut p = selected_params(k, items)?;
|
||||
if let Some(obj) = p.as_object_mut() {
|
||||
obj.remove("appearance");
|
||||
obj.remove("name");
|
||||
obj.remove("color");
|
||||
obj.remove("opacity");
|
||||
obj.remove("pbr");
|
||||
@@ -444,10 +444,7 @@ pub fn rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &mut [Sel
|
||||
k.pbr = look.pbr;
|
||||
k.look = Some(look.name.clone());
|
||||
k.remove_override = false;
|
||||
if let Some(mut p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref()) {
|
||||
if let Some(pbr) = look.pbr {
|
||||
p["pbr"] = json!(pbr);
|
||||
}
|
||||
if let Some(p) = apply_params(&targets, look.color, look.opacity, k.look.as_deref(), look.pbr) {
|
||||
k.note = Some(match app.run("appearance.assign", p.clone()) {
|
||||
Ok(_) => {
|
||||
k.applied = Some(p);
|
||||
@@ -564,7 +561,7 @@ mod tests {
|
||||
a.viewport.hover = None;
|
||||
a.viewport.hover_bodies = vec!["Body2".into()];
|
||||
assert_eq!(drop_target(&a, false), vec![Sel::Body { name: "Body2".into() }]);
|
||||
let face = apply_params(&[Sel::Face { body: "Body1".into(), index: 0, point: p }], [1, 2, 3], 0.5, None).unwrap();
|
||||
let face = apply_params(&[Sel::Face { body: "Body1".into(), index: 0, point: p }], [1, 2, 3], 0.5, None, None).unwrap();
|
||||
assert_eq!(face["faces"][0]["body"], "Body1");
|
||||
assert_eq!(face["opacity"], 0.5);
|
||||
assert!(!library(&mut a).is_empty());
|
||||
@@ -609,7 +606,7 @@ mod tests {
|
||||
let Kind::Appearance(k) = &a.dialog.as_ref().unwrap().kind else { panic!("appearance dialog") };
|
||||
assert_eq!(k.opacity, 0.25);
|
||||
assert_eq!(k.look.as_deref(), Some("Tinted glass"));
|
||||
let p = apply_params(&[Sel::Body { name: "Body2".into() }], look.color, look.opacity, Some(&look.name)).unwrap();
|
||||
let p = apply_params(&[Sel::Body { name: "Body2".into() }], look.color, look.opacity, Some(&look.name), look.pbr).unwrap();
|
||||
a.run("appearance.assign", p).unwrap();
|
||||
assert_eq!(a.session.doc.appearances.bodies.get("Body1"), a.session.doc.appearances.bodies.get("Body2"));
|
||||
}
|
||||
@@ -746,4 +743,67 @@ mod tests {
|
||||
assert!(crate::scenario::handle_at(handle).is_some(), "missing {handle}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selectable_library_excludes_unavailable_starter_glass_recipes() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
let listed = h.app.session.execute("appearance.library", &json!({})).unwrap();
|
||||
let rows = listed["appearances"].as_array().unwrap();
|
||||
let available = rows.iter().filter(|row| row.get("available").and_then(Value::as_bool) != Some(false)).count();
|
||||
let selectable = library(&mut h.app);
|
||||
assert_eq!(selectable.len(), available);
|
||||
for preset in &solidcraft_engine::doc::appearance_pack::STARTER_PACK {
|
||||
assert_eq!(selectable.iter().any(|look| look.name == preset.name), preset.is_opaque());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applying_and_clearing_owned_recipe_labels_never_resolves_the_recipe() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"body_name":"Owned","length":10,"width":10,"height":10})).unwrap();
|
||||
for name in ["starter.glass.clear-soda-lime", "Glass Recipe - Low Iron Clear", "Metal - Warm Reflective Gold"] {
|
||||
h.app.run("appearance.assign", json!({"bodies":["Owned"],"name":name,"color":[31,61,91],"pbr":null})).unwrap();
|
||||
let original = h.app.session.doc.appearances.bodies["Owned"].clone();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Owned".into() }];
|
||||
h.app.start("appearance.assign");
|
||||
let dialog = h.app.dialog.as_ref().unwrap();
|
||||
let Kind::Appearance(state) = &dialog.kind else { panic!("appearance editor expected") };
|
||||
let params = selected_params(state, &dialog.inputs[0].items).unwrap();
|
||||
let clear = clear_params(state, &dialog.inputs[0].items).unwrap();
|
||||
assert_eq!(params["name"], name);
|
||||
assert!(params.get("appearance").is_none());
|
||||
assert_eq!(params["pbr"], Value::Null);
|
||||
h.app.run("appearance.assign", params).unwrap();
|
||||
assert_eq!(h.app.session.doc.appearances.bodies["Owned"], original);
|
||||
assert!(clear.get("name").is_none() && clear.get("appearance").is_none());
|
||||
h.app.run("appearance.assign", clear).unwrap();
|
||||
assert!(!h.app.session.doc.appearances.bodies.contains_key("Owned"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_scalar_and_pbr_requests_match_ok_without_an_extra_undo() {
|
||||
for response in [None, Some(PbrParameters::new(0.7, 0.25, 1.5).unwrap())] {
|
||||
let mut a = app();
|
||||
a.session.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
a.start("appearance.assign");
|
||||
let mut dialog = a.dialog.clone().unwrap();
|
||||
let Kind::Appearance(state) = &mut dialog.kind else { panic!("appearance editor") };
|
||||
state.look = Some("Owned swatch".into());
|
||||
state.color = [31, 61, 91];
|
||||
state.opacity = 1.0;
|
||||
state.pbr = response;
|
||||
let params = apply_params(&dialog.inputs[0].items, state.color, state.opacity, state.look.as_deref(), state.pbr).unwrap();
|
||||
assert!(params.get("pbr").is_some(), "the complete owned response is explicit");
|
||||
assert_eq!(params["pbr"], json!(response));
|
||||
let original = a.session.doc.clone();
|
||||
let undo = a.session.undo.len();
|
||||
a.run("appearance.assign", params.clone()).unwrap();
|
||||
state.applied = Some(params);
|
||||
assert_eq!(a.session.undo.len(), undo + 1);
|
||||
assert!(commands(state, &dialog.inputs).is_empty(), "OK after a drop is not another edit");
|
||||
a.run("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(a.session.doc, original);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -235,8 +235,8 @@ fn import_rows(app: &mut SolveApp, ui: &mut egui::Ui, k: &mut Ap, inputs: &[SelI
|
||||
Ok(_) => {
|
||||
if let Definition::Appearance(look) = entry.definition {
|
||||
k.color = look.color;
|
||||
k.pbr = None;
|
||||
k.opacity = look.opacity;
|
||||
k.pbr = look.pbr;
|
||||
k.look = Some(look.name.clone());
|
||||
k.remove_override = false;
|
||||
k.applied = selected_params(k, picked);
|
||||
|
||||
@@ -4,21 +4,32 @@ use solidcraft_engine::{Sel, Session};
|
||||
|
||||
#[test]
|
||||
fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
|
||||
clicked_catalog(None, "solidcraft-library/1").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_clicked_pbr_load_apply_export_and_ok_retains_the_optical_response() {
|
||||
clicked_catalog(Some(solidcraft_engine::doc::pbr_appearance::PbrParameters::new(1., 0.3, 1.5).unwrap()), "solidcraft-library/2").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalog_clicked_rich_entry_without_pbr_clears_the_old_optical_response() {
|
||||
clicked_catalog(None, "solidcraft-library/2").unwrap();
|
||||
}
|
||||
|
||||
fn clicked_catalog(pbr: Option<solidcraft_engine::doc::pbr_appearance::PbrParameters>, format: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.resize", json!({"width":1600,"height":1400}));
|
||||
let path = h.dir.join("source-library.json").to_string_lossy().into_owned();
|
||||
let mut source = Session::default();
|
||||
source.execute("solid.box", &json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
source.execute("appearance.assign", &json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":0.8})).unwrap();
|
||||
source.execute("library.export", &json!({"path":path})).unwrap();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.app
|
||||
.run(
|
||||
"appearance.assign",
|
||||
json!({"bodies":["Body1"],"color":[20,20,20],
|
||||
"pbr":{"metallic":1.0,"roughness":0.3,"dielectric_ior":1.5}}),
|
||||
)
|
||||
.unwrap();
|
||||
source.execute("solid.box", &json!({"length":10,"width":10,"height":10}))?;
|
||||
source.execute(
|
||||
"appearance.assign",
|
||||
&json!({"bodies":["Body1"],"appearance":"Warm oak colour","color":[180,120,70],"opacity":if pbr.is_some(){1.0}else{0.8},"pbr":pbr}),
|
||||
)?;
|
||||
source.execute("library.export", &json!({"path":path,"format":format}))?;
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10}))?;
|
||||
h.app.run("appearance.assign", json!({"bodies":["Body1"],"color":[20,20,20],"pbr":{"metallic":1.0,"roughness":0.6,"dielectric_ior":1.7}}))?;
|
||||
h.app.session.mark_saved();
|
||||
h.app.session.selection = vec![Sel::Body { name: "Body1".into() }];
|
||||
h.app.start("appearance.assign");
|
||||
@@ -29,7 +40,7 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
|
||||
}
|
||||
h.frames(3);
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"widget":"Load library"})).unwrap();
|
||||
h.step(&json!({"widget":"Load library"}))?;
|
||||
for handle in [
|
||||
"library:path",
|
||||
"library:load",
|
||||
@@ -49,26 +60,36 @@ fn catalog_clicked_load_apply_export_and_ok_keep_one_design_undo_step() {
|
||||
{
|
||||
assert!(h.widget(message).is_some(), "missing {message}");
|
||||
}
|
||||
h.step(&json!({"click":{"handle":"library:search"}})).unwrap();
|
||||
h.step(&json!({"text":"no such appearance"})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"library:search"}}))?;
|
||||
h.step(&json!({"text":"no such appearance"}))?;
|
||||
assert!(h.widget("No matching appearance presets.").is_some());
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"key":"Backspace"})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"library:entry:0"}})).unwrap();
|
||||
h.step(&json!({"widget":"Apply library appearance"})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true}))?;
|
||||
h.step(&json!({"key":"Backspace"}))?;
|
||||
h.step(&json!({"click":{"handle":"library:entry:0"}}))?;
|
||||
h.step(&json!({"widget":"Apply library appearance"}))?;
|
||||
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].name, "Warm oak colour");
|
||||
assert!(h.app.session.doc.appearances.bodies["Body1"].pbr.is_none());
|
||||
assert_eq!(h.app.session.doc.appearances.bodies["Body1"].pbr, pbr);
|
||||
if let Some(dialog) = &h.app.dialog
|
||||
&& let Kind::Appearance(state) = &dialog.kind
|
||||
{
|
||||
assert_eq!(state.pbr, pbr);
|
||||
}
|
||||
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
let revision = h.app.session.revision;
|
||||
h.step(&json!({"widget":"Export design library..."})).unwrap();
|
||||
h.step(&json!({"widget":"Export design library..."}))?;
|
||||
let exported = h.dir.join("my-library.solidcraft-library.json");
|
||||
let catalog = Catalog::read(&exported.to_string_lossy()).unwrap();
|
||||
let catalog = Catalog::read(&exported.to_string_lossy())?;
|
||||
assert_eq!(catalog.entries.len(), 1);
|
||||
assert_eq!(catalog.entries[0].definition.name(), "Warm oak colour");
|
||||
if let Definition::Appearance(look) = &catalog.entries[0].definition {
|
||||
assert_eq!(look.pbr, pbr);
|
||||
}
|
||||
assert_eq!(h.app.session.revision, revision);
|
||||
h.step(&json!({"widget":"OK"})).unwrap();
|
||||
h.step(&json!({"widget":"OK"}))?;
|
||||
assert!(h.app.dialog.is_none());
|
||||
assert_eq!(h.app.session.undo.len(), undo + 1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn appearance_fixture() -> Result<(Harness, Arc<()>), String> {
|
||||
|
||||
@@ -117,11 +117,30 @@ impl Locale {
|
||||
/// Translate source labels from engine/UI tables, preserving unknown labels.
|
||||
/// Also accepts stable IDs while table helpers migrate away from source text.
|
||||
pub fn label(self, label: &str) -> &str {
|
||||
static LABEL_IDS: OnceLock<BTreeMap<&'static str, &'static str>> = OnceLock::new();
|
||||
// Stable IDs are authoritative. An untranslated legacy ID identical to its
|
||||
// English caption can still use another translated ID for that source label.
|
||||
if ENGLISH.messages().get(label).is_some_and(|text| text != label || self.0.messages().contains_key(label)) {
|
||||
return self.text(label);
|
||||
}
|
||||
static LABEL_IDS: OnceLock<BTreeMap<&'static str, Vec<&'static str>>> = OnceLock::new();
|
||||
let ids = LABEL_IDS.get_or_init(|| {
|
||||
ENGLISH.messages().iter().filter(|(_, text)| !text.contains('{')).map(|(id, text)| (text.as_str(), id.as_str())).collect()
|
||||
let mut ids: BTreeMap<_, Vec<_>> = BTreeMap::new();
|
||||
for (id, text) in ENGLISH.messages().iter().filter(|(_, text)| !text.contains('{')) {
|
||||
ids.entry(text.as_str()).or_default().push(id.as_str());
|
||||
}
|
||||
ids
|
||||
});
|
||||
ids.get(label).map_or_else(|| self.text(label), |id| self.text(id))
|
||||
if let Some(candidates) = ids.get(label) {
|
||||
// Preserve the old last-ID preference among available translations. A new
|
||||
// English-only alias must not hide a translation that already exists here.
|
||||
if let Some(text) = candidates.iter().rev().find_map(|id| self.0.messages().get(*id)) {
|
||||
return text;
|
||||
}
|
||||
if let Some(id) = candidates.last() {
|
||||
return self.text(id);
|
||||
}
|
||||
}
|
||||
self.text(label)
|
||||
}
|
||||
|
||||
pub fn format(self, id: &str, args: &[(&str, &str)]) -> String {
|
||||
@@ -248,6 +267,22 @@ pub fn tr_name(text: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
fn alias_resolution_regression() {
|
||||
static LEGACY: Catalog = Catalog::new("test-legacy", "Test", r#"{"Pitch":"legacy pitch"}"#);
|
||||
static ALIAS: Catalog = Catalog::new("test-alias", "Test", r#"{"coupling.pitch":"mechanical pitch"}"#);
|
||||
assert_eq!(ENGLISH.messages().get("Pitch").map(String::as_str), Some("Pitch"));
|
||||
assert_eq!(ENGLISH.messages().get("coupling.pitch").map(String::as_str), Some("Pitch"));
|
||||
let legacy = Locale(&LEGACY);
|
||||
assert_eq!(legacy.label("Pitch"), "legacy pitch");
|
||||
assert_eq!(legacy.label("coupling.pitch"), "Pitch");
|
||||
let alias = Locale(&ALIAS);
|
||||
assert_eq!(alias.label("Pitch"), "mechanical pitch");
|
||||
assert_eq!(alias.label("coupling.pitch"), "mechanical pitch");
|
||||
assert_eq!(alias.label("original unknown caption"), "original unknown caption");
|
||||
}
|
||||
|
||||
/// English first, then by id, so adding a locale edits only its own line.
|
||||
#[test]
|
||||
fn locales_are_listed_english_first_then_by_id() {
|
||||
|
||||
@@ -696,6 +696,12 @@ impl SolveApp {
|
||||
None
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
let adapter = rs.adapter.clone();
|
||||
let key = render_workspace::adapter_key(&adapter.get_info());
|
||||
self.render_workspace.defer_path_host(key, move || solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string()));
|
||||
}
|
||||
render_workspace::sync_backend(self);
|
||||
self.msaa_supported = rs.msaa_supported.clone();
|
||||
let info = rs.adapter.get_info();
|
||||
@@ -722,6 +728,7 @@ impl SolveApp {
|
||||
"ground_shadow": self.ui.ground_shadow,
|
||||
"agent_cursor": self.ui.agent_cursor,
|
||||
"preferences": self.preferences,
|
||||
"gpu_path_setup_seconds": self.render_workspace.setup_seconds,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
@@ -734,6 +741,9 @@ impl SolveApp {
|
||||
if let Some(d) = flag("dark") {
|
||||
self.ui.dark = d;
|
||||
}
|
||||
if let Some(t) = v.get("gpu_path_setup_seconds") {
|
||||
self.render_workspace.setup_seconds = render_workspace::load_setup_seconds(t);
|
||||
}
|
||||
if let Some(g) = flag("grid") {
|
||||
self.ui.show_grid = g;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use std::sync::{Arc, Mutex};
|
||||
use egui::{Color32, Sense};
|
||||
use serde_json::json;
|
||||
use solidcraft_engine::doc::render_settings::RenderSetup;
|
||||
use solidcraft_engine::render::job::ImageHost;
|
||||
use solidcraft_engine::render::{
|
||||
Availability, Camera, ExecutorDescriptor, ExecutorId, ProductScene, RenderLimits, RenderMode, RenderOperation, RenderProfile, RenderRequest,
|
||||
ScratchLayout,
|
||||
@@ -21,8 +22,17 @@ const MAX_GEOMETRY_BYTES: usize = 128 * 1024 * 1024;
|
||||
#[path = "render_workspace_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
type PathFactory = Box<dyn FnOnce() -> Result<ImageHost, String> + Send>;
|
||||
|
||||
pub struct State {
|
||||
pending_setup: Option<PathFactory>,
|
||||
pub host: Option<ExecutorDescriptor>,
|
||||
pub path_host: PathHost,
|
||||
/// How long the last path tracer setup took, in seconds, per viewport adapter
|
||||
/// ([`adapter_key`]); kept in the preferences to estimate the next one.
|
||||
pub setup_seconds: std::collections::BTreeMap<String, f64>,
|
||||
/// The viewport adapter the current setup runs on.
|
||||
pub setup_key: Option<String>,
|
||||
pub image_host: Option<solidcraft_engine::render::job::CpuHost>,
|
||||
pub view: ViewState,
|
||||
revision: u64,
|
||||
@@ -31,6 +41,121 @@ pub struct State {
|
||||
key: Option<u64>,
|
||||
scene_signature: Option<(u64, usize, u64)>,
|
||||
scene: Option<ProductScene>,
|
||||
image: Option<(solidcraft_engine::render::job::JobId, Result<egui::TextureHandle, String>)>,
|
||||
}
|
||||
|
||||
/// Application-wide setup, retained across document changes. The worker owns a
|
||||
/// cloned adapter and initializes the job device; the UI receives the finished host.
|
||||
#[derive(Default)]
|
||||
pub enum PathHost {
|
||||
#[default]
|
||||
NotStarted,
|
||||
/// The worker's answer, and when it started: shader compilation can take minutes.
|
||||
Preparing(std::sync::mpsc::Receiver<Result<ImageHost, String>>, std::time::Instant),
|
||||
Ready(ImageHost),
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl PathHost {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn start(&mut self, create: impl FnOnce() -> Result<ImageHost, String> + Send + 'static) {
|
||||
if !matches!(self, Self::NotStarted) {
|
||||
return;
|
||||
}
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
// Dropping the handle detaches: neither startup nor shutdown joins a driver
|
||||
// that may spend minutes compiling or warming up its pipelines.
|
||||
*self = match std::thread::Builder::new().name("gpu-path-setup".into()).spawn(move || {
|
||||
let result =
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(create)).unwrap_or_else(|_| Err("GPU path tracer setup panicked".into()));
|
||||
let _ = tx.send(result);
|
||||
}) {
|
||||
Ok(_) => Self::Preparing(rx, std::time::Instant::now()),
|
||||
Err(e) => Self::failed(format!("could not start setup worker: {e}")),
|
||||
};
|
||||
}
|
||||
|
||||
fn failed(message: String) -> Self {
|
||||
log::warn!("progressive render unavailable: {message}");
|
||||
Self::Failed(message)
|
||||
}
|
||||
|
||||
/// Take the worker's answer if it has come; returns how long a successful setup took.
|
||||
fn poll(&mut self) -> Option<std::time::Duration> {
|
||||
let Self::Preparing(rx, started) = self else { return None };
|
||||
let took = started.elapsed();
|
||||
*self = match rx.try_recv() {
|
||||
Ok(Ok(host)) => Self::Ready(host),
|
||||
Ok(Err(error)) => Self::failed(error),
|
||||
Err(std::sync::mpsc::TryRecvError::Empty) => return None,
|
||||
Err(std::sync::mpsc::TryRecvError::Disconnected) => Self::failed("setup worker stopped without returning a host".into()),
|
||||
};
|
||||
matches!(self, Self::Ready(_)).then_some(took)
|
||||
}
|
||||
}
|
||||
|
||||
/// The samples slider's top end: 2^12 = 4096 samples per pixel.
|
||||
const MAX_SAMPLES_EXPONENT: u32 = 12;
|
||||
|
||||
/// Setup times kept per adapter, at most.
|
||||
const MAX_SETUP_TIMES: usize = 16;
|
||||
|
||||
/// The key [`State::setup_seconds`] files an adapter's setup time under: a driver update
|
||||
/// usually empties the driver's shader cache, so the driver is part of it.
|
||||
pub fn adapter_key(info: &egui_wgpu::wgpu::AdapterInfo) -> String {
|
||||
format!("{} | {:?} | {} {}", info.name.trim(), info.backend, info.driver.trim(), info.driver_info.trim())
|
||||
}
|
||||
|
||||
/// Remember how long the setup on `key` took, for the next start's estimate.
|
||||
fn record_setup(state: &mut State, took: std::time::Duration) {
|
||||
let Some(key) = state.setup_key.clone() else { return };
|
||||
if state.setup_seconds.len() >= MAX_SETUP_TIMES && !state.setup_seconds.contains_key(&key) {
|
||||
return;
|
||||
}
|
||||
state.setup_seconds.insert(key, took.as_secs_f64());
|
||||
}
|
||||
|
||||
/// Setup times from the preferences: finite, non-negative and at most a day each.
|
||||
pub fn load_setup_seconds(value: &serde_json::Value) -> std::collections::BTreeMap<String, f64> {
|
||||
value
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(k, v)| Some((k.clone(), v.as_f64().filter(|s| s.is_finite() && (0.0..=86_400.0).contains(s))?)))
|
||||
.take(MAX_SETUP_TIMES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Minutes and seconds, as `1:05`.
|
||||
fn mmss(d: std::time::Duration) -> String {
|
||||
let s = d.as_secs();
|
||||
format!("{}:{:02}", s / 60, s % 60)
|
||||
}
|
||||
|
||||
/// The path tracer's setup as it goes: a progress bar estimated from the last setup on this
|
||||
/// adapter, else a spinner with the time so far. Drivers report no progress of their own.
|
||||
fn setup_progress(ui: &mut egui::Ui, app: &SolveApp, started: std::time::Instant) {
|
||||
let elapsed = started.elapsed();
|
||||
let expected = app.render_workspace.setup_key.as_ref().and_then(|k| app.render_workspace.setup_seconds.get(k)).copied();
|
||||
ui.label(app.tr("render.gpu_path_preparing"));
|
||||
match expected.map(|s| std::time::Duration::from_secs_f64(s.max(1.0))) {
|
||||
Some(expected) if elapsed < expected.mul_f64(1.5) => {
|
||||
let fraction = (elapsed.as_secs_f32() / expected.as_secs_f32()).min(0.99);
|
||||
crate::ui_kit::progress(ui, "gpu_path_setup", fraction);
|
||||
ui.label(format!("{} / ~{}", mmss(elapsed), mmss(expected)));
|
||||
// The bar moves with time, so keep drawing while it is shown.
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
_ => {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(mmss(elapsed));
|
||||
});
|
||||
if expected.is_none() {
|
||||
ui.small(app.tr("render.gpu_path_first_setup"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Small per-document editor state; GPU uploads and host capabilities stay outside tabs.
|
||||
@@ -42,12 +167,24 @@ pub struct ViewState {
|
||||
job: Option<solidcraft_engine::render::job::JobId>,
|
||||
completed: Option<solidcraft_engine::render::job::JobId>,
|
||||
transparent: bool,
|
||||
progressive: bool,
|
||||
samples: u32,
|
||||
overwrite: bool,
|
||||
}
|
||||
|
||||
impl Default for ViewState {
|
||||
fn default() -> Self {
|
||||
Self { name: "Studio".into(), draft: RenderSetup::default(), dirty: false, job: None, completed: None, transparent: false, overwrite: false }
|
||||
Self {
|
||||
name: "Studio".into(),
|
||||
draft: RenderSetup::default(),
|
||||
dirty: false,
|
||||
job: None,
|
||||
completed: None,
|
||||
transparent: false,
|
||||
progressive: false,
|
||||
samples: 64,
|
||||
overwrite: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +192,10 @@ impl Default for State {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: None,
|
||||
path_host: PathHost::default(),
|
||||
pending_setup: None,
|
||||
setup_seconds: Default::default(),
|
||||
setup_key: None,
|
||||
image_host: None,
|
||||
view: Default::default(),
|
||||
revision: u64::MAX,
|
||||
@@ -63,6 +204,29 @@ impl Default for State {
|
||||
key: None,
|
||||
scene_signature: None,
|
||||
scene: None,
|
||||
image: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// Retain the selected viewport adapter, but do not open another device or
|
||||
/// compile shaders until Render is used. A later viewport installation (for
|
||||
/// example safe-mode fallback) replaces only a not-yet-started factory.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn defer_path_host(&mut self, key: String, create: impl FnOnce() -> Result<ImageHost, String> + Send + 'static) {
|
||||
if matches!(self.path_host, PathHost::NotStarted) {
|
||||
self.setup_key = Some(key);
|
||||
self.pending_setup = Some(Box::new(create));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn start_deferred_path_host(&mut self) {
|
||||
if matches!(self.path_host, PathHost::NotStarted)
|
||||
&& let Some(create) = self.pending_setup.take()
|
||||
{
|
||||
self.path_host.start(create);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +263,20 @@ pub fn host_executor(max_dimension: u32, depth: bool, samples: u32) -> Result<Ex
|
||||
}
|
||||
|
||||
pub fn sync_backend(app: &mut SolveApp) {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if active(app) {
|
||||
app.render_workspace.start_deferred_path_host();
|
||||
}
|
||||
if let Some(took) = app.render_workspace.path_host.poll() {
|
||||
record_setup(&mut app.render_workspace, took);
|
||||
}
|
||||
if let PathHost::Ready(host) = &app.render_workspace.path_host
|
||||
&& let Err(e) = app.session.register_render_job_host(host.clone())
|
||||
{
|
||||
app.render_workspace.path_host = PathHost::failed(e.to_string());
|
||||
app.set_status(e.to_string(), true);
|
||||
}
|
||||
|
||||
if app.session.render_job_host().is_none()
|
||||
&& let Some(host) = &app.render_workspace.image_host
|
||||
&& let Err(e) = app.session.set_render_job_host(Some(host.clone()))
|
||||
@@ -116,6 +294,10 @@ pub fn sync_backend(app: &mut SolveApp) {
|
||||
/// Cooperative work belongs to the frame loop, never to a status/control request. Keep
|
||||
/// scheduling frames even after leaving Render so a browser job can finish in Design.
|
||||
pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if matches!(app.render_workspace.path_host, PathHost::Preparing(..)) {
|
||||
// Poll even in Design or an idle window, without continuously redrawing.
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
if let Some(host) = &app.render_workspace.image_host {
|
||||
match host.pump(1024) {
|
||||
Ok(true) => ctx.request_repaint(),
|
||||
@@ -129,7 +311,11 @@ pub fn reset_document(app: &mut SolveApp) {
|
||||
app.ui.ribbon_context = None;
|
||||
let host = app.render_workspace.host.clone();
|
||||
let image_host = app.render_workspace.image_host.clone();
|
||||
app.render_workspace = State { host, image_host, ..Default::default() };
|
||||
let path_host = std::mem::take(&mut app.render_workspace.path_host);
|
||||
let setup_seconds = std::mem::take(&mut app.render_workspace.setup_seconds);
|
||||
let setup_key = app.render_workspace.setup_key.take();
|
||||
let pending_setup = app.render_workspace.pending_setup.take();
|
||||
app.render_workspace = State { host, image_host, path_host, setup_seconds, setup_key, pending_setup, ..Default::default() };
|
||||
if active(app) {
|
||||
app.ui.tab = "MODEL".into();
|
||||
}
|
||||
@@ -157,6 +343,8 @@ pub fn enter(app: &mut SolveApp, render: bool) -> Result<(), String> {
|
||||
app.cam.fit(&scene.bounds);
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
app.render_workspace.start_deferred_path_host();
|
||||
} else {
|
||||
if let Some((camera, perspective)) = app.render_workspace.design_camera.take() {
|
||||
app.cam = camera;
|
||||
@@ -184,7 +372,7 @@ pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
ui.heading(app.tr("cmd.render_settings_get"));
|
||||
ui.label(app.tr("render.preview"));
|
||||
ui.small(app.tr("render.opaque_preview_help"));
|
||||
ui.add(egui::Label::new(egui::RichText::new(app.tr("render.opaque_preview_help")).small()).wrap());
|
||||
ui.separator();
|
||||
let names: Vec<_> = app.session.doc.render.setups.keys().cloned().collect();
|
||||
let old_name = app.render_workspace.view.name.clone();
|
||||
@@ -266,7 +454,7 @@ pub fn panel(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
|
||||
fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
ui.heading(app.tr("render.image_title"));
|
||||
ui.small(app.tr("render.opaque_image_summary"));
|
||||
ui.small(app.tr(if app.render_workspace.view.progressive { "render.progressive_image_summary" } else { "render.opaque_image_summary" }));
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(app.tr("render.image_width"));
|
||||
if crate::ui_kit::number_field(ui, &mut app.render_workspace.view.draft.width, 1..=4096, 1.0, "").changed() {
|
||||
@@ -289,11 +477,32 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.render_workspace.view.dirty = true;
|
||||
}
|
||||
}
|
||||
match &app.render_workspace.path_host {
|
||||
PathHost::Preparing(_, started) => setup_progress(ui, app, *started),
|
||||
PathHost::Failed(error) => {
|
||||
ui.add(egui::Label::new(format!("{}: {error}", app.tr("render.gpu_path_failed"))).wrap());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if matches!(app.render_workspace.path_host, PathHost::Ready(_)) {
|
||||
let label = app.tr("render.image_progressive").to_string();
|
||||
crate::ui_kit::checkbox(ui, &label, &mut app.render_workspace.view.progressive);
|
||||
if app.render_workspace.view.progressive {
|
||||
ui.label(app.tr("render.image_samples"));
|
||||
// Powers of two from 1 to 4096 samples per pixel; requests from elsewhere keep any count.
|
||||
let samples = &mut app.render_workspace.view.samples;
|
||||
let mut exponent = (*samples).max(1).ilog2().min(MAX_SAMPLES_EXPONENT);
|
||||
let slider = crate::ui_kit::Slider { integer: true, format: |e| format!("{}", 1u32 << (e as u32).min(31)), ..Default::default() };
|
||||
if crate::ui_kit::slider(ui, &mut exponent, 0..=MAX_SAMPLES_EXPONENT, slider).changed() {
|
||||
*samples = 1 << exponent.min(MAX_SAMPLES_EXPONENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
let transparent_label = app.tr("render.image_transparent").to_string();
|
||||
crate::ui_kit::checkbox(ui, &transparent_label, &mut app.render_workspace.view.transparent);
|
||||
if crate::ui_kit::button(ui, app.tr("cmd.render_preflight"), crate::ui_kit::Button::Normal).clicked() {
|
||||
let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
|
||||
if app.run("render.preflight", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})).is_ok() {
|
||||
if app.run("render.preflight", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent,"progressive":app.render_workspace.view.progressive,"samples":app.render_workspace.view.progressive.then_some(app.render_workspace.view.samples)})).is_ok() {
|
||||
app.set_status(app.tr("render.image_supported").to_string(), false);
|
||||
}
|
||||
}
|
||||
@@ -318,7 +527,7 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
app.render_workspace.view.completed = Some(job);
|
||||
}
|
||||
if let Some(message) = status["error"]["message"].as_str() {
|
||||
ui.label(message);
|
||||
ui.add(egui::Label::new(message).wrap());
|
||||
if status["error"]["code"] == "limit_exceeded" {
|
||||
ui.small(app.tr("render.image_reduce"));
|
||||
}
|
||||
@@ -339,13 +548,13 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
ui.label(e.to_string());
|
||||
ui.add(egui::Label::new(e.to_string()).wrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
if crate::ui_kit::button(ui, app.tr("render.image_start"), crate::ui_kit::Button::Enabled(!busy)).clicked() {
|
||||
let settings = RenderSetup { camera: app.cam, ..app.render_workspace.view.draft.clone() };
|
||||
if let Ok(reply) = app.run("render.start", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent})) {
|
||||
if let Ok(reply) = app.run("render.start", json!({"settings":settings,"transparent_background":app.render_workspace.view.transparent,"progressive":app.render_workspace.view.progressive,"samples":app.render_workspace.view.progressive.then_some(app.render_workspace.view.samples)})) {
|
||||
app.render_workspace.view.job = serde_json::from_value(reply["job"].clone()).ok();
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
@@ -358,6 +567,56 @@ fn image_controls(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
{
|
||||
export_image(app, job, app.render_workspace.view.overwrite);
|
||||
}
|
||||
completed_image(app, ui);
|
||||
}
|
||||
|
||||
/// The job currently publishes a PNG only after encoding completes. Decode/upload once per
|
||||
/// completed job, not once per UI frame; keep the last exportable image during a new render.
|
||||
fn completed_image(app: &mut SolveApp, ui: &mut egui::Ui) {
|
||||
let Some(job) = app.render_workspace.view.completed else {
|
||||
app.render_workspace.image = None;
|
||||
return;
|
||||
};
|
||||
if app.render_workspace.image.as_ref().is_none_or(|(old, _)| *old != job) {
|
||||
let result = (|| -> Result<egui::TextureHandle, String> {
|
||||
let handle = app.session.render_job_handle(job).map_err(|e| e.to_string())?;
|
||||
let frame = handle.frame().map_err(|e| e.to_string())?.ok_or("render has no completed image")?;
|
||||
let image = decode_image(frame.png(), frame.info.width, frame.info.height)?;
|
||||
Ok(ui.ctx().load_texture("sc_render_image", image, egui::TextureOptions::LINEAR))
|
||||
})();
|
||||
// Cache errors too, so a bad frame cannot repeatedly allocate or decode on repaint.
|
||||
app.render_workspace.image = Some((job, result));
|
||||
}
|
||||
if let Some((_, image)) = &app.render_workspace.image {
|
||||
match image {
|
||||
Ok(texture) => {
|
||||
ui.add(egui::Image::new(texture).max_size(egui::vec2(ui.available_width(), 240.0)).shrink_to_fit())
|
||||
.on_hover_text(app.tr("render.image_completed"));
|
||||
}
|
||||
Err(error) => {
|
||||
ui.add(egui::Label::new(error).wrap());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_image(png: &[u8], width: u32, height: u32) -> Result<egui::ColorImage, String> {
|
||||
let size = (u64::from(width), u64::from(height));
|
||||
let budget = RenderLimits::default();
|
||||
if budget.fit(size.0, size.1, None) != Some(size) {
|
||||
return Err("render image exceeds preview limits".into());
|
||||
}
|
||||
let mut reader = image::ImageReader::with_format(std::io::Cursor::new(png), image::ImageFormat::Png);
|
||||
let mut limits = image::Limits::default();
|
||||
limits.max_image_width = Some(width);
|
||||
limits.max_image_height = Some(height);
|
||||
limits.max_alloc = Some(budget.max_pixels * 16);
|
||||
reader.limits(limits);
|
||||
let image = reader.decode().map_err(|e| e.to_string())?.to_rgba8();
|
||||
if image.dimensions() != (width, height) {
|
||||
return Err("render image dimensions do not match its job".into());
|
||||
}
|
||||
Ok(egui::ColorImage::from_rgba_unmultiplied([width as usize, height as usize], image.as_raw()))
|
||||
}
|
||||
|
||||
/// Keep the selected image and overwrite consent fixed while the upstream picker holds model input.
|
||||
@@ -399,17 +658,21 @@ pub fn gpu_scene(scene: &ProductScene) -> Result<(u64, GpuScene), String> {
|
||||
body.name.hash(&mut hash);
|
||||
body.color.hash(&mut hash);
|
||||
body.opacity.to_bits().hash(&mut hash);
|
||||
body.transmission.is_some().hash(&mut hash);
|
||||
hash_surface(body.surface, &mut hash);
|
||||
let looks: std::collections::BTreeMap<_, _> = body.faces.iter().map(|l| (l.face, l)).collect();
|
||||
for face in &body.faces {
|
||||
face.face.hash(&mut hash);
|
||||
face.color.hash(&mut hash);
|
||||
face.opacity.to_bits().hash(&mut hash);
|
||||
face.transmission.is_some().hash(&mut hash);
|
||||
hash_surface(face.surface, &mut hash);
|
||||
}
|
||||
for (index, triangle) in body.mesh.triangles.iter().enumerate() {
|
||||
let face = body.mesh.tri_face.get(index).and_then(|f| looks.get(f));
|
||||
let (color, opacity, surface) = face.map_or((body.color, body.opacity, body.surface), |l| (l.color, l.opacity, l.surface));
|
||||
let (color, opacity, surface) = face.map_or((body.color, body.opacity, body.surface.filter(|_| body.transmission.is_none())), |l| {
|
||||
(l.color, l.opacity, l.surface.filter(|_| l.transmission.is_none()))
|
||||
});
|
||||
if surface.is_some() && opacity != 1.0 {
|
||||
return Err("opaque PBR requires opacity 1".into());
|
||||
}
|
||||
|
||||
@@ -2,6 +2,249 @@ use super::*;
|
||||
use crate::scenario::Harness;
|
||||
use solidcraft_engine::Session;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod async_path_host {
|
||||
use super::*;
|
||||
use solidcraft_engine::render::job;
|
||||
use std::sync::mpsc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Advertise the path capability without opening a device or rendering anything.
|
||||
struct TestExecutor;
|
||||
impl job::ImageExecutor for TestExecutor {
|
||||
fn descriptor(&self) -> job::Result<ExecutorDescriptor> {
|
||||
let mut descriptor = job::CpuHost::cooperative().descriptor()?;
|
||||
descriptor.id = ExecutorId::new("test-path").unwrap();
|
||||
descriptor.profiles[0].features.push(solidcraft_engine::render::RenderFeature::Progressive);
|
||||
Ok(descriptor)
|
||||
}
|
||||
fn admit(&self, _: &ProductScene, _: &job::ImageRequest, _: job::JobOptions) -> job::Result<job::Admission> {
|
||||
panic!("setup tests must not render")
|
||||
}
|
||||
fn begin(&self, _: ProductScene, _: job::ImageRequest, _: job::JobOptions, _: job::CancelToken) -> job::Result<Box<dyn job::ImageWork>> {
|
||||
panic!("setup tests must not render")
|
||||
}
|
||||
}
|
||||
|
||||
fn panel_fixture() -> Harness {
|
||||
let mut h = Harness::new();
|
||||
h.call("ui.resize", json!({"width":1600,"height":1600}));
|
||||
enter(&mut h.app, true).unwrap();
|
||||
h.frames(3);
|
||||
h
|
||||
}
|
||||
|
||||
fn finish_setup(h: &mut Harness) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while matches!(h.app.render_workspace.path_host, PathHost::Preparing(..)) {
|
||||
assert!(Instant::now() < deadline, "test setup worker did not finish");
|
||||
h.frames(1);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
h.frames(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn design_frames_and_document_reset_never_start_the_deferred_path_host() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let observed = calls.clone();
|
||||
let mut h = Harness::new();
|
||||
h.app.render_workspace.defer_path_host("selected viewport".into(), move || {
|
||||
observed.fetch_add(1, Ordering::SeqCst);
|
||||
job::ImageHost::with_executor(Arc::new(TestExecutor)).map_err(|e| e.to_string())
|
||||
});
|
||||
h.frames(5);
|
||||
crate::documents::adopt(&mut h.app, Session::default());
|
||||
h.frames(5);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
assert!(matches!(h.app.render_workspace.path_host, PathHost::NotStarted));
|
||||
assert!(h.app.render_workspace.pending_setup.is_some());
|
||||
enter(&mut h.app, true).unwrap();
|
||||
finish_setup(&mut h);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(matches!(h.app.render_workspace.path_host, PathHost::Ready(_)));
|
||||
enter(&mut h.app, false).unwrap();
|
||||
enter(&mut h.app, true).unwrap();
|
||||
h.frames(5);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_render_before_adapter_install_starts_once_when_the_adapter_arrives() {
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let observed = calls.clone();
|
||||
let mut h = panel_fixture();
|
||||
assert!(matches!(h.app.render_workspace.path_host, PathHost::NotStarted));
|
||||
h.app.render_workspace.defer_path_host("late selected viewport".into(), move || {
|
||||
observed.fetch_add(1, Ordering::SeqCst);
|
||||
Err("deliberate device refusal".into())
|
||||
});
|
||||
sync_backend(&mut h.app);
|
||||
finish_setup(&mut h);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
assert!(matches!(h.app.render_workspace.path_host, PathHost::Failed(_)));
|
||||
h.app.render_workspace.defer_path_host("must not retry".into(), || panic!("failed setup retried"));
|
||||
enter(&mut h.app, false).unwrap();
|
||||
enter(&mut h.app, true).unwrap();
|
||||
h.frames(5);
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fallback_viewport_replaces_an_unstarted_factory_without_opening_the_old_device() {
|
||||
let mut h = Harness::new();
|
||||
h.app.render_workspace.defer_path_host("old hardware adapter".into(), || panic!("superseded device opened"));
|
||||
h.app.render_workspace.defer_path_host("selected safe-mode adapter".into(), || Err("selected adapter unavailable".into()));
|
||||
h.frames(5);
|
||||
assert_eq!(h.app.render_workspace.setup_key.as_deref(), Some("selected safe-mode adapter"));
|
||||
enter(&mut h.app, true).unwrap();
|
||||
finish_setup(&mut h);
|
||||
let PathHost::Failed(message) = &h.app.render_workspace.path_host else { panic!("expected selected adapter result") };
|
||||
assert_eq!(message, "selected adapter unavailable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preparing_keeps_frames_responsive_then_registers_ready_host() {
|
||||
let mut h = panel_fixture();
|
||||
let cpu_host = h.app.session.render_job_host().unwrap().clone();
|
||||
let (release, wait) = mpsc::channel();
|
||||
let (started, thread) = mpsc::channel();
|
||||
h.app.render_workspace.path_host.start(move || {
|
||||
started.send(std::thread::current().id()).unwrap();
|
||||
wait.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
job::ImageHost::with_executor(Arc::new(TestExecutor)).map_err(|e| e.to_string())
|
||||
});
|
||||
assert_ne!(thread.recv_timeout(Duration::from_secs(10)).unwrap(), std::thread::current().id());
|
||||
// A second viewport installation cannot replace or duplicate setup.
|
||||
h.app.render_workspace.path_host.start(|| panic!("setup started twice"));
|
||||
h.frames(3);
|
||||
assert!(h.widget("GPU path tracer: compiling shaders…").is_some());
|
||||
assert!(h.widget("Progressive rendering").is_none());
|
||||
assert!(!h.app.session.render_backend().executors.iter().any(|e| e.id.as_str() == "test-path"));
|
||||
let transparent = h.app.tr("render.image_transparent").to_string();
|
||||
h.step(&json!({"widget":transparent})).unwrap();
|
||||
assert!(h.app.render_workspace.view.transparent);
|
||||
// Replacing the current document must retain the receiver while setup waits.
|
||||
crate::documents::adopt(&mut h.app, Session::default());
|
||||
assert!(matches!(h.app.render_workspace.path_host, PathHost::Preparing(..)));
|
||||
enter(&mut h.app, true).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget("GPU path tracer: compiling shaders…").is_some());
|
||||
enter(&mut h.app, false).unwrap();
|
||||
release.send(()).unwrap();
|
||||
finish_setup(&mut h);
|
||||
assert!(matches!(h.app.render_workspace.path_host, PathHost::Ready(_)));
|
||||
enter(&mut h.app, true).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget("GPU path tracer: compiling shaders…").is_none());
|
||||
assert!(h.widget("Progressive rendering").is_some());
|
||||
assert_eq!(h.app.session.render_backend().executors.iter().filter(|e| e.id.as_str() == "test-path").count(), 1);
|
||||
assert!(h.app.session.render_job_host().unwrap().same_host(&cpu_host));
|
||||
h.step(&json!({"widget":"Progressive rendering"})).unwrap();
|
||||
assert!(h.app.render_workspace.view.progressive);
|
||||
crate::documents::adopt(&mut h.app, Session::default());
|
||||
assert!(matches!(h.app.render_workspace.path_host, PathHost::Ready(_)));
|
||||
assert_eq!(h.app.session.render_backend().executors.iter().filter(|e| e.id.as_str() == "test-path").count(), 1);
|
||||
}
|
||||
|
||||
/// The first setup on an adapter shows a spinner, the time so far and a note; a later one
|
||||
/// a bar estimated from the time the first took, which the preferences keep.
|
||||
#[test]
|
||||
fn setup_progress_is_estimated_from_the_last_setup_on_the_adapter() {
|
||||
let mut h = panel_fixture();
|
||||
h.app.render_workspace.setup_key = Some("Test GPU | Vulkan | 1.0".into());
|
||||
let (release, wait) = mpsc::channel::<()>();
|
||||
h.app.render_workspace.path_host.start(move || {
|
||||
wait.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
job::ImageHost::with_executor(Arc::new(TestExecutor)).map_err(|e| e.to_string())
|
||||
});
|
||||
h.frames(3);
|
||||
let first = h.app.tr("render.gpu_path_first_setup").to_string();
|
||||
assert!(h.widget("GPU path tracer: compiling shaders…").is_some());
|
||||
assert!(h.widget(&first).is_some(), "the first setup explains the wait");
|
||||
assert!(h.widget("0:00").is_some(), "and counts the time");
|
||||
release.send(()).unwrap();
|
||||
finish_setup(&mut h);
|
||||
let took = h.app.render_workspace.setup_seconds.get("Test GPU | Vulkan | 1.0").copied();
|
||||
assert!(took.is_some_and(|s| (0.0..10.0).contains(&s)), "{took:?}");
|
||||
|
||||
// The next start on the same adapter has an estimate, kept in the preferences.
|
||||
let mut h = panel_fixture();
|
||||
h.app.load_prefs(&json!({"gpu_path_setup_seconds": {"Test GPU | Vulkan | 1.0": 120.0}}).to_string());
|
||||
h.app.render_workspace.setup_key = Some("Test GPU | Vulkan | 1.0".into());
|
||||
let (release, wait) = mpsc::channel::<()>();
|
||||
h.app.render_workspace.path_host.start(move || {
|
||||
wait.recv_timeout(Duration::from_secs(10)).unwrap();
|
||||
job::ImageHost::with_executor(Arc::new(TestExecutor)).map_err(|e| e.to_string())
|
||||
});
|
||||
h.frames(3);
|
||||
assert!(h.widget(&first).is_none());
|
||||
assert!(h.widget("0:00 / ~2:00").is_some(), "a bar with the estimate");
|
||||
release.send(()).unwrap();
|
||||
finish_setup(&mut h);
|
||||
let prefs: serde_json::Value = serde_json::from_str(&h.app.prefs()).unwrap();
|
||||
assert!(prefs["gpu_path_setup_seconds"]["Test GPU | Vulkan | 1.0"].as_f64().is_some_and(|s| s < 10.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_setup_times_are_ignored() {
|
||||
let times = load_setup_seconds(&json!({"a": -1.0, "b": 1e300, "c": "slow", "d": 30.0}));
|
||||
assert_eq!(times.into_iter().collect::<Vec<_>>(), [("d".to_string(), 30.0)]);
|
||||
let many: serde_json::Map<String, serde_json::Value> = (0..100).map(|i| (format!("gpu {i}"), json!(1.0))).collect();
|
||||
assert_eq!(load_setup_seconds(&serde_json::Value::Object(many)).len(), MAX_SETUP_TIMES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn setup_error_is_visible_and_survives_document_reset() {
|
||||
let mut h = panel_fixture();
|
||||
h.app.render_workspace.path_host.start(|| Err("test driver refused the device".into()));
|
||||
finish_setup(&mut h);
|
||||
let message = "GPU path tracer unavailable: test driver refused the device";
|
||||
assert!(h.widget(message).is_some());
|
||||
assert!(h.widget("GPU path tracer: compiling shaders…").is_none());
|
||||
assert!(h.widget("Progressive rendering").is_none());
|
||||
assert!(h.app.session.render_job_host().is_some());
|
||||
completed_preview(&mut h, [200, 210, 220]);
|
||||
crate::documents::adopt(&mut h.app, Session::default());
|
||||
enter(&mut h.app, true).unwrap();
|
||||
h.frames(3);
|
||||
assert!(h.widget(message).is_some());
|
||||
h.app.render_workspace.path_host.start(|| panic!("failed setup retried on viewport install"));
|
||||
assert!(matches!(h.app.render_workspace.path_host, PathHost::Failed(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disconnected_setup_is_reported_in_the_panel() {
|
||||
let mut h = panel_fixture();
|
||||
let (tx, rx) = mpsc::channel();
|
||||
h.app.render_workspace.path_host = PathHost::Preparing(rx, Instant::now());
|
||||
drop(tx);
|
||||
h.frames(3);
|
||||
assert!(h.widget("GPU path tracer unavailable: setup worker stopped without returning a host").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panicking_setup_is_reported_in_the_panel() {
|
||||
let mut h = panel_fixture();
|
||||
h.app.render_workspace.path_host.start(|| panic!("simulated driver panic"));
|
||||
finish_setup(&mut h);
|
||||
assert!(h.widget("GPU path tracer unavailable: GPU path tracer setup panicked").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_failure_is_reported_in_the_panel() {
|
||||
let mut h = panel_fixture();
|
||||
// A different host with the same CPU descriptor cannot be registered twice.
|
||||
h.app.render_workspace.path_host.start(|| Ok(job::CpuHost::cooperative()));
|
||||
finish_setup(&mut h);
|
||||
let PathHost::Failed(message) = &h.app.render_workspace.path_host else { panic!("duplicate executor must fail") };
|
||||
assert!(h.widget(&format!("GPU path tracer unavailable: {message}")).is_some());
|
||||
assert!(h.widget("Progressive rendering").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn opaque_pbr_product_gpu_and_cpu_share_color_and_material_semantics() {
|
||||
@@ -32,7 +275,15 @@ fn opaque_pbr_product_gpu_and_cpu_share_color_and_material_semantics() {
|
||||
let eye = camera.eye().to_f32();
|
||||
let mut task = ImageTask::new(
|
||||
scene,
|
||||
ImageRequest { camera, width: SIZE, height: SIZE, background: [255; 3], transparent_background: false },
|
||||
ImageRequest {
|
||||
textures: None,
|
||||
environment: None,
|
||||
camera,
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
background: [255; 3],
|
||||
transparent_background: false,
|
||||
},
|
||||
CancelToken::default(),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -211,6 +462,55 @@ fn saved_camera_survives_frames_and_product_fit_ignores_selection() {
|
||||
assert_eq!(h.app.cam, design);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn progressive_image_controls_route_to_the_registered_host() {
|
||||
let host = solidcraft_gpu::select::Policy::from_env()
|
||||
.and_then(|policy| solidcraft_gpu::select::pick(policy).ok_or_else(|| "no qualifying adapter".to_string()))
|
||||
.and_then(|(adapter, _)| solidcraft_gpu::path::host_on_adapter(&adapter).map_err(|e| e.to_string()));
|
||||
let host = match host {
|
||||
Ok(host) => host,
|
||||
Err(e) => {
|
||||
assert!(std::env::var_os("SOLIDCRAFT_REQUIRE_GPU").is_none(), "required GPU unavailable: {e}");
|
||||
eprintln!("skipping progressive UI device test: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut h = Harness::new();
|
||||
h.app.render_workspace.path_host = PathHost::Ready(host);
|
||||
sync_backend(&mut h.app);
|
||||
h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap();
|
||||
h.app.session.mark_saved();
|
||||
h.call("ui.resize", json!({"width":1600,"height":1600}));
|
||||
enter(&mut h.app, true).unwrap();
|
||||
h.app.render_workspace.view.draft.width = 16;
|
||||
h.app.render_workspace.view.draft.height = 16;
|
||||
h.app.render_workspace.view.samples = 4;
|
||||
h.frames(3);
|
||||
let doc = h.app.session.doc.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
h.step(&json!({"widget":"Progressive rendering"})).unwrap();
|
||||
assert!(h.app.render_workspace.view.progressive);
|
||||
let start = h.app.tr("render.image_start").to_string();
|
||||
h.step(&json!({"widget":start})).unwrap();
|
||||
let job = h.app.render_workspace.view.job.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while h.app.render_workspace.view.completed.is_none() {
|
||||
assert!(std::time::Instant::now() < deadline, "progressive UI job did not complete");
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
h.frames(1);
|
||||
}
|
||||
let status = h.app.session.execute("render.status", &json!({"job":job})).unwrap();
|
||||
assert_eq!(status["frame"]["executor"], "gpu-path", "{status}");
|
||||
assert_eq!(status["options"]["samples"], 4);
|
||||
let export = h.app.tr("render.image_export").to_string();
|
||||
h.step(&json!({"widget":export})).unwrap();
|
||||
assert!(h.dir.join("render.png").is_file());
|
||||
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert!(!h.app.session.is_dirty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicked_render_workspace_saves_one_setup_and_restores_design_view() {
|
||||
for language in ["en", "de", "ko", "ru"] {
|
||||
@@ -291,6 +591,28 @@ fn product_gpu_buffers_preserve_opacity_and_never_contain_overlays() {
|
||||
assert!(empty.tris.is_empty() && empty.glass.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transmission_uses_legacy_preview_buffers_and_invalidates_the_opaque_cache() {
|
||||
let mut s = Session::default();
|
||||
s.execute("solid.box", &json!({"length":10,"width":10,"height":10,"body_name":"Glass"})).unwrap();
|
||||
let appearance = json!({"bodies":["Glass"],"color":"#336699",
|
||||
"pbr":{"metallic":0,"roughness":0.1,"dielectric_ior":1.5}});
|
||||
s.execute("appearance.assign", &appearance).unwrap();
|
||||
let (opaque_key, opaque) = gpu_scene(&solidcraft_engine::product_scene::extract(&s).unwrap()).unwrap();
|
||||
assert!(!opaque.pbr.is_empty());
|
||||
let mut scene = solidcraft_engine::product_scene::extract(&s).unwrap();
|
||||
scene.meshes[0].transmission =
|
||||
Some(solidcraft_engine::render::transmission::Transmission::new(solidcraft_engine::geom::Vec3::ZERO, None).unwrap());
|
||||
let (glass_key, glass) = gpu_scene(&scene).unwrap();
|
||||
assert_ne!(opaque_key, glass_key);
|
||||
assert!(glass.pbr.is_empty());
|
||||
assert!(!glass.tris.is_empty());
|
||||
scene.meshes[0].opacity = 0.4;
|
||||
let (_, transparent) = gpu_scene(&scene).unwrap();
|
||||
assert!(transparent.pbr.is_empty());
|
||||
assert!(!transparent.glass.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_profiles_advertise_only_installed_opaque_pbr_not_hdri_refraction_or_final() {
|
||||
use solidcraft_engine::render::*;
|
||||
@@ -341,6 +663,100 @@ fn completed_preview(h: &mut Harness, background: [u8; 3]) -> solidcraft_engine:
|
||||
panic!("small cooperative image did not complete within the work budget");
|
||||
}
|
||||
|
||||
fn image_panel_frame(app: &mut SolveApp, ctx: &egui::Context) -> egui::FullOutput {
|
||||
ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(285.0, 1200.0))), ..Default::default() },
|
||||
|ui| image_controls(app, ui),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_image_is_painted_once_available_and_upload_is_cached() {
|
||||
let mut h = Harness::new();
|
||||
let ctx = egui::Context::default();
|
||||
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
|
||||
assert!(h.app.render_workspace.image.is_none());
|
||||
let doc = h.app.session.doc.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
let job = completed_preview(&mut h, [20, 40, 60]);
|
||||
h.app.render_workspace.view.job = Some(job);
|
||||
let mut output = image_panel_frame(&mut h.app, &ctx);
|
||||
let (cached_job, texture) = h.app.render_workspace.image.as_ref().unwrap();
|
||||
let texture = texture.as_ref().unwrap();
|
||||
let id = texture.id();
|
||||
assert_eq!(*cached_job, job);
|
||||
assert_eq!(texture.size(), [16, 16]);
|
||||
let uploaded = &output.textures_delta.set.get(&id).unwrap().first().unwrap().image;
|
||||
let egui::ImageData::Color(pixels) = uploaded;
|
||||
assert!(pixels.pixels.iter().all(|p| *p == Color32::from_rgb(20, 40, 60)));
|
||||
// egui requires frame texture deltas to be applied or cleared.
|
||||
output.textures_delta.clear();
|
||||
assert!(
|
||||
ctx.tessellate(output.shapes, output.pixels_per_point)
|
||||
.iter()
|
||||
.any(|shape| { matches!(&shape.primitive, egui::epaint::Primitive::Mesh(mesh) if mesh.texture_id == id && !mesh.indices.is_empty()) })
|
||||
);
|
||||
let mut output = image_panel_frame(&mut h.app, &ctx);
|
||||
assert!(!output.textures_delta.set.contains_key(&id), "repaint must reuse the texture");
|
||||
// egui requires frame texture deltas to be applied or cleared.
|
||||
output.textures_delta.clear();
|
||||
assert!(Arc::ptr_eq(&doc, &h.app.session.doc));
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
let path = h.dir.join("displayed.png");
|
||||
h.app.run("render.export", json!({"job":job,"path":path})).unwrap();
|
||||
let frame = h.app.session.render_job_handle(job).unwrap().frame().unwrap().unwrap();
|
||||
assert_eq!(std::fs::read(path).unwrap(), frame.png(), "displaying the image must not change export bytes");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_image_keeps_last_export_during_work_and_updates_for_the_next_job() {
|
||||
let mut h = Harness::new();
|
||||
let ctx = egui::Context::default();
|
||||
let first = completed_preview(&mut h, [20, 40, 60]);
|
||||
h.app.render_workspace.view.job = Some(first);
|
||||
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
|
||||
let old_texture = h.app.render_workspace.image.as_ref().unwrap().1.as_ref().unwrap().id();
|
||||
let reply = h.app.run("render.start", json!({"settings":{"width":16,"height":16}})).unwrap();
|
||||
let pending = serde_json::from_value(reply["job"].clone()).unwrap();
|
||||
h.app.render_workspace.view.job = Some(pending);
|
||||
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
|
||||
assert_eq!(h.app.render_workspace.image.as_ref().unwrap().0, first);
|
||||
h.app.run("render.cancel", json!({"job":pending})).unwrap();
|
||||
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
|
||||
assert_eq!(h.app.render_workspace.image.as_ref().unwrap().0, first);
|
||||
let second = completed_preview(&mut h, [80, 100, 120]);
|
||||
h.app.render_workspace.view.job = Some(second);
|
||||
let mut output = image_panel_frame(&mut h.app, &ctx);
|
||||
let (cached_job, texture) = h.app.render_workspace.image.as_ref().unwrap();
|
||||
assert_eq!(*cached_job, second);
|
||||
assert_ne!(texture.as_ref().unwrap().id(), old_texture);
|
||||
assert!(output.textures_delta.free.contains(&old_texture));
|
||||
// egui requires frame texture deltas to be applied or cleared.
|
||||
output.textures_delta.clear();
|
||||
crate::documents::open_tab(&mut h.app, Session::default()).unwrap();
|
||||
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
|
||||
assert!(h.app.render_workspace.image.is_none(), "another document must not show this image");
|
||||
crate::documents::switch(&mut h.app, 0);
|
||||
image_panel_frame(&mut h.app, &ctx).textures_delta.clear();
|
||||
assert_eq!(h.app.render_workspace.image.as_ref().unwrap().0, second);
|
||||
reset_document(&mut h.app);
|
||||
assert!(h.app.render_workspace.image.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_image_decoder_preserves_alpha_and_rejects_invalid_sizes_and_pngs() {
|
||||
let mut png = std::io::Cursor::new(Vec::new());
|
||||
let pixels = image::RgbaImage::from_pixel(2, 1, image::Rgba([80, 120, 160, 128]));
|
||||
pixels.write_to(&mut png, image::ImageFormat::Png).unwrap();
|
||||
let decoded = decode_image(png.get_ref(), 2, 1).unwrap();
|
||||
assert_eq!(decoded.size, [2, 1]);
|
||||
assert_eq!(decoded.pixels, vec![Color32::from_rgba_unmultiplied(80, 120, 160, 128); 2]);
|
||||
for (width, height) in [(0, 1), (u32::MAX, 1), (4096, 4096), (1, 1), (3, 1)] {
|
||||
assert!(decode_image(png.get_ref(), width, height).is_err(), "{width}x{height}");
|
||||
}
|
||||
assert!(decode_image(b"invalid PNG", 2, 1).is_err());
|
||||
}
|
||||
|
||||
fn async_export_fixture() -> (Harness, solidcraft_engine::render::job::JobId) {
|
||||
let mut h = Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":20,"height":5})).unwrap();
|
||||
@@ -672,3 +1088,47 @@ fn four_and_five_k_viewports_preview_downscaled_within_the_limits() {
|
||||
}
|
||||
assert_eq!(preview_size(None, (5120, 2880)), (5120, 2880), "no executor: resolution reports it");
|
||||
}
|
||||
|
||||
/// Backend failures must remain readable in the fixed-width Render panel, not
|
||||
/// disappear into an ellipsis before the reason and recovery guidance are shown.
|
||||
#[test]
|
||||
fn backend_diagnostics_and_preview_help_wrap_without_elision() {
|
||||
fn collect<'a>(shape: &'a egui::Shape, texts: &mut Vec<&'a egui::epaint::TextShape>) {
|
||||
match shape {
|
||||
egui::Shape::Text(text) => texts.push(text),
|
||||
egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| collect(shape, texts)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
for mode in ["floating", "compact"] {
|
||||
for dark in [false, true] {
|
||||
let mut h = Harness::new();
|
||||
h.app.home.open = false;
|
||||
h.app.ui.dark = dark;
|
||||
h.call("ui.resize", json!({"width":1400,"height":1800}));
|
||||
h.app.run("view.layout", json!({"mode":mode})).unwrap();
|
||||
enter(&mut h.app, true).unwrap();
|
||||
let reason = "The selected viewport adapter has no compatible Vulkan path device. Images remain available on the CPU; keep the original design and review the driver before retrying.";
|
||||
h.app.render_workspace.path_host = PathHost::Failed(reason.into());
|
||||
h.frames(3);
|
||||
let expected = format!("{}: {reason}", h.app.tr("render.gpu_path_failed"));
|
||||
let help = h.app.tr("render.opaque_preview_help").to_owned();
|
||||
let mut out = h.ctx.run_ui(
|
||||
egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 1800.0))), ..Default::default() },
|
||||
|ui| {
|
||||
h.app.logic(ui.ctx());
|
||||
h.app.ui(ui);
|
||||
},
|
||||
);
|
||||
out.textures_delta.clear();
|
||||
let mut texts = Vec::new();
|
||||
out.shapes.iter().for_each(|shape| collect(&shape.shape, &mut texts));
|
||||
for message in [&expected, &help] {
|
||||
let text = texts.iter().find(|text| text.galley.job.text == *message).expect("the complete diagnostic is painted");
|
||||
assert!(!text.galley.elided, "{mode}, dark={dark}: {message}");
|
||||
assert!(text.galley.rows.len() > 1, "long messages must wrap in the narrow panel");
|
||||
assert!(text.galley.rect.width() <= 285.0, "the message must not expand the panel");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
{"expect": {"dialog": "Appearance", "shown": "Remove override"}},
|
||||
{"note": "The shared 28 px controls make this card scroll; reveal Search inside its body."},
|
||||
{"call": "ui.scroll", "params": {"x": 1345, "y": 650, "delta": -400}},
|
||||
{"note": "Let the scroll animation settle before resolving the moving Search handle; the larger starter catalog otherwise puts a swatch under that click."},
|
||||
{"frames": 20},
|
||||
{"click": {"handle": "appearance:search"}},
|
||||
{"text": "no-such-appearance"},
|
||||
{"expect": {"shown": "No matching appearances"}},
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Local scalar libraries
|
||||
# Local material libraries
|
||||
|
||||
The first local-library slice (#846) reuses the existing appearance and physical
|
||||
material commands. It is a small consumer of the broader [library proposal](libraries.md),
|
||||
not the complete resource-pack implementation. It adds no texture mapping, wood grain,
|
||||
PBR shader, HDRI lighting, online discovery or document-format version.
|
||||
The first scalar slice (#846) and portable v2 extension (#1062) reuse existing
|
||||
appearance/material commands. V2 transports existing opaque PBR looks and validated
|
||||
engineering cards; it does not add a shader, texture mapping, wood grain, HDRI,
|
||||
online discovery or document-format version. This remains a small consumer of
|
||||
the broader [library proposal](libraries.md).
|
||||
|
||||
## Desktop workflow
|
||||
|
||||
@@ -16,7 +17,7 @@ PBR shader, HDRI lighting, online discovery or document-format version.
|
||||
3. Open that library in another design, search its appearance presets, select one and
|
||||
choose **Apply library appearance**. The normal body/face/component targets are reused.
|
||||
OK after this immediate application does not add a second undo step.
|
||||
4. Save the design. Its selected scalar definitions are ordinary copied design data;
|
||||
4. Save the design. Its selected definitions are ordinary copied design data;
|
||||
the source library can be moved or removed without changing the saved result.
|
||||
|
||||
Export does not edit the design, its dirty state or history. An existing file is refused
|
||||
@@ -72,11 +73,30 @@ outer undo transaction through the existing material commands. Mass remains the
|
||||
volume × density calculation; a generic illustrative density is not an engineering grade.
|
||||
|
||||
`library.export` also accepts `id` (default `user.local`), `name` (default `My library`),
|
||||
`provenance` and `overwrite` (default false). Only explicit assignments/design-local
|
||||
`provenance`, `overwrite` (default false) and `format` (default `solidcraft-library/2`).
|
||||
Explicit `solidcraft-library/1` refuses PBR or engineering-card loss before publishing,
|
||||
including with overwrite enabled. Only explicit assignments/design-local
|
||||
definitions are inspected, without evaluating geometry. Duplicate exact definitions are
|
||||
deduplicated; different values with the same display name remain distinct entries.
|
||||
|
||||
## Scalar catalog v1
|
||||
## Portable catalog v2 and scalar v1 compatibility
|
||||
|
||||
`solidcraft-library/2` extends the same envelope and definition kinds with optional
|
||||
`pbr: {metallic, roughness, dielectric_ior}` on appearance values and optional
|
||||
`physical_card` on material values. PBR uses the existing bounded opaque contract
|
||||
and requires opacity exactly one. Cards use the existing bounded SI validator;
|
||||
source/condition records survive import, but validation does not authenticate or
|
||||
approve their sources. A card does not replace assigned mass density or supply a
|
||||
solver. Materials can carry both a card and an independent default PBR look.
|
||||
|
||||
Absent richer fields are omitted from serialization. Existing v1 scalar bytes,
|
||||
content revisions and identities are preserved exactly; regression fixtures pin
|
||||
an appearance and a material revision from the pre-v2 writer. Changing only the
|
||||
envelope version does not change a revision. Nonempty PBR/card data participates
|
||||
in it. V1 wire input rejects even null v2-only fields; it cannot disguise richer
|
||||
data with an older format marker. Older readers refuse v2.
|
||||
|
||||
The following v1 shape remains readable and writable explicitly:
|
||||
|
||||
The independent JSON format marker is `solidcraft-library/1`. It has `id`, `name` and an
|
||||
`entries` array. Each entry has `id`, `revision`, `provenance` and a typed `definition`:
|
||||
@@ -105,18 +125,24 @@ Provenance contains a required `license`, optional `author` and optional `source
|
||||
It is supplied metadata, not verified permission or a publisher signature. URLs are never
|
||||
fetched. The default `LicenseRef-User-Provided` does not claim that user data is openly
|
||||
licensed. No external art/data is bundled in this slice. Catalog provenance/revisions stay
|
||||
in the catalog: the first consumer copies scalar values into existing `Look`/`UserMaterial`
|
||||
records, without a persistent catalog-origin/update receipt. Full resource embedding,
|
||||
in the catalog: values are copied into existing `Look`/`UserMaterial` records,
|
||||
without a persistent catalog-origin/update receipt. Engineering-card sources and
|
||||
conditions belong to the card and are copied with it. Full resource embedding,
|
||||
design-owned provenance, scopes and explicit update links remain proposed follow-ups.
|
||||
|
||||
## Bounds and failure behavior
|
||||
|
||||
The reader accepts at most 4 MiB encoded JSON and 1,024 entries. Entry decoding stops at
|
||||
The reader accepts at most 4 MiB encoded JSON and 1,024 entries. A bounded raw wire
|
||||
preflight preserves v1 field rules before typed decoding. Entry decoding stops at
|
||||
the count cap with fallible vector growth. Names are trimmed, nonempty and at most 128
|
||||
UTF-8 bytes; provenance license/author/source URL limits are 128/256/2,048 bytes. Control
|
||||
characters, invalid identifiers, duplicate identities, bad revisions and nonfinite scalar
|
||||
values fail. Export inspects at most 100,000 source references, deduplicating into at most
|
||||
1,024 entries. These are encoded/work limits, not a process-memory or latency guarantee.
|
||||
1,024 entries. Rich export bounds retained unique definitions before whole-catalog
|
||||
encoding, reserving 4 KiB for the envelope and counting each entry's pretty bytes plus
|
||||
nested-line indentation. This conservative writer admission can refuse a catalog
|
||||
close to 4 MiB even when a compact representation fits. These are encoded/work limits,
|
||||
not a process-memory or latency guarantee.
|
||||
|
||||
Native reads are bounded regular-file reads; browser reads check VFS size before cloning.
|
||||
Native no-clobber writes reuse atomic new-file publication; explicit replacement is atomic
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
`material.properties.validate {card}` checks a caller-supplied candidate record,
|
||||
without assigning it, saving it in the design, reading files, fetching sources or
|
||||
changing history, selection, camera or live geometry. This is phase 1 of #923/#922,
|
||||
building towards #785. Existing `material.create/edit` and their density/appearance
|
||||
behavior are unchanged. The design format is unchanged.
|
||||
building towards #785. The initial validator remains read-only. The design-owned persistence
|
||||
section below extends `material.create/edit` without changing assigned density or
|
||||
optical appearance.
|
||||
|
||||
Validation means numerical and structural consistency. It does not authenticate
|
||||
sources, approve redistribution, certify design values, provide a solver or enable
|
||||
@@ -189,3 +190,31 @@ The card's test conditions still apply; changing direction does not extrapolate
|
||||
temperature, moisture, process, rate, damage or nonlinear behavior. Synthetic analytic
|
||||
tests cover principal axes, the isotropic limit and off-axis shear/coupling terms.
|
||||
No physical dataset or external implementation is included.
|
||||
|
||||
## Design-owned persistence
|
||||
|
||||
`material.create` accepts an optional `physical_card`; omission or null means
|
||||
unknown. `material.edit` retains an omitted card, validates and replaces a supplied
|
||||
card, and clears it only with explicit null. Density-only, appearance-only and
|
||||
rename edits retain the card. Assignment, configurations, undo/redo and Paste New
|
||||
keep using the same design-owned material definition.
|
||||
|
||||
`material.list` reports the optional card, its validation report and a density
|
||||
comparison. Assigned density in g/cm3 and recorded density in kg/m3 stay separate:
|
||||
card data never silently changes the density used for mass. Source and condition
|
||||
claims are preserved, not certified, and this does not add an analysis solver.
|
||||
|
||||
The single pending descriptor is `material-engineering-cards.rs`. The build
|
||||
assigns the pending format automatically; no released number, history or migration
|
||||
is edited. Older designs keep absent cards as unknown. Older readers refuse the
|
||||
newer saved format rather than silently losing engineering records.
|
||||
|
||||
Only `user_materials[].physical_card` receives the typed SI admission needed for
|
||||
elastic moduli above coordinate limits. Other numeric/depth limits, including the
|
||||
second range check after integer-valued coordinates become typed floats, remain
|
||||
unchanged. Normal loading admits at most 1000 design-owned materials.
|
||||
|
||||
Scalar catalog v1 refuses cards before creating or replacing an export file.
|
||||
Rich catalog transport is the separate #1086 contribution. The synthetic example
|
||||
`examples/material-card.json` uses an assigned density of 0.8 g/cm3, a recorded
|
||||
700 kg/m3 and invented isotropic constants; it is not a real material dataset.
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Anisotropic GGX numerical core
|
||||
|
||||
`render::anisotropic_surface` supplies the independent optical core tracked in
|
||||
#1694/#1699. This constituent extracts the original source and analytic tests
|
||||
already published in #1698 at `463a3e0daaa6b84f169ba5f8f215fba4642c194a`.
|
||||
It adds no renderer registration, saved field, command, device dependency or
|
||||
engineering-material assignment. Runtime consumers remain separate work.
|
||||
|
||||
## Contract
|
||||
|
||||
`VisualFrame::new` admits finite, nonzero, perpendicular normal/tangent inputs,
|
||||
normalizes them, and constructs a right-handed visual frame. This is not a physical
|
||||
material-axis assignment. An original geometric normal is supplied separately;
|
||||
response and reflected samples reject either hemisphere when appropriate.
|
||||
|
||||
Both perceptual roughness axes use the opaque surface range `[0.045, 1]`; alpha is
|
||||
roughness squared. Linear-sRGB reflectance, metal fraction and dielectric IOR use
|
||||
the existing `OpaqueSurface` admission. Invalid data is refused, not clamped.
|
||||
Equal roughness axes preserve the isotropic opaque BRDF contract exactly.
|
||||
|
||||
Sampling uses the projected **normal distribution (NDF)**, not a visible-normal
|
||||
sampler. `normal_pdf` is `D(h) * n.h`; the unconditional reflected-direction PDF
|
||||
includes the `1 / (4 * v.h)` Jacobian. A rejected microfacet is a null event that
|
||||
must remain in the sample denominator. Never retry until a sample succeeds.
|
||||
Reflection weights contain the specular component only. Diffuse/environment
|
||||
mixtures, visibility, ray spawning, image budgets and multiple scattering belong
|
||||
to the consumer; this module does not claim to implement them.
|
||||
|
||||
The implementation is original Rust using elementary GGX/Smith/Schlick equations.
|
||||
The public mathematical reference is Heitz, JCGT 7(4), 2018, equations 1 and 2:
|
||||
https://jcgt.org/published/0007/04/01/. No external implementation was adapted.
|
||||
|
||||
## Reproduce the focused checks
|
||||
|
||||
```sh
|
||||
cargo test --profile ci -p solidcraft-render --lib anisotropic_surface
|
||||
cargo clippy --profile ci -p solidcraft-render --all-targets -- -D warnings
|
||||
cargo check --target wasm32-unknown-unknown -p solidcraft-render
|
||||
```
|
||||
|
||||
Analytic and deterministic quadrature tests cover hostile inputs, orthonormality,
|
||||
normal-incidence peaks, isotropic equivalence, reciprocity, 90-degree visual-axis
|
||||
rotation, geometric/shading hemisphere separation, grazing directions, inverse-CDF
|
||||
sampling, PDF/throughput agreement and retained null-event mass. Broad-lobe energy
|
||||
checks are scoped consistency tests, not universal energy certification, measured
|
||||
material data or evidence of an integrated rendered image.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Render backend capabilities (R1)
|
||||
|
||||
Native progressive opaque PNG rendering is described in [Progressive product images](render-path.md). It registers alongside the CPU executor.
|
||||
|
||||
This page describes the R1 metadata contract. The installed GPU viewport and CPU
|
||||
product-job hosts extend it through complete profiles; HDR input preparation adds
|
||||
no lighting capability. See [the integrated phase boundaries](render-integration.md).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Runtime clear coat
|
||||
|
||||
Progressive image jobs accept an independent dielectric GGX coat over an opaque
|
||||
metallic/roughness substrate. A dark, moderately rough plastic base with a smooth
|
||||
coat can show narrow softbox reflections while retaining a dark body. Chrome uses
|
||||
the existing metallic surface; it does not need a coat.
|
||||
|
||||
```json
|
||||
{"command":"render.start","params":{"progressive":true,"raytrace":{"clear_coats":[{"body_index":0,"body_name":"Housing","weight":1,"roughness":0.12,"ior":1.5}]}}}
|
||||
```
|
||||
|
||||
`render.preflight` accepts the same override. Index and exact name must both match
|
||||
the captured `render.scene` body. At most 64 distinct bodies can be overridden.
|
||||
Weight is finite in [0,1], perceptual roughness in [0.045,1], and relative IOR in
|
||||
[1,3]. Unknown fields and repeated indices are errors. Weight zero is a no-op.
|
||||
Coats cannot be combined with runtime transmission on the same body. Overrides
|
||||
apply to the body and its faces in the job snapshot, never to the saved document,
|
||||
preview, undo history or appearance format. They require progressive rendering.
|
||||
|
||||
The implementation adds single-scattering GGX/Smith coat reflection with its own
|
||||
Schlick Fresnel term. For cosine `c`, define the conservative directional bound
|
||||
`B(c) = Fcoat(c / sqrt(2 * (1 + sqrt(1 - c*c))))`. The base BRDF is multiplied by
|
||||
`(1-weight*B(N.V))*(1-weight*B(N.L))`. This is deliberately stronger attenuation
|
||||
than macro-normal Fresnel, which can leak energy with rough coats over shiny bases.
|
||||
|
||||
For any reflected upper-hemisphere direction, the view/half-vector cosine is at
|
||||
least the argument used in B. Schlick decreases with cosine, and a unit-Fresnel
|
||||
single-scattering Smith GGX lobe reflects at most one. Thus the coat contributes
|
||||
at most `weight*B(N.V)`; the attenuated unit-energy base contributes at most
|
||||
`1-weight*B(N.V)`. The symmetric product retains reciprocity. This conservative
|
||||
allocation can darken grazing views and omits internal inter-layer scattering and
|
||||
refracted substrate directions; it is not a full layered transport solution.
|
||||
Importance-sampled f64 furnace quadrature covers shiny bases, rough coats, partial
|
||||
weights, IORs and grazing directions, alongside analytic and reciprocity checks.
|
||||
|
||||
Continuation selects the coat GGX lobe with probability `weight/2`, otherwise the
|
||||
existing equal cosine/GGX base mixture. Evaluation uses the complete layered BRDF;
|
||||
the PDF is the same combined mixture for continuation and direct-light MIS. GGX
|
||||
samples below the surface are rejected without renormalizing the PDF. Zero weight
|
||||
preserves the old BRDF and random-number consumption exactly. GPU BRDF/PDF tests
|
||||
compare the production shader to the independent f64 numerical contract, including
|
||||
roughness 0.045 and grazing directions.
|
||||
|
||||
The per-triangle resolved material occupies 64 bytes, including a 16-byte coat
|
||||
record. Storage-limit checks and peak-memory estimates include this additional
|
||||
record.
|
||||
|
||||
The model was designed from the public descriptions of
|
||||
[layered materials in PBRT](https://pbr-book.org/4ed/Light_Transport_II_Volume_Rendering/Scattering_from_Layered_Materials)
|
||||
and [Filament's clear-coat model](https://google.github.io/filament/main/filament.html#materialsystem/clearcoatmodel).
|
||||
These motivate independent interfaces and substrate attenuation; this implementation
|
||||
is original and deliberately simpler than a multilayer random walk. No reference
|
||||
implementation source was copied.
|
||||
@@ -0,0 +1,51 @@
|
||||
# Optional spatial denoising
|
||||
|
||||
Native progressive GPU image requests accept `"denoise": true`. Omission and
|
||||
explicit `false` preserve the existing raw image path and allocate no denoising
|
||||
buffers or pipelines. This runtime flag is not stored in designs or appearances.
|
||||
The request is rejected on non-progressive or non-GPU executors.
|
||||
|
||||
The optional final pass runs after all requested path samples, on linear
|
||||
premultiplied radiance, before the existing single exposure/tone-map/sRGB output
|
||||
step. It never changes the raw linear accumulator or the path random-number
|
||||
stream. Raw radiance and medium diagnostics are checked first, so denoising
|
||||
cannot conceal a failed glass path. To retain both raw and filtered PNGs, submit
|
||||
two otherwise identical jobs with the flag false and true respectively.
|
||||
|
||||
The original implementation uses three edge-aware a-trous passes with pixel
|
||||
steps 1, 2 and 4 and the separable five-tap kernel [1,4,6,4,1]. It is inspired by
|
||||
the published method of [Dammertz, Sewtz, Hanika and Lensch, HPG 2010](https://holger.dammertz.org/publications/dammertz10hpg_bibtex.html).
|
||||
No third-party filtering source or weights are copied. A deterministic pixel-centre
|
||||
ray supplies textured albedo, shading normal, body identity, world position and
|
||||
depth. Bilateral weights use albedo difference, normal agreement, distance from
|
||||
the local tangent planes, axial depth difference, and radiance contrast. Spatial thresholds scale with
|
||||
the projected pixel footprint. Every pass retains the original alpha; the
|
||||
online convex average avoids overflowing radiance multiplied by kernel weights.
|
||||
|
||||
First-hit guides cannot describe an image seen through refraction or a sharp
|
||||
reflection. Pixels first hitting glass, metals with roughness below 0.25,
|
||||
near-mirror coats with roughness below 0.05, light emitters, background or
|
||||
partial coverage bypass the filter. Glossier coats are filtered: on the studio
|
||||
coffee scenes their broad reflections of the cyclorama and area lights keep
|
||||
their shape, while the grain from choosing between coat and base layers is
|
||||
removed. A coat reflecting fine detail can lose some of it. First-hit guides also do not model depth-of-field correspondences:
|
||||
the centre lens ray is used, so out-of-focus boundaries may retain noise or
|
||||
acquire spatial bias. Rough glossy effects and soft shadows can lose detail.
|
||||
This is a biased, optional spatial filter, not a temporal or learned denoiser,
|
||||
and it cannot guarantee preservation of every lighting or texture feature.
|
||||
|
||||
Preflight includes the enabled method, three passes, guide policy, bypass cases
|
||||
and these limitations. The job options retain the explicit flag. A job admits
|
||||
80 additional GPU bytes per pixel only when enabled: 48 for guides and 32 for
|
||||
two colour buffers. The 512 MiB job estimate and each device storage-binding
|
||||
limit apply. At 1920x1080 the guide buffer is 99,532,800 bytes; larger dimensions
|
||||
may be rejected before allocation rather than silently reducing resolution.
|
||||
The guide pass reuses the existing six-storage-buffer path layout; the filter
|
||||
needs three storage buffers. Cancellation is checked between bounded tiles.
|
||||
|
||||
Regression coverage includes an independent f64 gather oracle, constant-image
|
||||
and coverage identity, checker noise reduction, albedo/normal/depth/body edge
|
||||
preservation, synthetic GPU/oracle agreement, production centre-ray guides,
|
||||
default/explicit-false image identity, raw-buffer preservation, admission and
|
||||
runtime preflight behaviour. GPU checks must be run on the target backends;
|
||||
source-level and shader validation alone do not establish rendered quality.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Runtime sun and environment lighting
|
||||
|
||||
These controls extend progressive image jobs. They do not change the saved design,
|
||||
its render setups, the viewport lighting, or the legacy default render.
|
||||
|
||||
An original artistic sky is available without downloading an image:
|
||||
|
||||
```json
|
||||
{
|
||||
"progressive": true,
|
||||
"samples": 256,
|
||||
"max_depth": 8,
|
||||
"environment": {
|
||||
"kind": "sky",
|
||||
"gradient": {"zenith": [0.16, 0.32, 0.7], "horizon": [0.8, 0.9, 1.0], "ground": [0.12, 0.1, 0.08]},
|
||||
"intensity": 1,
|
||||
"rotation_rad": 0
|
||||
},
|
||||
"lighting": {
|
||||
"sun": {"direction": [-0.4, -0.6, 0.7], "angular_radius_rad": 0.02, "radiance": [1000, 950, 850]}
|
||||
},
|
||||
"tone_map": "aces_fitted"
|
||||
}
|
||||
```
|
||||
|
||||
The gradient is an original horizon-to-zenith/ground interpolation, sampled to
|
||||
512x256. It is an artistic environment, not a calibrated atmospheric model.
|
||||
Sun direction points toward the light. Its finite angular disk has uniform linear
|
||||
RGB radiance and supports radii 0.001..pi/2 radians. Increasing radius at fixed
|
||||
radiance increases total illumination; the cone's solid angle is
|
||||
`4*pi*sin(radius/2)^2`. The sun is a separate additive emitter, so a sun already
|
||||
present in an HDR image is not automatically removed.
|
||||
|
||||
For a captured Radiance HDR file, replace the environment object with:
|
||||
|
||||
```json
|
||||
{"kind": "hdr", "path": "original-studio.hdr", "assume_linear_srgb": true, "rotation_rad": 0.3, "intensity": 1}
|
||||
```
|
||||
|
||||
Input is bounded to 16 MiB encoded, 2048x1024, 2:1 equirectangular and the standard
|
||||
`-Y +X` orientation. Header exposure and channel corrections are applied by the
|
||||
existing decoder. Declared primaries must be sRGB/D65. A source without primaries
|
||||
requires explicit `assume_linear_srgb: true`; this is an interpretation choice,
|
||||
not colour conversion. Other declared primaries are rejected even when that flag
|
||||
is set. No HDR values are tone mapped before lighting. Intensity is 0..100 and
|
||||
rotation is a finite bounded angle in radians about Z.
|
||||
|
||||
Equirectangular +Z is the north pole, -Z the south pole. Column zero begins at +X;
|
||||
columns run toward +Y. Positive rotation turns the image about +Z. Horizontal
|
||||
coordinates wrap; polar rows do not repeat. Map radiance replaces the constant
|
||||
`lighting.environment` when a map is supplied. Rectangle and sun emitters remain
|
||||
additive. The camera backplate remains the request's background; environment
|
||||
radiance appears through surface lighting, reflections and refractions.
|
||||
|
||||
The CLI accepts `--progressive --environment FILE.json`; that small JSON file
|
||||
contains the environment object above. `--lighting FILE.json` supplies the sun
|
||||
and/or rectangle lighting object. The engine captures bounded file bytes through
|
||||
its scoped VFS. Queued jobs own immutable shared bytes and never reopen paths.
|
||||
Preflight reports source SHA-256, dimensions and colour policy. A later start is a
|
||||
new capture; compare its descriptor if a source file may have changed.
|
||||
|
||||
## Sampling and bounded preparation
|
||||
|
||||
The worker uses the existing cooperative HDR decoder, then builds an alias table
|
||||
from luminance times exact texel solid angle. A row's solid angle per texel is
|
||||
`(2*pi/width)*(cos(theta_top)-cos(theta_bottom))`, evaluated with a stable sine
|
||||
identity near the poles. Within a selected texel, azimuth and cosine of polar
|
||||
angle are sampled uniformly. The directional density is texel probability divided
|
||||
by solid angle. Radiance uses the same piecewise-constant texel mapping. A black
|
||||
image uses a uniform distribution with zero emitted radiance. Independent random
|
||||
variates select the alias bucket and coin; sharing one 24-bit variate would bias
|
||||
large maps.
|
||||
|
||||
The sun samples its cone uniformly. Environment and sun are sampled separately,
|
||||
and each escaped BSDF ray receives each emitter's radiance with its own matching
|
||||
power-heuristic weight. The existing opaque-branch, delta-event and final-bounce
|
||||
rules apply. Thin-sheet branch densities multiply the competing BSDF density;
|
||||
connections that exhaust the remaining depth use NEE only. Enabled straight
|
||||
solid-shadow connections also use NEE only, with their transmitted emitter hits
|
||||
suppressed to avoid double counting, as described in `render-glass-shadows.md`.
|
||||
This extends the original estimator from
|
||||
[PBRT fourth edition, sections 12.5](https://pbr-book.org/4ed/Light_Sources/Infinite_Area_Lights)
|
||||
and [13.4](https://pbr-book.org/4ed/Light_Transport_I_Surface_Reflection/A_Better_Path_Tracer);
|
||||
no PBRT source or images are copied.
|
||||
|
||||
GPU storage uses one additional binding, 32 bytes per texel plus a 16-byte header.
|
||||
Admission conservatively adds retained encoded-buffer capacity, 160 bytes per texel and 64 KiB to the
|
||||
job's existing estimate for decoded/table/work/staging/device buffers. Device
|
||||
binding limits and the existing 512 MiB job limit also apply. Preparation checks
|
||||
cancellation while decoding, generating rows, building tables and staging uploads.
|
||||
CPU product rendering rejects environment resources explicitly.
|
||||
|
||||
Tests use original in-memory RGBE fixtures and the generated gradient. They cover
|
||||
colour policy, malformed inputs, cancellation, polar/seam lookup, PDF normalization,
|
||||
unequal-mass sampling frequencies, resource admission, furnace consistency, sun
|
||||
cone quadrature and immutable engine preflight. No third-party HDRI or material
|
||||
assets are bundled. Original fixture generators live in `examples/studio/textures`;
|
||||
planar runtime colour/normal mapping is described in `render-runtime-textures.md`.
|
||||
|
||||
The showcase PNG maps and RGBE gradient are generated locally before replay with
|
||||
`python examples/studio/author_showcase.py`. Image outputs are ignored and are not
|
||||
committed source assets; the recipes, generators and provenance remain editable.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Bounded linear film accumulation
|
||||
|
||||
`solidcraft_render::film` is the independent accumulator for #1696. It is extracted from the original renderer donor at `3b892eab`; it does not install a CPU integrator, PNG resolver, camera coverage buffer or progressive UI.
|
||||
|
||||
Samples are nonnegative finite linear RGB. Per-pixel running means and unbiased sample variance remain linear: no exposure, denoising, tone mapping or display conversion occurs here. A later consumer applies its output transform exactly once after resolving a complete pass. Sample order is significant; splitting the same ordered samples into bounded contiguous calls preserves identical results, while arbitrary floating-point reordering is not promised.
|
||||
|
||||
The caller reserves `required_bytes` in the existing image-job lease and retains it throughout the film lifetime. Allocation is fallible and actual capacity is checked. Dimensions, pixel count, sample count, storage and update sizes are bounded. This module creates no second pool, thread, cache or cancellation token. Initialization and updates debit the same caller-provided cumulative work budget.
|
||||
|
||||
`add_range` stages at most 256 pixel updates before committing. Invalid input, range/count overflow, exhausted work or cancellation before the bounded commit leaves prior pixels unchanged; consumed work is not refunded. Cancellation after commit must still be checked by the consumer before publication.
|
||||
|
||||
`complete_pass` refuses empty or uneven sample counts and returns an immutable borrowed view, not an owned or publishable image. The caller must separately admit any output snapshot and enforce its existing generation/cancellation publication lock. Camera background coverage and actual progressive image delivery remain separate integration work.
|
||||
|
||||
No design data is newly saved, so no format descriptor is introduced. No extra dependency, asset, geometry operation or renderer capability is advertised.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user