From cdca1e59f31f66f75f36b7125fada596da28745b Mon Sep 17 00:00:00 2001 From: bherbruck Date: Wed, 7 Oct 2026 17:30:37 +0000 Subject: [PATCH] M0: engine command registry, file formats, view math and CPU renderer Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 91 ++- crates/engine/Cargo.toml | 12 + crates/engine/src/cmd/edit.rs | 233 +++++++ crates/engine/src/cmd/features.rs | 320 +++++++++ crates/engine/src/cmd/file.rs | 85 +++ crates/engine/src/cmd/inspect.rs | 200 ++++++ crates/engine/src/cmd/mod.rs | 116 ++++ crates/engine/src/cmd/sketch.rs | 1061 +++++++++++++++++++++++++++++ crates/engine/src/lib.rs | 255 +++++++ crates/engine/src/params.rs | 79 +++ crates/engine/src/tests.rs | 301 ++++++++ crates/io/Cargo.toml | 7 + crates/io/src/lib.rs | 247 +++++++ crates/render/Cargo.toml | 6 + crates/render/src/camera.rs | 259 +++++++ crates/render/src/lib.rs | 10 + crates/render/src/raster.rs | 234 +++++++ 17 files changed, 3515 insertions(+), 1 deletion(-) create mode 100644 crates/engine/src/cmd/edit.rs create mode 100644 crates/engine/src/cmd/features.rs create mode 100644 crates/engine/src/cmd/file.rs create mode 100644 crates/engine/src/cmd/inspect.rs create mode 100644 crates/engine/src/cmd/mod.rs create mode 100644 crates/engine/src/cmd/sketch.rs create mode 100644 crates/engine/src/params.rs create mode 100644 crates/engine/src/tests.rs create mode 100644 crates/render/src/camera.rs create mode 100644 crates/render/src/raster.rs diff --git a/Cargo.lock b/Cargo.lock index 411c4a89..956a0fee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "cc" version = "1.6.0" @@ -208,6 +214,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -221,7 +236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -303,6 +318,19 @@ dependencies = [ "cc", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", +] + [[package]] name = "itertools" version = "0.13.0" @@ -404,6 +432,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "miniz_oxide" version = "0.9.1" @@ -414,6 +452,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "nom" version = "3.2.1" @@ -493,6 +541,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -535,6 +596,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "quick-xml" version = "0.22.0" @@ -752,6 +819,17 @@ dependencies = [ [[package]] name = "solvecraft-engine" version = "0.1.0" +dependencies = [ + "log", + "serde", + "serde_json", + "solvecraft-doc", + "solvecraft-geom", + "solvecraft-io", + "solvecraft-kernel", + "solvecraft-sketch", + "thiserror 2.0.21", +] [[package]] name = "solvecraft-geom" @@ -764,6 +842,12 @@ dependencies = [ [[package]] name = "solvecraft-io" version = "0.1.0" +dependencies = [ + "solvecraft-doc", + "solvecraft-geom", + "solvecraft-kernel", + "thiserror 2.0.21", +] [[package]] name = "solvecraft-kernel" @@ -783,6 +867,11 @@ dependencies = [ [[package]] name = "solvecraft-render" version = "0.1.0" +dependencies = [ + "image", + "serde", + "solvecraft-geom", +] [[package]] name = "solvecraft-sketch" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index de800ca1..8671370b 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -1,8 +1,20 @@ [package] name = "solvecraft-engine" +description = "SolveCraft engine: the session, the command registry (everything is a command), undo/redo and selection" version.workspace = true edition.workspace = true license.workspace = true [lints] workspace = true + +[dependencies] +solvecraft-geom = { workspace = true } +solvecraft-sketch = { workspace = true } +solvecraft-kernel = { workspace = true } +solvecraft-doc = { workspace = true } +solvecraft-io = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +log = { workspace = true } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs new file mode 100644 index 00000000..0e4d72c3 --- /dev/null +++ b/crates/engine/src/cmd/edit.rs @@ -0,0 +1,233 @@ +//! Undo/redo, parameters, the timeline, deletion and selection. + +use serde_json::{Value, json}; +use solvecraft_doc::{Feature, FeatureKind}; + +use super::CommandSpec; +use crate::params::{bad, bool_, num, str_, string_list}; +use crate::{EngineError, Result, Sel, Session, Snapshot}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("UndoCommand", "Undo", undo).icon("undo").key("Ctrl+Z").noundo(), + CommandSpec::new("RedoCommand", "Redo", redo).icon("redo").key("Ctrl+Y").noundo(), + CommandSpec::new("ChangeParameterCommand", "Change Parameters", change_param) + .at("SOLID", "MODIFY") + .icon("params") + .params("name, expression (number or text), unit?: mm|deg|\"\", comment?; or delete: name"), + CommandSpec::new("FusionComputeAllCommand", "Compute All", compute_all).at("SOLID", "MODIFY").icon("compute").noundo(), + CommandSpec::new("FusionDeleteCommand", "Delete", delete) + .at("SOLID", "MODIFY") + .icon("delete") + .key("Delete") + .params("features: [id or name] (a sketch takes its dependent features with it)"), + CommandSpec::new("FusionRenameTimelineEntryCommand", "Rename", rename).params("feature: id|name, name"), + CommandSpec::new("timeline.rollback", "Move Timeline Marker", rollback).params("position: number of features to keep active (omit = end)"), + CommandSpec::new("timeline.suppress", "Suppress Feature", suppress).params("feature: id|name, suppressed?: bool (default toggles)"), + CommandSpec::new("timeline.edit", "Edit Feature", edit_feature) + .params("feature: id|name, set: {fields to change, e.g. {\"extent\": {\"distance\": \"30\"}}}"), + CommandSpec::new("select.set", "Select", select_set) + .noundo() + .params("items: [{type: body|edge|face|feature|sketch_curve|sketch_point|profile, …}], add?: bool"), + CommandSpec::new("select.clear", "Clear Selection", select_clear).noundo(), +]; + +fn undo(s: &mut Session, _p: &Value) -> Result { + let Some(prev) = s.undo.pop() else { return Err(EngineError::Other("nothing to undo".into())) }; + let label = prev.label.clone(); + s.redo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch }); + s.doc = prev.doc; + s.active_sketch = prev.active_sketch; + s.selection.clear(); + s.refresh(); + Ok(json!({"undone": label})) +} + +fn redo(s: &mut Session, _p: &Value) -> Result { + let Some(next) = s.redo.pop() else { return Err(EngineError::Other("nothing to redo".into())) }; + let label = next.label.clone(); + s.undo.push(Snapshot { label: label.clone(), doc: s.doc.clone(), active_sketch: s.active_sketch }); + s.doc = next.doc; + s.active_sketch = next.active_sketch; + s.selection.clear(); + s.refresh(); + Ok(json!({"redone": label})) +} + +fn change_param(s: &mut Session, p: &Value) -> Result { + let cmd = "ChangeParameterCommand"; + if let Some(n) = str_(p, "delete") { + s.doc_mut().remove_param(n)?; + return Ok(json!({"deleted": n})); + } + let name = str_(p, "name").ok_or_else(|| bad(cmd, "`name` is required"))?; + let e = match p.get("expression").or_else(|| p.get("value")) { + Some(Value::String(x)) => x.clone(), + Some(Value::Number(n)) => n.to_string(), + _ => return Err(bad(cmd, "`expression` must be a number or text")), + }; + let unit = str_(p, "unit"); + let comment = str_(p, "comment"); + s.doc_mut().set_param(name, &e, unit, comment)?; + s.refresh(); + let (vals, _) = s.doc.param_values(); + let v = vals.get(name).map(|v| v.v); + // Report features that now fail. + let errors: Vec = s.model.results.iter().filter_map(|r| r.error.as_ref().map(|e| json!({"feature": r.name, "error": e}))).collect(); + Ok(json!({"name": name, "expression": e, "value": v, "recomputed": s.model.last_recomputed, "errors": errors})) +} + +fn compute_all(s: &mut Session, _p: &Value) -> Result { + s.model = solvecraft_doc::Model::new(); + s.refresh(); + let errors = s.model.results.iter().filter(|r| r.error.is_some()).count(); + Ok(json!({"features": s.model.results.len(), "errors": errors})) +} + +fn feature_id(s: &Session, v: Option<&Value>, cmd: &str) -> Result { + let key = match v { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad(cmd, "`feature` must be an id or name")), + }; + s.doc.find_feature(&key).map(|f| f.id).ok_or_else(|| bad(cmd, format!("no feature `{key}`"))) +} + +fn delete(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionDeleteCommand"; + let mut keys = string_list(p, "features"); + if let Some(Value::Array(a)) = p.get("features") { + keys.extend(a.iter().filter_map(Value::as_u64).map(|n| n.to_string())); + } + if let Some(f) = p.get("feature") { + keys.push(match f { + Value::Number(n) => n.to_string(), + Value::String(x) => x.clone(), + _ => String::new(), + }); + } + if keys.is_empty() { + return Err(bad(cmd, "`features` must list features")); + } + let mut gone = Vec::new(); + for k in keys { + let Some(id) = s.doc.find_feature(&k).map(|f| f.id) else { + if gone.is_empty() { + return Err(bad(cmd, format!("no feature `{k}`"))); + } + continue; + }; + gone.extend(s.doc_mut().delete_feature(id)?); + } + if s.active_sketch.is_some_and(|a| gone.contains(&a)) { + s.active_sketch = None; + } + s.selection.clear(); + Ok(json!({"deleted": gone})) +} + +fn rename(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionRenameTimelineEntryCommand"; + let id = feature_id(s, p.get("feature"), cmd)?; + let name = + str_(p, "name").map(str::trim).filter(|n| !n.is_empty() && n.len() <= 128).ok_or_else(|| bad(cmd, "`name` must be 1…128 characters"))?; + if s.doc.features.iter().any(|f| f.name == name && f.id != id) { + return Err(bad(cmd, format!("another feature is called `{name}`"))); + } + if let Some(f) = s.doc_mut().feature_mut(id) { + f.name = name.to_string(); + } + Ok(json!({"feature": id, "name": name})) +} + +fn rollback(s: &mut Session, p: &Value) -> Result { + let n = s.doc.features.len(); + let pos = num(p, "position").map(|x| x.max(0.0) as usize); + s.doc_mut().marker = match pos { + Some(k) if k < n => Some(k), + _ => None, + }; + s.selection.clear(); + Ok(json!({"marker": s.doc.marker.unwrap_or(n), "features": n})) +} + +fn suppress(s: &mut Session, p: &Value) -> Result { + let id = feature_id(s, p.get("feature"), "timeline.suppress")?; + let want = bool_(p, "suppressed"); + let f = s.doc_mut().feature_mut(id).ok_or_else(|| EngineError::Other("feature".into()))?; + f.suppressed = want.unwrap_or(!f.suppressed); + Ok(json!({"feature": id, "suppressed": f.suppressed})) +} + +fn merge(dst: &mut Value, src: &Value, depth: usize) { + if depth > 16 { + return; + } + match (dst, src) { + (Value::Object(d), Value::Object(s)) => { + for (k, v) in s { + match d.get_mut(k) { + Some(slot) if slot.is_object() && v.is_object() => merge(slot, v, depth + 1), + _ => { + d.insert(k.clone(), v.clone()); + } + } + } + } + (d, s) => *d = s.clone(), + } +} + +fn edit_feature(s: &mut Session, p: &Value) -> Result { + let cmd = "timeline.edit"; + let id = feature_id(s, p.get("feature"), cmd)?; + let set = p.get("set").filter(|v| v.is_object()).ok_or_else(|| bad(cmd, "`set` must be an object of fields"))?; + let f = s.doc.feature(id).cloned().ok_or_else(|| bad(cmd, "feature"))?; + let mut v = serde_json::to_value(&f).map_err(|e| EngineError::Other(e.to_string()))?; + merge(&mut v, set, 0); + let mut nf: Feature = serde_json::from_value(v).map_err(|e| bad(cmd, format!("invalid feature: {e}")))?; + nf.id = f.id; + if std::mem::discriminant(&nf.kind) != std::mem::discriminant(&f.kind) { + return Err(bad(cmd, "the feature type cannot change")); + } + if let (FeatureKind::Sketch { sketch: a, .. }, FeatureKind::Sketch { sketch: b, .. }) = (&nf.kind, &f.kind) + && a != b + { + return Err(bad(cmd, "edit sketch geometry with the sketch commands")); + } + if let Some(slot) = s.doc_mut().feature_mut(id) { + *slot = nf; + } + s.refresh(); + if let Some(e) = s.model.result(id).and_then(|r| r.error.clone()) { + return Err(EngineError::Other(e)); + } + Ok(json!({"feature": id, "recomputed": s.model.last_recomputed})) +} + +fn select_set(s: &mut Session, p: &Value) -> Result { + let items = p.get("items").and_then(Value::as_array).cloned().unwrap_or_default(); + if items.len() > 10_000 { + return Err(bad("select.set", "too many items")); + } + let mut sel: Vec = Vec::new(); + for it in items { + let one: Sel = serde_json::from_value(it).map_err(|e| bad("select.set", e.to_string()))?; + sel.push(one); + } + if !bool_(p, "add").unwrap_or(false) { + s.selection.clear(); + } + for x in sel { + if !s.selection.contains(&x) { + s.selection.push(x); + } + } + s.revision += 1; + Ok(json!({"selected": s.selection.len()})) +} + +fn select_clear(s: &mut Session, _p: &Value) -> Result { + s.selection.clear(); + s.revision += 1; + Ok(json!({"selected": 0})) +} diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs new file mode 100644 index 00000000..cb5c496d --- /dev/null +++ b/crates/engine/src/cmd/features.rs @@ -0,0 +1,320 @@ +//! Solid feature commands: extrude, revolve, fillet, chamfer, primitives, combine, move. + +use serde_json::{Value, json}; +use solvecraft_doc::{AxisRef, Direction, Extent, FeatureKind, Operation, ProfileSel, expr::Kind}; +use solvecraft_geom::Vec3; + +use super::CommandSpec; +use crate::params::{bad, bool_, expr, req_expr, str_, string_list, vec2, vec3}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("Extrude", "Extrude", extrude) + .at("SOLID", "CREATE") + .icon("extrude") + .key("E") + .params("distance: expr; sketch?: id|name (default: active or last sketch); profiles?: all | [index] | [[curve ids]] | [{point:[x,y]}]; direction?: positive|negative|symmetric; distance2?; start_offset?; operation?: new|join|cut|intersect; targets?: [body]; name?; body_name?"), + CommandSpec::new("Revolve", "Revolve", revolve) + .at("SOLID", "CREATE") + .icon("revolve") + .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); sketch?, profiles?, operation?, targets?, name?, body_name?"), + CommandSpec::new("PrimitiveBox", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"), + CommandSpec::new("PrimitiveCylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"), + CommandSpec::new("PrimitiveSphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"), + CommandSpec::new("PrimitiveTorus", "Torus", prim_torus).at("SOLID", "CREATE").icon("torus").params("major, minor: expr (radii); center?; operation?"), + CommandSpec::new("FusionFilletEdgesCommand", "Fillet", fillet) + .at("SOLID", "MODIFY") + .icon("fillet") + .key("F") + .params("edges: [[x,y,z] point on edge | {body, index}]; radius: expr; body?"), + CommandSpec::new("FusionChamferCommand", "Chamfer", chamfer).at("SOLID", "MODIFY").icon("chamfer").params("edges: [[x,y,z] | {body, index}]; distance: expr; body?"), + CommandSpec::new("FusionCombineCommand", "Combine", combine).at("SOLID", "MODIFY").icon("combine").params("target: body; tools: [body]; operation?: join|cut|intersect; keep_tools?: bool"), + CommandSpec::new("FusionMoveCommand", "Move/Copy", move_bodies).at("SOLID", "MODIFY").icon("move").key("M").params("bodies: [names]; translate?: [x,y,z] (exprs or numbers); axis?: [x,y,z]; angle?: expr"), +]; + +fn operation(p: &Value, cmd: &str) -> Result { + match str_(p, "operation") { + None => Ok(Operation::NewBody), + Some(o) => Operation::parse(o).ok_or_else(|| bad(cmd, format!("unknown operation `{o}` (new, join, cut, intersect)"))), + } +} + +/// The sketch a feature uses: `sketch` param, the active sketch, or the last sketch. +fn feature_sketch(s: &Session, p: &Value, cmd: &str) -> Result { + if let Some(v) = p.get("sketch") { + let key = match v { + Value::Number(n) => n.to_string(), + Value::String(x) => x.clone(), + _ => return Err(bad(cmd, "`sketch` must be an id or name")), + }; + let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?; + return match f.kind { + FeatureKind::Sketch { .. } => Ok(f.id), + _ => Err(bad(cmd, format!("`{key}` is not a sketch"))), + }; + } + if let Some(a) = s.active_sketch { + return Ok(a); + } + let limit = s.doc.marker.unwrap_or(usize::MAX); + s.doc + .features + .iter() + .take(limit) + .rev() + .find(|f| matches!(f.kind, FeatureKind::Sketch { .. })) + .map(|f| f.id) + .ok_or_else(|| bad(cmd, "there is no sketch to use (create one first)")) +} + +fn profiles(p: &Value, cmd: &str) -> Result { + let Some(v) = p.get("profiles") else { return Ok(ProfileSel::All) }; + match v { + Value::String(s) if s.eq_ignore_ascii_case("all") => Ok(ProfileSel::All), + Value::Number(n) => Ok(ProfileSel::Indices { indices: vec![n.as_u64().ok_or_else(|| bad(cmd, "profile index"))? as usize] }), + Value::Array(a) if a.is_empty() => Err(bad(cmd, "`profiles` is empty")), + Value::Array(a) if a.len() > 10_000 => Err(bad(cmd, "too many profiles")), + Value::Array(a) => { + if a.iter().all(Value::is_u64) { + return Ok(ProfileSel::Indices { indices: a.iter().filter_map(Value::as_u64).map(|x| x as usize).collect() }); + } + if a.iter().all(|x| x.get("point").is_some()) { + let pts = + a.iter().map(|x| x.get("point").and_then(vec2).ok_or_else(|| bad(cmd, "profile point must be [x, y]"))).collect::>()?; + return Ok(ProfileSel::Points { points: pts }); + } + // [[ids]], [{curves: [...]}] or recipe-style [{loops: [{outer: true, curves: [...]}]}]. + let mut loops = Vec::new(); + for x in a { + let ids: Vec = match x { + Value::Array(ids) => ids.iter().filter_map(|i| i.as_str().map(str::to_string)).collect(), + Value::Object(o) => { + if let Some(c) = o.get("curves") { + string_list(&json!({"c": c}), "c") + } else if let Some(ls) = o.get("loops").and_then(Value::as_array) { + let outer = ls.iter().find(|l| l.get("outer").and_then(Value::as_bool).unwrap_or(true)).or(ls.first()); + outer.map(|l| string_list(l, "curves")).unwrap_or_default() + } else { + Vec::new() + } + } + _ => Vec::new(), + }; + if ids.is_empty() { + return Err(bad(cmd, "each profile must be an index, a list of curve ids, {curves}, {loops} or {point}")); + } + loops.push(ids); + } + Ok(ProfileSel::Curves { loops }) + } + _ => Err(bad(cmd, "`profiles` must be \"all\" or a list")), + } +} + +fn check_expr(s: &Session, e: &str, kind: Kind, cmd: &str, what: &str) -> Result<()> { + s.doc.eval(e, kind).map(|_| ()).map_err(|err| bad(cmd, format!("{what}: {err}"))) +} + +/// Add a feature, evaluate, and report its result (an evaluation error fails the command). +fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result { + let name = str_(p, "name"); + let id = s.doc_mut().add_feature(kind, name)?; + let mut names = string_list(p, "body_names"); + if let Some(b) = str_(p, "body_name") { + names.insert(0, b.to_string()); + } + if !names.is_empty() + && let Some(f) = s.doc_mut().feature_mut(id) + { + f.body_names = names; + } + s.active_sketch = None; + s.refresh(); + let r = s.model.result(id).cloned(); + if let Some(e) = r.as_ref().and_then(|r| r.error.clone()) { + return Err(EngineError::Other(e)); + } + let st = s.model.state(); + let bodies: Vec<&str> = st.bodies.iter().map(|b| b.name.as_str()).collect(); + let fname = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default(); + Ok(json!({"feature": id, "name": fname, "bodies": bodies, "ms": r.map(|r| r.ms)})) +} + +fn extrude(s: &mut Session, p: &Value) -> Result { + let cmd = "Extrude"; + let sketch = feature_sketch(s, p, cmd)?; + let distance = req_expr(cmd, p, "distance")?; + check_expr(s, &distance, Kind::Length, cmd, "distance")?; + let direction = match str_(p, "direction").map(str::to_ascii_lowercase).as_deref() { + None | Some("positive") | Some("one_side") => Direction::Positive, + Some("negative") | Some("reverse") | Some("flip") => Direction::Negative, + Some("symmetric") => Direction::Symmetric, + Some(o) => return Err(bad(cmd, format!("unknown direction `{o}`"))), + }; + let distance2 = expr(p, "distance2"); + if let Some(d) = &distance2 { + check_expr(s, d, Kind::Length, cmd, "distance2")?; + } + let start_offset = expr(p, "start_offset"); + if let Some(d) = &start_offset { + check_expr(s, d, Kind::Length, cmd, "start_offset")?; + } + let kind = FeatureKind::Extrude { + sketch, + profiles: profiles(p, cmd)?, + extent: Extent { distance, direction, distance2, start_offset }, + operation: operation(p, cmd)?, + targets: string_list(p, "targets"), + }; + add_feature(s, p, kind) +} + +fn revolve(s: &mut Session, p: &Value) -> Result { + let cmd = "Revolve"; + let sketch = feature_sketch(s, p, cmd)?; + let axis = match str_(p, "axis") { + Some(a @ ("X" | "Y" | "Z")) => AxisRef::World { axis: a.into() }, + Some(a @ ("x" | "y")) => AxisRef::SketchAxis { axis: a.into() }, + Some(id) => AxisRef::SketchLine { curve: id.into() }, + None => return Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes) or X, Y, Z")), + }; + let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into()); + check_expr(s, &angle, Kind::Angle, cmd, "angle")?; + let kind = + FeatureKind::Revolve { sketch, profiles: profiles(p, cmd)?, axis, angle, operation: operation(p, cmd)?, targets: string_list(p, "targets") }; + add_feature(s, p, kind) +} + +fn radius_expr(p: &Value, cmd: &str) -> Result { + match (expr(p, "radius"), expr(p, "diameter")) { + (Some(r), _) => Ok(r), + (None, Some(d)) => Ok(format!("({d}) / 2")), + _ => Err(bad(cmd, "needs `radius` or `diameter`")), + } +} + +fn prim_box(s: &mut Session, p: &Value) -> Result { + let cmd = "PrimitiveBox"; + let (l, w, h) = (req_expr(cmd, p, "length")?, req_expr(cmd, p, "width")?, req_expr(cmd, p, "height")?); + for (e, n) in [(&l, "length"), (&w, "width"), (&h, "height")] { + check_expr(s, e, Kind::Length, cmd, n)?; + } + let corner = match (p.get("corner").and_then(vec3), p.get("center").and_then(vec3)) { + (Some(c), _) => c, + (None, Some(c)) => { + let v = |e: &str| s.doc.eval(e, Kind::Length).unwrap_or(0.0); + c - Vec3::new(v(&l), v(&w), v(&h)) * 0.5 + } + _ => Vec3::ZERO, + }; + add_feature(s, p, FeatureKind::Box { corner, length: l, width: w, height: h, operation: operation(p, cmd)? }) +} + +fn prim_cylinder(s: &mut Session, p: &Value) -> Result { + let cmd = "PrimitiveCylinder"; + let radius = radius_expr(p, cmd)?; + let height = req_expr(cmd, p, "height")?; + check_expr(s, &radius, Kind::Length, cmd, "radius")?; + check_expr(s, &height, Kind::Length, cmd, "height")?; + let base = p.get("base").and_then(vec3).unwrap_or(Vec3::ZERO); + let axis = p.get("axis").and_then(vec3).unwrap_or(Vec3::Z); + add_feature(s, p, FeatureKind::Cylinder { base, axis, radius, height, operation: operation(p, cmd)? }) +} + +fn prim_sphere(s: &mut Session, p: &Value) -> Result { + let cmd = "PrimitiveSphere"; + let radius = radius_expr(p, cmd)?; + check_expr(s, &radius, Kind::Length, cmd, "radius")?; + let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO); + add_feature(s, p, FeatureKind::Sphere { center, radius, operation: operation(p, cmd)? }) +} + +fn prim_torus(s: &mut Session, p: &Value) -> Result { + let cmd = "PrimitiveTorus"; + let (major, minor) = (req_expr(cmd, p, "major")?, req_expr(cmd, p, "minor")?); + check_expr(s, &major, Kind::Length, cmd, "major")?; + check_expr(s, &minor, Kind::Length, cmd, "minor")?; + let center = p.get("center").and_then(vec3).unwrap_or(Vec3::ZERO); + add_feature(s, p, FeatureKind::Torus { center, major, minor, operation: operation(p, cmd)? }) +} + +/// Edge references → points on the edges. +fn edge_points(s: &Session, p: &Value, cmd: &str) -> Result> { + let a = p.get("edges").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`edges` must be a list"))?; + if a.is_empty() || a.len() > 1000 { + return Err(bad(cmd, "select 1…1000 edges")); + } + let st = s.model.state(); + let mut out = Vec::new(); + for e in a { + if let Some(pt) = vec3(e).or_else(|| e.get("point").and_then(vec3)) { + out.push(pt); + continue; + } + let body = e.get("body").and_then(Value::as_str).ok_or_else(|| bad(cmd, "an edge is a point [x,y,z], {point} or {body, index}"))?; + let idx = e.get("index").and_then(Value::as_u64).ok_or_else(|| bad(cmd, "edge `index` must be a number"))? as usize; + let b = st.body(body).ok_or_else(|| bad(cmd, format!("no body `{body}`")))?; + let tol = (b.body.size() * 2e-3).max(1e-3); + let edges = b.body.edges(tol)?; + out.push(edges.get(idx).ok_or_else(|| bad(cmd, format!("{body} has no edge {idx}")))?.mid); + } + Ok(out) +} + +fn fillet(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionFilletEdgesCommand"; + let edges = edge_points(s, p, cmd)?; + let radius = req_expr(cmd, p, "radius")?; + check_expr(s, &radius, Kind::Length, cmd, "radius")?; + add_feature(s, p, FeatureKind::Fillet { edges, radius, body: str_(p, "body").map(str::to_string) }) +} + +fn chamfer(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionChamferCommand"; + let edges = edge_points(s, p, cmd)?; + let distance = req_expr(cmd, p, "distance")?; + check_expr(s, &distance, Kind::Length, cmd, "distance")?; + add_feature(s, p, FeatureKind::Chamfer { edges, distance, body: str_(p, "body").map(str::to_string) }) +} + +fn combine(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionCombineCommand"; + let target = str_(p, "target").ok_or_else(|| bad(cmd, "`target` must be a body name"))?.to_string(); + let tools = string_list(p, "tools"); + if tools.is_empty() { + return Err(bad(cmd, "`tools` must list bodies")); + } + let op = match str_(p, "operation") { + None => Operation::Join, + Some(o) => Operation::parse(o).filter(|o| *o != Operation::NewBody).ok_or_else(|| bad(cmd, "operation must be join, cut or intersect"))?, + }; + add_feature(s, p, FeatureKind::Combine { target, tools, operation: op, keep_tools: bool_(p, "keep_tools").unwrap_or(false) }) +} + +fn move_bodies(s: &mut Session, p: &Value) -> Result { + let cmd = "FusionMoveCommand"; + let bodies = string_list(p, "bodies"); + if bodies.is_empty() { + return Err(bad(cmd, "`bodies` must list bodies")); + } + let t = match p.get("translate") { + Some(Value::Array(a)) if a.len() == 3 => { + let mut out: [String; 3] = Default::default(); + for (i, v) in a.iter().enumerate() { + let e = expr(&json!({"v": v}), "v").ok_or_else(|| bad(cmd, "translate entries must be numbers or expressions"))?; + check_expr(s, &e, Kind::Length, cmd, "translate")?; + if let Some(slot) = out.get_mut(i) { + *slot = e; + } + } + out + } + None => ["0".into(), "0".into(), "0".into()], + _ => return Err(bad(cmd, "`translate` must be [x, y, z]")), + }; + let angle = expr(p, "angle"); + if let Some(a) = &angle { + check_expr(s, a, Kind::Angle, cmd, "angle")?; + } + add_feature(s, p, FeatureKind::Move { bodies, translate: t, rotate_axis: p.get("axis").and_then(vec3), angle }) +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs new file mode 100644 index 00000000..b0e5e386 --- /dev/null +++ b/crates/engine/src/cmd/file.rs @@ -0,0 +1,85 @@ +//! Documents and files: new, open, save, export. + +use serde_json::{Value, json}; +use solvecraft_doc::Document; +use solvecraft_io::Format; + +use super::CommandSpec; +use crate::params::{bad, bool_, str_, string_list}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("NewDocumentCommand", "New Design", new_doc).icon("new").key("Ctrl+N").noundo().params("name?"), + CommandSpec::new("doc.open", "Open", open).icon("open").key("Ctrl+O").noundo().params("path: .solvecraft design"), + CommandSpec::new("SaveDocumentCommand", "Save", save).icon("save").key("Ctrl+S").noundo().params("path? (default: current file)"), + CommandSpec::new("SaveDocumentAsCommand", "Save As", save_as).icon("save").noundo().params("path"), + CommandSpec::new("ExportCommand", "Export", export) + .icon("export") + .noundo() + .params("path; format?: stl|stla|obj|step (default from extension); bodies?: [names]"), + CommandSpec::new("FusionSaveAsSTLCommand", "Save As Mesh", save_stl).icon("export").noundo().params("path; bodies?: [names]; ascii?: bool"), +]; + +fn new_doc(s: &mut Session, p: &Value) -> Result { + let name = str_(p, "name").unwrap_or("Untitled"); + *s = Session::new(Document::new(name)); + Ok(json!({"name": name})) +} + +fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> { + str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "`path` is required")) +} + +fn open(s: &mut Session, p: &Value) -> Result { + let path = path_arg(p, "doc.open")?; + let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + if meta.len() as usize > solvecraft_io::MAX_DESIGN_BYTES { + return Err(EngineError::Other(format!("{path}: file too large"))); + } + let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + let doc = solvecraft_io::read_design(&bytes)?; + *s = Session::new(doc); + s.path = Some(path.to_string()); + let errors = s.model.results.iter().filter(|r| r.error.is_some()).count(); + Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors})) +} + +fn write(s: &mut Session, path: &str) -> Result { + let bytes = solvecraft_io::write_design(&s.doc); + std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + s.path = Some(path.to_string()); + s.mark_saved(); + Ok(json!({"path": path, "bytes": bytes.len()})) +} + +fn save(s: &mut Session, p: &Value) -> Result { + let path = match str_(p, "path") { + Some(x) if !x.trim().is_empty() => x.to_string(), + _ => s.path.clone().ok_or_else(|| bad("SaveDocumentCommand", "the design has no file yet: give a `path`"))?, + }; + write(s, &path) +} + +fn save_as(s: &mut Session, p: &Value) -> Result { + let path = path_arg(p, "SaveDocumentAsCommand")?.to_string(); + write(s, &path) +} + +fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Result { + let name = std::path::Path::new(path).file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| s.doc.name.clone()); + let bytes = solvecraft_io::export(&s.model.state(), bodies, format, &name)?; + std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?; + Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")})) +} + +fn export(s: &mut Session, p: &Value) -> Result { + let path = path_arg(p, "ExportCommand")?; + let format = Format::from_name(str_(p, "format").unwrap_or(path))?; + export_to(s, path, format, &string_list(p, "bodies")) +} + +fn save_stl(s: &mut Session, p: &Value) -> Result { + let path = path_arg(p, "FusionSaveAsSTLCommand")?; + let f = if bool_(p, "ascii").unwrap_or(false) { Format::StlAscii } else { Format::StlBinary }; + export_to(s, path, f, &string_list(p, "bodies")) +} diff --git a/crates/engine/src/cmd/inspect.rs b/crates/engine/src/cmd/inspect.rs new file mode 100644 index 00000000..139d509c --- /dev/null +++ b/crates/engine/src/cmd/inspect.rs @@ -0,0 +1,200 @@ +//! Inspection: measure bodies, describe the document, sketches and model topology. + +use serde_json::{Value, json}; +use solvecraft_sketch::CurveKind; + +use super::CommandSpec; +use crate::params::{bad, bool_, str_, string_list}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("MeasureCommand", "Measure", measure) + .at("SOLID", "INSPECT") + .icon("measure") + .key("I") + .noundo() + .params("bodies?: [names] (default all)"), + CommandSpec::new("document.inspect", "Inspect Design", inspect).noundo().params("measure?: bool (include body measurements)"), + CommandSpec::new("sketch.inspect", "Inspect Sketch", sketch_inspect).noundo().params("sketch?: id|name (default active)"), + CommandSpec::new("model.edges", "List Edges", model_edges).noundo().params("body: name"), + CommandSpec::new("model.faces", "List Faces", model_faces).noundo().params("body: name"), + CommandSpec::new("engine.commands", "List Commands", commands).noundo(), +]; + +pub fn measure_json(b: &solvecraft_doc::ModelBody) -> Value { + match solvecraft_kernel::measure(&b.body) { + Ok(m) => json!({ + "name": b.name, + "volume_mm3": m.volume, + "area_mm2": m.area, + "center_of_mass": m.centroid, + "bbox": {"min": m.bbox.min, "max": m.bbox.max}, + "faces": m.merged.faces, + "edges": m.merged.edges, + "vertices": m.merged.vertices, + "face_types": m.merged.face_types, + "kernel": {"faces": m.faces, "edges": m.edges, "vertices": m.vertices, "shells": m.shells}, + }), + Err(e) => json!({"name": b.name, "error": e.to_string()}), + } +} + +fn measure(s: &mut Session, p: &Value) -> Result { + let want = string_list(p, "bodies"); + let st = s.model.state(); + let bodies: Vec = st.bodies.iter().filter(|b| want.is_empty() || want.contains(&b.name)).map(measure_json).collect(); + if !want.is_empty() && bodies.len() != want.len() { + return Err(bad("MeasureCommand", "unknown body name")); + } + let tv: f64 = bodies.iter().filter_map(|b| b["volume_mm3"].as_f64()).sum(); + let ta: f64 = bodies.iter().filter_map(|b| b["area_mm2"].as_f64()).sum(); + let tf: u64 = bodies.iter().filter_map(|b| b["faces"].as_u64()).sum(); + let te: u64 = bodies.iter().filter_map(|b| b["edges"].as_u64()).sum(); + let tvx: u64 = bodies.iter().filter_map(|b| b["vertices"].as_u64()).sum(); + Ok(json!({"body_count": bodies.len(), "bodies": bodies, "total": {"volume_mm3": tv, "area_mm2": ta, "faces": tf, "edges": te, "vertices": tvx}})) +} + +fn sketch_json(s: &Session, id: u64) -> Option { + let state = s.model.state(); + let ss = state.sketch(id)?; + let sk = &ss.sketch; + let curves: Vec = sk + .curves + .iter() + .enumerate() + .map(|(i, c)| { + let det = ss.report.curve_determined.get(i).copied().unwrap_or(false); + let pid = |k: usize| sk.points.get(k).map(|p| p.id.clone()).unwrap_or_default(); + match c.kind { + CurveKind::Line { a, b } => json!({"id": c.id, "type": "line", "start": pid(a), "end": pid(b), "start_at": sk.point(a), "end_at": sk.point(b), "construction": c.construction, "fully_constrained": det}), + CurveKind::Circle { c: cc, r } => json!({"id": c.id, "type": "circle", "center": pid(cc), "center_at": sk.point(cc), "radius": r, "construction": c.construction, "fully_constrained": det}), + CurveKind::Arc { c: cc, a, b } => json!({"id": c.id, "type": "arc", "center": pid(cc), "start": pid(a), "end": pid(b), "center_at": sk.point(cc), "start_at": sk.point(a), "end_at": sk.point(b), "radius": sk.radius(i), "construction": c.construction, "fully_constrained": det}), + } + }) + .collect(); + let points: Vec = sk + .points + .iter() + .enumerate() + .map(|(i, p)| json!({"id": p.id, "at": p.pos, "world": ss.plane.to_world(p.pos), "fixed": p.fixed, "fully_constrained": ss.report.point_determined.get(i).copied().unwrap_or(false)})) + .collect(); + let constraints: Vec = sk + .constraints + .iter() + .map(|c| { + let mut v = serde_json::to_value(&c.kind).unwrap_or_default(); + if let Some(o) = v.as_object_mut() { + o.insert("id".into(), json!(c.id)); + o.insert("name".into(), json!(c.kind.name())); + if let Some(p) = &c.param { + o.insert("param".into(), json!(p)); + o.insert("expression".into(), json!(s.doc.param(p).map(|x| x.expr.clone()))); + } + } + v + }) + .collect(); + let profiles: Vec = ss + .profiles + .iter() + .enumerate() + .map(|(i, p)| json!({"index": i, "area_mm2": p.area, "centroid_sketch": p.centroid, "curves": p.outer_curves, "holes": p.hole_curves})) + .collect(); + Some(json!({ + "id": id, + "name": ss.name, + "plane": ss.plane, + "status": ss.report.status, + "dof": ss.report.dof, + "fully_constrained": ss.report.fully_constrained(), + "failing": ss.report.failing, + "curves": curves, + "points": points, + "constraints": constraints, + "profiles": profiles, + })) +} + +fn sketch_inspect(s: &mut Session, p: &Value) -> Result { + let id = match p.get("sketch") { + Some(Value::Number(n)) => s.doc.find_feature(&n.to_string()).map(|f| f.id), + Some(Value::String(x)) => s.doc.find_feature(x).map(|f| f.id), + _ => s.active_sketch, + } + .ok_or_else(|| bad("sketch.inspect", "no such sketch (and none is active)"))?; + sketch_json(s, id).ok_or_else(|| EngineError::Other("that sketch is not evaluated (rolled back or not a sketch)".into())) +} + +fn inspect(s: &mut Session, p: &Value) -> Result { + let st = s.model.state(); + let (vals, perr) = s.doc.param_values(); + let params: Vec = s + .doc + .params + .iter() + .map(|p| json!({"name": p.name, "expression": p.expr, "unit": p.unit, "value": vals.get(&p.name).map(|v| v.v), "model": p.model, "comment": p.comment, "error": perr.get(&p.name)})) + .collect(); + let marker = s.doc.marker.unwrap_or(s.doc.features.len()); + let timeline: Vec = s + .doc + .features + .iter() + .enumerate() + .map(|(i, f)| { + let r = s.model.result(f.id); + json!({ + "id": f.id, "name": f.name, "type": f.kind.type_name(), "suppressed": f.suppressed, "rolled_back": i >= marker, + "error": r.and_then(|r| r.error.clone()), "warning": r.and_then(|r| r.warning.clone()), "ms": r.map(|r| r.ms), + }) + }) + .collect(); + let with_measure = bool_(p, "measure").unwrap_or(false); + let bodies: Vec = + st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect(); + let sketches: Vec = st + .sketches + .iter() + .map(|ss| json!({"id": ss.feature, "name": ss.name, "dof": ss.report.dof, "status": ss.report.status, "curves": ss.sketch.curves.len(), "profiles": ss.profiles.len()})) + .collect(); + Ok(json!({ + "name": s.doc.name, "units": s.doc.units, "path": s.path, "dirty": s.is_dirty(), "revision": s.revision, + "active_sketch": s.active_sketch, "marker": marker, + "params": params, "timeline": timeline, "bodies": bodies, "sketches": sketches, + "selection": s.selection, "undo": s.undo.len(), "redo": s.redo.len(), + })) +} + +fn body_of<'a>(st: &'a solvecraft_doc::ModelState, p: &Value, cmd: &str) -> Result<&'a solvecraft_doc::ModelBody> { + let n = str_(p, "body").ok_or_else(|| bad(cmd, "`body` is required"))?; + st.body(n).ok_or_else(|| bad(cmd, format!("no body `{n}`"))) +} + +fn model_edges(s: &mut Session, p: &Value) -> Result { + let st = s.model.state(); + let b = body_of(&st, p, "model.edges")?; + let tol = (b.body.size() * 2e-3).max(1e-3); + let edges: Vec = b + .body + .edges(tol)? + .iter() + .map(|e| json!({"index": e.index, "mid": e.mid, "length": e.length, "start": e.points.first(), "end": e.points.last()})) + .collect(); + Ok(json!({"body": b.name, "edges": edges})) +} + +fn model_faces(s: &mut Session, p: &Value) -> Result { + let st = s.model.state(); + let b = body_of(&st, p, "model.faces")?; + let tol = (b.body.size() * 2e-3).max(1e-3); + let faces: Vec = b + .body + .faces(tol)? + .iter() + .map(|f| json!({"index": f.index, "area": f.area, "centroid": f.centroid, "plane_normal": f.plane_normal})) + .collect(); + Ok(json!({"body": b.name, "faces": faces})) +} + +fn commands(s: &mut Session, _p: &Value) -> Result { + Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect())) +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs new file mode 100644 index 00000000..eab96bb3 --- /dev/null +++ b/crates/engine/src/cmd/mod.rs @@ -0,0 +1,116 @@ +//! The command registry. Ids follow Fusion's command ids where Fusion has the command; the +//! toolbar placement (`tab`, `panel`) follows Fusion's Design workspace so the registry doubles +//! as the parity metric (`cargo xtask parity`). + +mod edit; +mod features; +mod file; +mod inspect; +mod sketch; + +use serde::Serialize; +use serde_json::Value; + +use crate::{Result, Session}; + +pub type Run = fn(&mut Session, &Value) -> Result; +pub type Enabled = fn(&Session) -> std::result::Result<(), String>; + +pub struct CommandSpec { + pub id: &'static str, + pub label: &'static str, + /// Toolbar tab (`SOLID`, `SKETCH`…); empty = not on the toolbar. + pub tab: &'static str, + /// Toolbar panel (`CREATE`, `MODIFY`…). + pub panel: &'static str, + /// Icon name for the UI (drawn in code). + pub icon: &'static str, + pub shortcut: Option<&'static str>, + /// One-line parameter documentation. + pub params: &'static str, + pub enabled: Enabled, + pub run: Run, + pub undoable: bool, +} + +impl CommandSpec { + pub const fn new(id: &'static str, label: &'static str, run: Run) -> Self { + CommandSpec { id, label, tab: "", panel: "", icon: "", shortcut: None, params: "", enabled: always, run, undoable: true } + } + pub const fn at(mut self, tab: &'static str, panel: &'static str) -> Self { + self.tab = tab; + self.panel = panel; + self + } + pub const fn icon(mut self, i: &'static str) -> Self { + self.icon = i; + self + } + pub const fn key(mut self, k: &'static str) -> Self { + self.shortcut = Some(k); + self + } + pub const fn params(mut self, p: &'static str) -> Self { + self.params = p; + self + } + pub const fn enabled(mut self, e: Enabled) -> Self { + self.enabled = e; + self + } + pub const fn noundo(mut self) -> Self { + self.undoable = false; + self + } + pub fn info(&self, s: &Session) -> CommandInfo { + let e = (self.enabled)(s); + CommandInfo { + id: self.id, + label: self.label, + tab: self.tab, + panel: self.panel, + icon: self.icon, + shortcut: self.shortcut, + params: self.params, + enabled: e.is_ok(), + disabled_reason: e.err(), + } + } +} + +#[derive(Clone, Debug, Serialize)] +pub struct CommandInfo { + pub id: &'static str, + pub label: &'static str, + pub tab: &'static str, + pub panel: &'static str, + pub icon: &'static str, + pub shortcut: Option<&'static str>, + pub params: &'static str, + pub enabled: bool, + pub disabled_reason: Option, +} + +pub fn always(_: &Session) -> std::result::Result<(), String> { + Ok(()) +} + +pub fn in_sketch(s: &Session) -> std::result::Result<(), String> { + if s.active_sketch.is_some() { Ok(()) } else { Err("no sketch is being edited (Create Sketch or Edit Sketch first)".into()) } +} + +/// Every command, in toolbar order. +pub fn command_specs() -> Vec<&'static CommandSpec> { + let mut v: Vec<&'static CommandSpec> = Vec::new(); + v.extend(sketch::COMMANDS.iter()); + v.extend(features::COMMANDS.iter()); + v.extend(edit::COMMANDS.iter()); + v.extend(file::COMMANDS.iter()); + v.extend(inspect::COMMANDS.iter()); + v +} + +pub fn find_command(id: &str) -> Option<&'static CommandSpec> { + let all = command_specs(); + all.iter().find(|c| c.id == id).or_else(|| all.iter().find(|c| c.id.eq_ignore_ascii_case(id))).copied() +} diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs new file mode 100644 index 00000000..9271441b --- /dev/null +++ b/crates/engine/src/cmd/sketch.rs @@ -0,0 +1,1061 @@ +//! Sketch commands: create/edit/finish sketches, draw curves, constraints and dimensions. + +use serde_json::{Value, json}; +use solvecraft_doc::{FeatureKind, PlaneRef}; +use solvecraft_geom::{Plane, Vec2, Vec3}; +use solvecraft_sketch::{ConstraintKind, CurveKind, Sketch, solve}; + +use super::{CommandSpec, in_sketch}; +use crate::params::{bad, bool_, expr, num, req_vec2, str_, string_list, vec2, vec3}; +use crate::{EngineError, Result, Session}; + +pub static COMMANDS: &[CommandSpec] = &[ + CommandSpec::new("SketchCreate", "Create Sketch", create_sketch) + .at("SOLID", "CREATE") + .icon("sketch") + .params("plane: XY|XZ|YZ | {origin, x_dir, y_dir} | {face: [x,y,z]} (planar face at point), offset?: expr, name?"), + CommandSpec::new("SketchActivate", "Edit Sketch", edit_sketch).icon("sketch").params("sketch: id or name"), + CommandSpec::new("SketchStop", "Finish Sketch", finish_sketch).at("SKETCH", "FINISH SKETCH").icon("finish").enabled(in_sketch).noundo(), + CommandSpec::new("DrawPolyline", "Line", draw_line) + .at("SKETCH", "CREATE") + .icon("line") + .key("L") + .enabled(in_sketch) + .params("points: [[x,y] | \"l1.end\" | {at, ref}…], closed?: bool, construction?: bool, ids?: [..], infer?: bool (add horizontal/vertical)"), + CommandSpec::new("ShapeRectangleTwoPoint", "2-Point Rectangle", rect_two_point) + .at("SKETCH", "CREATE") + .icon("rect") + .key("R") + .enabled(in_sketch) + .params("p0, p1: opposite corners [x,y]; construction?"), + CommandSpec::new("ShapeRectangleThreePoint", "3-Point Rectangle", rect_three_point) + .at("SKETCH", "CREATE") + .icon("rect3") + .enabled(in_sketch) + .params("p0, p1: first edge; p2: point on the opposite edge"), + CommandSpec::new("ShapeRectangleCenter", "Center Rectangle", rect_center) + .at("SKETCH", "CREATE") + .icon("rect_center") + .enabled(in_sketch) + .params("center, corner"), + CommandSpec::new("CircleCenterRadius", "Center Diameter Circle", circle_center) + .at("SKETCH", "CREATE") + .icon("circle") + .key("C") + .enabled(in_sketch) + .params("center: [x,y] or point ref, radius | diameter: number"), + CommandSpec::new("CircleTwoPoint", "2-Point Circle", circle_two) + .at("SKETCH", "CREATE") + .icon("circle2") + .enabled(in_sketch) + .params("p0, p1: diameter ends"), + CommandSpec::new("CircleThreePoint", "3-Point Circle", circle_three) + .at("SKETCH", "CREATE") + .icon("circle3") + .enabled(in_sketch) + .params("p0, p1, p2"), + CommandSpec::new("ArcThreePoint", "3-Point Arc", arc_three).at("SKETCH", "CREATE").icon("arc3").enabled(in_sketch).params("start, end, through"), + CommandSpec::new("ArcCenterTwoPoint", "Center Point Arc", arc_center) + .at("SKETCH", "CREATE") + .icon("arc_center") + .enabled(in_sketch) + .params("center, start, end (counter-clockwise from start)"), + CommandSpec::new("ShapePolygonInscribed", "Inscribed Polygon", polygon_inscribed) + .at("SKETCH", "CREATE") + .icon("polygon") + .enabled(in_sketch) + .params("center, radius (to vertices), sides, angle?: deg"), + CommandSpec::new("ShapePolygonCircumscribed", "Circumscribed Polygon", polygon_circumscribed) + .at("SKETCH", "CREATE") + .icon("polygon") + .enabled(in_sketch) + .params("center, radius (to edge midpoints), sides, angle?: deg"), + CommandSpec::new("ShapePolygonEdge", "Edge Polygon", polygon_edge) + .at("SKETCH", "CREATE") + .icon("polygon") + .enabled(in_sketch) + .params("p0, p1: one edge, sides"), + CommandSpec::new("ShapeSlotCenterToCenter", "Center to Center Slot", slot_c2c) + .at("SKETCH", "CREATE") + .icon("slot") + .enabled(in_sketch) + .params("p0, p1: arc centres, width"), + CommandSpec::new("ShapeSlotOverall", "Overall Slot", slot_overall) + .at("SKETCH", "CREATE") + .icon("slot") + .enabled(in_sketch) + .params("p0, p1: overall ends, width"), + CommandSpec::new("DrawPoint", "Point", draw_point).at("SKETCH", "CREATE").icon("point").enabled(in_sketch).params("point: [x,y], id?"), + CommandSpec::new("SketchDimension", "Sketch Dimension", dimension).at("SKETCH", "CREATE").icon("dimension").key("D").enabled(in_sketch).params( + "entities: [refs], type?: auto|distance|horizontal|vertical|length|radius|diameter|angle, value?: number or expression (default: current)", + ), + CommandSpec::new("ConstraintHorizontalVertical", "Horizontal/Vertical", c_horizontal_vertical) + .at("SKETCH", "CONSTRAINTS") + .icon("c_hv") + .enabled(in_sketch) + .params("line: id | points: [a, b]; mode?: horizontal|vertical"), + CommandSpec::new("ConstraintCoincident", "Coincident", c_coincident) + .at("SKETCH", "CONSTRAINTS") + .icon("c_coincident") + .enabled(in_sketch) + .params("a: point, b: point or curve"), + CommandSpec::new("ConstraintTangent", "Tangent", c_tangent) + .at("SKETCH", "CONSTRAINTS") + .icon("c_tangent") + .enabled(in_sketch) + .params("a, b: curves"), + CommandSpec::new("ConstraintEqual", "Equal", c_equal) + .at("SKETCH", "CONSTRAINTS") + .icon("c_equal") + .enabled(in_sketch) + .params("a, b: lines or circles/arcs"), + CommandSpec::new("ConstraintParallel", "Parallel", c_parallel) + .at("SKETCH", "CONSTRAINTS") + .icon("c_parallel") + .enabled(in_sketch) + .params("a, b: lines"), + CommandSpec::new("ConstraintPerpendicular", "Perpendicular", c_perpendicular) + .at("SKETCH", "CONSTRAINTS") + .icon("c_perpendicular") + .enabled(in_sketch) + .params("a, b: lines"), + CommandSpec::new("ConstraintFix", "Fix/UnFix", c_fix) + .at("SKETCH", "CONSTRAINTS") + .icon("c_fix") + .enabled(in_sketch) + .params("entity: point or curve; fixed?: bool (default toggles)"), + CommandSpec::new("ConstraintMidPoint", "MidPoint", c_midpoint) + .at("SKETCH", "CONSTRAINTS") + .icon("c_midpoint") + .enabled(in_sketch) + .params("point, line"), + CommandSpec::new("ConstraintConcentric", "Concentric", c_concentric) + .at("SKETCH", "CONSTRAINTS") + .icon("c_concentric") + .enabled(in_sketch) + .params("a, b: circles/arcs"), + CommandSpec::new("ConstraintCollinear", "Collinear", c_collinear) + .at("SKETCH", "CONSTRAINTS") + .icon("c_collinear") + .enabled(in_sketch) + .params("a, b: lines"), + CommandSpec::new("ConstraintSymmetry", "Symmetry", c_symmetry) + .at("SKETCH", "CONSTRAINTS") + .icon("c_symmetry") + .enabled(in_sketch) + .params("a, b: points, line: symmetry line"), + CommandSpec::new("sketch.construction", "Normal/Construction", construction) + .at("SKETCH", "CREATE") + .icon("construction") + .enabled(in_sketch) + .params("curves: [ids], value?: bool (default toggles)"), + CommandSpec::new("sketch.delete", "Delete Sketch Entities", sketch_delete) + .enabled(in_sketch) + .params("entities: [curve, point or constraint ids]"), + CommandSpec::new("sketch.move_point", "Drag Sketch Point", move_point).enabled(in_sketch).params("point: ref, to: [x,y]"), + CommandSpec::new("sketch.solve", "Solve Sketch", solve_cmd) + .enabled(in_sketch) + .noundo() + .params("reports the solver status and degrees of freedom"), +]; + +// --------------------------------------------------------------------------------------------- +// Helpers + +/// Resolve the sketch a command acts on: `sketch` param (id or name) or the active sketch. +fn target_sketch(s: &Session, p: &Value, cmd: &str) -> Result { + if let Some(v) = p.get("sketch") { + let key = match v { + Value::Number(n) => n.to_string(), + Value::String(x) => x.clone(), + _ => return Err(bad(cmd, "`sketch` must be an id or a name")), + }; + let f = s.doc.find_feature(&key).ok_or_else(|| bad(cmd, format!("no sketch `{key}`")))?; + if !matches!(f.kind, FeatureKind::Sketch { .. }) { + return Err(bad(cmd, format!("`{key}` is not a sketch"))); + } + return Ok(f.id); + } + s.active_sketch.ok_or_else(|| EngineError::Other("no sketch is being edited".into())) +} + +/// Edit a sketch: clone it, apply `f`, solve with the document's dimension values and store +/// it. With `strict`, an edit that makes the constraints unsolvable is rejected. +fn edit( + s: &mut Session, + p: &Value, + cmd: &str, + strict: bool, + f: impl FnOnce(&mut Sketch, &mut solvecraft_doc::Document) -> Result, +) -> Result<(T, Value)> { + let id = target_sketch(s, p, cmd)?; + let mut doc = (*s.doc).clone(); + let mut sk = doc.sketch(id)?.clone(); + let out = f(&mut sk, &mut doc)?; + let (vals, _) = doc.param_values(); + doc.apply_dimension_values(&vals, &mut sk)?; + let rep = solve(&mut sk); + if strict && !rep.ok() { + return Err(EngineError::Other(format!("that would over-constrain the sketch (conflicts with {})", rep.failing.join(", ")))); + } + *doc.sketch_mut(id)? = sk; + *s.doc_mut() = doc; + Ok((out, json!({"solved": rep.ok(), "dof": rep.dof}))) +} + +/// A point argument: `[x, y]` (new point), a point reference string, or `{at, ref}`. +#[derive(Clone)] +enum PArg { + At(Vec2), + Ref(usize, Vec2), +} + +impl PArg { + fn pos(&self) -> Vec2 { + match self { + PArg::At(p) | PArg::Ref(_, p) => *p, + } + } + fn idx(&self) -> Option { + match self { + PArg::Ref(i, _) => Some(*i), + PArg::At(_) => None, + } + } +} + +fn parg(sk: &Sketch, v: &Value, cmd: &str) -> Result { + if let Some(p) = vec2(v) { + return Ok(PArg::At(p)); + } + let r = match v { + Value::String(r) => Some(r.as_str()), + Value::Object(o) => o.get("ref").and_then(Value::as_str), + _ => None, + }; + if let Some(r) = r { + let i = sk.resolve_point(r).ok_or_else(|| bad(cmd, format!("unknown sketch point `{r}`")))?; + return Ok(PArg::Ref(i, sk.point(i).unwrap_or_default())); + } + if let Some(at) = v.get("at").and_then(vec2) { + return Ok(PArg::At(at)); + } + Err(bad(cmd, "a point must be [x, y], a point id like \"l1.end\", or {at, ref}")) +} + +fn req_parg(sk: &Sketch, p: &Value, k: &str, cmd: &str) -> Result { + parg(sk, p.get(k).ok_or_else(|| bad(cmd, format!("missing `{k}`")))?, cmd) +} + +fn curve_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result { + let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a curve id")))?; + sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown sketch curve `{id}`"))) +} + +fn point_ref(sk: &Sketch, v: Option<&Value>, cmd: &str, what: &str) -> Result { + let id = v.and_then(Value::as_str).ok_or_else(|| bad(cmd, format!("`{what}` must be a point reference")))?; + sk.resolve_point(id).ok_or_else(|| bad(cmd, format!("unknown sketch point `{id}`"))) +} + +fn ids_of(sk: &Sketch, curves: &[usize]) -> Vec { + curves.iter().filter_map(|c| sk.curves.get(*c).map(|c| c.id.clone())).collect() +} + +fn add_c(sk: &mut Sketch, k: ConstraintKind) -> Result { + Ok(sk.add_constraint(k, None)?) +} + +fn mark_construction(sk: &mut Sketch, curves: &[usize], p: &Value) { + if bool_(p, "construction").unwrap_or(false) { + for c in curves { + if let Some(cu) = sk.curves.get_mut(*c) { + cu.construction = true; + } + } + } +} + +fn result(sk_out: (Vec, Vec), info: Value) -> Value { + json!({"curves": sk_out.0, "constraints": sk_out.1, "sketch": info}) +} + +// --------------------------------------------------------------------------------------------- +// Sketch lifecycle + +fn plane_ref(s: &Session, p: &Value, cmd: &str) -> Result { + let base = match p.get("plane") { + None => PlaneRef::Origin { name: "XY".into() }, + Some(Value::String(n)) => { + if Plane::named(n).is_none() { + return Err(bad(cmd, format!("unknown plane `{n}` (XY, XZ or YZ)"))); + } + PlaneRef::Origin { name: n.to_ascii_uppercase() } + } + Some(v @ Value::Object(o)) => { + if let Some(fp) = o.get("face").and_then(vec3) { + // A planar face of a body at this point. + let st = s.model.state(); + let mut found = None; + for b in &st.bodies { + let tol = (b.body.size() * 1e-3).max(1e-3); + for f in b.body.faces(tol).unwrap_or_default() { + if let Some(n) = f.plane_normal + && (fp - f.centroid).dot(n).abs() < tol * 10.0 + && b.body.tessellate(tol).map(|m| face_contains(&m, f.index, fp, tol * 10.0)).unwrap_or(false) + { + found = Plane::from_normal(f.centroid + n * (fp - f.centroid).dot(n), n); + } + } + } + let pl = found.ok_or_else(|| bad(cmd, "no planar face at that point"))?; + PlaneRef::Custom { plane: pl } + } else { + let origin = v.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO); + let x = v.get("x_dir").and_then(vec3).ok_or_else(|| bad(cmd, "plane needs x_dir"))?; + let y = v.get("y_dir").and_then(vec3).ok_or_else(|| bad(cmd, "plane needs y_dir"))?; + PlaneRef::Custom { plane: Plane::new(origin, x, y).ok_or_else(|| bad(cmd, "degenerate plane"))? } + } + } + _ => return Err(bad(cmd, "`plane` must be XY, XZ, YZ or an object")), + }; + Ok(match expr(p, "offset") { + Some(d) => PlaneRef::Offset { base: Box::new(base), distance: d }, + None => base, + }) +} + +/// Does triangle set of face `fi` contain point `p` (within `tol` of the face plane)? +fn face_contains(m: &solvecraft_geom::Mesh, fi: usize, p: Vec3, tol: f64) -> bool { + m.triangles.iter().zip(&m.tri_face).filter(|(_, f)| **f as usize == fi).any(|(t, _)| { + let Some([a, b, c]) = m.tri(t) else { return false }; + let n = (b - a).cross(c - a); + let Some(nn) = n.normalized() else { return false }; + if (p - a).dot(nn).abs() > tol { + return false; + } + let s1 = (b - a).cross(p - a).dot(n); + let s2 = (c - b).cross(p - b).dot(n); + let s3 = (a - c).cross(p - c).dot(n); + (s1 >= 0.0 && s2 >= 0.0 && s3 >= 0.0) || (s1 <= 0.0 && s2 <= 0.0 && s3 <= 0.0) + }) +} + +fn create_sketch(s: &mut Session, p: &Value) -> Result { + let cmd = "SketchCreate"; + let plane = plane_ref(s, p, cmd)?; + // Validate the plane resolves. + let (vals, _) = s.doc.param_values(); + s.doc.resolve_plane(&vals, &plane, 0)?; + let name = str_(p, "name"); + let id = s.doc_mut().add_feature(FeatureKind::Sketch { plane, sketch: Sketch::new() }, name)?; + s.active_sketch = Some(id); + let name = s.doc.feature(id).map(|f| f.name.clone()).unwrap_or_default(); + Ok(json!({"sketch": id, "name": name})) +} + +fn edit_sketch(s: &mut Session, p: &Value) -> Result { + let key = match p.get("sketch") { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad("SketchActivate", "`sketch` must be an id or a name")), + }; + let f = s.doc.find_feature(&key).ok_or_else(|| bad("SketchActivate", format!("no sketch `{key}`")))?; + if !matches!(f.kind, FeatureKind::Sketch { .. }) { + return Err(bad("SketchActivate", format!("`{key}` is not a sketch"))); + } + let (id, name) = (f.id, f.name.clone()); + s.active_sketch = Some(id); + s.revision += 1; + Ok(json!({"sketch": id, "name": name})) +} + +fn finish_sketch(s: &mut Session, _p: &Value) -> Result { + let id = s.active_sketch.take(); + s.revision += 1; + let profiles = id.and_then(|i| s.model.state().sketch(i).map(|ss| ss.profiles.len())).unwrap_or(0); + Ok(json!({"sketch": id, "profiles": profiles})) +} + +// --------------------------------------------------------------------------------------------- +// Drawing + +fn draw_line(s: &mut Session, p: &Value) -> Result { + let cmd = "DrawPolyline"; + let list = p.get("points").and_then(Value::as_array).cloned().ok_or_else(|| bad(cmd, "`points` must be a list"))?; + if list.len() < 2 || list.len() > 10_000 { + return Err(bad(cmd, "a line needs 2 or more points")); + } + let closed = bool_(p, "closed").unwrap_or(false); + let infer = bool_(p, "infer").unwrap_or(false); + let ids = string_list(p, "ids"); + let ((curves, cons), info) = edit(s, p, cmd, false, |sk, _| { + let args: Vec = list.iter().map(|v| parg(sk, v, cmd)).collect::>()?; + let n = args.len(); + let segs = if closed { n } else { n - 1 }; + let mut pts: Vec> = args.iter().map(PArg::idx).collect(); + let mut curves = Vec::new(); + let mut cons = Vec::new(); + for i in 0..segs { + let j = (i + 1) % n; + let (Some(a), Some(b)) = (args.get(i), args.get(j)) else { continue }; + if a.pos().dist(b.pos()) < 1e-9 { + return Err(bad(cmd, "zero-length line")); + } + let id = ids.get(i).map(String::as_str); + let c = sk.add_line(a.pos(), b.pos(), pts.get(i).copied().flatten(), pts.get(j).copied().flatten(), id)?; + if let Some(CurveKind::Line { a: pa, b: pb }) = sk.curves.get(c).map(|c| c.kind.clone()) { + if let Some(slot) = pts.get_mut(i) { + *slot = Some(pa); + } + if let Some(slot) = pts.get_mut(j) { + *slot = Some(pb); + } + } + if infer { + let d = b.pos() - a.pos(); + if d.y.abs() <= d.len() * 1e-9 { + cons.push(add_c(sk, ConstraintKind::Horizontal { l: c })?); + } else if d.x.abs() <= d.len() * 1e-9 { + cons.push(add_c(sk, ConstraintKind::Vertical { l: c })?); + } + } + curves.push(c); + } + mark_construction(sk, &curves, p); + Ok((ids_of(sk, &curves), cons)) + })?; + Ok(result((curves, cons), info)) +} + +/// Closed polygon of lines through new or referenced corner points. +fn polygon_lines(sk: &mut Sketch, corners: &[Vec2]) -> Result<(Vec, Vec)> { + let pts: Vec = corners.iter().map(|c| sk.add_point(*c, None)).collect::>()?; + let n = pts.len(); + let mut lines = Vec::new(); + for i in 0..n { + let (Some(a), Some(b)) = (pts.get(i), pts.get((i + 1) % n)) else { continue }; + lines.push(sk.add_line_pts(*a, *b, None)?); + } + Ok((lines, pts)) +} + +fn rect_constraints(sk: &mut Sketch, l: &[usize]) -> Result> { + let mut cons = Vec::new(); + if let [a, b, c, d] = l[..] { + cons.push(add_c(sk, ConstraintKind::Horizontal { l: a })?); + cons.push(add_c(sk, ConstraintKind::Horizontal { l: c })?); + cons.push(add_c(sk, ConstraintKind::Vertical { l: b })?); + cons.push(add_c(sk, ConstraintKind::Vertical { l: d })?); + } + Ok(cons) +} + +fn rect_two_point(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapeRectangleTwoPoint"; + let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?); + if (a.x - b.x).abs() < 1e-9 || (a.y - b.y).abs() < 1e-9 { + return Err(bad(cmd, "the corners must differ in x and y")); + } + let (lo, hi) = (Vec2::new(a.x.min(b.x), a.y.min(b.y)), Vec2::new(a.x.max(b.x), a.y.max(b.y))); + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (l, _) = polygon_lines(sk, &[lo, Vec2::new(hi.x, lo.y), hi, Vec2::new(lo.x, hi.y)])?; + let cons = rect_constraints(sk, &l)?; + mark_construction(sk, &l, p); + Ok((ids_of(sk, &l), cons)) + })?; + Ok(result(out, info)) +} + +fn rect_center(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapeRectangleCenter"; + let (c, k) = (req_vec2(cmd, p, "center")?, req_vec2(cmd, p, "corner")?); + let h = Vec2::new((k.x - c.x).abs(), (k.y - c.y).abs()); + if h.x < 1e-9 || h.y < 1e-9 { + return Err(bad(cmd, "the corner must differ from the centre in x and y")); + } + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (l, pts) = polygon_lines(sk, &[c - h, Vec2::new(c.x + h.x, c.y - h.y), c + h, Vec2::new(c.x - h.x, c.y + h.y)])?; + let mut cons = rect_constraints(sk, &l)?; + if let (Some(p0), Some(p2)) = (pts.first(), pts.get(2)) { + let diag = sk.add_line_pts(*p0, *p2, None)?; + if let Some(cu) = sk.curves.get_mut(diag) { + cu.construction = true; + } + let cp = sk.add_point(c, None)?; + cons.push(add_c(sk, ConstraintKind::Midpoint { p: cp, l: diag })?); + } + mark_construction(sk, &l, p); + Ok((ids_of(sk, &l), cons)) + })?; + Ok(result(out, info)) +} + +fn rect_three_point(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapeRectangleThreePoint"; + let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?); + let d = (b - a).normalized().ok_or_else(|| bad(cmd, "p0 and p1 must differ"))?; + let n = d.perp(); + let w = (c - a).dot(n); + if w.abs() < 1e-9 { + return Err(bad(cmd, "p2 must not be on the first edge")); + } + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (l, _) = polygon_lines(sk, &[a, b, b + n * w, a + n * w])?; + let mut cons = Vec::new(); + if let [l0, l1, l2, l3] = l[..] { + cons.push(add_c(sk, ConstraintKind::Perpendicular { a: l0, b: l1 })?); + cons.push(add_c(sk, ConstraintKind::Parallel { a: l0, b: l2 })?); + cons.push(add_c(sk, ConstraintKind::Parallel { a: l1, b: l3 })?); + } + mark_construction(sk, &l, p); + Ok((ids_of(sk, &l), cons)) + })?; + Ok(result(out, info)) +} + +fn radius_arg(p: &Value, cmd: &str) -> Result { + let r = match (num(p, "radius"), num(p, "diameter")) { + (Some(r), _) => r, + (None, Some(d)) => d / 2.0, + _ => return Err(bad(cmd, "needs `radius` or `diameter`")), + }; + if r > 1e-9 { Ok(r) } else { Err(bad(cmd, "radius must be positive")) } +} + +fn circle_center(s: &mut Session, p: &Value) -> Result { + let cmd = "CircleCenterRadius"; + let r = radius_arg(p, cmd)?; + let id = str_(p, "id").map(str::to_string); + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let c = req_parg(sk, p, "center", cmd)?; + let ci = sk.add_circle(c.pos(), r, c.idx(), id.as_deref())?; + mark_construction(sk, &[ci], p); + Ok((ids_of(sk, &[ci]), Vec::new())) + })?; + Ok(result(out, info)) +} + +fn circle_two(s: &mut Session, p: &Value) -> Result { + let cmd = "CircleTwoPoint"; + let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?); + let r = a.dist(b) / 2.0; + if r < 1e-9 { + return Err(bad(cmd, "the points must differ")); + } + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let ci = sk.add_circle((a + b) * 0.5, r, None, None)?; + Ok((ids_of(sk, &[ci]), Vec::new())) + })?; + Ok(result(out, info)) +} + +/// Circle through three points. +fn circumcircle(a: Vec2, b: Vec2, c: Vec2) -> Option<(Vec2, f64)> { + let d = 2.0 * (a.x * (b.y - c.y) + b.x * (c.y - a.y) + c.x * (a.y - b.y)); + if d.abs() < 1e-12 { + return None; + } + let (a2, b2, c2) = (a.len2(), b.len2(), c.len2()); + let ux = (a2 * (b.y - c.y) + b2 * (c.y - a.y) + c2 * (a.y - b.y)) / d; + let uy = (a2 * (c.x - b.x) + b2 * (a.x - c.x) + c2 * (b.x - a.x)) / d; + let o = Vec2::new(ux, uy); + let r = o.dist(a); + (o.is_finite() && r.is_finite() && r < 1e8).then_some((o, r)) +} + +fn circle_three(s: &mut Session, p: &Value) -> Result { + let cmd = "CircleThreePoint"; + let (a, b, c) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?, req_vec2(cmd, p, "p2")?); + let (o, r) = circumcircle(a, b, c).ok_or_else(|| bad(cmd, "the points are collinear"))?; + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let ci = sk.add_circle(o, r, None, None)?; + Ok((ids_of(sk, &[ci]), Vec::new())) + })?; + Ok(result(out, info)) +} + +fn arc_three(s: &mut Session, p: &Value) -> Result { + let cmd = "ArcThreePoint"; + let id = str_(p, "id").map(str::to_string); + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let (a, b) = (req_parg(sk, p, "start", cmd)?, req_parg(sk, p, "end", cmd)?); + let t = req_vec2(cmd, p, "through")?; + let (o, _) = circumcircle(a.pos(), t, b.pos()).ok_or_else(|| bad(cmd, "the points are collinear"))?; + // Counter-clockwise from start to end must pass the through point; otherwise swap. + let ang = |q: Vec2| (q - o).angle(); + let ccw_span = |from: f64, to: f64| (to - from).rem_euclid(std::f64::consts::TAU); + let (s0, e0) = if ccw_span(ang(a.pos()), ang(t)) < ccw_span(ang(a.pos()), ang(b.pos())) { (a, b) } else { (b, a) }; + let ci = sk.add_arc(o, s0.pos(), e0.pos(), [None, s0.idx(), e0.idx()], id.as_deref())?; + Ok((ids_of(sk, &[ci]), Vec::new())) + })?; + Ok(result(out, info)) +} + +fn arc_center(s: &mut Session, p: &Value) -> Result { + let cmd = "ArcCenterTwoPoint"; + let id = str_(p, "id").map(str::to_string); + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let c = req_parg(sk, p, "center", cmd)?; + let a = req_parg(sk, p, "start", cmd)?; + let b = req_parg(sk, p, "end", cmd)?; + let ci = sk.add_arc(c.pos(), a.pos(), b.pos(), [c.idx(), a.idx(), b.idx()], id.as_deref())?; + Ok((ids_of(sk, &[ci]), Vec::new())) + })?; + Ok(result(out, info)) +} + +fn polygon(s: &mut Session, p: &Value, cmd: &str, center: Vec2, r_vertex: f64, start: f64, n: usize, inscribed: bool) -> Result { + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let corners: Vec = (0..n).map(|i| center + Vec2::from_angle(start + std::f64::consts::TAU * i as f64 / n as f64) * r_vertex).collect(); + let (l, pts) = polygon_lines(sk, &corners)?; + let mut cons = Vec::new(); + for w in l.windows(2) { + if let [a, b] = w { + cons.push(add_c(sk, ConstraintKind::Equal { a: *a, b: *b })?); + } + } + // Construction circle tying the polygon to its centre. + let rc = if inscribed { r_vertex } else { r_vertex * (std::f64::consts::PI / n as f64).cos() }; + let circ = sk.add_circle(center, rc, None, None)?; + if let Some(cu) = sk.curves.get_mut(circ) { + cu.construction = true; + } + if inscribed { + for pt in &pts { + cons.push(add_c(sk, ConstraintKind::PointOnCurve { p: *pt, c: circ })?); + } + } else { + for li in &l { + cons.push(add_c(sk, ConstraintKind::Tangent { a: *li, b: circ })?); + } + } + Ok((ids_of(sk, &l), cons)) + })?; + Ok(result(out, info)) +} + +fn sides(p: &Value, cmd: &str) -> Result { + let n = num(p, "sides").unwrap_or(6.0); + if !(3.0..=200.0).contains(&n) { + return Err(bad(cmd, "sides must be 3…200")); + } + Ok(n as usize) +} + +fn polygon_inscribed(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapePolygonInscribed"; + let c = req_vec2(cmd, p, "center")?; + let r = radius_arg(p, cmd)?; + let n = sides(p, cmd)?; + let a = num(p, "angle").unwrap_or(90.0).to_radians(); + polygon(s, p, cmd, c, r, a, n, true) +} + +fn polygon_circumscribed(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapePolygonCircumscribed"; + let c = req_vec2(cmd, p, "center")?; + let r = radius_arg(p, cmd)?; + let n = sides(p, cmd)?; + let a = num(p, "angle").unwrap_or(90.0).to_radians(); + polygon(s, p, cmd, c, r / (std::f64::consts::PI / n as f64).cos(), a + std::f64::consts::PI / n as f64, n, false) +} + +fn polygon_edge(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapePolygonEdge"; + let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?); + let n = sides(p, cmd)?; + let e = a.dist(b); + if e < 1e-9 { + return Err(bad(cmd, "the points must differ")); + } + let rv = e / (2.0 * (std::f64::consts::PI / n as f64).sin()); + let apothem = rv * (std::f64::consts::PI / n as f64).cos(); + let mid = (a + b) * 0.5; + let dir = (b - a).normalized().unwrap_or(Vec2::X); + let center = mid + dir.perp() * apothem; + let start = (a - center).angle(); + polygon(s, p, cmd, center, rv, start, n, true) +} + +fn slot(s: &mut Session, p: &Value, cmd: &str, c0: Vec2, c1: Vec2, w: f64) -> Result { + let h = w / 2.0; + let d = (c1 - c0).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?; + let n = d.perp(); + let (out, info) = edit(s, p, cmd, false, |sk, _| { + let pts: Vec = + [c0 - n * h, c1 - n * h, c1 + n * h, c0 + n * h].iter().map(|q| sk.add_point(*q, None)).collect::>()?; + let (p0, p1, p2, p3) = (pts[0], pts[1], pts[2], pts[3]); + let l1 = sk.add_line_pts(p0, p1, None)?; + let a1 = sk.add_arc(c1, c1 - n * h, c1 + n * h, [None, Some(p1), Some(p2)], None)?; + let l2 = sk.add_line_pts(p2, p3, None)?; + let a0 = sk.add_arc(c0, c0 + n * h, c0 - n * h, [None, Some(p3), Some(p0)], None)?; + let mut cons = Vec::new(); + for (l, a) in [(l1, a1), (l2, a1), (l1, a0), (l2, a0)] { + cons.push(add_c(sk, ConstraintKind::Tangent { a: l, b: a })?); + } + cons.push(add_c(sk, ConstraintKind::Equal { a: a0, b: a1 })?); + Ok((ids_of(sk, &[l1, a1, l2, a0]), cons)) + })?; + Ok(result(out, info)) +} + +fn slot_c2c(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapeSlotCenterToCenter"; + let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?); + let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?; + slot(s, p, cmd, a, b, w) +} + +fn slot_overall(s: &mut Session, p: &Value) -> Result { + let cmd = "ShapeSlotOverall"; + let (a, b) = (req_vec2(cmd, p, "p0")?, req_vec2(cmd, p, "p1")?); + let w = num(p, "width").filter(|w| *w > 1e-9).ok_or_else(|| bad(cmd, "`width` must be positive"))?; + let d = (b - a).normalized().ok_or_else(|| bad(cmd, "the slot ends must differ"))?; + if a.dist(b) <= w { + return Err(bad(cmd, "the slot must be longer than it is wide")); + } + slot(s, p, cmd, a + d * (w / 2.0), b - d * (w / 2.0), w) +} + +fn draw_point(s: &mut Session, p: &Value) -> Result { + let cmd = "DrawPoint"; + let at = req_vec2(cmd, p, "point")?; + let id = str_(p, "id").map(str::to_string); + let (pid, info) = edit(s, p, cmd, false, |sk, _| { + let i = sk.add_point(at, id.as_deref())?; + Ok(sk.points.get(i).map(|q| q.id.clone()).unwrap_or_default()) + })?; + Ok(json!({"point": pid, "sketch": info})) +} + +// --------------------------------------------------------------------------------------------- +// Constraints + +fn constrain(s: &mut Session, p: &Value, cmd: &str, f: impl FnOnce(&Sketch) -> Result) -> Result { + let (id, info) = edit(s, p, cmd, true, |sk, _| { + let k = f(sk)?; + add_c(sk, k) + })?; + Ok(json!({"constraint": id, "sketch": info})) +} + +fn c_horizontal_vertical(s: &mut Session, p: &Value) -> Result { + let cmd = "ConstraintHorizontalVertical"; + let mode = str_(p, "mode").map(str::to_ascii_lowercase); + constrain(s, p, cmd, |sk| { + if let Some(pts) = p.get("points").and_then(Value::as_array) { + let a = point_ref(sk, pts.first(), cmd, "points[0]")?; + let b = point_ref(sk, pts.get(1), cmd, "points[1]")?; + let d = sk.point(b).unwrap_or_default() - sk.point(a).unwrap_or_default(); + let horiz = match mode.as_deref() { + Some("horizontal") => true, + Some("vertical") => false, + _ => d.x.abs() >= d.y.abs(), + }; + return Ok(if horiz { ConstraintKind::HorizontalPoints { p: a, q: b } } else { ConstraintKind::VerticalPoints { p: a, q: b } }); + } + let l = curve_ref(sk, p.get("line"), cmd, "line")?; + let (a, b) = match sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => (*a, *b), + _ => return Err(bad(cmd, "`line` must be a line")), + }; + let d = sk.point(b).unwrap_or_default() - sk.point(a).unwrap_or_default(); + let horiz = match mode.as_deref() { + Some("horizontal") => true, + Some("vertical") => false, + _ => d.x.abs() >= d.y.abs(), + }; + Ok(if horiz { ConstraintKind::Horizontal { l } } else { ConstraintKind::Vertical { l } }) + }) +} + +fn c_coincident(s: &mut Session, p: &Value) -> Result { + let cmd = "ConstraintCoincident"; + constrain(s, p, cmd, |sk| { + let a = point_ref(sk, p.get("a"), cmd, "a")?; + let bref = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?; + if let Some(b) = sk.resolve_point(bref) { + return Ok(ConstraintKind::Coincident { p: a, q: b }); + } + let c = sk.curve_index(bref).ok_or_else(|| bad(cmd, format!("unknown entity `{bref}`")))?; + Ok(ConstraintKind::PointOnCurve { p: a, c }) + }) +} + +fn two_curves(sk: &Sketch, p: &Value, cmd: &str) -> Result<(usize, usize)> { + Ok((curve_ref(sk, p.get("a"), cmd, "a")?, curve_ref(sk, p.get("b"), cmd, "b")?)) +} + +fn c_tangent(s: &mut Session, p: &Value) -> Result { + constrain(s, p, "ConstraintTangent", |sk| two_curves(sk, p, "ConstraintTangent").map(|(a, b)| ConstraintKind::Tangent { a, b })) +} +fn c_equal(s: &mut Session, p: &Value) -> Result { + constrain(s, p, "ConstraintEqual", |sk| two_curves(sk, p, "ConstraintEqual").map(|(a, b)| ConstraintKind::Equal { a, b })) +} +fn c_parallel(s: &mut Session, p: &Value) -> Result { + constrain(s, p, "ConstraintParallel", |sk| two_curves(sk, p, "ConstraintParallel").map(|(a, b)| ConstraintKind::Parallel { a, b })) +} +fn c_perpendicular(s: &mut Session, p: &Value) -> Result { + constrain(s, p, "ConstraintPerpendicular", |sk| two_curves(sk, p, "ConstraintPerpendicular").map(|(a, b)| ConstraintKind::Perpendicular { a, b })) +} +fn c_concentric(s: &mut Session, p: &Value) -> Result { + constrain(s, p, "ConstraintConcentric", |sk| two_curves(sk, p, "ConstraintConcentric").map(|(a, b)| ConstraintKind::Concentric { a, b })) +} +fn c_collinear(s: &mut Session, p: &Value) -> Result { + constrain(s, p, "ConstraintCollinear", |sk| two_curves(sk, p, "ConstraintCollinear").map(|(a, b)| ConstraintKind::Collinear { a, b })) +} +fn c_midpoint(s: &mut Session, p: &Value) -> Result { + let cmd = "ConstraintMidPoint"; + constrain(s, p, cmd, |sk| { + Ok(ConstraintKind::Midpoint { p: point_ref(sk, p.get("point"), cmd, "point")?, l: curve_ref(sk, p.get("line"), cmd, "line")? }) + }) +} +fn c_symmetry(s: &mut Session, p: &Value) -> Result { + let cmd = "ConstraintSymmetry"; + constrain(s, p, cmd, |sk| { + Ok(ConstraintKind::Symmetric { + p: point_ref(sk, p.get("a"), cmd, "a")?, + q: point_ref(sk, p.get("b"), cmd, "b")?, + l: curve_ref(sk, p.get("line"), cmd, "line")?, + }) + }) +} + +fn c_fix(s: &mut Session, p: &Value) -> Result { + let cmd = "ConstraintFix"; + let want = bool_(p, "fixed"); + let (n, info) = edit(s, p, cmd, false, |sk, _| { + let r = p.get("entity").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`entity` must be a point or curve id"))?; + let pts: Vec = if let Some(i) = sk.resolve_point(r) { + vec![i] + } else { + let c = sk.curve_index(r).ok_or_else(|| bad(cmd, format!("unknown entity `{r}`")))?; + match sk.curves.get(c).map(|c| c.kind.clone()) { + Some(CurveKind::Line { a, b }) => vec![a, b], + Some(CurveKind::Circle { c, .. }) => vec![c], + Some(CurveKind::Arc { c, a, b }) => vec![c, a, b], + None => vec![], + } + }; + let is_fixed = |sk: &Sketch, q: usize| sk.constraints.iter().any(|c| c.kind == ConstraintKind::Fix { p: q }); + let all_fixed = pts.iter().all(|q| is_fixed(sk, *q)); + let fix = want.unwrap_or(!all_fixed); + let mut n = 0; + for q in pts { + if q == 0 { + continue; + } + if fix && !is_fixed(sk, q) { + add_c(sk, ConstraintKind::Fix { p: q })?; + n += 1; + } else if !fix { + sk.constraints.retain(|c| c.kind != ConstraintKind::Fix { p: q }); + n += 1; + } + } + Ok(json!({"fixed": fix, "points": n})) + })?; + Ok(json!({"result": n, "sketch": info})) +} + +// --------------------------------------------------------------------------------------------- +// Dimensions + +fn line_pts(sk: &Sketch, l: usize) -> Option<(Vec2, Vec2)> { + match sk.curves.get(l)?.kind { + CurveKind::Line { a, b } => Some((sk.point(a)?, sk.point(b)?)), + _ => None, + } +} + +fn dimension(s: &mut Session, p: &Value) -> Result { + let cmd = "SketchDimension"; + let ents = string_list(p, "entities"); + if ents.is_empty() || ents.len() > 2 { + return Err(bad(cmd, "`entities` must list one or two sketch entities")); + } + let ty = str_(p, "type").unwrap_or("auto").to_ascii_lowercase(); + let value = expr(p, "value"); + let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| { + let ent = |r: &str| -> Result<(Option, Option)> { + if let Some(c) = sk.curve_index(r) { + return Ok((None, Some(c))); + } + if let Some(q) = sk.resolve_point(r) { + return Ok((Some(q), None)); + } + Err(bad(cmd, format!("unknown sketch entity `{r}`"))) + }; + let e0 = ent(ents.first().map(String::as_str).unwrap_or(""))?; + let e1 = match ents.get(1) { + Some(r) => Some(ent(r)?), + None => None, + }; + let is_line = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Line { .. })); + let is_circle = |c: usize| matches!(sk.curves.get(c).map(|c| &c.kind), Some(CurveKind::Circle { .. })); + let (k, cur): (ConstraintKind, f64) = match (e0, e1) { + ((None, Some(c)), None) if is_line(c) => { + let (a, b) = line_pts(sk, c).unwrap_or_default(); + match ty.as_str() { + "horizontal" => { + (ConstraintKind::DistanceX { p: line_ends(sk, c).0, q: line_ends(sk, c).1, value: (b.x - a.x).abs() }, (b.x - a.x).abs()) + } + "vertical" => { + (ConstraintKind::DistanceY { p: line_ends(sk, c).0, q: line_ends(sk, c).1, value: (b.y - a.y).abs() }, (b.y - a.y).abs()) + } + _ => (ConstraintKind::Length { l: c, value: a.dist(b) }, a.dist(b)), + } + } + ((None, Some(c)), None) => { + let r = sk.radius(c).unwrap_or(1.0); + let diameter = ty == "diameter" || (ty != "radius" && is_circle(c)); + if diameter { (ConstraintKind::Diameter { c, value: 2.0 * r }, 2.0 * r) } else { (ConstraintKind::Radius { c, value: r }, r) } + } + ((Some(a), None), Some((Some(b), None))) => { + let (pa, pb) = (sk.point(a).unwrap_or_default(), sk.point(b).unwrap_or_default()); + match ty.as_str() { + "horizontal" => (ConstraintKind::DistanceX { p: a, q: b, value: (pb.x - pa.x).abs() }, (pb.x - pa.x).abs()), + "vertical" => (ConstraintKind::DistanceY { p: a, q: b, value: (pb.y - pa.y).abs() }, (pb.y - pa.y).abs()), + _ => (ConstraintKind::Distance { p: a, q: b, value: pa.dist(pb) }, pa.dist(pb)), + } + } + ((Some(q), None), Some((None, Some(l)))) | ((None, Some(l)), Some((Some(q), None))) if is_line(l) => { + let (a, b) = line_pts(sk, l).unwrap_or_default(); + let pq = sk.point(q).unwrap_or_default(); + let d = (b - a).normalized().map(|d| d.cross(pq - a).abs()).unwrap_or(0.0); + (ConstraintKind::PointLineDistance { p: q, l, value: d }, d) + } + ((None, Some(a)), Some((None, Some(b)))) if is_line(a) && is_line(b) => { + let (a0, a1) = line_pts(sk, a).unwrap_or_default(); + let (b0, b1) = line_pts(sk, b).unwrap_or_default(); + let (da, db) = (a1 - a0, b1 - b0); + if da.cross(db).abs() < 1e-9 * da.len() * db.len() && ty != "angle" { + // Parallel lines: distance between them. + let (bq, _) = line_ends(sk, b); + let d = da.normalized().map(|d| d.cross(b0 - a0).abs()).unwrap_or(0.0); + (ConstraintKind::PointLineDistance { p: bq, l: a, value: d }, d) + } else { + let mut ang = da.cross(db).atan2(da.dot(db)); + let (mut ka, mut kb) = (a, b); + if ang < 0.0 { + ang = -ang; + std::mem::swap(&mut ka, &mut kb); + } + (ConstraintKind::Angle { a: ka, b: kb, value: ang }, ang) + } + } + _ => return Err(bad(cmd, "cannot dimension that combination of entities")), + }; + let is_angle = k.is_angle(); + let (unit, default_expr) = + if is_angle { ("deg", format!("{} deg", round6(cur.to_degrees()))) } else { ("mm", format!("{} mm", round6(cur))) }; + let e = value.clone().unwrap_or(default_expr); + let pname = doc.new_model_param(&e, unit); + // The value must evaluate. + let (vals, errs) = doc.param_values(); + if let Some(err) = errs.get(&pname) { + return Err(bad(cmd, format!("value: {err}"))); + } + let _ = vals; + let name = k.name(); + sk.add_constraint(k, Some(pname.clone()))?; + Ok((pname, name, cur)) + })?; + let v = s.doc.param(¶m).map(|p| p.expr.clone()).unwrap_or_default(); + Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info})) +} + +fn round6(x: f64) -> f64 { + (x * 1e6).round() / 1e6 +} + +fn line_ends(sk: &Sketch, l: usize) -> (usize, usize) { + match sk.curves.get(l).map(|c| &c.kind) { + Some(CurveKind::Line { a, b }) => (*a, *b), + _ => (0, 0), + } +} + +// --------------------------------------------------------------------------------------------- +// Editing + +fn construction(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.construction"; + let ids = string_list(p, "curves"); + if ids.is_empty() { + return Err(bad(cmd, "`curves` must list curve ids")); + } + let want = bool_(p, "value"); + let (n, info) = edit(s, p, cmd, false, |sk, _| { + let mut n = 0; + for id in &ids { + let c = sk.curve_index(id).ok_or_else(|| bad(cmd, format!("unknown curve `{id}`")))?; + if let Some(cu) = sk.curves.get_mut(c) { + cu.construction = want.unwrap_or(!cu.construction); + n += 1; + } + } + Ok(n) + })?; + Ok(json!({"changed": n, "sketch": info})) +} + +fn sketch_delete(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.delete"; + let ids = string_list(p, "entities"); + if ids.is_empty() { + return Err(bad(cmd, "`entities` must list sketch entity ids")); + } + let (n, info) = edit(s, p, cmd, false, |sk, doc| { + let mut curves = Vec::new(); + let mut points = Vec::new(); + let mut cons = Vec::new(); + for id in &ids { + if let Some(c) = sk.curve_index(id) { + curves.push(c); + } else if let Some(q) = sk.point_index(id) { + if q == 0 { + return Err(bad(cmd, "the sketch origin cannot be deleted")); + } + points.push(q); + } else if sk.constraints.iter().any(|c| &c.id == id) { + cons.push(id.clone()); + } else { + return Err(bad(cmd, format!("unknown sketch entity `{id}`"))); + } + } + sk.constraints.retain(|c| !cons.contains(&c.id)); + let pids: Vec = points.iter().filter_map(|q| sk.points.get(*q).map(|x| x.id.clone())).collect(); + sk.remove_curves(&curves); + let pidx: Vec = pids.iter().filter_map(|id| sk.point_index(id)).collect(); + sk.remove_points(&pidx); + doc.prune_model_params(); + Ok(ids.len()) + })?; + // Dimension parameters may have been dropped; prune again with the stored sketch. + s.doc_mut().prune_model_params(); + Ok(json!({"deleted": n, "sketch": info})) +} + +fn move_point(s: &mut Session, p: &Value) -> Result { + let cmd = "sketch.move_point"; + let to = req_vec2(cmd, p, "to")?; + let (_, info) = edit(s, p, cmd, false, |sk, _| { + let q = point_ref(sk, p.get("point"), cmd, "point")?; + if let Some(pt) = sk.points.get_mut(q) + && !pt.fixed + { + pt.pos = to; + } + // Move whole circles when their centre moves (keeps the radius). + Ok(()) + })?; + Ok(json!({"sketch": info})) +} + +fn solve_cmd(s: &mut Session, p: &Value) -> Result { + let id = target_sketch(s, p, "sketch.solve")?; + let st = s.model.state(); + let ss = st.sketch(id).ok_or_else(|| EngineError::Other("the sketch has not been evaluated".into()))?; + Ok(json!({"status": ss.report.status, "dof": ss.report.dof, "failing": ss.report.failing, "profiles": ss.profiles.len()})) +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8b137891..b2f278ad 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1 +1,256 @@ +//! The SolveCraft engine. +//! +//! Every user-visible action is a command with a stable id. Ids are Fusion's command ids where +//! Fusion has the command (`Extrude`, `SketchCreate`, `FusionFilletEdgesCommand`…) and dotted +//! lower-case ids otherwise (`timeline.rollback`, `document.inspect`). Commands take JSON +//! parameters and never open dialogs; the UI, the command palette, scripts, the CLI and the +//! control channel all run the same commands through [`Session::execute`]. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] +pub mod cmd; +pub mod params; + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use solvecraft_doc::{Document, Model}; +use solvecraft_geom::Vec3; + +pub use cmd::{CommandInfo, CommandSpec, command_specs, find_command}; +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_sketch as sketch; + +#[derive(Debug, thiserror::Error)] +pub enum EngineError { + #[error("unknown command `{0}`")] + UnknownCommand(String), + #[error("`{0}` is not available right now: {1}")] + Disabled(String, String), + #[error("invalid parameters for `{cmd}`: {msg}")] + BadParams { cmd: String, msg: String }, + #[error("{0}")] + Other(String), + /// A command panicked; the document was kept as it was (a bug: please report it). + #[error("internal error in `{0}` (the design was kept as it was): {1}")] + Internal(String, String), +} + +impl From for EngineError { + fn from(e: solvecraft_doc::DocError) -> Self { + EngineError::Other(e.to_string()) + } +} +impl From for EngineError { + fn from(e: solvecraft_sketch::SketchError) -> Self { + EngineError::Other(e.to_string()) + } +} +impl From for EngineError { + fn from(e: solvecraft_kernel::KernelError) -> Self { + EngineError::Other(e.to_string()) + } +} +impl From for EngineError { + fn from(e: solvecraft_io::IoError) -> Self { + EngineError::Other(e.to_string()) + } +} + +pub type Result = std::result::Result; + +/// Something selected in the model. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Sel { + Body { + name: String, + }, + /// An edge, identified by a point on it. + Edge { + body: String, + index: usize, + point: Vec3, + }, + Face { + body: String, + index: usize, + point: Vec3, + }, + Feature { + id: u64, + }, + /// A sketch curve or point (by id) of the active sketch. + SketchCurve { + id: String, + }, + SketchPoint { + id: String, + }, + /// A closed profile of a sketch (by index). + Profile { + sketch: u64, + index: usize, + }, +} + +/// One undo step. +#[derive(Clone, Debug)] +pub struct Snapshot { + pub label: String, + pub doc: Arc, + pub active_sketch: Option, +} + +/// An editing session on one design. +pub struct Session { + pub doc: Arc, + pub model: Model, + pub undo: Vec, + pub redo: Vec, + /// Sketch feature being edited (sketch mode). + pub active_sketch: Option, + pub selection: Vec, + pub path: Option, + saved: Arc, + /// Messages for the command line / log. + pub log: Vec, + /// Increments on every document change (views use it to refresh). + pub revision: u64, +} + +const MAX_UNDO: usize = 200; +const MAX_LOG: usize = 2000; + +impl Default for Session { + fn default() -> Self { + Session::new(Document::new("Untitled")) + } +} + +impl Session { + pub fn new(doc: Document) -> Self { + let doc = Arc::new(doc); + let mut model = Model::new(); + model.evaluate(&doc); + Session { + saved: doc.clone(), + doc, + model, + undo: Vec::new(), + redo: Vec::new(), + active_sketch: None, + selection: Vec::new(), + path: None, + log: Vec::new(), + revision: 1, + } + } + + pub fn is_dirty(&self) -> bool { + !Arc::ptr_eq(&self.doc, &self.saved) && *self.doc != *self.saved + } + pub fn mark_saved(&mut self) { + self.saved = self.doc.clone(); + } + + pub fn echo(&mut self, s: impl Into) { + self.log.push(s.into()); + if self.log.len() > MAX_LOG { + let n = self.log.len() - MAX_LOG; + self.log.drain(..n); + } + } + + /// Mutable document (copy-on-write). + pub fn doc_mut(&mut self) -> &mut Document { + Arc::make_mut(&mut self.doc) + } + + /// Re-evaluate the model after a document change. + pub fn refresh(&mut self) { + self.model.evaluate(&self.doc); + self.revision += 1; + if let Some(id) = self.active_sketch + && self.doc.feature(id).is_none() + { + self.active_sketch = None; + } + } + + /// Run a command with JSON parameters (no dialogs). Undoable commands record an undo step + /// when they change the document; a failing or panicking command leaves it unchanged. + pub fn execute(&mut self, id: &str, params: &Value) -> Result { + let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.to_string()))?; + (spec.enabled)(self).map_err(|why| EngineError::Disabled(spec.id.to_string(), why))?; + let before = Snapshot { label: spec.label.to_string(), doc: self.doc.clone(), active_sketch: self.active_sketch }; + let null = Value::Object(Default::default()); + let params = if params.is_null() { &null } else { params }; + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, params))); + let r = match r { + Ok(r) => r, + Err(p) => { + let msg = p.downcast_ref::<&str>().map(|s| s.to_string()).or_else(|| p.downcast_ref::().cloned()).unwrap_or_default(); + Err(EngineError::Internal(spec.id.to_string(), msg)) + } + }; + let changed = !Arc::ptr_eq(&self.doc, &before.doc); + match r { + Ok(v) => { + if changed { + if spec.undoable { + self.undo.push(before); + if self.undo.len() > MAX_UNDO { + self.undo.remove(0); + } + self.redo.clear(); + } + self.refresh(); + } + Ok(v) + } + Err(e) => { + if changed { + self.doc = before.doc; + self.active_sketch = before.active_sketch; + self.refresh(); + } + Err(e) + } + } + } + + /// Run a script: a JSON array of `{"command": id, "params": {...}}` (or an object with a + /// `commands` array). Stops at the first error. Returns each command's result. + pub fn run_script(&mut self, script: &Value) -> Result> { + let list = match script { + Value::Array(a) => a.clone(), + Value::Object(o) => { + o.get("commands").and_then(Value::as_array).cloned().ok_or_else(|| EngineError::Other("script: missing `commands` array".into()))? + } + _ => return Err(EngineError::Other("script must be a JSON array or {\"commands\": [...]}".into())), + }; + if list.len() > 100_000 { + return Err(EngineError::Other("script too long".into())); + } + let mut out = Vec::new(); + for (i, c) in list.iter().enumerate() { + let id = c + .get("command") + .or_else(|| c.get("id")) + .and_then(Value::as_str) + .ok_or_else(|| EngineError::Other(format!("script step {}: missing `command`", i + 1)))?; + let p = c.get("params").cloned().unwrap_or(Value::Null); + let r = self.execute(id, &p).map_err(|e| EngineError::Other(format!("step {} ({id}): {e}", i + 1)))?; + out.push(r); + } + Ok(out) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/engine/src/params.rs b/crates/engine/src/params.rs new file mode 100644 index 00000000..aa271a3d --- /dev/null +++ b/crates/engine/src/params.rs @@ -0,0 +1,79 @@ +//! Helpers to read hostile JSON parameters. + +use serde_json::Value; +use solvecraft_geom::{Vec2, Vec3}; + +use crate::{EngineError, Result}; + +pub fn bad(cmd: &str, msg: impl Into) -> EngineError { + EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() } +} + +pub fn finite(x: f64) -> Option { + (x.is_finite() && x.abs() < 1e9).then_some(x) +} + +pub fn num(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_f64).and_then(finite) +} + +pub fn str_<'a>(p: &'a Value, k: &str) -> Option<&'a str> { + p.get(k).and_then(Value::as_str) +} + +pub fn bool_(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_bool) +} + +pub fn vec2(v: &Value) -> Option { + match v { + Value::Array(a) if a.len() == 2 => Some(Vec2::new(finite(a.first()?.as_f64()?)?, finite(a.get(1)?.as_f64()?)?)), + Value::Object(o) => Some(Vec2::new(finite(o.get("x")?.as_f64()?)?, finite(o.get("y")?.as_f64()?)?)), + _ => None, + } +} + +pub fn vec3(v: &Value) -> Option { + match v { + Value::Array(a) if a.len() == 3 => Some(Vec3::new(finite(a.first()?.as_f64()?)?, finite(a.get(1)?.as_f64()?)?, finite(a.get(2)?.as_f64()?)?)), + Value::Object(o) => Some(Vec3::new(finite(o.get("x")?.as_f64()?)?, finite(o.get("y")?.as_f64()?)?, finite(o.get("z")?.as_f64()?)?)), + _ => None, + } +} + +pub fn req_vec2(cmd: &str, p: &Value, k: &str) -> Result { + p.get(k).and_then(vec2).ok_or_else(|| bad(cmd, format!("`{k}` must be a point [x, y]"))) +} + +pub fn req_vec3(cmd: &str, p: &Value, k: &str) -> Result { + p.get(k).and_then(vec3).ok_or_else(|| bad(cmd, format!("`{k}` must be a point [x, y, z]"))) +} + +pub fn vec2_list(cmd: &str, p: &Value, k: &str, max: usize) -> Result> { + let a = p.get(k).and_then(Value::as_array).ok_or_else(|| bad(cmd, format!("`{k}` must be a list of points")))?; + if a.len() > max { + return Err(bad(cmd, format!("`{k}` has too many points"))); + } + a.iter().map(|v| vec2(v).ok_or_else(|| bad(cmd, format!("`{k}` must contain [x, y] points")))).collect() +} + +/// An expression parameter: a number (mm or degrees by context) or an expression string. +pub fn expr(p: &Value, k: &str) -> Option { + match p.get(k)? { + Value::Number(n) => n.as_f64().and_then(finite).map(|v| format!("{v}")), + Value::String(s) if !s.trim().is_empty() && s.len() <= 4096 => Some(s.trim().to_string()), + _ => None, + } +} + +pub fn req_expr(cmd: &str, p: &Value, k: &str) -> Result { + expr(p, k).ok_or_else(|| bad(cmd, format!("`{k}` must be a number or an expression"))) +} + +pub fn string_list(p: &Value, k: &str) -> Vec { + match p.get(k) { + Some(Value::Array(a)) => a.iter().take(10_000).filter_map(|v| v.as_str().map(str::to_string)).collect(), + Some(Value::String(s)) => vec![s.clone()], + _ => Vec::new(), + } +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs new file mode 100644 index 00000000..b2f1763e --- /dev/null +++ b/crates/engine/src/tests.rs @@ -0,0 +1,301 @@ +use std::f64::consts::PI; + +use serde_json::{Value, json}; + +use crate::*; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + match s.execute(id, &p) { + Ok(v) => v, + Err(e) => panic!("{id} {p}: {e}"), + } +} + +fn volume(s: &mut Session) -> f64 { + let m = run(s, "MeasureCommand", json!({})); + m["total"]["volume_mm3"].as_f64().unwrap() +} + +fn rel(a: f64, b: f64) -> f64 { + (a - b).abs() / b.abs().max(1e-12) +} + +/// Sketch a 40 x 30 rectangle with dimensions, extrude 20, fillet a vertical edge, cut a hole. +#[test] +fn box_fillet_cut_part() { + let mut s = Session::default(); + run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "40 mm"})); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + let r = run(&mut s, "ShapeRectangleTwoPoint", json!({"p0": [0, 0], "p1": [38, 29]})); + let l: Vec = r["curves"].as_array().unwrap().iter().map(|v| v.as_str().unwrap().to_string()).collect(); + let corner = s.doc.sketch(s.active_sketch.unwrap()).unwrap().curves[0].clone(); + let start = match corner.kind { + solvecraft_sketch::CurveKind::Line { a, .. } => s.doc.sketch(s.active_sketch.unwrap()).unwrap().points[a].id.clone(), + _ => panic!(), + }; + run(&mut s, "ConstraintCoincident", json!({"a": start, "b": "origin"})); + run(&mut s, "SketchDimension", json!({"entities": [l[0]], "value": "width"})); + run(&mut s, "SketchDimension", json!({"entities": [l[1]], "value": 30})); + let si = run(&mut s, "sketch.inspect", json!({})); + assert_eq!(si["dof"], 0, "{si}"); + assert_eq!(si["profiles"].as_array().unwrap().len(), 1); + run(&mut s, "SketchStop", json!({})); + run(&mut s, "Extrude", json!({"distance": 20})); + assert!(rel(volume(&mut s), 24000.0) < 1e-9); + run(&mut s, "FusionFilletEdgesCommand", json!({"edges": [[0, 0, 10]], "radius": 3})); + let filleted = 24000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0; + assert!(rel(volume(&mut s), filleted) < 2e-4); + run(&mut s, "SketchCreate", json!({"plane": "XY"})); + run(&mut s, "CircleCenterRadius", json!({"center": [20, 15], "radius": 5})); + run(&mut s, "Extrude", json!({"distance": 20, "operation": "cut"})); + let expect = filleted - PI * 25.0 * 20.0; + let v = volume(&mut s); + assert!(rel(v, expect) < 5e-4, "{v} vs {expect}"); + let m = run(&mut s, "MeasureCommand", json!({})); + assert_eq!(m["bodies"][0]["faces"], 8, "{m}"); + + // Parameter edit re-evaluates the timeline from the sketch. + let r = run(&mut s, "ChangeParameterCommand", json!({"name": "width", "expression": "50"})); + assert_eq!(r["errors"].as_array().unwrap().len(), 0, "{r}"); + let expect2 = 30000.0 - (9.0 - PI * 9.0 / 4.0) * 20.0 - PI * 25.0 * 20.0; + assert!(rel(volume(&mut s), expect2) < 5e-4); + + // Undo the parameter change, redo it. + run(&mut s, "UndoCommand", json!({})); + assert!(rel(volume(&mut s), expect) < 5e-4); + run(&mut s, "RedoCommand", json!({})); + assert!(rel(volume(&mut s), expect2) < 5e-4); + + // Export STL and STEP. + let dir = std::env::temp_dir().join(format!("solvecraft-test-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let stl = dir.join("part.stl"); + run(&mut s, "ExportCommand", json!({"path": stl.to_string_lossy()})); + assert!(std::fs::metadata(&stl).unwrap().len() > 1000); + let step = dir.join("part.step"); + run(&mut s, "ExportCommand", json!({"path": step.to_string_lossy()})); + assert!(std::fs::read_to_string(&step).unwrap().contains("ISO-10303-21")); + let design = dir.join("part.solvecraft"); + run(&mut s, "SaveDocumentAsCommand", json!({"path": design.to_string_lossy()})); + let mut s2 = Session::default(); + run(&mut s2, "doc.open", json!({"path": design.to_string_lossy()})); + assert!(rel(volume(&mut s2), expect2) < 5e-4); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn revolve_and_primitives() { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "XZ"})); + run(&mut s, "DrawPolyline", json!({"points": [[10, 0], [15, 0], [15, 5], [10, 5]], "closed": true})); + run(&mut s, "Revolve", json!({"axis": "y"})); + assert!(rel(volume(&mut s), PI * 125.0 * 5.0) < 5e-4); + run(&mut s, "PrimitiveBox", json!({"length": 10, "width": 10, "height": 10, "corner": [30, 0, 0]})); + run(&mut s, "PrimitiveCylinder", json!({"diameter": 4, "height": 20, "base": [35, 5, -5]})); + run(&mut s, "FusionCombineCommand", json!({"target": "Body2", "tools": ["Body3"], "operation": "cut"})); + let m = run(&mut s, "MeasureCommand", json!({})); + assert_eq!(m["body_count"], 2, "{m}"); + assert!(rel(m["bodies"][1]["volume_mm3"].as_f64().unwrap(), 1000.0 - PI * 4.0 * 10.0) < 5e-4); + run(&mut s, "FusionChamferCommand", json!({"edges": [[30, 5, 10]], "distance": 1})); + run(&mut s, "timeline.rollback", json!({"position": 3})); + assert_eq!(run(&mut s, "MeasureCommand", json!({}))["body_count"], 2); + run(&mut s, "timeline.rollback", json!({})); + run(&mut s, "timeline.edit", json!({"feature": "Revolve1", "set": {"angle": "180 deg"}})); + let m = run(&mut s, "MeasureCommand", json!({"bodies": ["Body1"]})); + assert!(rel(m["total"]["volume_mm3"].as_f64().unwrap(), PI * 125.0 * 5.0 / 2.0) < 5e-4); +} + +#[test] +fn sketch_tools_and_constraints() { + let mut s = Session::default(); + run(&mut s, "SketchCreate", json!({"plane": "YZ"})); + run(&mut s, "ShapeRectangleCenter", json!({"center": [0, 0], "corner": [10, 5]})); + run(&mut s, "ShapeRectangleThreePoint", json!({"p0": [20, 0], "p1": [30, 5], "p2": [28, 10]})); + run(&mut s, "CircleTwoPoint", json!({"p0": [40, 0], "p1": [44, 0]})); + run(&mut s, "CircleThreePoint", json!({"p0": [50, 0], "p1": [52, 2], "p2": [54, 0]})); + run(&mut s, "ArcThreePoint", json!({"start": [60, 0], "end": [64, 0], "through": [62, 2]})); + run(&mut s, "ArcCenterTwoPoint", json!({"center": [70, 0], "start": [72, 0], "end": [70, 2]})); + run(&mut s, "ShapePolygonInscribed", json!({"center": [80, 0], "radius": 3, "sides": 6})); + run(&mut s, "ShapePolygonCircumscribed", json!({"center": [90, 0], "radius": 3, "sides": 5})); + run(&mut s, "ShapePolygonEdge", json!({"p0": [100, 0], "p1": [103, 0], "sides": 4})); + run(&mut s, "ShapeSlotCenterToCenter", json!({"p0": [110, 0], "p1": [120, 0], "width": 4})); + run(&mut s, "ShapeSlotOverall", json!({"p0": [130, 0], "p1": [140, 0], "width": 4})); + run(&mut s, "DrawPoint", json!({"point": [150, 0]})); + let r = run(&mut s, "DrawPolyline", json!({"points": [[0, 20], [10, 21], [20, 30]], "ids": ["m1", "m2"]})); + assert_eq!(r["curves"], json!(["m1", "m2"])); + run(&mut s, "ConstraintHorizontalVertical", json!({"line": "m1"})); + run(&mut s, "ConstraintPerpendicular", json!({"a": "m1", "b": "m2"})); + let _ = s.execute("SketchDimension", &json!({"entities": ["m1", "m2"], "type": "angle", "value": "90 deg"})); + let si = run(&mut s, "sketch.inspect", json!({})); + assert_eq!(si["status"], "solved", "{}", si["failing"]); + // Over-constraining is rejected and leaves the sketch unchanged. + let before = s.doc.clone(); + assert!(s.execute("ConstraintParallel", &json!({"a": "m1", "b": "m2"})).is_err()); + assert_eq!(*s.doc, *before); + // Profiles: rectangles, circles, closed arcs/polygons/slots. + let n = si["profiles"].as_array().unwrap().len(); + assert!(n >= 8, "{n} profiles"); + run(&mut s, "sketch.construction", json!({"curves": ["m1"]})); + run(&mut s, "sketch.delete", json!({"entities": ["m2"]})); + run(&mut s, "ConstraintFix", json!({"entity": "m1"})); + run(&mut s, "sketch.move_point", json!({"point": "m1.end", "to": [15, 20]})); + run(&mut s, "SketchStop", json!({})); + assert!(s.execute("DrawPolyline", &json!({"points": [[0, 0], [1, 1]]})).is_err(), "needs an active sketch"); +} + +/// The recipe format of the Fusion oracle (01-box) drives the same commands. +#[test] +fn script_runs_commands() { + let mut s = Session::default(); + let script = json!({"commands": [ + {"command": "SketchCreate", "params": {"plane": "XY"}}, + {"command": "DrawPolyline", "params": {"points": [[0, 0], [40, 0], [40, 30], [0, 30]], "closed": true, "ids": ["l1", "l2", "l3", "l4"]}}, + {"command": "Extrude", "params": {"distance": 20, "profiles": [{"loops": [{"outer": true, "curves": ["l1", "l2", "l3", "l4"]}]}], "body_name": "Box"}}, + ]}); + let out = s.run_script(&script).unwrap(); + assert_eq!(out.len(), 3); + let m = run(&mut s, "MeasureCommand", json!({})); + assert_eq!(m["bodies"][0]["name"], "Box"); + assert_eq!((m["total"]["faces"].as_u64(), m["total"]["edges"].as_u64(), m["total"]["vertices"].as_u64()), (Some(6), Some(12), Some(8))); + assert!(s.run_script(&json!([{"command": "Nope"}])).is_err()); +} + +fn hostile_values() -> Vec { + vec![ + Value::Null, + json!(true), + json!(-1), + json!(0), + json!(1e308), + json!(-1e308), + json!(""), + json!("nope"), + json!("1/0"), + json!("((((((((((1"), + json!([]), + json!([1e308, -1e308]), + json!([0, 0, 0]), + json!([[0, 0], [0, 0]]), + json!(["origin", "origin"]), + json!({}), + json!({"x": 1}), + json!([{"point": [0, 0]}]), + json!([{"body": "Body1", "index": 999999}]), + ] +} + +/// Every command, every parameter name it documents, every hostile value: no panic escapes and +/// no command panics internally. +#[test] +fn hostile_params_never_panic() { + let keys = [ + "plane", + "offset", + "name", + "sketch", + "points", + "closed", + "construction", + "ids", + "infer", + "p0", + "p1", + "p2", + "center", + "corner", + "radius", + "diameter", + "start", + "end", + "through", + "sides", + "angle", + "width", + "point", + "id", + "entities", + "type", + "value", + "line", + "mode", + "a", + "b", + "entity", + "fixed", + "curves", + "to", + "distance", + "profiles", + "direction", + "distance2", + "start_offset", + "operation", + "targets", + "body_name", + "axis", + "length", + "height", + "base", + "major", + "minor", + "edges", + "body", + "target", + "tools", + "keep_tools", + "bodies", + "translate", + "expression", + "unit", + "comment", + "delete", + "features", + "feature", + "position", + "suppressed", + "set", + "items", + "add", + "path", + "format", + "ascii", + "measure", + ]; + let mut internal = Vec::new(); + for spec in command_specs() { + if matches!(spec.id, "doc.open" | "SaveDocumentCommand" | "SaveDocumentAsCommand" | "ExportCommand" | "FusionSaveAsSTLCommand") { + continue; // file system side effects are covered by their own tests + } + for v in hostile_values() { + for k in keys { + let mut s = Session::default(); + // A model with a sketch, a body and an active sketch, so most commands get far. + let _ = s.execute("PrimitiveBox", &json!({"length": 10, "width": 10, "height": 10})); + let _ = s.execute("SketchCreate", &json!({"plane": "XY"})); + let _ = s.execute("ShapeRectangleTwoPoint", &json!({"p0": [0, 0], "p1": [5, 5]})); + let p = json!({ k: v.clone() }); + if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &p) { + internal.push(format!("{id} {p}: {msg}")); + } + // Whole-value hostility too. + if let Err(EngineError::Internal(id, msg)) = s.execute(spec.id, &v) { + internal.push(format!("{id} {v}: {msg}")); + } + } + } + } + assert!(internal.is_empty(), "commands panicked:\n{}", internal.join("\n")); +} + +#[test] +fn registry_is_consistent() { + let specs = command_specs(); + let mut ids: Vec<&str> = specs.iter().map(|c| c.id).collect(); + ids.sort(); + let n = ids.len(); + ids.dedup(); + assert_eq!(ids.len(), n, "duplicate command ids"); + assert!(find_command("extrude").is_some(), "case-insensitive lookup"); + let s = Session::default(); + assert!(!find_command("DrawPolyline").unwrap().info(&s).enabled); +} diff --git a/crates/io/Cargo.toml b/crates/io/Cargo.toml index f8b045bf..f96c3845 100644 --- a/crates/io/Cargo.toml +++ b/crates/io/Cargo.toml @@ -1,8 +1,15 @@ [package] name = "solvecraft-io" +description = "SolveCraft file formats: designs (JSON), STL, OBJ and STEP export" version.workspace = true edition.workspace = true license.workspace = true [lints] workspace = true + +[dependencies] +solvecraft-geom = { workspace = true } +solvecraft-kernel = { workspace = true } +solvecraft-doc = { workspace = true } +thiserror = { workspace = true } diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs index 8b137891..dcff4ff0 100644 --- a/crates/io/src/lib.rs +++ b/crates/io/src/lib.rs @@ -1 +1,248 @@ +//! File formats. Designs are saved as JSON (`.solvecraft`): the parametric document only, the +//! model is recomputed on load. Bodies export as STL (binary or ASCII), OBJ and STEP. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] +use solvecraft_doc::{Document, ModelState}; +use solvecraft_geom::Mesh; + +/// Design file extension. +pub const DESIGN_EXT: &str = "solvecraft"; +/// Largest design file we read. +pub const MAX_DESIGN_BYTES: usize = 256 << 20; + +#[derive(Debug, thiserror::Error)] +pub enum IoError { + #[error("unsupported format `{0}` (use stl, obj, step or solvecraft)")] + Format(String), + #[error("nothing to export: the design has no bodies")] + Empty, + #[error("{0}")] + Invalid(String), + #[error(transparent)] + Kernel(#[from] solvecraft_kernel::KernelError), + #[error(transparent)] + Doc(#[from] solvecraft_doc::DocError), +} + +pub type Result = std::result::Result; + +/// Export formats. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Format { + StlBinary, + StlAscii, + Obj, + Step, + Design, +} + +impl Format { + /// From a file name or extension (`part.stl`, `step`, `stp`…). + pub fn from_name(name: &str) -> Result { + let ext = name.rsplit('.').next().unwrap_or(name).to_ascii_lowercase(); + Ok(match ext.as_str() { + "stl" => Format::StlBinary, + "stla" | "stl-ascii" => Format::StlAscii, + "obj" => Format::Obj, + "step" | "stp" => Format::Step, + "solvecraft" | "json" => Format::Design, + _ => return Err(IoError::Format(ext)), + }) + } +} + +/// Fine export mesh for a body (chord tolerance 2e-4 of its size). +pub fn export_mesh(b: &solvecraft_kernel::Body) -> Result { + Ok(b.tessellate((b.size() * 2e-4).max(1e-3))?) +} + +/// Binary STL of several meshes (one solid). +pub fn stl_binary(meshes: &[Mesh], name: &str) -> Vec { + let n: usize = meshes.iter().map(|m| m.triangles.len()).sum(); + let mut out = Vec::with_capacity(84 + n * 50); + let mut header = format!("SolveCraft STL {name}").into_bytes(); + header.resize(80, b' '); + out.extend_from_slice(&header); + out.extend_from_slice(&u32::try_from(n).unwrap_or(u32::MAX).to_le_bytes()); + for m in meshes { + for t in &m.triangles { + let Some([a, b, c]) = m.tri(t) else { continue }; + let nrm = (b - a).cross(c - a).normalized().unwrap_or_default(); + for v in [nrm, a, b, c] { + for x in v.to_f32() { + out.extend_from_slice(&x.to_le_bytes()); + } + } + out.extend_from_slice(&[0, 0]); + } + } + out +} + +/// ASCII STL. +pub fn stl_ascii(meshes: &[Mesh], name: &str) -> String { + let name: String = name.chars().filter(|c| c.is_ascii_graphic()).collect(); + let mut s = format!("solid {name}\n"); + for m in meshes { + for t in &m.triangles { + let Some([a, b, c]) = m.tri(t) else { continue }; + let n = (b - a).cross(c - a).normalized().unwrap_or_default(); + s += &format!(" facet normal {:e} {:e} {:e}\n outer loop\n", n.x, n.y, n.z); + for v in [a, b, c] { + s += &format!(" vertex {:e} {:e} {:e}\n", v.x, v.y, v.z); + } + s += " endloop\n endfacet\n"; + } + } + s += &format!("endsolid {name}\n"); + s +} + +/// Wavefront OBJ, one object per body. +pub fn obj(meshes: &[(String, Mesh)]) -> String { + let mut s = String::from("# SolveCraft OBJ export (mm)\n"); + let mut base = 1usize; + for (name, m) in meshes { + s += &format!("o {}\n", name.replace(char::is_whitespace, "_")); + for p in &m.positions { + s += &format!("v {} {} {}\n", p.x, p.y, p.z); + } + for n in &m.normals { + s += &format!("vn {} {} {}\n", n.x, n.y, n.z); + } + for t in &m.triangles { + let [a, b, c] = t.map(|i| i as usize + base); + s += &format!("f {a}//{a} {b}//{b} {c}//{c}\n"); + } + base += m.positions.len(); + } + s +} + +/// Export the given bodies of a model (all when `bodies` is empty) in a format. +pub fn export(state: &ModelState, bodies: &[String], format: Format, name: &str) -> Result> { + let sel: Vec<&solvecraft_doc::ModelBody> = state.bodies.iter().filter(|b| bodies.is_empty() || bodies.contains(&b.name)).collect(); + if sel.is_empty() { + return Err(IoError::Empty); + } + Ok(match format { + Format::StlBinary | Format::StlAscii | Format::Obj => { + let meshes: Vec<(String, Mesh)> = sel.iter().map(|b| Ok((b.name.clone(), export_mesh(&b.body)?))).collect::>()?; + match format { + Format::StlBinary => stl_binary(&meshes.into_iter().map(|(_, m)| m).collect::>(), name), + Format::StlAscii => stl_ascii(&meshes.into_iter().map(|(_, m)| m).collect::>(), name).into_bytes(), + _ => obj(&meshes).into_bytes(), + } + } + Format::Step => { + let bs: Vec<&solvecraft_kernel::Body> = sel.iter().map(|b| &b.body).collect(); + solvecraft_kernel::step_export(&bs, "SolveCraft")?.into_bytes() + } + Format::Design => return Err(IoError::Format("solvecraft (save the document instead)".into())), + }) +} + +/// Read a design file's bytes. +pub fn read_design(bytes: &[u8]) -> Result { + if bytes.len() > MAX_DESIGN_BYTES { + return Err(IoError::Invalid("design file too large".into())); + } + let s = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("design file is not UTF-8 JSON".into()))?; + Ok(Document::from_json(s)?) +} + +/// Design file bytes. +pub fn write_design(doc: &Document) -> Vec { + doc.to_json().into_bytes() +} + +/// Parse a binary or ASCII STL (used by tests and round-trip checks): triangles. +pub fn read_stl(bytes: &[u8]) -> Result> { + let ascii = bytes.starts_with(b"solid") && std::str::from_utf8(bytes).is_ok_and(|s| s.contains("facet")); + let mut tris = Vec::new(); + if ascii { + let s = std::str::from_utf8(bytes).map_err(|_| IoError::Invalid("bad STL".into()))?; + let mut cur: Vec<[f32; 3]> = Vec::new(); + for l in s.lines() { + let mut it = l.split_whitespace(); + if it.next() == Some("vertex") { + let v: Vec = it.filter_map(|x| x.parse().ok()).collect(); + if let [x, y, z] = v[..] { + cur.push([x, y, z]); + } + if let [a, b, c] = cur[..] { + tris.push([a, b, c]); + cur.clear(); + } + } + } + return Ok(tris); + } + let n = bytes.get(80..84).map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]]) as usize).ok_or_else(|| IoError::Invalid("short STL".into()))?; + if bytes.len() < 84 + n.saturating_mul(50) { + return Err(IoError::Invalid("truncated STL".into())); + } + let f = |o: usize| bytes.get(o..o + 4).map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]])).unwrap_or(0.0); + for i in 0..n { + let o = 84 + i * 50 + 12; + let v = |k: usize| [f(o + k * 12), f(o + k * 12 + 4), f(o + k * 12 + 8)]; + tris.push([v(0), v(1), v(2)]); + } + Ok(tris) +} + +#[cfg(test)] +mod tests { + use super::*; + use solvecraft_doc::{FeatureKind, Model, Operation}; + use solvecraft_geom::Vec3; + + fn model() -> (Document, Model) { + let mut doc = Document::new("t"); + doc.add_feature( + FeatureKind::Box { corner: Vec3::ZERO, length: "10".into(), width: "20".into(), height: "30".into(), operation: Operation::NewBody }, + None, + ) + .unwrap(); + let mut m = Model::new(); + m.evaluate(&doc); + (doc, m) + } + + #[test] + fn stl_round_trip_volume() { + let (_, m) = model(); + let bytes = export(&m.state(), &[], Format::StlBinary, "t").unwrap(); + let tris = read_stl(&bytes).unwrap(); + let v: f64 = tris + .iter() + .map(|t| { + let p = |k: usize| Vec3::new(t[k][0] as f64, t[k][1] as f64, t[k][2] as f64); + p(0).dot(p(1).cross(p(2))) / 6.0 + }) + .sum(); + assert!((v - 6000.0).abs() < 1e-2, "{v}"); + let a = export(&m.state(), &[], Format::StlAscii, "t").unwrap(); + assert_eq!(read_stl(&a).unwrap().len(), tris.len()); + let o = String::from_utf8(export(&m.state(), &[], Format::Obj, "t").unwrap()).unwrap(); + assert!(o.contains("o Body1") && o.contains("\nf ")); + } + + #[test] + fn step_and_design() { + let (doc, m) = model(); + let s = String::from_utf8(export(&m.state(), &[], Format::Step, "t").unwrap()).unwrap(); + assert!(s.starts_with("ISO-10303-21;") && s.contains("CLOSED_SHELL")); + let back = read_design(&write_design(&doc)).unwrap(); + assert_eq!(back, doc); + assert!(read_design(b"\xff\xfe").is_err()); + assert!(matches!(export(&m.state(), &["nope".into()], Format::Step, "t"), Err(IoError::Empty))); + assert_eq!(Format::from_name("a.STP").unwrap(), Format::Step); + assert!(Format::from_name("a.dwg").is_err()); + assert!(read_stl(&[0u8; 10]).is_err()); + let mut fake = vec![0u8; 84]; + fake[80..84].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(read_stl(&fake).is_err()); + } +} diff --git a/crates/render/Cargo.toml b/crates/render/Cargo.toml index 41f84311..9c90a2c6 100644 --- a/crates/render/Cargo.toml +++ b/crates/render/Cargo.toml @@ -1,8 +1,14 @@ [package] name = "solvecraft-render" +description = "SolveCraft view math (orbit camera, projection, picking rays) and a CPU rasterizer for headless snapshots" version.workspace = true edition.workspace = true license.workspace = true [lints] workspace = true + +[dependencies] +solvecraft-geom = { workspace = true } +serde = { workspace = true } +image = { workspace = true } diff --git a/crates/render/src/camera.rs b/crates/render/src/camera.rs new file mode 100644 index 00000000..de20ef1d --- /dev/null +++ b/crates/render/src/camera.rs @@ -0,0 +1,259 @@ +use serde::{Deserialize, Serialize}; +use solvecraft_geom::{Aabb3, Vec3}; + +/// Column-major 4×4 matrix. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Mat4(pub [[f64; 4]; 4]); + +impl Mat4 { + pub const IDENTITY: Mat4 = Mat4([[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0], [0.0, 0.0, 0.0, 1.0]]); + pub fn mul(&self, o: &Mat4) -> Mat4 { + let mut r = [[0.0; 4]; 4]; + for (c, col) in r.iter_mut().enumerate() { + for (rr, cell) in col.iter_mut().enumerate() { + *cell = (0..4).map(|k| self.0[k][rr] * o.0[c][k]).sum(); + } + } + Mat4(r) + } + /// Transform a point; returns (x, y, z, w). + pub fn apply(&self, p: Vec3) -> [f64; 4] { + let v = [p.x, p.y, p.z, 1.0]; + let mut out = [0.0; 4]; + for (r, o) in out.iter_mut().enumerate() { + *o = (0..4).map(|c| self.0[c][r] * v[c]).sum(); + } + out + } + pub fn to_f32(&self) -> [[f32; 4]; 4] { + self.0.map(|c| c.map(|x| x as f32)) + } +} + +/// Named view orientations. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StandardView { + Front, + Back, + Top, + Bottom, + Left, + Right, + Iso, +} + +impl StandardView { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "front" => StandardView::Front, + "back" => StandardView::Back, + "top" => StandardView::Top, + "bottom" => StandardView::Bottom, + "left" => StandardView::Left, + "right" => StandardView::Right, + "iso" | "home" | "isometric" => StandardView::Iso, + _ => return None, + }) + } + /// (yaw, pitch) in radians. Yaw 0 looks along +Y (front view); the iso view looks from the + /// front-right-top corner. Pitch is the elevation above the XY plane. + pub fn angles(self) -> (f64, f64) { + use std::f64::consts::{FRAC_PI_2, PI}; + match self { + StandardView::Front => (0.0, 0.0), + StandardView::Back => (PI, 0.0), + StandardView::Right => (-FRAC_PI_2, 0.0), + StandardView::Left => (FRAC_PI_2, 0.0), + StandardView::Top => (0.0, FRAC_PI_2), + StandardView::Bottom => (0.0, -FRAC_PI_2), + StandardView::Iso => (-std::f64::consts::FRAC_PI_4, 0.6154797086703874), + } + } +} + +/// Orbit camera around `target` (Z up). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct Camera { + pub target: Vec3, + /// Rotation about Z; 0 = looking along +Y (from the front). + pub yaw: f64, + /// Elevation above the XY plane, in (−π/2, π/2]. + pub pitch: f64, + /// Distance from target to eye. + pub distance: f64, + /// Vertical field of view (radians); 0 = orthographic. + pub fov: f64, +} + +impl Default for Camera { + fn default() -> Self { + let (yaw, pitch) = StandardView::Iso.angles(); + Camera { target: Vec3::ZERO, yaw, pitch, distance: 200.0, fov: 0.0 } + } +} + +impl Camera { + /// Direction from the target toward the eye. + pub fn back(&self) -> Vec3 { + let (cp, sp) = (self.pitch.cos(), self.pitch.sin()); + Vec3::new(-self.yaw.sin() * cp, -self.yaw.cos() * cp, sp).normalized().unwrap_or(Vec3::Z) + } + pub fn eye(&self) -> Vec3 { + self.target + self.back() * self.distance + } + /// Camera basis: right, up, back (orthonormal). + pub fn basis(&self) -> (Vec3, Vec3, Vec3) { + let b = self.back(); + let right = Vec3::new(self.yaw.cos(), -self.yaw.sin(), 0.0); + let up = b.cross(right).normalized().unwrap_or(Vec3::Z); + (right, up, b) + } + pub fn view(&self) -> Mat4 { + let (r, u, b) = self.basis(); + let e = self.eye(); + Mat4([[r.x, u.x, b.x, 0.0], [r.y, u.y, b.y, 0.0], [r.z, u.z, b.z, 0.0], [-r.dot(e), -u.dot(e), -b.dot(e), 1.0]]) + } + /// Half the visible height at the target distance. + pub fn half_height(&self) -> f64 { + if self.fov > 0.0 { self.distance * (self.fov * 0.5).tan() } else { self.distance * 0.4 } + } + /// Near/far planes that keep a scene of radius `radius` around the target visible. + pub fn clip(&self, radius: f64) -> (f64, f64) { + let r = radius.max(1e-3); + if self.fov > 0.0 { + ((self.distance - r * 2.0).max(self.distance * 1e-3).max(1e-3), self.distance + r * 2.0) + } else { + (self.distance - r * 4.0 - 1.0, self.distance + r * 4.0 + 1.0) + } + } + /// Projection to clip space (OpenGL/wgpu style, depth 0..1). + pub fn proj(&self, aspect: f64, near: f64, far: f64) -> Mat4 { + let aspect = if aspect.is_finite() && aspect > 1e-6 { aspect } else { 1.0 }; + let (n, f) = (near, far.max(near + 1e-6)); + if self.fov > 0.0 { + let t = 1.0 / (self.fov * 0.5).tan(); + Mat4([[t / aspect, 0.0, 0.0, 0.0], [0.0, t, 0.0, 0.0], [0.0, 0.0, f / (n - f), -1.0], [0.0, 0.0, n * f / (n - f), 0.0]]) + } else { + let h = self.half_height(); + let w = h * aspect; + Mat4([[1.0 / w, 0.0, 0.0, 0.0], [0.0, 1.0 / h, 0.0, 0.0], [0.0, 0.0, 1.0 / (n - f), 0.0], [0.0, 0.0, n / (n - f), 1.0]]) + } + } + pub fn view_proj(&self, aspect: f64, radius: f64) -> Mat4 { + let (n, f) = self.clip(radius); + self.proj(aspect, n, f).mul(&self.view()) + } + /// World point → pixel coordinates (x right, y down) and depth (0 near … 1 far). + pub fn to_screen(&self, p: Vec3, w: f64, h: f64, radius: f64) -> Option<(f64, f64, f64)> { + let c = self.view_proj(w / h.max(1.0), radius).apply(p); + if c[3].abs() < 1e-12 { + return None; + } + let (x, y, z) = (c[0] / c[3], c[1] / c[3], c[2] / c[3]); + (c[3] > 0.0).then_some(((x + 1.0) * 0.5 * w, (1.0 - y) * 0.5 * h, z)) + } + /// Picking ray through pixel (x, y): origin and unit direction. + pub fn ray(&self, x: f64, y: f64, w: f64, h: f64) -> (Vec3, Vec3) { + let (r, u, b) = self.basis(); + let hh = self.half_height(); + let aspect = w / h.max(1.0); + let nx = (x / w.max(1.0)) * 2.0 - 1.0; + let ny = 1.0 - (y / h.max(1.0)) * 2.0; + if self.fov > 0.0 { + let d = (r * (nx * hh * aspect) + u * (ny * hh) - b * self.distance).normalized().unwrap_or(-b); + (self.eye(), d) + } else { + let o = self.target + r * (nx * hh * aspect) + u * (ny * hh) + b * (self.distance * 4.0); + (o, -b) + } + } + /// Zoom by `factor` (< 1 zooms in) toward a world point that stays put on screen. + pub fn zoom_at(&mut self, factor: f64, anchor: Option) { + let f = if factor.is_finite() { factor.clamp(0.05, 20.0) } else { 1.0 }; + let nd = (self.distance * f).clamp(1e-3, 1e7); + if let Some(a) = anchor { + self.target = a + (self.target - a) * (nd / self.distance); + } + self.distance = nd; + } + /// Pan by a screen delta in pixels for a viewport of height `h`. + pub fn pan(&mut self, dx: f64, dy: f64, h: f64) { + let (r, u, _) = self.basis(); + let k = 2.0 * self.half_height() / h.max(1.0); + self.target = self.target - r * (dx * k) + u * (dy * k); + } + /// Orbit by pixel deltas. + pub fn orbit(&mut self, dx: f64, dy: f64) { + let lim = std::f64::consts::FRAC_PI_2 - 1e-4; + self.yaw = (self.yaw - dx * 0.008) % std::f64::consts::TAU; + self.pitch = (self.pitch + dy * 0.008).clamp(-lim, lim); + } + pub fn set_view(&mut self, v: StandardView) { + let lim = std::f64::consts::FRAC_PI_2 - 1e-6; + let (y, p) = v.angles(); + self.yaw = y; + self.pitch = p.clamp(-lim, lim); + } + /// Frame a bounding box. + pub fn fit(&mut self, b: &Aabb3) { + if b.is_empty() { + return; + } + self.target = b.center(); + let r = (b.diagonal() * 0.5).max(1.0); + self.distance = if self.fov > 0.0 { r / (self.fov * 0.5).sin() * 1.1 } else { r / 0.4 * 1.15 }; + } + pub fn is_valid(&self) -> bool { + self.target.is_finite() && self.yaw.is_finite() && self.pitch.is_finite() && self.distance.is_finite() && self.distance > 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn front_view_projects_target_to_centre() { + let mut c = Camera { target: Vec3::new(1.0, 2.0, 3.0), distance: 50.0, ..Default::default() }; + c.set_view(StandardView::Front); + let (x, y, z) = c.to_screen(c.target, 800.0, 600.0, 10.0).unwrap(); + assert!((x - 400.0).abs() < 1e-6 && (y - 300.0).abs() < 1e-6 && (0.0..=1.0).contains(&z)); + // Front view looks along +Y: +X is to the right, +Z up. + let (xr, _, _) = c.to_screen(c.target + Vec3::X, 800.0, 600.0, 10.0).unwrap(); + let (_, yu, _) = c.to_screen(c.target + Vec3::Z, 800.0, 600.0, 10.0).unwrap(); + assert!(xr > 400.0 && yu < 300.0); + let (o, d) = c.ray(400.0, 300.0, 800.0, 600.0); + assert!(d.dist(Vec3::Y) < 1e-9, "{d:?}"); + assert!((o - c.target).cross(d).len() < 1e-6); + } + + #[test] + fn top_view_and_perspective() { + let mut c = Camera { distance: 100.0, fov: 0.8, ..Default::default() }; + c.set_view(StandardView::Top); + let (xr, _, _) = c.to_screen(Vec3::X, 800.0, 600.0, 10.0).unwrap(); + let (_, yu, _) = c.to_screen(Vec3::Y, 800.0, 600.0, 10.0).unwrap(); + assert!(xr > 400.0 && yu < 300.0, "{xr} {yu}"); + let near = c.to_screen(Vec3::new(0.0, 0.0, 10.0), 800.0, 600.0, 20.0).unwrap().2; + let far = c.to_screen(Vec3::new(0.0, 0.0, -10.0), 800.0, 600.0, 20.0).unwrap().2; + assert!(near < far); + let (o, d) = c.ray(600.0, 300.0, 800.0, 600.0); + let hit = o + d * (o.z / -d.z); + let (sx, sy, _) = c.to_screen(hit, 800.0, 600.0, 20.0).unwrap(); + assert!((sx - 600.0).abs() < 1e-6 && (sy - 300.0).abs() < 1e-6); + } + + #[test] + fn fit_and_hostile() { + let mut c = Camera::default(); + let b = Aabb3 { min: Vec3::ZERO, max: Vec3::new(40.0, 30.0, 20.0) }; + c.fit(&b); + assert!(c.target.dist(Vec3::new(20.0, 15.0, 10.0)) < 1e-9); + c.zoom_at(f64::NAN, None); + c.orbit(1e9, -1e9); + assert!(c.is_valid()); + c.fit(&Aabb3::EMPTY); + assert!(c.is_valid()); + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8b137891..a9b99177 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1 +1,11 @@ +//! View math shared by the GPU viewport and headless rendering: an orbit camera with +//! perspective or orthographic projection, screen ↔ world mapping and picking rays, plus a small +//! CPU rasterizer (z-buffer, shaded triangles, depth-tested lines) for snapshots without a GPU. +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] +mod camera; +mod raster; + +pub use camera::{Camera, Mat4, StandardView}; +pub use raster::{Canvas, Rgb, Scene, SceneLine, SceneMesh, render, render_png}; diff --git a/crates/render/src/raster.rs b/crates/render/src/raster.rs new file mode 100644 index 00000000..22f0a913 --- /dev/null +++ b/crates/render/src/raster.rs @@ -0,0 +1,234 @@ +//! CPU rasterizer for headless snapshots: z-buffered, smooth-shaded triangles and depth-tested +//! lines, rendered at 2× and downsampled for anti-aliasing. + +use std::sync::Arc; + +use solvecraft_geom::{Mesh, Vec3}; + +use crate::Camera; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rgb(pub u8, pub u8, pub u8); + +impl Rgb { + fn f(self) -> [f64; 3] { + [self.0 as f64 / 255.0, self.1 as f64 / 255.0, self.2 as f64 / 255.0] + } +} + +pub struct SceneMesh { + pub mesh: Arc, + pub color: Rgb, +} + +pub struct SceneLine { + pub points: Vec, + pub color: Rgb, + /// Width in output pixels. + pub width: f64, + /// Draw over everything (sketch geometry being edited). + pub on_top: bool, +} + +#[derive(Default)] +pub struct Scene { + pub meshes: Vec, + pub lines: Vec, + /// Background gradient top → bottom. + pub background: Option<(Rgb, Rgb)>, + /// Scene radius for clipping planes. + pub radius: f64, +} + +/// RGBA pixels plus depth. +pub struct Canvas { + pub w: usize, + pub h: usize, + pub rgba: Vec, + depth: Vec, +} + +const SS: usize = 2; +const MAX_DIM: usize = 8192; + +impl Canvas { + fn new(w: usize, h: usize) -> Canvas { + Canvas { w, h, rgba: vec![255; w * h * 4], depth: vec![f32::INFINITY; w * h] } + } + fn put(&mut self, x: usize, y: usize, c: [f64; 3], a: f64) { + let i = (y * self.w + x) * 4; + if let Some(px) = self.rgba.get_mut(i..i + 3) { + for (k, v) in px.iter_mut().enumerate() { + let old = *v as f64 / 255.0; + *v = ((old * (1.0 - a) + c[k] * a).clamp(0.0, 1.0) * 255.0).round() as u8; + } + } + } +} + +fn shade(n: Vec3, view_dir: Vec3, base: [f64; 3]) -> [f64; 3] { + let n = if n.dot(view_dir) < 0.0 { -n } else { n }; + let key = (view_dir * 0.8 + Vec3::new(-0.3, 0.2, 0.6)).normalized().unwrap_or(view_dir); + let diff = n.dot(key).max(0.0); + 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; + [(base[0] * k + spec).min(1.0), (base[1] * k + spec).min(1.0), (base[2] * k + spec).min(1.0)] +} + +/// Render a scene with the camera into an RGBA canvas of `w × h` pixels. +pub fn render(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Canvas { + let (w, h) = (w.clamp(1, MAX_DIM), h.clamp(1, MAX_DIM)); + let (sw, sh) = (w * SS, h * SS); + let mut c = Canvas::new(sw, sh); + let (top, bot) = scene.background.unwrap_or((Rgb(236, 240, 245), Rgb(205, 212, 222))); + for y in 0..sh { + let t = y as f64 / sh.max(1) as f64; + let (a, b) = (top.f(), bot.f()); + let col = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; + for x in 0..sw { + c.put(x, y, col, 1.0); + } + } + let vp = cam.view_proj(sw as f64 / sh as f64, scene.radius.max(1.0)); + let project = |p: Vec3| -> Option<[f64; 3]> { + let q = vp.apply(p); + if q[3] <= 1e-9 { + return None; + } + Some([(q[0] / q[3] + 1.0) * 0.5 * sw as f64, (1.0 - q[1] / q[3]) * 0.5 * sh as f64, q[2] / q[3]]) + }; + let view_dir = cam.back(); + for sm in &scene.meshes { + let m = &sm.mesh; + let base = sm.color.f(); + let pts: Vec> = m.positions.iter().map(|p| project(*p)).collect(); + for t in &m.triangles { + let (Some(Some(a)), Some(Some(b)), Some(Some(cc))) = (pts.get(t[0] as usize), pts.get(t[1] as usize), pts.get(t[2] as usize)) else { + continue; + }; + let ns: Vec = t.iter().map(|k| m.normals.get(*k as usize).copied().unwrap_or(Vec3::Z)).collect(); + let (x0, x1) = (a[0].min(b[0]).min(cc[0]).floor().max(0.0), a[0].max(b[0]).max(cc[0]).ceil().min(sw as f64 - 1.0)); + let (y0, y1) = (a[1].min(b[1]).min(cc[1]).floor().max(0.0), a[1].max(b[1]).max(cc[1]).ceil().min(sh as f64 - 1.0)); + if !(x0 <= x1 && y0 <= y1) { + continue; + } + let area = (b[0] - a[0]) * (cc[1] - a[1]) - (b[1] - a[1]) * (cc[0] - a[0]); + if area.abs() < 1e-12 { + continue; + } + for y in y0 as usize..=y1 as usize { + for x in x0 as usize..=x1 as usize { + let (px, py) = (x as f64 + 0.5, y as f64 + 0.5); + let w0 = ((b[0] - px) * (cc[1] - py) - (b[1] - py) * (cc[0] - px)) / area; + let w1 = ((cc[0] - px) * (a[1] - py) - (cc[1] - py) * (a[0] - px)) / area; + let w2 = 1.0 - w0 - w1; + if w0 < -1e-9 || w1 < -1e-9 || w2 < -1e-9 { + continue; + } + let z = (w0 * a[2] + w1 * b[2] + w2 * cc[2]) as f32; + let i = y * sw + x; + if c.depth.get(i).is_some_and(|d| z < *d) { + if let Some(d) = c.depth.get_mut(i) { + *d = z; + } + let n = (ns[0] * w0 + ns[1] * w1 + ns[2] * w2).normalized().unwrap_or(Vec3::Z); + c.put(x, y, shade(n, view_dir, base), 1.0); + } + } + } + } + } + for l in &scene.lines { + let col = l.color.f(); + let half = (l.width * SS as f64 * 0.5).max(0.5); + for seg in l.points.windows(2) { + let (Some(p), Some(q)) = (seg.first(), seg.get(1)) else { continue }; + let (Some(a), Some(b)) = (project(*p), project(*q)) else { continue }; + let len = ((b[0] - a[0]).powi(2) + (b[1] - a[1]).powi(2)).sqrt(); + let steps = (len.ceil() as usize).clamp(1, 20_000); + for k in 0..=steps { + let t = k as f64 / steps as f64; + let (x, y, z) = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t); + let r = half.ceil() as i64; + for dy in -r..=r { + for dx in -r..=r { + let (ix, iy) = (x.floor() as i64 + dx, y.floor() as i64 + dy); + if ix < 0 || iy < 0 || ix >= sw as i64 || iy >= sh as i64 { + continue; + } + let d = ((ix as f64 + 0.5 - x).powi(2) + (iy as f64 + 0.5 - y).powi(2)).sqrt(); + let cov = (half + 0.5 - d).clamp(0.0, 1.0); + if cov <= 0.0 { + continue; + } + let i = iy as usize * sw + ix as usize; + let visible = l.on_top || c.depth.get(i).is_some_and(|dd| (z as f32) <= *dd + 2e-4); + if visible { + c.put(ix as usize, iy as usize, col, cov); + } + } + } + } + } + } + // Downsample. + let mut out = Canvas::new(w, h); + for y in 0..h { + for x in 0..w { + let mut acc = [0u32; 4]; + for sy in 0..SS { + for sx in 0..SS { + let i = ((y * SS + sy) * sw + x * SS + sx) * 4; + for (k, a) in acc.iter_mut().enumerate() { + *a += c.rgba.get(i + k).copied().unwrap_or(0) as u32; + } + } + } + let o = (y * w + x) * 4; + for (k, a) in acc.iter().enumerate() { + if let Some(v) = out.rgba.get_mut(o + k) { + *v = (*a / (SS * SS) as u32) as u8; + } + } + } + } + out +} + +/// Render to PNG bytes. +pub fn render_png(scene: &Scene, cam: &Camera, w: usize, h: usize) -> Option> { + let c = render(scene, cam, w, h); + let img = image::RgbaImage::from_raw(c.w as u32, c.h as u32, c.rgba)?; + let mut buf = std::io::Cursor::new(Vec::new()); + img.write_to(&mut buf, image::ImageFormat::Png).ok()?; + Some(buf.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::StandardView; + + #[test] + fn renders_a_triangle() { + let mesh = Mesh { + positions: vec![Vec3::new(-10.0, 0.0, -10.0), Vec3::new(10.0, 0.0, -10.0), Vec3::new(0.0, 0.0, 10.0)], + normals: vec![Vec3::new(0.0, -1.0, 0.0); 3], + triangles: vec![[0, 1, 2]], + tri_face: vec![0], + edges: vec![], + }; + let scene = + Scene { meshes: vec![SceneMesh { mesh: Arc::new(mesh), color: Rgb(200, 30, 30) }], lines: vec![], background: None, radius: 20.0 }; + let mut cam = Camera { distance: 50.0, ..Default::default() }; + cam.set_view(StandardView::Front); + let c = render(&scene, &cam, 64, 64); + let i = (32 * 64 + 32) * 4; + assert!(c.rgba[i] > c.rgba[i + 1] + 50, "centre pixel is red: {:?}", &c.rgba[i..i + 4]); + assert!(render_png(&scene, &cam, 16, 16).unwrap().starts_with(&[0x89, b'P', b'N', b'G'])); + // Hostile sizes are clamped. + assert_eq!(render(&scene, &cam, 0, usize::MAX).w, 1); + } +}