diff --git a/Cargo.lock b/Cargo.lock index 956a0fee..5fea573f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -802,6 +802,10 @@ version = "0.1.0" [[package]] name = "solvecraft-cli" version = "0.1.0" +dependencies = [ + "serde_json", + "solvecraft-engine", +] [[package]] name = "solvecraft-doc" @@ -827,6 +831,7 @@ dependencies = [ "solvecraft-geom", "solvecraft-io", "solvecraft-kernel", + "solvecraft-render", "solvecraft-sketch", "thiserror 2.0.21", ] diff --git a/apps/solvecraft-cli/Cargo.toml b/apps/solvecraft-cli/Cargo.toml index bbefa761..89aaab95 100644 --- a/apps/solvecraft-cli/Cargo.toml +++ b/apps/solvecraft-cli/Cargo.toml @@ -1,5 +1,17 @@ [package] name = "solvecraft-cli" +description = "SolveCraft headless CLI: run command scripts, measure, export, render snapshots, replay oracle recipes" version.workspace = true edition.workspace = true license.workspace = true + +[lints] +workspace = true + +[[bin]] +name = "solvecraft-cli" +path = "src/main.rs" + +[dependencies] +solvecraft-engine = { workspace = true } +serde_json = { workspace = true } diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index f328e4d9..86fabbaf 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -1 +1,157 @@ -fn main() {} +//! SolveCraft headless command line. +//! +//! ```text +//! solvecraft-cli run [--out FILE]... [--save FILE] [--quiet] +//! solvecraft-cli eval (alias: inspect) measurements as JSON +//! solvecraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|top|…] +//! solvecraft-cli exec [json-params] run one command on an empty design +//! solvecraft-cli commands the command registry as JSON +//! solvecraft-cli recipe translate an oracle recipe to a script +//! solvecraft-cli oracle ... replay recipes and compare with measure.json +//! ``` +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] + +mod oracle; +mod recipe; + +use std::process::ExitCode; + +use serde_json::{Value, json}; +use solvecraft_engine::Session; +use solvecraft_engine::render::{StandardView, render_png}; + +const USAGE: &str = "usage: + solvecraft-cli run [--out FILE]... [--save FILE] [--quiet] + solvecraft-cli eval (alias: inspect) + solvecraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right] + solvecraft-cli exec [json-params] + solvecraft-cli commands + solvecraft-cli recipe + solvecraft-cli oracle ... [--json] +"; + +fn main() -> ExitCode { + let args: Vec = std::env::args().skip(1).collect(); + let r = match args.first().map(String::as_str) { + Some("run") => cmd_run(&args[1..]), + Some("eval" | "inspect") => cmd_eval(&args[1..]), + Some("snapshot") => cmd_snapshot(&args[1..]), + Some("exec") => cmd_exec(&args[1..]), + Some("commands") => { + let mut s = Session::default(); + s.execute("engine.commands", &json!({})).map(|v| println!("{}", pretty(&v))).map_err(|e| e.to_string()) + } + Some("recipe") => args.get(1).ok_or_else(|| USAGE.to_string()).and_then(|p| { + let v = read_json(p)?; + recipe::to_script(&v).map(|s| println!("{}", pretty(&s))) + }), + Some("oracle") => oracle::run(&args[1..]), + Some("--version" | "-V") => { + println!("solvecraft-cli {}", env!("CARGO_PKG_VERSION")); + Ok(()) + } + _ => Err(USAGE.to_string()), + }; + match r { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {e}"); + ExitCode::FAILURE + } + } +} + +fn pretty(v: &Value) -> String { + serde_json::to_string_pretty(v).unwrap_or_default() +} + +pub fn read_json(path: &str) -> Result { + let s = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; + serde_json::from_str(&s).map_err(|e| format!("{path}: {e}")) +} + +/// A session from a design file, a command script, or an oracle recipe. +pub fn load(path: &str) -> Result { + let v = read_json(path)?; + let mut s = Session::default(); + if v.get("format").and_then(Value::as_str).is_some_and(|f| f.starts_with("solvecraft")) { + s.execute("doc.open", &json!({"path": path})).map_err(|e| e.to_string())?; + } else if v.get("features").is_some() && v.get("commands").is_none() { + let script = recipe::to_script(&v)?; + s.run_script(&script).map_err(|e| e.to_string())?; + } else { + s.run_script(&v).map_err(|e| e.to_string())?; + } + Ok(s) +} + +fn flag<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).map(String::as_str) +} + +fn flags<'a>(args: &'a [String], name: &str) -> Vec<&'a str> { + args.windows(2).filter(|w| w[0] == name).map(|w| w[1].as_str()).collect() +} + +fn cmd_run(args: &[String]) -> Result<(), String> { + let path = args.first().ok_or_else(|| USAGE.to_string())?; + let mut s = load(path)?; + for out in flags(args, "--out") { + s.execute("ExportCommand", &json!({"path": out})).map_err(|e| e.to_string())?; + } + if let Some(p) = flag(args, "--save") { + s.execute("SaveDocumentAsCommand", &json!({"path": p})).map_err(|e| e.to_string())?; + } + if !args.iter().any(|a| a == "--quiet") { + let m = s.execute("MeasureCommand", &json!({})).map_err(|e| e.to_string())?; + println!("{}", pretty(&m)); + } + Ok(()) +} + +fn cmd_eval(args: &[String]) -> Result<(), String> { + let path = args.first().ok_or_else(|| USAGE.to_string())?; + let mut s = load(path)?; + let m = s.execute("MeasureCommand", &json!({})).map_err(|e| e.to_string())?; + let d = s.execute("document.inspect", &json!({})).map_err(|e| e.to_string())?; + let out = json!({ + "body_count": m["body_count"], + "bodies": m["bodies"], + "total": m["total"], + "timeline": d["timeline"], + "params": d["params"], + "sketches": d["sketches"], + }); + println!("{}", pretty(&out)); + Ok(()) +} + +fn cmd_snapshot(args: &[String]) -> Result<(), String> { + let path = args.first().ok_or_else(|| USAGE.to_string())?; + let out = flag(args, "--out").ok_or("snapshot needs --out FILE.png")?; + let w = flag(args, "--width").and_then(|x| x.parse().ok()).unwrap_or(1280usize); + let h = flag(args, "--height").and_then(|x| x.parse().ok()).unwrap_or(800usize); + let s = load(path)?; + let mut cam = solvecraft_engine::view::home_camera(&s); + if let Some(v) = flag(args, "--view") { + cam.set_view(StandardView::parse(v).ok_or_else(|| format!("unknown view `{v}`"))?); + } + let scene = solvecraft_engine::view::scene(&s, &cam); + let png = render_png(&scene, &cam, w, h).ok_or("render failed")?; + std::fs::write(out, &png).map_err(|e| format!("{out}: {e}"))?; + println!("{}", pretty(&json!({"path": out, "width": w, "height": h, "bytes": png.len()}))); + Ok(()) +} + +fn cmd_exec(args: &[String]) -> Result<(), String> { + let id = args.first().ok_or_else(|| USAGE.to_string())?; + let p: Value = match args.get(1) { + Some(t) => serde_json::from_str(t).map_err(|e| format!("params: {e}"))?, + None => json!({}), + }; + let mut s = Session::default(); + let v = s.execute(id, &p).map_err(|e| e.to_string())?; + println!("{}", pretty(&v)); + Ok(()) +} diff --git a/apps/solvecraft-cli/src/oracle.rs b/apps/solvecraft-cli/src/oracle.rs new file mode 100644 index 00000000..f3c9a2e5 --- /dev/null +++ b/apps/solvecraft-cli/src/oracle.rs @@ -0,0 +1,83 @@ +//! Replay oracle recipes and compare with Fusion's measurements. + +use serde_json::{Value, json}; + +use crate::{read_json, recipe}; + +/// Relative tolerance for volume and area (our curved-face tessellation is within ~2e-4). +pub const REL_TOL: f64 = 1e-3; + +fn check_case(dir: &str) -> Value { + let name = std::path::Path::new(dir).file_name().map(|x| x.to_string_lossy().to_string()).unwrap_or_default(); + let rp = format!("{dir}/recipe.json"); + let mp = format!("{dir}/measure.json"); + let (recipe, want) = match (read_json(&rp), read_json(&mp)) { + (Ok(r), Ok(m)) => (r, m), + (Err(e), _) | (_, Err(e)) => return json!({"case": name, "pass": false, "error": e}), + }; + let script = match recipe::to_script(&recipe) { + Ok(s) => s, + Err(e) => return json!({"case": name, "pass": false, "skipped": true, "error": e}), + }; + let mut s = solvecraft_engine::Session::default(); + if let Err(e) = s.run_script(&script) { + return json!({"case": name, "pass": false, "error": e.to_string()}); + } + let got = match s.execute("MeasureCommand", &json!({})) { + Ok(m) => m, + Err(e) => return json!({"case": name, "pass": false, "error": e.to_string()}), + }; + let mut checks = Vec::new(); + let mut pass = true; + let mut num = |what: &str, g: Option, w: Option, rel: f64| { + let ok = match (g, w) { + (Some(g), Some(w)) => (g - w).abs() <= rel * w.abs().max(1.0), + _ => false, + }; + pass &= ok; + checks.push(json!({"check": what, "got": g, "want": w, "ok": ok})); + }; + num("body_count", got["body_count"].as_f64(), want["body_count"].as_f64(), 0.0); + num("volume_mm3", got["total"]["volume_mm3"].as_f64(), want["total"]["volume_mm3"].as_f64(), REL_TOL); + num("area_mm2", got["total"]["area_mm2"].as_f64(), want["total"]["area_mm2"].as_f64(), REL_TOL); + num("faces", got["total"]["faces"].as_f64(), want["total"]["faces"].as_f64(), 0.0); + num("edges", got["total"]["edges"].as_f64(), want["total"]["edges"].as_f64(), 0.0); + num("vertices", got["total"]["vertices"].as_f64(), want["total"]["vertices"].as_f64(), 0.0); + json!({"case": name, "pass": pass, "checks": checks}) +} + +pub fn run(args: &[String]) -> Result<(), String> { + let dirs: Vec<&String> = args.iter().filter(|a| !a.starts_with("--")).collect(); + if dirs.is_empty() { + return Err("oracle: give one or more case directories".into()); + } + let results: Vec = dirs.iter().map(|d| check_case(d)).collect(); + if args.iter().any(|a| a == "--json") { + println!("{}", serde_json::to_string_pretty(&Value::Array(results.clone())).unwrap_or_default()); + } else { + for r in &results { + let status = if r["pass"].as_bool().unwrap_or(false) { + "PASS" + } else if r["skipped"].as_bool().unwrap_or(false) { + "SKIP" + } else { + "FAIL" + }; + let detail: Vec = r["checks"] + .as_array() + .into_iter() + .flatten() + .filter(|c| !c["ok"].as_bool().unwrap_or(false)) + .map(|c| format!("{} got {} want {}", c["check"].as_str().unwrap_or(""), c["got"], c["want"])) + .collect(); + let err = r["error"].as_str().map(|e| format!(" ({e})")).unwrap_or_default(); + println!( + "{status} {}{err}{}", + r["case"].as_str().unwrap_or(""), + if detail.is_empty() { String::new() } else { format!(": {}", detail.join("; ")) } + ); + } + } + let failed = results.iter().filter(|r| !r["pass"].as_bool().unwrap_or(false) && !r["skipped"].as_bool().unwrap_or(false)).count(); + if failed > 0 { Err(format!("{failed} oracle case(s) failed")) } else { Ok(()) } +} diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs new file mode 100644 index 00000000..3c81924f --- /dev/null +++ b/apps/solvecraft-cli/src/recipe.rs @@ -0,0 +1,159 @@ +//! Oracle recipes (`plan/fusion/oracle/*/recipe.json`) → SolveCraft command scripts. +//! +//! A recipe lists features with sketch entities in sketch-local coordinates; point references +//! (`l1.end`) share points. Each feature becomes one or more commands. + +use serde_json::{Value, json}; + +fn plane_param(pl: &Value) -> Value { + let r = pl.get("ref").and_then(Value::as_str).unwrap_or(""); + let origin_zero = pl.get("origin").and_then(Value::as_array).is_none_or(|o| o.iter().all(|x| x.as_f64().unwrap_or(0.0).abs() < 1e-12)); + let std = match r.to_ascii_uppercase().as_str() { + "XY" => Some(([1.0, 0.0, 0.0], [0.0, 1.0, 0.0])), + "XZ" => Some(([1.0, 0.0, 0.0], [0.0, 0.0, 1.0])), + "YZ" => Some(([0.0, 1.0, 0.0], [0.0, 0.0, 1.0])), + _ => None, + }; + let axes_match = |k: &str, want: [f64; 3]| { + pl.get(k).and_then(Value::as_array).is_some_and(|a| a.iter().zip(want).all(|(x, w)| (x.as_f64().unwrap_or(f64::NAN) - w).abs() < 1e-9)) + }; + match std { + Some((x, y)) if origin_zero && axes_match("x_dir", x) && axes_match("y_dir", y) => json!(r.to_ascii_uppercase()), + _ => json!({"origin": pl.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "x_dir": pl.get("x_dir"), "y_dir": pl.get("y_dir")}), + } +} + +/// A point argument: the shared reference if any, else the coordinates. +fn parg(p: Option<&Value>) -> Value { + match p { + Some(v) => match (v.get("ref"), v.get("at")) { + (Some(Value::String(r)), _) => json!(r), + (_, Some(at)) => at.clone(), + _ => v.clone(), + }, + None => Value::Null, + } +} + +fn op(f: &Value) -> Value { + f.get("operation").cloned().unwrap_or(json!("new")) +} + +fn sketch_cmds(f: &Value, out: &mut Vec) -> Result<(), String> { + let mut p = json!({"plane": plane_param(f.get("plane").unwrap_or(&Value::Null))}); + if let Some(n) = f.get("name") { + p["name"] = n.clone(); + } + out.push(json!({"command": "SketchCreate", "params": p})); + for e in f.get("entities").and_then(Value::as_array).into_iter().flatten() { + let id = e.get("id").cloned().unwrap_or(Value::Null); + let construction = e.get("construction").cloned().unwrap_or(json!(false)); + match e.get("type").and_then(Value::as_str) { + Some("line") => out.push(json!({"command": "DrawPolyline", "params": {"points": [parg(e.get("start")), parg(e.get("end"))], "ids": [id], "construction": construction}})), + Some("circle") => out.push(json!({"command": "CircleCenterRadius", "params": {"center": parg(e.get("center")), "radius": e.get("radius"), "id": id, "construction": construction}})), + Some("arc") => { + if e.get("center").is_some() { + out.push(json!({"command": "ArcCenterTwoPoint", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "end": parg(e.get("end")), "id": id}})) + } else { + out.push(json!({"command": "ArcThreePoint", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": parg(e.get("through").or(e.get("mid"))), "id": id}})) + } + } + Some("point") => out.push(json!({"command": "DrawPoint", "params": {"point": parg(e.get("at").map(|a| json!({"at": a})).as_ref().or(Some(e))), "id": id}})), + Some(other) => return Err(format!("recipe: sketch entity type `{other}` is not supported yet")), + None => return Err("recipe: sketch entity without type".into()), + } + } + for c in f.get("constraints").and_then(Value::as_array).into_iter().flatten() { + let ty = c.get("type").and_then(Value::as_str).unwrap_or(""); + let cmd = match ty.to_ascii_lowercase().as_str() { + "horizontal" | "vertical" => { + let mut p = c.clone(); + p["mode"] = json!(ty.to_ascii_lowercase()); + out.push(json!({"command": "ConstraintHorizontalVertical", "params": p})); + continue; + } + "coincident" => "ConstraintCoincident", + "tangent" => "ConstraintTangent", + "equal" => "ConstraintEqual", + "parallel" => "ConstraintParallel", + "perpendicular" => "ConstraintPerpendicular", + "fix" => "ConstraintFix", + "midpoint" => "ConstraintMidPoint", + "concentric" => "ConstraintConcentric", + "collinear" => "ConstraintCollinear", + "symmetry" | "symmetric" => "ConstraintSymmetry", + other => return Err(format!("recipe: constraint `{other}` is not supported yet")), + }; + out.push(json!({"command": cmd, "params": c})); + } + for d in f.get("dimensions").and_then(Value::as_array).into_iter().flatten() { + out.push(json!({"command": "SketchDimension", "params": d})); + } + out.push(json!({"command": "SketchStop", "params": {}})); + Ok(()) +} + +fn extent(f: &Value) -> (Value, Value, Value) { + let e = f.get("extent").cloned().unwrap_or(Value::Null); + let d = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null); + let dir = e.get("direction").cloned().unwrap_or(json!("positive")); + (d, dir, e.get("distance2").cloned().unwrap_or(Value::Null)) +} + +/// The sketch a feature uses (name), from its profiles. +fn profile_sketch(f: &Value) -> Value { + f.get("profiles") + .and_then(Value::as_array) + .and_then(|a| a.first()) + .and_then(|p| p.get("sketch")) + .cloned() + .or_else(|| f.get("sketch").cloned()) + .unwrap_or(Value::Null) +} + +pub fn to_script(recipe: &Value) -> Result { + let mut out: Vec = Vec::new(); + for p in recipe.get("parameters").and_then(Value::as_array).into_iter().flatten() { + out.push(json!({"command": "ChangeParameterCommand", "params": {"name": p.get("name"), "expression": p.get("expression").or(p.get("value")), "unit": p.get("unit").cloned().unwrap_or(json!("mm"))}})); + } + let features = recipe.get("features").and_then(Value::as_array).ok_or("recipe: missing `features`")?; + for f in features { + let name = f.get("name").cloned().unwrap_or(Value::Null); + let bodies = f.get("result_bodies").cloned().unwrap_or(json!([])); + match f.get("op").and_then(Value::as_str) { + Some("sketch") => sketch_cmds(f, &mut out)?, + Some("extrude") => { + let (d, dir, d2) = extent(f); + let mut p = json!({"sketch": profile_sketch(f), "profiles": f.get("profiles").cloned().unwrap_or(json!("all")), "distance": d, "direction": dir, "operation": op(f), "name": name, "body_names": bodies}); + if !d2.is_null() { + p["distance2"] = d2; + } + out.push(json!({"command": "Extrude", "params": p})); + } + Some("revolve") => out.push(json!({"command": "Revolve", "params": { + "sketch": profile_sketch(f), "profiles": f.get("profiles").cloned().unwrap_or(json!("all")), + "axis": f.get("axis").and_then(|a| a.get("ref").or(a.get("line")).cloned()).or_else(|| f.get("axis").cloned()), + "angle": f.get("angle").cloned().unwrap_or(json!("360 deg")), "operation": op(f), "name": name, "body_names": bodies}})), + Some("fillet") => { + out.push(json!({"command": "FusionFilletEdgesCommand", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}})) + } + Some("chamfer") => { + out.push(json!({"command": "FusionChamferCommand", "params": {"edges": edge_points(f), "distance": f.get("distance"), "name": name}})) + } + Some(other) => return Err(format!("recipe: feature op `{other}` is not supported yet")), + None => return Err("recipe: feature without `op`".into()), + } + } + Ok(json!({"commands": out})) +} + +fn edge_points(f: &Value) -> Value { + Value::Array( + f.get("edges") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|e| e.get("point").or(e.get("mid")).cloned().unwrap_or_else(|| e.clone())) + .collect(), + ) +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 8671370b..e397b7a3 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -14,6 +14,7 @@ solvecraft-sketch = { workspace = true } solvecraft-kernel = { workspace = true } solvecraft-doc = { workspace = true } solvecraft-io = { workspace = true } +solvecraft-render = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index b2f278ad..9540b1a5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -10,6 +10,7 @@ pub mod cmd; pub mod params; +pub mod view; use std::sync::Arc; @@ -23,6 +24,7 @@ pub use solvecraft_doc as doc; pub use solvecraft_geom as geom; pub use solvecraft_io as io; pub use solvecraft_kernel as kernel; +pub use solvecraft_render as render; pub use solvecraft_sketch as sketch; #[derive(Debug, thiserror::Error)] diff --git a/crates/engine/src/view.rs b/crates/engine/src/view.rs new file mode 100644 index 00000000..2a7ffbec --- /dev/null +++ b/crates/engine/src/view.rs @@ -0,0 +1,142 @@ +//! What the viewport shows, independent of the UI toolkit: body meshes, edges, sketches, the +//! grid and the origin. Used by the headless snapshot renderer; the GPU viewport draws the same +//! content. + +use std::sync::Arc; + +use solvecraft_geom::{Aabb3, Plane, Vec3}; +use solvecraft_render::{Camera, Rgb, Scene, SceneLine, SceneMesh}; +use solvecraft_sketch::Sketch; + +use crate::{Sel, Session}; + +/// Colours (shared with the GPU viewport). +pub mod colors { + use solvecraft_render::Rgb; + pub const BODY: Rgb = Rgb(176, 186, 198); + pub const BODY_SELECTED: Rgb = Rgb(110, 170, 235); + pub const EDGE: Rgb = Rgb(40, 44, 52); + pub const SKETCH: Rgb = Rgb(30, 90, 200); + pub const SKETCH_DONE: Rgb = Rgb(20, 20, 20); + pub const SKETCH_CONSTRUCTION: Rgb = Rgb(230, 130, 40); + pub const SKETCH_FIXED: Rgb = Rgb(20, 20, 20); + pub const PROFILE: Rgb = Rgb(255, 196, 120); + pub const GRID: Rgb = Rgb(196, 202, 212); + pub const GRID_MAJOR: Rgb = Rgb(170, 178, 190); + pub const AXIS_X: Rgb = Rgb(220, 60, 60); + pub const AXIS_Y: Rgb = Rgb(60, 170, 60); + pub const AXIS_Z: Rgb = Rgb(60, 100, 230); + pub const BG_TOP: Rgb = Rgb(240, 243, 247); + pub const BG_BOTTOM: Rgb = Rgb(203, 210, 221); +} + +/// Bounds of everything visible. +pub fn bounds(s: &Session) -> Aabb3 { + let st = s.model.state(); + let mut b = Aabb3::EMPTY; + for body in &st.bodies { + b = b.union(&body.mesh().bounds()); + } + for ss in &st.sketches { + if let Some((lo, hi)) = ss.sketch.bounds() { + for p in [lo, hi] { + b.add(ss.plane.to_world(p)); + } + } + } + b +} + +/// Grid spacing (minor, major) for a view of the given half height. +pub fn grid_step(half_height: f64) -> (f64, f64) { + let raw = (half_height / 10.0).max(1e-3); + let p = 10f64.powf(raw.log10().floor()); + let minor = if raw / p < 2.0 { + p + } else if raw / p < 5.0 { + 2.0 * p + } else { + 5.0 * p + }; + (minor, minor * 5.0) +} + +/// Sketch curves as world polylines with their colour. +pub fn sketch_lines(sk: &Sketch, plane: &Plane, active: bool, determined: &[bool]) -> Vec<(Vec, Rgb, bool)> { + let mut out = Vec::new(); + for (i, c) in sk.curves.iter().enumerate() { + let col = if c.construction { + colors::SKETCH_CONSTRUCTION + } else if !active { + colors::SKETCH_DONE + } else if determined.get(i).copied().unwrap_or(false) { + colors::SKETCH_FIXED + } else { + colors::SKETCH + }; + for seg in sk.segs(i) { + out.push((seg.polyline(0.02).iter().map(|p| plane.to_world(*p)).collect(), col, c.construction)); + } + } + out +} + +/// The scene for a headless render. +pub fn scene(s: &Session, cam: &Camera) -> Scene { + let st = s.model.state(); + let mut sc = Scene { background: Some((colors::BG_TOP, colors::BG_BOTTOM)), ..Default::default() }; + let b = bounds(s); + sc.radius = (b.diagonal() * 0.5).max(cam.half_height()).max(10.0) + b.center().dist(cam.target); + // Grid on XY. + let (minor, major) = grid_step(cam.half_height()); + let ext = (cam.half_height() * 3.0 / major).ceil() * major; + let c = Vec3::new((cam.target.x / major).round() * major, (cam.target.y / major).round() * major, 0.0); + let n = ((ext / minor) as i64).clamp(1, 400); + for i in -n..=n { + let t = i as f64 * minor; + let col = if (t / major).fract().abs() < 1e-9 { colors::GRID_MAJOR } else { colors::GRID }; + sc.lines.push(SceneLine { points: vec![c + Vec3::new(t, -ext, 0.0), c + Vec3::new(t, ext, 0.0)], color: col, width: 0.6, on_top: false }); + sc.lines.push(SceneLine { points: vec![c + Vec3::new(-ext, t, 0.0), c + Vec3::new(ext, t, 0.0)], color: col, width: 0.6, on_top: false }); + } + let axis = cam.half_height() * 0.25; + for (d, col) in [(Vec3::X, colors::AXIS_X), (Vec3::Y, colors::AXIS_Y), (Vec3::Z, colors::AXIS_Z)] { + sc.lines.push(SceneLine { points: vec![Vec3::ZERO, d * axis], color: col, width: 1.6, on_top: false }); + } + for body in &st.bodies { + let selected = s.selection.iter().any(|x| matches!(x, Sel::Body { name } if *name == body.name)); + let mesh = body.mesh(); + sc.meshes.push(SceneMesh { mesh: Arc::clone(&mesh), color: if selected { colors::BODY_SELECTED } else { colors::BODY } }); + for e in &mesh.edges { + sc.lines.push(SceneLine { points: e.clone(), color: colors::EDGE, width: 1.2, on_top: false }); + } + } + for ss in &st.sketches { + let active = s.active_sketch == Some(ss.feature); + // Finished sketches are hidden once a feature uses them, like Fusion's default. + if !active && sketch_consumed(s, ss.feature) { + continue; + } + for (pts, col, cons) in sketch_lines(&ss.sketch, &ss.plane, active, &ss.report.curve_determined) { + sc.lines.push(SceneLine { points: pts, color: col, width: if cons { 1.0 } else { 1.6 }, on_top: active }); + } + } + sc +} + +/// Is a sketch used by a later feature? +pub fn sketch_consumed(s: &Session, id: u64) -> bool { + use solvecraft_doc::FeatureKind; + s.doc.features.iter().any(|f| matches!(f.kind, FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if sketch == id)) +} + +/// Camera framing the model (iso view). +pub fn home_camera(s: &Session) -> Camera { + let mut c = Camera::default(); + let b = bounds(s); + if !b.is_empty() { + c.fit(&b); + } else { + c.distance = 150.0; + } + c +} diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs index 22f0a913..fd7cf24c 100644 --- a/crates/render/src/raster.rs +++ b/crates/render/src/raster.rs @@ -73,7 +73,7 @@ fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] { let fill = n.dot(view_dir).max(0.0); let h = (key + view_dir).normalized().unwrap_or(view_dir); let spec = n.dot(h).max(0.0).powi(40) * 0.25; - let k = 0.28 + 0.52 * diff + 0.22 * fill; + let k = 0.42 + 0.38 * diff + 0.25 * fill; [(base[0] * k + spec).min(1.0), (base[1] * k + spec).min(1.0), (base[2] * k + spec).min(1.0)] }