feat: preserve modeling intent across sketches, assemblies, and fabrication

Persist typed sketch dimensions and bounded ordered configuration rules in format 8. Use exact occurrence paths and source identity for editing, movement, appearance, and mass. Preserve sheet material regions through cutting, folding, and exact DXF export; retain nested cavities and material islands.

Validation: cargo xtask ci passed, including 368 engine tests, 178 UI tests, 68 UI scenarios, 136 kernel tests, and the 10-crate wasm check. Actual preview workflows and legacy format-7 origin-corner reopening were inspected.
This commit is contained in:
bherbruck committed 2026-10-11 01:28:01 -04:00
1 parent d71bdb78ec
commit 07932d0f42
65 files changed
+6145 -484

No files matched your search

+114
View File
@@ -0,0 +1,114 @@
//! Shape creation and its typed driving dimensions form one command and one undo step.
use serde_json::{Value, json};
use solvecraft_doc::expr::Kind;
use crate::{
Result, Session,
params::{bad, expr, str_},
};
pub(super) fn draw(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "sketch.draw.dimensioned";
let create = str_(p, "command").ok_or_else(|| bad(cmd, "`command` must name a supported sketch creator"))?;
let roles: &[&str] = match create {
"sketch.line" => &["length", "angle"],
"sketch.rectangle.two_point" | "sketch.rectangle.center" | "sketch.rectangle.three_point" => &["width", "height"],
"sketch.circle.center" | "sketch.circle.two_point" | "sketch.circle.three_point" => &["radius", "diameter"],
"sketch.arc.center_point" => &["radius", "sweep"],
"sketch.slot.center_to_center" | "sketch.slot.overall" => &["length", "width"],
"sketch.polygon.inscribed" | "sketch.polygon.circumscribed" | "sketch.polygon.edge" => &["radius"],
_ => return Err(bad(cmd, "unsupported sketch creator")),
};
let mut params = p.get("params").filter(|v| v.is_object()).cloned().ok_or_else(|| bad(cmd, "`params` must be an object"))?;
let dimensions = p.get("dimensions").and_then(Value::as_object).ok_or_else(|| bad(cmd, "`dimensions` must be an object"))?;
if dimensions.len() > roles.len() || dimensions.keys().any(|key| !roles.contains(&key.as_str())) {
return Err(bad(cmd, "unsupported dimension role for this creator"));
}
if dimensions.contains_key("radius") && dimensions.contains_key("diameter") {
return Err(bad(cmd, "choose radius or diameter, not both"));
}
let mut overall_length = None;
let mut slot_width = None;
for (role, value) in dimensions {
let e = expr(&json!({"value": value}), "value")
.filter(|e| e.len() <= 4096)
.ok_or_else(|| bad(cmd, "dimension values must be bounded expressions or finite numbers"))?;
let kind = if role == "angle" || role == "sweep" { Kind::Angle } else { Kind::Length };
let evaluated = s.doc.eval(&e, kind)?;
if role == "length" {
overall_length = Some(evaluated);
}
if role == "width" {
slot_width = Some(evaluated);
}
if !evaluated.is_finite() || evaluated.abs() > 1e9 || kind == Kind::Length && evaluated <= 1e-9 {
return Err(bad(cmd, format!("invalid {role} dimension")));
}
if role == "sweep" && !(evaluated.abs() > 1e-9 && evaluated.abs() < std::f64::consts::TAU - 1e-9) {
return Err(bad(cmd, "sweep must be nonzero and less than a full turn"));
}
if role == "sweep" {
params["sweep"] = json!(evaluated.to_degrees());
if let Some(o) = params.as_object_mut() {
o.remove("end");
}
}
}
if create == "sketch.slot.overall"
&& let Some(length) = overall_length
&& let Some(width) = slot_width.or_else(|| params.get("width").and_then(Value::as_f64))
&& length <= width + 1e-9
{
return Err(bad(cmd, "overall slot length must exceed its width"));
}
if create == "sketch.line" {
if params.get("closed").and_then(Value::as_bool).unwrap_or(false)
|| params.get("points").and_then(Value::as_array).is_none_or(|points| points.len() != 2)
{
return Err(bad(cmd, "dimensioned line creation needs exactly two points"));
}
if dimensions.contains_key("angle") {
params["infer"] = json!(false);
}
}
let mut scratch = s.scratch();
let mut out = scratch.execute(create, &params)?;
let curves = out.get("curves").and_then(Value::as_array).cloned().unwrap_or_default();
let curve = |index: usize| -> Result<Value> {
curves.get(index).filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "creator did not return the expected curve"))
};
let mut added = serde_json::Map::new();
for (role, value) in dimensions {
let (entities, ty) = match (create, role.as_str()) {
("sketch.line", "length") => (vec![curve(0)?], "length"),
("sketch.line", "angle") => (vec![curve(0)?], "angle"),
(c, "width" | "height") if c.starts_with("sketch.rectangle.") => (vec![curve(usize::from(role == "height"))?], "length"),
(c, "radius" | "diameter") if c.starts_with("sketch.circle.") => (vec![curve(0)?], role.as_str()),
("sketch.arc.center_point", "radius" | "sweep") => (vec![curve(0)?], role.as_str()),
("sketch.slot.center_to_center", "length") => (vec![curve(0)?], "length"),
("sketch.slot.overall", "length") => (vec![curve(1)?, curve(3)?], "overall_length"),
("sketch.slot.center_to_center" | "sketch.slot.overall", "width") => (vec![curve(1)?], "diameter"),
(c, "radius") if c.starts_with("sketch.polygon.") => {
let support =
out.get("support_circle").filter(|v| v.is_string()).cloned().ok_or_else(|| bad(cmd, "polygon support circle missing"))?;
(vec![support], "radius")
}
_ => return Err(bad(cmd, "unsupported dimension role")),
};
let mut dimension = json!({"entities": entities, "type": ty, "value": value});
if let Some(sketch) = params.get("sketch") {
dimension["sketch"] = sketch.clone();
}
let result = scratch.execute("sketch.dimension", &dimension)?;
added.insert(role.clone(), result);
}
// Session::execute records the wrapper's before snapshot. Nested history must not leak.
scratch.undo.clear();
s.accept_computed(scratch);
out["dimensions"] = Value::Object(added);
Ok(out)
}
#[cfg(test)]
#[path = "sketch_dimensioned_tests.rs"]
mod tests;