M0: engine command registry, file formats, view math and CPU renderer
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
a97af44979
commit
cdca1e59f3
17 files changed
+3515
-1
No files matched your search
Generated
+90
-1
@@ -97,6 +97,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.6.0"
|
version = "1.6.0"
|
||||||
@@ -208,6 +214,15 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
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]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@@ -221,7 +236,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"miniz_oxide",
|
"miniz_oxide 0.9.1",
|
||||||
"zlib-rs",
|
"zlib-rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -303,6 +318,19 @@ dependencies = [
|
|||||||
"cc",
|
"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]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.13.0"
|
version = "0.13.0"
|
||||||
@@ -404,6 +432,16 @@ version = "2.8.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
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]]
|
[[package]]
|
||||||
name = "miniz_oxide"
|
name = "miniz_oxide"
|
||||||
version = "0.9.1"
|
version = "0.9.1"
|
||||||
@@ -414,6 +452,16 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"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]]
|
[[package]]
|
||||||
name = "nom"
|
name = "nom"
|
||||||
version = "3.2.1"
|
version = "3.2.1"
|
||||||
@@ -493,6 +541,19 @@ version = "0.3.34"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
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]]
|
[[package]]
|
||||||
name = "ppv-lite86"
|
name = "ppv-lite86"
|
||||||
version = "0.2.21"
|
version = "0.2.21"
|
||||||
@@ -535,6 +596,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.30"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.22.0"
|
version = "0.22.0"
|
||||||
@@ -752,6 +819,17 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-engine"
|
name = "solvecraft-engine"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"log",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"solvecraft-doc",
|
||||||
|
"solvecraft-geom",
|
||||||
|
"solvecraft-io",
|
||||||
|
"solvecraft-kernel",
|
||||||
|
"solvecraft-sketch",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-geom"
|
name = "solvecraft-geom"
|
||||||
@@ -764,6 +842,12 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-io"
|
name = "solvecraft-io"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"solvecraft-doc",
|
||||||
|
"solvecraft-geom",
|
||||||
|
"solvecraft-kernel",
|
||||||
|
"thiserror 2.0.21",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-kernel"
|
name = "solvecraft-kernel"
|
||||||
@@ -783,6 +867,11 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-render"
|
name = "solvecraft-render"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"image",
|
||||||
|
"serde",
|
||||||
|
"solvecraft-geom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "solvecraft-sketch"
|
name = "solvecraft-sketch"
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solvecraft-engine"
|
name = "solvecraft-engine"
|
||||||
|
description = "SolveCraft engine: the session, the command registry (everything is a command), undo/redo and selection"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
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 }
|
||||||
@@ -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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> = 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<Value> {
|
||||||
|
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<u64> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Sel> = 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<Value> {
|
||||||
|
s.selection.clear();
|
||||||
|
s.revision += 1;
|
||||||
|
Ok(json!({"selected": 0}))
|
||||||
|
}
|
||||||
@@ -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<Operation> {
|
||||||
|
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<u64> {
|
||||||
|
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<ProfileSel> {
|
||||||
|
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::<Result<_>>()?;
|
||||||
|
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<String> = 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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<String> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Vec<Vec3>> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
@@ -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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
let path = path_arg(p, "SaveDocumentAsCommand")?.to_string();
|
||||||
|
write(s, &path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Result<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
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"))
|
||||||
|
}
|
||||||
@@ -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<Value> {
|
||||||
|
let want = string_list(p, "bodies");
|
||||||
|
let st = s.model.state();
|
||||||
|
let bodies: Vec<Value> = 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<Value> {
|
||||||
|
let state = s.model.state();
|
||||||
|
let ss = state.sketch(id)?;
|
||||||
|
let sk = &ss.sketch;
|
||||||
|
let curves: Vec<Value> = 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<Value> = 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<Value> = 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<Value> = 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<Value> {
|
||||||
|
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<Value> {
|
||||||
|
let st = s.model.state();
|
||||||
|
let (vals, perr) = s.doc.param_values();
|
||||||
|
let params: Vec<Value> = 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<Value> = 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<Value> =
|
||||||
|
st.bodies.iter().map(|b| if with_measure { measure_json(b) } else { json!({"name": b.name, "feature": b.feature}) }).collect();
|
||||||
|
let sketches: Vec<Value> = 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<Value> {
|
||||||
|
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<Value> = 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<Value> {
|
||||||
|
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<Value> = 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<Value> {
|
||||||
|
Ok(Value::Array(crate::command_specs().iter().map(|c| serde_json::to_value(c.info(s)).unwrap_or_default()).collect()))
|
||||||
|
}
|
||||||
@@ -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<Value>;
|
||||||
|
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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -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<solvecraft_doc::DocError> for EngineError {
|
||||||
|
fn from(e: solvecraft_doc::DocError) -> Self {
|
||||||
|
EngineError::Other(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<solvecraft_sketch::SketchError> for EngineError {
|
||||||
|
fn from(e: solvecraft_sketch::SketchError) -> Self {
|
||||||
|
EngineError::Other(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<solvecraft_kernel::KernelError> for EngineError {
|
||||||
|
fn from(e: solvecraft_kernel::KernelError) -> Self {
|
||||||
|
EngineError::Other(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
impl From<solvecraft_io::IoError> for EngineError {
|
||||||
|
fn from(e: solvecraft_io::IoError) -> Self {
|
||||||
|
EngineError::Other(e.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type Result<T> = std::result::Result<T, EngineError>;
|
||||||
|
|
||||||
|
/// 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<Document>,
|
||||||
|
pub active_sketch: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An editing session on one design.
|
||||||
|
pub struct Session {
|
||||||
|
pub doc: Arc<Document>,
|
||||||
|
pub model: Model,
|
||||||
|
pub undo: Vec<Snapshot>,
|
||||||
|
pub redo: Vec<Snapshot>,
|
||||||
|
/// Sketch feature being edited (sketch mode).
|
||||||
|
pub active_sketch: Option<u64>,
|
||||||
|
pub selection: Vec<Sel>,
|
||||||
|
pub path: Option<String>,
|
||||||
|
saved: Arc<Document>,
|
||||||
|
/// Messages for the command line / log.
|
||||||
|
pub log: Vec<String>,
|
||||||
|
/// 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<String>) {
|
||||||
|
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<Value> {
|
||||||
|
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::<String>().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<Vec<Value>> {
|
||||||
|
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;
|
||||||
@@ -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<String>) -> EngineError {
|
||||||
|
EngineError::BadParams { cmd: cmd.to_string(), msg: msg.into() }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finite(x: f64) -> Option<f64> {
|
||||||
|
(x.is_finite() && x.abs() < 1e9).then_some(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn num(p: &Value, k: &str) -> Option<f64> {
|
||||||
|
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<bool> {
|
||||||
|
p.get(k).and_then(Value::as_bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn vec2(v: &Value) -> Option<Vec2> {
|
||||||
|
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<Vec3> {
|
||||||
|
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<Vec2> {
|
||||||
|
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<Vec3> {
|
||||||
|
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<Vec<Vec2>> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String> = 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<Value> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,8 +1,15 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solvecraft-io"
|
name = "solvecraft-io"
|
||||||
|
description = "SolveCraft file formats: designs (JSON), STL, OBJ and STEP export"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
solvecraft-geom = { workspace = true }
|
||||||
|
solvecraft-kernel = { workspace = true }
|
||||||
|
solvecraft-doc = { workspace = true }
|
||||||
|
thiserror = { workspace = true }
|
||||||
@@ -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<T> = std::result::Result<T, IoError>;
|
||||||
|
|
||||||
|
/// 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<Format> {
|
||||||
|
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<Mesh> {
|
||||||
|
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<u8> {
|
||||||
|
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<Vec<u8>> {
|
||||||
|
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::<Result<_>>()?;
|
||||||
|
match format {
|
||||||
|
Format::StlBinary => stl_binary(&meshes.into_iter().map(|(_, m)| m).collect::<Vec<_>>(), name),
|
||||||
|
Format::StlAscii => stl_ascii(&meshes.into_iter().map(|(_, m)| m).collect::<Vec<_>>(), 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<Document> {
|
||||||
|
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<u8> {
|
||||||
|
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<Vec<[[f32; 3]; 3]>> {
|
||||||
|
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<f32> = 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "solvecraft-render"
|
name = "solvecraft-render"
|
||||||
|
description = "SolveCraft view math (orbit camera, projection, picking rays) and a CPU rasterizer for headless snapshots"
|
||||||
version.workspace = true
|
version.workspace = true
|
||||||
edition.workspace = true
|
edition.workspace = true
|
||||||
license.workspace = true
|
license.workspace = true
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
solvecraft-geom = { workspace = true }
|
||||||
|
serde = { workspace = true }
|
||||||
|
image = { workspace = true }
|
||||||
@@ -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<StandardView> {
|
||||||
|
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<Vec3>) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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<Mesh>,
|
||||||
|
pub color: Rgb,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct SceneLine {
|
||||||
|
pub points: Vec<Vec3>,
|
||||||
|
pub color: Rgb,
|
||||||
|
/// Width in output pixels.
|
||||||
|
pub width: f64,
|
||||||
|
/// Draw over everything (sketch geometry being edited).
|
||||||
|
pub on_top: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct Scene {
|
||||||
|
pub meshes: Vec<SceneMesh>,
|
||||||
|
pub lines: Vec<SceneLine>,
|
||||||
|
/// Background gradient top → bottom.
|
||||||
|
pub background: Option<(Rgb, Rgb)>,
|
||||||
|
/// Scene radius for clipping planes.
|
||||||
|
pub radius: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RGBA pixels plus depth.
|
||||||
|
pub struct Canvas {
|
||||||
|
pub w: usize,
|
||||||
|
pub h: usize,
|
||||||
|
pub rgba: Vec<u8>,
|
||||||
|
depth: Vec<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Option<[f64; 3]>> = 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<Vec3> = 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<Vec<u8>> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user