M1: oracle recipe coverage (arcs, refs-based constraints/dimensions, extents), taper and through-all extrudes, curve splitting in profiles, region merging, angle dimensions by sector, smoother tangency solve, merged topology for revolves
Oracle: 13/29 parts match Fusion. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0f3e7701a7
commit
b85ccd62cf
17 files changed
+1067
-164
No files matched your search
@@ -22,7 +22,7 @@ use solvecraft_engine::Session;
|
||||
use solvecraft_engine::render::{StandardView, render_png};
|
||||
|
||||
const USAGE: &str = "usage:
|
||||
solvecraft-cli run <script.json|design.solvecraft> [--out FILE]... [--save FILE] [--quiet]
|
||||
solvecraft-cli run <script.json|design.solvecraft> [--out FILE]... [--save FILE] [--quiet] [--results]
|
||||
solvecraft-cli eval <script.json|design.solvecraft> (alias: inspect)
|
||||
solvecraft-cli snapshot <script|design> --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right]
|
||||
solvecraft-cli exec <command> [json-params]
|
||||
@@ -96,6 +96,14 @@ fn flags<'a>(args: &'a [String], name: &str) -> Vec<&'a str> {
|
||||
|
||||
fn cmd_run(args: &[String]) -> Result<(), String> {
|
||||
let path = args.first().ok_or_else(|| USAGE.to_string())?;
|
||||
if args.iter().any(|a| a == "--results") {
|
||||
// Print every command's result (scripts only).
|
||||
let v = read_json(path)?;
|
||||
let mut s = Session::default();
|
||||
let r = s.run_script(&v).map_err(|e| e.to_string())?;
|
||||
println!("{}", pretty(&Value::Array(r)));
|
||||
return Ok(());
|
||||
}
|
||||
let mut s = load(path)?;
|
||||
for out in flags(args, "--out") {
|
||||
s.execute("ExportCommand", &json!({"path": out})).map_err(|e| e.to_string())?;
|
||||
|
||||
@@ -51,6 +51,8 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
|
||||
match e.get("type").and_then(Value::as_str) {
|
||||
Some("line") => out.push(json!({"command": "DrawPolyline", "params": {"points": [parg(e.get("start")), parg(e.get("end"))], "ids": [id], "construction": construction}})),
|
||||
Some("circle") => out.push(json!({"command": "CircleCenterRadius", "params": {"center": parg(e.get("center")), "radius": e.get("radius"), "id": id, "construction": construction}})),
|
||||
Some("arc_three_point") => out.push(json!({"command": "ArcThreePoint", "params": {"start": parg(e.get("start")), "end": parg(e.get("end")), "through": e.get("mid").or(e.get("through")), "id": id}})),
|
||||
Some("arc_center_start_sweep") => out.push(json!({"command": "ArcCenterTwoPoint", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "sweep": e.get("sweep_deg").or(e.get("sweep")), "id": id}})),
|
||||
Some("arc") => {
|
||||
if e.get("center").is_some() {
|
||||
out.push(json!({"command": "ArcCenterTwoPoint", "params": {"center": parg(e.get("center")), "start": parg(e.get("start")), "end": parg(e.get("end")), "id": id}}))
|
||||
@@ -64,46 +66,113 @@ fn sketch_cmds(f: &Value, out: &mut Vec<Value>) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
for c in f.get("constraints").and_then(Value::as_array).into_iter().flatten() {
|
||||
let ty = c.get("type").and_then(Value::as_str).unwrap_or("");
|
||||
let cmd = match ty.to_ascii_lowercase().as_str() {
|
||||
"horizontal" | "vertical" => {
|
||||
let mut p = c.clone();
|
||||
p["mode"] = json!(ty.to_ascii_lowercase());
|
||||
out.push(json!({"command": "ConstraintHorizontalVertical", "params": p}));
|
||||
continue;
|
||||
}
|
||||
"coincident" => "ConstraintCoincident",
|
||||
"tangent" => "ConstraintTangent",
|
||||
"equal" => "ConstraintEqual",
|
||||
"parallel" => "ConstraintParallel",
|
||||
"perpendicular" => "ConstraintPerpendicular",
|
||||
"fix" => "ConstraintFix",
|
||||
"midpoint" => "ConstraintMidPoint",
|
||||
"concentric" => "ConstraintConcentric",
|
||||
"collinear" => "ConstraintCollinear",
|
||||
"symmetry" | "symmetric" => "ConstraintSymmetry",
|
||||
other => return Err(format!("recipe: constraint `{other}` is not supported yet")),
|
||||
};
|
||||
out.push(json!({"command": cmd, "params": c}));
|
||||
out.push(constraint_cmd(c)?);
|
||||
}
|
||||
for d in f.get("dimensions").and_then(Value::as_array).into_iter().flatten() {
|
||||
out.push(json!({"command": "SketchDimension", "params": d}));
|
||||
let mut p = json!({"entities": d.get("refs").or(d.get("entities")), "type": d.get("type"), "value": d.get("value")});
|
||||
for k in ["orientation", "text_at"] {
|
||||
if let Some(v) = d.get(k) {
|
||||
p[k] = v.clone();
|
||||
}
|
||||
}
|
||||
out.push(json!({"command": "SketchDimension", "params": p}));
|
||||
}
|
||||
out.push(json!({"command": "SketchStop", "params": {}}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extent(f: &Value) -> (Value, Value, Value) {
|
||||
/// Extent parameters for the Extrude command.
|
||||
fn extent(f: &Value, p: &mut Value) {
|
||||
let e = f.get("extent").cloned().unwrap_or(Value::Null);
|
||||
let d = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null);
|
||||
let dir = e.get("direction").cloned().unwrap_or(json!("positive"));
|
||||
(d, dir, e.get("distance2").cloned().unwrap_or(Value::Null))
|
||||
let ty = e.get("type").and_then(Value::as_str).unwrap_or("distance");
|
||||
let dist = e.get("distance").cloned().or_else(|| f.get("distance").cloned()).unwrap_or(Value::Null);
|
||||
match ty {
|
||||
"symmetric" => {
|
||||
let full = e.get("is_full_length").and_then(Value::as_bool).unwrap_or(false);
|
||||
p["direction"] = json!("symmetric");
|
||||
p["distance"] = if full { json!(format!("({}) / 2", expr_text(&dist))) } else { dist };
|
||||
}
|
||||
"two_sides" => {
|
||||
p["distance"] = e.get("side1").cloned().unwrap_or(Value::Null);
|
||||
p["distance2"] = e.get("side2_opposite").or(e.get("side2")).cloned().unwrap_or(Value::Null);
|
||||
}
|
||||
"through_all" => {
|
||||
p["through_all"] = json!(true);
|
||||
p["direction"] = e.get("direction").cloned().unwrap_or(json!("positive"));
|
||||
}
|
||||
_ => {
|
||||
p["distance"] = dist;
|
||||
p["direction"] = e.get("direction").cloned().unwrap_or(json!("positive"));
|
||||
}
|
||||
}
|
||||
if let Some(t) = e.get("taper_deg") {
|
||||
p["taper"] = t.clone();
|
||||
}
|
||||
}
|
||||
|
||||
fn expr_text(v: &Value) -> String {
|
||||
match v {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn refs(c: &Value) -> Vec<Value> {
|
||||
c.get("refs").and_then(Value::as_array).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A recipe constraint `{type, refs}` → a constraint command.
|
||||
fn constraint_cmd(c: &Value) -> Result<Value, String> {
|
||||
let ty = c.get("type").and_then(Value::as_str).unwrap_or("").to_ascii_lowercase();
|
||||
let r = refs(c);
|
||||
let at = |i: usize| r.get(i).cloned().unwrap_or(Value::Null);
|
||||
let is_point = |v: &Value| v.as_str().is_some_and(|s| s.contains('.') || s == "origin" || s.starts_with('p'));
|
||||
let (cmd, p) = match ty.as_str() {
|
||||
"horizontal" | "vertical" if r.len() >= 2 => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": ty})),
|
||||
"horizontal" | "vertical" => ("ConstraintHorizontalVertical", json!({"line": at(0), "mode": ty})),
|
||||
"horizontal_points" => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": "horizontal"})),
|
||||
"vertical_points" => ("ConstraintHorizontalVertical", json!({"points": [at(0), at(1)], "mode": "vertical"})),
|
||||
"coincident" | "point_on_curve" => {
|
||||
// The point goes first.
|
||||
if is_point(&at(0)) {
|
||||
("ConstraintCoincident", json!({"a": at(0), "b": at(1)}))
|
||||
} else {
|
||||
("ConstraintCoincident", json!({"a": at(1), "b": at(0)}))
|
||||
}
|
||||
}
|
||||
"midpoint" => {
|
||||
if is_point(&at(0)) {
|
||||
("ConstraintMidPoint", json!({"point": at(0), "line": at(1)}))
|
||||
} else {
|
||||
("ConstraintMidPoint", json!({"point": at(1), "line": at(0)}))
|
||||
}
|
||||
}
|
||||
"symmetric" | "symmetry" => ("ConstraintSymmetry", json!({"a": at(0), "b": at(1), "line": at(2)})),
|
||||
"fix" => ("ConstraintFix", json!({"entity": at(0), "fixed": true})),
|
||||
"tangent" => ("ConstraintTangent", json!({"a": at(0), "b": at(1)})),
|
||||
"equal" => ("ConstraintEqual", json!({"a": at(0), "b": at(1)})),
|
||||
"parallel" => ("ConstraintParallel", json!({"a": at(0), "b": at(1)})),
|
||||
"perpendicular" => ("ConstraintPerpendicular", json!({"a": at(0), "b": at(1)})),
|
||||
"concentric" => ("ConstraintConcentric", json!({"a": at(0), "b": at(1)})),
|
||||
"collinear" => ("ConstraintCollinear", json!({"a": at(0), "b": at(1)})),
|
||||
other => return Err(format!("recipe: constraint `{other}` is not supported yet")),
|
||||
};
|
||||
Ok(json!({"command": cmd, "params": p}))
|
||||
}
|
||||
|
||||
/// The profiles a feature uses (`profiles` list or a single `profile`).
|
||||
fn profiles_of(f: &Value) -> Value {
|
||||
match (f.get("profiles"), f.get("profile")) {
|
||||
(Some(p), _) => p.clone(),
|
||||
(None, Some(p)) => json!([p]),
|
||||
_ => json!("all"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The sketch a feature uses (name), from its profiles.
|
||||
fn profile_sketch(f: &Value) -> Value {
|
||||
f.get("profiles")
|
||||
.and_then(Value::as_array)
|
||||
profiles_of(f)
|
||||
.as_array()
|
||||
.and_then(|a| a.first())
|
||||
.and_then(|p| p.get("sketch"))
|
||||
.cloned()
|
||||
@@ -123,17 +192,23 @@ pub fn to_script(recipe: &Value) -> Result<Value, String> {
|
||||
match f.get("op").and_then(Value::as_str) {
|
||||
Some("sketch") => sketch_cmds(f, &mut out)?,
|
||||
Some("extrude") => {
|
||||
let (d, dir, d2) = extent(f);
|
||||
let mut p = json!({"sketch": profile_sketch(f), "profiles": f.get("profiles").cloned().unwrap_or(json!("all")), "distance": d, "direction": dir, "operation": op(f), "name": name, "body_names": bodies});
|
||||
if !d2.is_null() {
|
||||
p["distance2"] = d2;
|
||||
}
|
||||
let mut p = json!({"sketch": profile_sketch(f), "profiles": profiles_of(f), "operation": op(f), "name": name, "body_names": bodies});
|
||||
extent(f, &mut p);
|
||||
out.push(json!({"command": "Extrude", "params": p}));
|
||||
}
|
||||
Some("revolve") => out.push(json!({"command": "Revolve", "params": {
|
||||
"sketch": profile_sketch(f), "profiles": f.get("profiles").cloned().unwrap_or(json!("all")),
|
||||
"axis": f.get("axis").and_then(|a| a.get("ref").or(a.get("line")).cloned()).or_else(|| f.get("axis").cloned()),
|
||||
"angle": f.get("angle").cloned().unwrap_or(json!("360 deg")), "operation": op(f), "name": name, "body_names": bodies}})),
|
||||
Some("combine") => out.push(json!({"command": "FusionCombineCommand", "params": {
|
||||
"target": f.get("target_body").or(f.get("target")), "tools": f.get("tool_bodies").or(f.get("tools")),
|
||||
"operation": op(f), "keep_tools": f.get("keep_tools").cloned().unwrap_or(json!(false)), "name": name}})),
|
||||
Some("revolve") => {
|
||||
let axis = match f.get("axis") {
|
||||
Some(a) if a.get("dir").is_some() => json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")}),
|
||||
Some(a) => a.get("line").or(a.get("ref")).cloned().unwrap_or_else(|| a.clone()),
|
||||
None => Value::Null,
|
||||
};
|
||||
out.push(json!({"command": "Revolve", "params": {
|
||||
"sketch": profile_sketch(f), "profiles": profiles_of(f), "axis": axis,
|
||||
"angle": f.get("angle").cloned().unwrap_or(json!("360 deg")), "operation": op(f), "name": name, "body_names": bodies}}));
|
||||
}
|
||||
Some("fillet") => {
|
||||
out.push(json!({"command": "FusionFilletEdgesCommand", "params": {"edges": edge_points(f), "radius": f.get("radius"), "name": name}}))
|
||||
}
|
||||
|
||||
@@ -101,6 +101,12 @@ pub struct Extent {
|
||||
/// Start offset from the sketch plane (expression).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub start_offset: Option<String>,
|
||||
/// Through all bodies in the direction(s) instead of `distance`.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub through_all: bool,
|
||||
/// Taper angle (expression; positive grows the profile).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub taper: Option<String>,
|
||||
}
|
||||
|
||||
/// Revolve axis.
|
||||
@@ -113,6 +119,8 @@ pub enum AxisRef {
|
||||
SketchAxis { axis: String },
|
||||
/// World X / Y / Z axis through the origin.
|
||||
World { axis: String },
|
||||
/// Any line in the sketch plane, in world coordinates.
|
||||
Line { origin: Vec3, dir: Vec3 },
|
||||
}
|
||||
|
||||
/// Feature definitions.
|
||||
@@ -260,6 +268,9 @@ impl FeatureKind {
|
||||
if let Some(d) = &extent.start_offset {
|
||||
v.push(d);
|
||||
}
|
||||
if let Some(d) = &extent.taper {
|
||||
v.push(d);
|
||||
}
|
||||
}
|
||||
FeatureKind::Revolve { angle, .. } => v.push(angle),
|
||||
FeatureKind::Fillet { radius, .. } => v.push(radius),
|
||||
|
||||
+47
-3
@@ -328,6 +328,31 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec<Body>, op: Operation
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Distance that reaches through every target body from the sketch plane (either side).
|
||||
fn through_all_distance(st: &ModelState, plane: &Plane, targets: &[String]) -> Result<f64> {
|
||||
let mut far: f64 = 0.0;
|
||||
let mut any = false;
|
||||
for b in st.bodies.iter().filter(|b| targets.is_empty() || targets.contains(&b.name)) {
|
||||
let bb = b.mesh().bounds();
|
||||
if bb.is_empty() {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
for i in 0..8 {
|
||||
let p = Vec3::new(
|
||||
if i & 1 == 0 { bb.min.x } else { bb.max.x },
|
||||
if i & 2 == 0 { bb.min.y } else { bb.max.y },
|
||||
if i & 4 == 0 { bb.min.z } else { bb.max.z },
|
||||
);
|
||||
far = far.max(plane.height(p).abs());
|
||||
}
|
||||
}
|
||||
if !any {
|
||||
return Err(DocError::Invalid("through all needs a body to cut".into()));
|
||||
}
|
||||
Ok(far * 1.02 + 1.0)
|
||||
}
|
||||
|
||||
/// Sample points inside a region (sketch coordinates).
|
||||
fn region_samples(r: &Region2) -> Vec<Vec2> {
|
||||
let mut out = vec![r.interior_point()];
|
||||
@@ -428,6 +453,14 @@ fn revolve_axis(ss: &SolvedSketch, axis: &AxisRef) -> Result<(Vec2, Vec2)> {
|
||||
}
|
||||
Ok((pl.to_local(Vec3::ZERO), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
|
||||
}
|
||||
AxisRef::Line { origin, dir } => {
|
||||
let pl = &ss.plane;
|
||||
let d = dir.normalized().ok_or_else(|| DocError::Invalid("revolve axis has no direction".into()))?;
|
||||
if d.dot(pl.normal()).abs() > 1e-9 || pl.height(*origin).abs() > 1e-6 {
|
||||
return Err(DocError::Invalid("the revolve axis does not lie in the sketch plane".into()));
|
||||
}
|
||||
Ok((pl.to_local(*origin), Vec2::new(d.dot(pl.x), d.dot(pl.y))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -448,8 +481,19 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
FeatureKind::Extrude { sketch, profiles, extent, operation, targets } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
||||
let regions = select_profiles(&ss, profiles)?;
|
||||
let d = val(vals, &extent.distance, Kind::Length)?;
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let d = if extent.through_all { through_all_distance(st, &ss.plane, targets)? } else { val(vals, &extent.distance, Kind::Length)? };
|
||||
if let Some(tp) = &extent.taper {
|
||||
let taper = val(vals, tp, Kind::Angle)?;
|
||||
if extent.distance2.is_some() || extent.start_offset.is_some() || extent.direction == crate::Direction::Symmetric {
|
||||
return Err(DocError::Invalid("not supported yet: taper with two-sided, symmetric or offset extents".into()));
|
||||
}
|
||||
let sign = if extent.direction == crate::Direction::Negative { -1.0 } else { 1.0 };
|
||||
let (len, sign) = if d < 0.0 { (-d, -sign) } else { (d, sign) };
|
||||
let tools =
|
||||
regions.iter().map(|r| kernel::extrude_tapered(&ss.plane, r, len, sign, taper)).collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
return apply_op(st, f, tools, *operation, targets);
|
||||
}
|
||||
let off = match &extent.start_offset {
|
||||
Some(e) => val(vals, e, Kind::Length)?,
|
||||
None => 0.0,
|
||||
@@ -471,7 +515,7 @@ fn eval_feature(doc: &Document, vals: &BTreeMap<String, Value>, f: &Feature, st:
|
||||
}
|
||||
FeatureKind::Revolve { sketch, profiles, axis, angle, operation, targets } => {
|
||||
let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?.clone();
|
||||
let regions = select_profiles(&ss, profiles)?;
|
||||
let regions = solvecraft_sketch::merge_regions(&select_profiles(&ss, profiles)?);
|
||||
let (o, d) = revolve_axis(&ss, axis)?;
|
||||
let ang = val(vals, angle, Kind::Angle)?;
|
||||
let tools = kernel::revolve(&ss.plane, ®ions, o, d, ang)?;
|
||||
|
||||
+16
-2
@@ -31,7 +31,14 @@ fn plate_doc() -> (Document, u64) {
|
||||
FeatureKind::Extrude {
|
||||
sketch: s,
|
||||
profiles: ProfileSel::All,
|
||||
extent: Extent { distance: "20".into(), direction: Direction::Positive, distance2: None, start_offset: None },
|
||||
extent: Extent {
|
||||
distance: "20".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
},
|
||||
operation: Operation::NewBody,
|
||||
targets: vec![],
|
||||
},
|
||||
@@ -82,7 +89,14 @@ fn box_fillet_cut_timeline() {
|
||||
FeatureKind::Extrude {
|
||||
sketch: s2,
|
||||
profiles: ProfileSel::Points { points: vec![Vec2::new(20.0, 15.0)] },
|
||||
extent: Extent { distance: "20".into(), direction: Direction::Positive, distance2: None, start_offset: None },
|
||||
extent: Extent {
|
||||
distance: "20".into(),
|
||||
direction: Direction::Positive,
|
||||
distance2: None,
|
||||
start_offset: None,
|
||||
through_all: false,
|
||||
taper: None,
|
||||
},
|
||||
operation: Operation::Cut,
|
||||
targets: vec![],
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.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?"),
|
||||
.params("distance: expr (or through_all: true); taper?: angle 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")
|
||||
@@ -143,8 +143,13 @@ fn add_feature(s: &mut Session, p: &Value, kind: FeatureKind) -> Result<Value> {
|
||||
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")?;
|
||||
let through_all = bool_(p, "through_all").unwrap_or(false) || str_(p, "extent").is_some_and(|e| e.eq_ignore_ascii_case("through_all"));
|
||||
let distance = if through_all { expr(p, "distance").unwrap_or_else(|| "0".into()) } else { req_expr(cmd, p, "distance")? };
|
||||
check_expr(s, &distance, Kind::Length, cmd, "distance")?;
|
||||
let taper = expr(p, "taper");
|
||||
if let Some(t) = &taper {
|
||||
check_expr(s, t, Kind::Angle, cmd, "taper")?;
|
||||
}
|
||||
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,
|
||||
@@ -162,7 +167,7 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let kind = FeatureKind::Extrude {
|
||||
sketch,
|
||||
profiles: profiles(p, cmd)?,
|
||||
extent: Extent { distance, direction, distance2, start_offset },
|
||||
extent: Extent { distance, direction, distance2, start_offset, through_all, taper },
|
||||
operation: operation(p, cmd)?,
|
||||
targets: string_list(p, "targets"),
|
||||
};
|
||||
@@ -172,11 +177,17 @@ fn extrude(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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 axis = if let Some(o) = p.get("axis").filter(|v| v.is_object()) {
|
||||
let origin = o.get("origin").and_then(vec3).unwrap_or(Vec3::ZERO);
|
||||
let dir = o.get("dir").and_then(vec3).ok_or_else(|| bad(cmd, "axis needs `dir`"))?;
|
||||
AxisRef::Line { origin, dir }
|
||||
} else {
|
||||
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), X, Y, Z or {origin, dir}")),
|
||||
}
|
||||
};
|
||||
let angle = expr(p, "angle").unwrap_or_else(|| "360 deg".into());
|
||||
check_expr(s, &angle, Kind::Angle, cmd, "angle")?;
|
||||
|
||||
+115
-15
@@ -59,7 +59,7 @@ pub static COMMANDS: &[CommandSpec] = &[
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("arc_center")
|
||||
.enabled(in_sketch)
|
||||
.params("center, start, end (counter-clockwise from start)"),
|
||||
.params("center, start, end (counter-clockwise from start) | center, start, sweep: signed degrees"),
|
||||
CommandSpec::new("ShapePolygonInscribed", "Inscribed Polygon", polygon_inscribed)
|
||||
.at("SKETCH", "CREATE")
|
||||
.icon("polygon")
|
||||
@@ -586,8 +586,12 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Counter-clockwise from start to end must pass the through point; otherwise swap.
|
||||
let ang = |q: Vec2| (q - o).angle();
|
||||
let ccw_span = |from: f64, to: f64| (to - from).rem_euclid(std::f64::consts::TAU);
|
||||
let (s0, e0) = if ccw_span(ang(a.pos()), ang(t)) < ccw_span(ang(a.pos()), ang(b.pos())) { (a, b) } else { (b, a) };
|
||||
let ccw = ccw_span(ang(a.pos()), ang(t)) < ccw_span(ang(a.pos()), ang(b.pos()));
|
||||
let (s0, e0) = if ccw { (a, b) } else { (b, a) };
|
||||
let ci = sk.add_arc(o, s0.pos(), e0.pos(), [None, s0.idx(), e0.idx()], id.as_deref())?;
|
||||
if let Some(c) = sk.curves.get_mut(ci) {
|
||||
c.reversed = !ccw;
|
||||
}
|
||||
Ok((ids_of(sk, &[ci]), Vec::new()))
|
||||
})?;
|
||||
Ok(result(out, info))
|
||||
@@ -596,9 +600,36 @@ fn arc_three(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn arc_center(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ArcCenterTwoPoint";
|
||||
let id = str_(p, "id").map(str::to_string);
|
||||
let sweep = num(p, "sweep");
|
||||
let (out, info) = edit(s, p, cmd, false, |sk, _| {
|
||||
let c = req_parg(sk, p, "center", cmd)?;
|
||||
let a = req_parg(sk, p, "start", cmd)?;
|
||||
if let Some(sw) = sweep {
|
||||
// Start point and signed sweep (degrees); clockwise arcs keep `<id>.start` at the start.
|
||||
if !(sw.abs() > 1e-9 && sw.abs() < 360.0) {
|
||||
return Err(bad(cmd, "`sweep` must be between -360 and 360 degrees (not 0)"));
|
||||
}
|
||||
let end = c.pos() + Vec2::from_angle((a.pos() - c.pos()).angle() + sw.to_radians()) * a.pos().dist(c.pos());
|
||||
let cid = match &id {
|
||||
Some(i) => i.clone(),
|
||||
None => sk.fresh("a"),
|
||||
};
|
||||
let ai = match a.idx() {
|
||||
Some(i) => i,
|
||||
None => sk.add_point(a.pos(), Some(&format!("{cid}.start")))?,
|
||||
};
|
||||
let bi = sk.add_point(end, Some(&format!("{cid}.end")))?;
|
||||
let ci = if sw > 0.0 {
|
||||
sk.add_arc(c.pos(), a.pos(), end, [c.idx(), Some(ai), Some(bi)], Some(&cid))?
|
||||
} else {
|
||||
let ci = sk.add_arc(c.pos(), end, a.pos(), [c.idx(), Some(bi), Some(ai)], Some(&cid))?;
|
||||
if let Some(cu) = sk.curves.get_mut(ci) {
|
||||
cu.reversed = true;
|
||||
}
|
||||
ci
|
||||
};
|
||||
return Ok((ids_of(sk, &[ci]), Vec::new()));
|
||||
}
|
||||
let b = req_parg(sk, p, "end", cmd)?;
|
||||
let ci = sk.add_arc(c.pos(), a.pos(), b.pos(), [c.idx(), a.idx(), b.idx()], id.as_deref())?;
|
||||
Ok((ids_of(sk, &[ci]), Vec::new()))
|
||||
@@ -814,13 +845,43 @@ fn c_midpoint(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
fn c_symmetry(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "ConstraintSymmetry";
|
||||
constrain(s, p, cmd, |sk| {
|
||||
Ok(ConstraintKind::Symmetric {
|
||||
p: point_ref(sk, p.get("a"), cmd, "a")?,
|
||||
q: point_ref(sk, p.get("b"), cmd, "b")?,
|
||||
l: curve_ref(sk, p.get("line"), cmd, "line")?,
|
||||
})
|
||||
})
|
||||
let (ids, info) = edit(s, p, cmd, true, |sk, _| {
|
||||
let l = curve_ref(sk, p.get("line"), cmd, "line")?;
|
||||
let ra = p.get("a").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`a` must be a point or curve"))?;
|
||||
let rb = p.get("b").and_then(Value::as_str).ok_or_else(|| bad(cmd, "`b` must be a point or curve"))?;
|
||||
let mut out = Vec::new();
|
||||
match (sk.resolve_point(ra), sk.resolve_point(rb)) {
|
||||
(Some(a), Some(b)) => out.push(add_c(sk, ConstraintKind::Symmetric { p: a, q: b, l })?),
|
||||
_ => {
|
||||
// Two curves: circles/arcs mirror their centres and keep equal radii; lines mirror
|
||||
// their end points.
|
||||
let ca = sk.curve_index(ra).ok_or_else(|| bad(cmd, format!("unknown entity `{ra}`")))?;
|
||||
let cb = sk.curve_index(rb).ok_or_else(|| bad(cmd, format!("unknown entity `{rb}`")))?;
|
||||
match (sk.curves.get(ca).map(|c| c.kind.clone()), sk.curves.get(cb).map(|c| c.kind.clone())) {
|
||||
(Some(CurveKind::Line { a: a0, b: a1 }), Some(CurveKind::Line { a: b0, b: b1 })) => {
|
||||
out.push(add_c(sk, ConstraintKind::Symmetric { p: a0, q: b0, l })?);
|
||||
out.push(add_c(sk, ConstraintKind::Symmetric { p: a1, q: b1, l })?);
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
let pa = round_center(sk, ca).ok_or_else(|| bad(cmd, "symmetry needs two lines or two circles/arcs"))?;
|
||||
let pb = round_center(sk, cb).ok_or_else(|| bad(cmd, "symmetry needs two lines or two circles/arcs"))?;
|
||||
out.push(add_c(sk, ConstraintKind::Symmetric { p: pa, q: pb, l })?);
|
||||
out.push(add_c(sk, ConstraintKind::Equal { a: ca, b: cb })?);
|
||||
}
|
||||
_ => return Err(bad(cmd, "symmetry needs two points or two curves")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})?;
|
||||
Ok(json!({"constraints": ids, "sketch": info}))
|
||||
}
|
||||
|
||||
fn round_center(sk: &Sketch, c: usize) -> Option<usize> {
|
||||
match sk.curves.get(c)?.kind {
|
||||
CurveKind::Circle { c, .. } | CurveKind::Arc { c, .. } => Some(c),
|
||||
CurveKind::Line { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn c_fix(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
@@ -877,7 +938,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(cmd, "`entities` must list one or two sketch entities"));
|
||||
}
|
||||
let ty = str_(p, "type").unwrap_or("auto").to_ascii_lowercase();
|
||||
let ty = match (ty.as_str(), str_(p, "orientation").map(str::to_ascii_lowercase).as_deref()) {
|
||||
("distance" | "auto", Some("horizontal")) => "horizontal".to_string(),
|
||||
("distance" | "auto", Some("vertical")) => "vertical".to_string(),
|
||||
("offset", _) => "distance".to_string(),
|
||||
_ => ty,
|
||||
};
|
||||
let value = expr(p, "value");
|
||||
let text_at = p.get("text_at").and_then(vec2);
|
||||
let ((param, kind_name, current), info) = edit(s, p, cmd, true, |sk, doc| {
|
||||
let ent = |r: &str| -> Result<(Option<usize>, Option<usize>)> {
|
||||
if let Some(c) = sk.curve_index(r) {
|
||||
@@ -937,13 +1005,18 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let d = da.normalized().map(|d| d.cross(b0 - a0).abs()).unwrap_or(0.0);
|
||||
(ConstraintKind::PointLineDistance { p: bq, l: a, value: d }, d)
|
||||
} else {
|
||||
let mut ang = da.cross(db).atan2(da.dot(db));
|
||||
let (mut ka, mut kb) = (a, b);
|
||||
if ang < 0.0 {
|
||||
ang = -ang;
|
||||
std::mem::swap(&mut ka, &mut kb);
|
||||
match text_at {
|
||||
Some(t) => angle_by_sector(a, b, (a0, a1), (b0, b1), t).ok_or_else(|| bad(cmd, "cannot place that angle"))?,
|
||||
None => {
|
||||
let mut ang = da.cross(db).atan2(da.dot(db));
|
||||
let (mut ka, mut kb) = (a, b);
|
||||
if ang < 0.0 {
|
||||
ang = -ang;
|
||||
std::mem::swap(&mut ka, &mut kb);
|
||||
}
|
||||
(ConstraintKind::Angle { a: ka, b: kb, value: ang, flip: false }, ang)
|
||||
}
|
||||
}
|
||||
(ConstraintKind::Angle { a: ka, b: kb, value: ang }, ang)
|
||||
}
|
||||
}
|
||||
_ => return Err(bad(cmd, "cannot dimension that combination of entities")),
|
||||
@@ -967,6 +1040,33 @@ fn dimension(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"param": param, "type": kind_name, "expression": v, "measured": current, "sketch": info}))
|
||||
}
|
||||
|
||||
/// The angle dimension between two lines whose sector contains `t` (the text position): returns
|
||||
/// the constraint and the current value of that sector angle.
|
||||
fn angle_by_sector(a: usize, b: usize, (a0, a1): (Vec2, Vec2), (b0, b1): (Vec2, Vec2), t: Vec2) -> Option<(ConstraintKind, f64)> {
|
||||
let (da, db) = ((a1 - a0).normalized()?, (b1 - b0).normalized()?);
|
||||
let den = da.cross(db);
|
||||
if den.abs() < 1e-12 {
|
||||
return None;
|
||||
}
|
||||
let s = (b0 - a0).cross(db) / den;
|
||||
let x = a0 + da * s;
|
||||
let ta = (t - x).angle();
|
||||
// The four rays from the intersection, with their line and direction sign.
|
||||
let mut rays: Vec<(f64, usize, bool)> = vec![(da.angle(), a, true), ((-da).angle(), a, false), (db.angle(), b, true), ((-db).angle(), b, false)];
|
||||
rays.sort_by(|p, q| p.0.total_cmp(&q.0));
|
||||
let tau = std::f64::consts::TAU;
|
||||
for k in 0..4 {
|
||||
let (s0, l0, p0) = rays[k];
|
||||
let (s1, l1, p1) = rays[(k + 1) % 4];
|
||||
let span = (s1 - s0).rem_euclid(tau);
|
||||
let off = (ta - s0).rem_euclid(tau);
|
||||
if off <= span && l0 != l1 {
|
||||
return Some((ConstraintKind::Angle { a: l0, b: l1, value: span, flip: p0 != p1 }, span));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn round6(x: f64) -> f64 {
|
||||
(x * 1e6).round() / 1e6
|
||||
}
|
||||
|
||||
@@ -202,3 +202,97 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result<Body> {
|
||||
let mut v = revolve(&plane, &[r], Vec2::ZERO, Vec2::Y, std::f64::consts::TAU)?;
|
||||
v.pop().ok_or_else(|| KernelError::Failed("torus".into()))
|
||||
}
|
||||
|
||||
/// Offset every segment of a loop to its right by `d` (outward for a region's outer loop and
|
||||
/// holes alike). Loops of lines (mitred corners) and loops of arcs on one circle are supported.
|
||||
fn offset_loop(lp: &Loop2, d: f64) -> Result<Loop2> {
|
||||
if d == 0.0 {
|
||||
return Ok(lp.clone());
|
||||
}
|
||||
let all_arcs = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. }));
|
||||
if all_arcs {
|
||||
let segs = lp
|
||||
.segs
|
||||
.iter()
|
||||
.map(|s| match *s {
|
||||
Seg2::Arc { center, radius, start, sweep } => {
|
||||
let r = radius + sweep.signum() * d;
|
||||
if r > 1e-6 {
|
||||
Ok(Seg2::Arc { center, radius: r, start, sweep })
|
||||
} else {
|
||||
Err(KernelError::Invalid("the taper closes the profile".into()))
|
||||
}
|
||||
}
|
||||
Seg2::Line { .. } => Err(KernelError::Failed("not supported yet: tapered profiles mixing lines and arcs".into())),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
return Ok(Loop2 { segs });
|
||||
}
|
||||
let n = lp.segs.len();
|
||||
let mut lines: Vec<(Vec2, Vec2)> = Vec::with_capacity(n);
|
||||
for s in &lp.segs {
|
||||
let Seg2::Line { a, b } = *s else { return Err(KernelError::Failed("not supported yet: tapered profiles mixing lines and arcs".into())) };
|
||||
let dir = (b - a).normalized().ok_or_else(|| KernelError::Invalid("zero-length edge".into()))?;
|
||||
let right = Vec2::new(dir.y, -dir.x);
|
||||
lines.push((a + right * d, dir));
|
||||
}
|
||||
let mut pts = Vec::with_capacity(n);
|
||||
for i in 0..n {
|
||||
let (Some(&(p0, d0)), Some(&(p1, d1))) = (lines.get((i + n - 1) % n), lines.get(i)) else { continue };
|
||||
let den = d0.cross(d1);
|
||||
if den.abs() < 1e-12 {
|
||||
pts.push(p1);
|
||||
continue;
|
||||
}
|
||||
let t = (p1 - p0).cross(d1) / den;
|
||||
pts.push(p0 + d0 * t);
|
||||
}
|
||||
let out = Loop2::polygon(&pts);
|
||||
if out.signed_area().signum() != lp.signed_area().signum() || out.signed_area().abs() < 1e-9 {
|
||||
return Err(KernelError::Invalid("the taper closes the profile".into()));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Extrude one region by `length` along `dir_sign` × the plane normal with a taper angle
|
||||
/// (radians; positive grows the profile).
|
||||
pub fn extrude_tapered(plane: &Plane, region: &Region2, length: f64, dir_sign: f64, taper: f64) -> Result<Body> {
|
||||
let (length, taper) = (finite(length, "distance")?, finite(taper, "taper")?);
|
||||
if length <= 1e-6 {
|
||||
return Err(KernelError::Invalid("extrude distance is zero".into()));
|
||||
}
|
||||
if taper.abs() >= std::f64::consts::FRAC_PI_2 - 1e-3 {
|
||||
return Err(KernelError::Invalid("taper must be between −90° and 90°".into()));
|
||||
}
|
||||
region_ok(region)?;
|
||||
let d = length * taper.tan();
|
||||
let top_plane = plane.offset(length * dir_sign.signum());
|
||||
let loops0: Vec<Loop2> = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect();
|
||||
let loops1: Vec<Loop2> = loops0.iter().map(|l| offset_loop(l, d)).collect::<Result<_>>()?;
|
||||
guard("tapered extrude", || {
|
||||
let w0: Vec<mt::Wire> = loops0.iter().map(|l| wire(plane, l)).collect::<Result<_>>()?;
|
||||
let w1: Vec<mt::Wire> = loops1.iter().map(|l| wire(&top_plane, l)).collect::<Result<_>>()?;
|
||||
let mut faces: Vec<mt::Face> = Vec::new();
|
||||
for (a, b) in w0.iter().zip(&w1) {
|
||||
let sides = builder::try_wire_homotopy(a, b).map_err(|e| KernelError::Failed(format!("taper sides: {e}")))?;
|
||||
faces.extend(sides.face_iter().cloned());
|
||||
}
|
||||
let bottom = builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::<Vec<_>>())
|
||||
.map_err(|e| KernelError::Failed(format!("taper bottom: {e}")))?;
|
||||
let top = builder::try_attach_plane(&w1).map_err(|e| KernelError::Failed(format!("taper top: {e}")))?;
|
||||
let mut tries: Vec<(mt::Face, mt::Face)> = vec![(bottom.clone(), top.clone())];
|
||||
tries.push((bottom.inverse(), top.inverse()));
|
||||
let mut last = String::new();
|
||||
for (b, t) in tries {
|
||||
let mut all = faces.clone();
|
||||
all.push(b);
|
||||
all.push(t);
|
||||
let shell: mt::Shell = all.into();
|
||||
match Solid::try_new(vec![shell]) {
|
||||
Ok(s) => return Body::new(s),
|
||||
Err(e) => last = e.to_string(),
|
||||
}
|
||||
}
|
||||
Err(KernelError::Failed(format!("tapered extrude: {last}")))
|
||||
})
|
||||
}
|
||||
@@ -22,7 +22,7 @@ mod topo;
|
||||
|
||||
pub use blend::{chamfer, fillet};
|
||||
pub use body::{Body, EdgeInfo, FaceInfo};
|
||||
pub use build::{box_solid, cylinder, extrude, revolve, sphere, torus};
|
||||
pub use build::{box_solid, cylinder, extrude, extrude_tapered, revolve, sphere, torus};
|
||||
pub use measure::{BodyMeasure, measure};
|
||||
pub use ops::{BoolOp, boolean, transform};
|
||||
pub use step::step_export;
|
||||
|
||||
@@ -145,6 +145,30 @@ fn fillets_on_several_edges_and_angles() {
|
||||
assert!(fillet(&u, &[Vec3::new(15.0, 10.0, 20.0)], 1.0).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tapered_extrudes() {
|
||||
// 40 x 40 square, 30 up, 10° outward: a frustum.
|
||||
let sq = Region2 {
|
||||
outer: Loop2::polygon(&[Vec2::new(-20.0, -20.0), Vec2::new(20.0, -20.0), Vec2::new(20.0, 20.0), Vec2::new(-20.0, 20.0)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let b = extrude_tapered(&Plane::XY, &sq, 30.0, 1.0, 10f64.to_radians()).unwrap();
|
||||
let top = 40.0 + 2.0 * 30.0 * 10f64.to_radians().tan();
|
||||
let v = 30.0 / 3.0 * (1600.0 + top * top + 40.0 * top);
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(rel(m.volume, v) < 1e-6, "{} vs {v}", m.volume);
|
||||
assert_eq!(m.merged.faces, 6);
|
||||
// Inward taper on a circle (a cone frustum), downward.
|
||||
let c = Region2 { outer: Loop2::circle(Vec2::ZERO, 10.0), holes: vec![] };
|
||||
let b = extrude_tapered(&Plane::XY, &c, 10.0, -1.0, -20f64.to_radians()).unwrap();
|
||||
let r2 = 10.0 - 10.0 * 20f64.to_radians().tan();
|
||||
let v = PI * 10.0 / 3.0 * (100.0 + r2 * r2 + 10.0 * r2);
|
||||
let m = measure(&b).unwrap();
|
||||
assert!(rel(m.volume, v) < 5e-4, "{} vs {v}", m.volume);
|
||||
assert!(m.bbox.max.z < 1e-6 && m.bbox.min.z > -10.0 - 1e-6, "{:?}", m.bbox);
|
||||
assert!(extrude_tapered(&Plane::XY, &c, 10.0, 1.0, -60f64.to_radians()).is_err(), "closes the profile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_convergence() {
|
||||
@@ -216,3 +240,27 @@ fn debug_revolve_doc_case() {
|
||||
let q = revolve(&Plane::XY, std::slice::from_ref(&r2), Vec2::ZERO, Vec2::Y, PI / 2.0).unwrap().pop().unwrap();
|
||||
println!("reversed: {}", measure(&q).unwrap().volume);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn debug_coplanar_union_rate() {
|
||||
let a = box_solid(Vec3::ZERO, Vec3::new(40.0, 40.0, 20.0)).unwrap();
|
||||
let b = extrude(&Plane::XY, &[Region2 { outer: Loop2::circle(Vec2::new(40.0, 20.0), 12.0), holes: vec![] }], 0.0, 40.0).unwrap().pop().unwrap();
|
||||
let mut ok = 0;
|
||||
for i in 0..20 {
|
||||
let sh = Vec3::new(0.0137 * i as f64, 0.0291 * i as f64, -0.0173 * i as f64);
|
||||
let (a2, b2) = (transform(&a, sh, Vec3::ZERO, Vec3::Z, 0.0).unwrap(), transform(&b, sh, Vec3::ZERO, Vec3::Z, 0.0).unwrap());
|
||||
for tol in [1e-3, 1e-2, 5e-2] {
|
||||
let r = guard("t", || {
|
||||
let (sa, sb) = (a2.deep_copy(), b2.deep_copy());
|
||||
Ok(truck_shapeops::or(&sa, &sb, tol))
|
||||
});
|
||||
let good = matches!(r, Ok(Some(_)));
|
||||
if good {
|
||||
ok += 1;
|
||||
}
|
||||
println!("shift {i} tol {tol}: {}", if good { "ok" } else { "fail" });
|
||||
}
|
||||
}
|
||||
println!("ok {ok}/60");
|
||||
}
|
||||
+78
-20
@@ -128,6 +128,62 @@ fn classify(pts: &[Vec3], nrm: &[Vec3], tol: f64, id: usize) -> Surf {
|
||||
Surf::Other(id)
|
||||
}
|
||||
|
||||
/// Per-face vertex samples (position, normal) from the tessellation.
|
||||
fn face_vertices(mesh: &Mesh, nf: usize) -> Vec<Vec<(Vec3, Vec3)>> {
|
||||
let mut out: Vec<Vec<(Vec3, Vec3)>> = vec![Vec::new(); nf];
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
for &k in t {
|
||||
if let (Some(p), Some(n), Some(v)) = (mesh.positions.get(k as usize), mesh.normals.get(k as usize), out.get_mut(*f as usize)) {
|
||||
v.push((*p, *n));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Normal of face `f` nearest to `p`.
|
||||
fn normal_near(verts: &[(Vec3, Vec3)], p: Vec3) -> Option<Vec3> {
|
||||
verts.iter().min_by(|a, b| a.0.dist(p).total_cmp(&b.0.dist(p))).map(|v| v.1)
|
||||
}
|
||||
|
||||
/// Cone signature of a non-analytic face: (axis, cos of the normal/axis angle) when all normals
|
||||
/// make the same angle with one axis.
|
||||
fn cone_signature(verts: &[(Vec3, Vec3)]) -> Option<(Vec3, f64)> {
|
||||
let n0 = verts.first()?.1;
|
||||
let n1 = verts.iter().map(|v| v.1).max_by(|a, b| (*a - n0).len().total_cmp(&(*b - n0).len()))?;
|
||||
let n2 = verts.iter().map(|v| v.1).max_by(|a, b| (*a - n0).cross(n1 - n0).len().total_cmp(&(*b - n0).cross(n1 - n0).len()))?;
|
||||
let mut a = (n1 - n0).cross(n2 - n0).normalized()?;
|
||||
if a.x + a.y * 1e-3 + a.z * 1e-6 < 0.0 {
|
||||
a = -a;
|
||||
}
|
||||
let c = n0.dot(a);
|
||||
verts.iter().all(|v| (v.1.dot(a) - c).abs() < 2e-3).then_some((a, c))
|
||||
}
|
||||
|
||||
/// Do adjacent faces `a` and `b` (sharing the edge polyline `edge`) lie on one surface?
|
||||
/// Analytic classes compare parameters; other faces must join smoothly along the edge and have
|
||||
/// the same cone signature (or both have none).
|
||||
fn same_surface(surfs: &[Surf], verts: &[Vec<(Vec3, Vec3)>], edge: Option<&Vec<Vec3>>, a: usize, b: usize, tol: f64) -> bool {
|
||||
let (Some(sa), Some(sb)) = (surfs.get(a), surfs.get(b)) else { return false };
|
||||
match (sa, sb) {
|
||||
(Surf::Other(_), Surf::Other(_)) => {}
|
||||
_ => return sa.same(sb, tol),
|
||||
}
|
||||
let (Some(va), Some(vb), Some(e)) = (verts.get(a), verts.get(b), edge) else { return false };
|
||||
let smooth = e.iter().all(|p| match (normal_near(va, *p), normal_near(vb, *p)) {
|
||||
(Some(na), Some(nb)) => na.dot(nb) > 1.0 - 2e-3,
|
||||
_ => false,
|
||||
});
|
||||
if !smooth {
|
||||
return false;
|
||||
}
|
||||
match (cone_signature(va), cone_signature(vb)) {
|
||||
(Some((xa, ca)), Some((xb, cb))) => xa.dot(xb) > 1.0 - 1e-4 && (ca - cb).abs() < 1e-3,
|
||||
(None, None) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
while let Some(&q) = p.get(i) {
|
||||
if q == i {
|
||||
@@ -144,6 +200,7 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec<bool> {
|
||||
let nf = solid.face_iter().count();
|
||||
let tol = (b.size() * 1e-4).max(1e-6);
|
||||
let surfs = classify_faces(mesh, nf, tol);
|
||||
let verts = face_vertices(mesh, nf);
|
||||
let mut faces_of: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
for (fi, f) in solid.face_iter().enumerate() {
|
||||
@@ -160,11 +217,11 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec<bool> {
|
||||
}
|
||||
order
|
||||
.iter()
|
||||
.map(|k| match faces_of.get(k).map(Vec::as_slice) {
|
||||
Some([a, b2]) => match (surfs.get(*a), surfs.get(*b2)) {
|
||||
(Some(sa), Some(sb)) => !matches!(sa, Surf::Plane { .. }) && sa.same(sb, tol * 10.0),
|
||||
_ => false,
|
||||
},
|
||||
.enumerate()
|
||||
.map(|(ei, k)| match faces_of.get(k).map(Vec::as_slice) {
|
||||
Some([a, b2]) => {
|
||||
!matches!(surfs.get(*a), Some(Surf::Plane { .. })) && same_surface(&surfs, &verts, mesh.edges.get(ei), *a, *b2, tol * 10.0)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
.collect()
|
||||
@@ -191,21 +248,17 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
let solid = &*b.solid;
|
||||
let nf = solid.face_iter().count();
|
||||
let tol = (b.size() * 1e-4).max(1e-6);
|
||||
let mut pts: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
let mut nrm: Vec<Vec<Vec3>> = vec![Vec::new(); nf];
|
||||
let surfs = classify_faces(mesh, nf, tol);
|
||||
let verts = face_vertices(mesh, nf);
|
||||
// Zero-area faces (a revolved profile edge lying on the axis) don't count.
|
||||
let mut area = vec![0.0f64; nf];
|
||||
for (t, f) in mesh.triangles.iter().zip(&mesh.tri_face) {
|
||||
for &k in t {
|
||||
if let (Some(p), Some(n), Some(pv), Some(nv)) =
|
||||
(mesh.positions.get(k as usize), mesh.normals.get(k as usize), pts.get_mut(*f as usize), nrm.get_mut(*f as usize))
|
||||
{
|
||||
pv.push(*p);
|
||||
nv.push(*n);
|
||||
}
|
||||
if let (Some([p, q, r]), Some(a)) = (mesh.tri(t), area.get_mut(*f as usize)) {
|
||||
*a += (q - p).cross(r - p).len() * 0.5;
|
||||
}
|
||||
}
|
||||
let surfs: Vec<Surf> = (0..nf)
|
||||
.map(|i| classify(pts.get(i).map(Vec::as_slice).unwrap_or(&[]), nrm.get(i).map(Vec::as_slice).unwrap_or(&[]), tol * 10.0, i))
|
||||
.collect();
|
||||
let degenerate: Vec<bool> = area.iter().map(|a| *a < (b.size() * 1e-6).powi(2)).collect();
|
||||
let is_deg = |f: usize| degenerate.get(f).copied().unwrap_or(false);
|
||||
|
||||
// Edge → faces, edge → vertices.
|
||||
let mut edge_faces: HashMap<usize, Vec<usize>> = HashMap::new();
|
||||
@@ -237,10 +290,9 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
}
|
||||
// Merge faces.
|
||||
let mut fp: Vec<usize> = (0..nf).collect();
|
||||
for faces in edge_faces.values() {
|
||||
for (ei, faces) in &edge_faces {
|
||||
if let [a, b] = faces[..]
|
||||
&& let (Some(sa), Some(sb)) = (surfs.get(a), surfs.get(b))
|
||||
&& sa.same(sb, tol * 10.0)
|
||||
&& same_surface(&surfs, &verts, mesh.edges.get(*ei), a, b, tol * 10.0)
|
||||
{
|
||||
let (ra, rb) = (find(&mut fp, a), find(&mut fp, b));
|
||||
if let Some(s) = fp.get_mut(ra) {
|
||||
@@ -251,6 +303,9 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
let mut groups: BTreeMap<usize, usize> = BTreeMap::new();
|
||||
let mut face_types: BTreeMap<String, usize> = BTreeMap::new();
|
||||
for f in 0..nf {
|
||||
if is_deg(f) {
|
||||
continue;
|
||||
}
|
||||
let r = find(&mut fp, f);
|
||||
if groups.insert(r, f).is_none()
|
||||
&& let Some(s) = surfs.get(f)
|
||||
@@ -264,6 +319,9 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result<TopoCounts> {
|
||||
eids.sort();
|
||||
for e in eids {
|
||||
let faces = edge_faces.get(e).cloned().unwrap_or_default();
|
||||
if faces.iter().any(|f| is_deg(*f)) {
|
||||
continue;
|
||||
}
|
||||
let gs: Vec<usize> = faces.iter().map(|f| find(&mut fp, *f)).collect();
|
||||
let pair = match gs[..] {
|
||||
[a, b] if a == b => continue,
|
||||
|
||||
@@ -14,7 +14,7 @@ mod profiles;
|
||||
mod solver;
|
||||
|
||||
pub use model::{Constraint, ConstraintKind, Curve, CurveKind, SPoint, Sketch, SketchError};
|
||||
pub use profiles::{Profile, find_profiles};
|
||||
pub use profiles::{Profile, find_profiles, merge_regions};
|
||||
pub use solver::{SolveReport, SolveStatus, solve};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -49,6 +49,10 @@ pub struct Curve {
|
||||
pub kind: CurveKind,
|
||||
#[serde(default)]
|
||||
pub construction: bool,
|
||||
/// An arc drawn clockwise: stored counter-clockwise, but `<id>.start` / `<id>.end` keep the
|
||||
/// as-drawn meaning.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub reversed: bool,
|
||||
}
|
||||
|
||||
/// Geometric constraints and dimensions. Indices refer to `Sketch::points` (`p`, `q`) or
|
||||
@@ -147,11 +151,15 @@ pub enum ConstraintKind {
|
||||
c: usize,
|
||||
value: f64,
|
||||
},
|
||||
/// Angle from line `a` to line `b`, radians, counter-clockwise.
|
||||
/// Angle from line `a` to line `b`, radians, counter-clockwise. With `flip` the angle is
|
||||
/// measured to the reversed direction of one line (the constraint holds `value + 180°`), which
|
||||
/// lets a dimension pick any of the four angles between two lines.
|
||||
Angle {
|
||||
a: usize,
|
||||
b: usize,
|
||||
value: f64,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
flip: bool,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -316,7 +324,8 @@ impl Sketch {
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh(&mut self, prefix: &str) -> String {
|
||||
/// A new unused id with the given prefix (`l` → `l3`).
|
||||
pub fn fresh(&mut self, prefix: &str) -> String {
|
||||
let n = self.counters.entry(prefix.to_string()).or_insert(0);
|
||||
loop {
|
||||
*n += 1;
|
||||
@@ -386,7 +395,7 @@ impl Sketch {
|
||||
return Err(SketchError::Invalid("line needs two distinct points".into()));
|
||||
}
|
||||
let id = self.curve_id(id, "l")?;
|
||||
self.curves.push(Curve { id, kind: CurveKind::Line { a, b }, construction: false });
|
||||
self.curves.push(Curve { id, kind: CurveKind::Line { a, b }, construction: false, reversed: false });
|
||||
Ok(self.curves.len() - 1)
|
||||
}
|
||||
|
||||
@@ -415,7 +424,7 @@ impl Sketch {
|
||||
Some(i) if i < self.points.len() => i,
|
||||
_ => self.own_point(&id, "center", center)?,
|
||||
};
|
||||
self.curves.push(Curve { id, kind: CurveKind::Circle { c, r }, construction: false });
|
||||
self.curves.push(Curve { id, kind: CurveKind::Circle { c, r }, construction: false, reversed: false });
|
||||
Ok(self.curves.len() - 1)
|
||||
}
|
||||
|
||||
@@ -441,7 +450,7 @@ impl Sketch {
|
||||
Some(i) if i < n && i != a => i,
|
||||
_ => self.own_point(&id, "end", p1)?,
|
||||
};
|
||||
self.curves.push(Curve { id, kind: CurveKind::Arc { c, a, b }, construction: false });
|
||||
self.curves.push(Curve { id, kind: CurveKind::Arc { c, a, b }, construction: false, reversed: false });
|
||||
Ok(self.curves.len() - 1)
|
||||
}
|
||||
|
||||
@@ -656,6 +665,11 @@ impl Sketch {
|
||||
}
|
||||
let (cid, role) = r.rsplit_once('.')?;
|
||||
let c = self.curves.get(self.curve_index(cid)?)?;
|
||||
let role = match (role, c.reversed) {
|
||||
("start", true) => "end",
|
||||
("end", true) => "start",
|
||||
(r, _) => r,
|
||||
};
|
||||
match (&c.kind, role) {
|
||||
(CurveKind::Line { a, .. }, "start") | (CurveKind::Arc { a, .. }, "start") => Some(*a),
|
||||
(CurveKind::Line { b, .. }, "end") | (CurveKind::Arc { b, .. }, "end") => Some(*b),
|
||||
|
||||
+373
-48
@@ -41,10 +41,30 @@ impl Profile {
|
||||
}
|
||||
|
||||
struct Edge {
|
||||
curve: usize,
|
||||
seg: Seg2,
|
||||
u: usize,
|
||||
v: usize,
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Parameter of `p` at an end of the shape (lines: 0 or 1), for intersections at end points.
|
||||
fn end_param(sh: &Shape, p: Vec2, tol: f64) -> Option<f64> {
|
||||
match *sh {
|
||||
Shape::Line { a, b } => {
|
||||
if p.dist(a) <= tol {
|
||||
Some(0.0)
|
||||
} else if p.dist(b) <= tol {
|
||||
Some(1.0)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
Shape::Round { c, r, start, sweep } => {
|
||||
let s0 = c + Vec2::from_angle(start) * r;
|
||||
let s1 = c + Vec2::from_angle(start + sweep) * r;
|
||||
(p.dist(s0) <= tol || p.dist(s1) <= tol).then_some(0.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
@@ -57,64 +77,258 @@ fn find(p: &mut [usize], mut i: usize) -> usize {
|
||||
i
|
||||
}
|
||||
|
||||
/// Find all closed profiles of a sketch.
|
||||
pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
||||
// Node ids: point indices merged by position.
|
||||
let n = sk.points.len();
|
||||
let mut parent: Vec<usize> = (0..n).collect();
|
||||
let mut order: Vec<usize> = (0..n).collect();
|
||||
order.sort_by(|a, b| {
|
||||
let (pa, pb) = (sk.points.get(*a).map(|p| p.pos.x).unwrap_or(0.0), sk.points.get(*b).map(|p| p.pos.x).unwrap_or(0.0));
|
||||
pa.total_cmp(&pb)
|
||||
});
|
||||
for (k, &i) in order.iter().enumerate() {
|
||||
let Some(pi) = sk.points.get(i).map(|p| p.pos) else { continue };
|
||||
for &j in order.iter().skip(k + 1) {
|
||||
let Some(pj) = sk.points.get(j).map(|p| p.pos) else { continue };
|
||||
if pj.x - pi.x > MERGE_TOL {
|
||||
break;
|
||||
}
|
||||
if pi.dist(pj) <= MERGE_TOL {
|
||||
let (ri, rj) = (find(&mut parent, i), find(&mut parent, j));
|
||||
if ri != rj
|
||||
&& let Some(s) = parent.get_mut(rj)
|
||||
{
|
||||
*s = ri;
|
||||
/// A curve of the sketch as an analytic shape for splitting.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Shape {
|
||||
Line {
|
||||
a: Vec2,
|
||||
b: Vec2,
|
||||
},
|
||||
/// Circle (full when `sweep` is 2π) from `start` angle sweeping counter-clockwise.
|
||||
Round {
|
||||
c: Vec2,
|
||||
r: f64,
|
||||
start: f64,
|
||||
sweep: f64,
|
||||
},
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
/// Curve parameter of `p` if it lies on the curve's interior (line t in (0,1); round:
|
||||
/// angle offset from start in (0, sweep)).
|
||||
fn param_on(&self, p: Vec2, tol: f64) -> Option<f64> {
|
||||
match *self {
|
||||
Shape::Line { a, b } => {
|
||||
let d = b - a;
|
||||
let l2 = d.len2();
|
||||
if l2 < 1e-24 {
|
||||
return None;
|
||||
}
|
||||
let t = (p - a).dot(d) / l2;
|
||||
let q = a + d * t;
|
||||
(q.dist(p) <= tol && t * l2.sqrt() > tol && (1.0 - t) * l2.sqrt() > tol).then_some(t)
|
||||
}
|
||||
Shape::Round { c, r, start, sweep } => {
|
||||
if (p.dist(c) - r).abs() > tol {
|
||||
return None;
|
||||
}
|
||||
let off = ((p - c).angle() - start).rem_euclid(TAU);
|
||||
let ang_tol = tol / r.max(1e-12);
|
||||
let full = sweep >= TAU - 1e-12;
|
||||
if full {
|
||||
return Some(off);
|
||||
}
|
||||
(off > ang_tol && off < sweep - ang_tol).then_some(off)
|
||||
}
|
||||
}
|
||||
}
|
||||
fn point(&self, t: f64) -> Vec2 {
|
||||
match *self {
|
||||
Shape::Line { a, b } => a.lerp(b, t),
|
||||
Shape::Round { c, r, start, .. } => c + Vec2::from_angle(start + t) * r,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut loops: Vec<(Loop2, Vec<String>, usize)> = Vec::new(); // (ccw loop, curve ids, component)
|
||||
let mut edges: Vec<Edge> = Vec::new();
|
||||
/// Intersection points of two shapes (as circles/infinite lines; callers filter by range).
|
||||
fn intersections(x: &Shape, y: &Shape) -> Vec<Vec2> {
|
||||
match (*x, *y) {
|
||||
(Shape::Line { a, b }, Shape::Line { a: c, b: d }) => {
|
||||
let (r, s) = (b - a, d - c);
|
||||
let den = r.cross(s);
|
||||
if den.abs() < 1e-12 * r.len() * s.len() {
|
||||
return Vec::new();
|
||||
}
|
||||
let t = (c - a).cross(s) / den;
|
||||
vec![a + r * t]
|
||||
}
|
||||
(Shape::Line { a, b }, Shape::Round { c, r, .. }) | (Shape::Round { c, r, .. }, Shape::Line { a, b }) => {
|
||||
let d = b - a;
|
||||
let f = a - c;
|
||||
let (qa, qb, qc) = (d.len2(), 2.0 * f.dot(d), f.len2() - r * r);
|
||||
let disc = qb * qb - 4.0 * qa * qc;
|
||||
if qa < 1e-24 || disc < 0.0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let sq = disc.sqrt();
|
||||
vec![a + d * ((-qb - sq) / (2.0 * qa)), a + d * ((-qb + sq) / (2.0 * qa))]
|
||||
}
|
||||
(Shape::Round { c: c1, r: r1, .. }, Shape::Round { c: c2, r: r2, .. }) => {
|
||||
let dv = c2 - c1;
|
||||
let d = dv.len();
|
||||
if d < 1e-12 || d > r1 + r2 || d < (r1 - r2).abs() {
|
||||
return Vec::new();
|
||||
}
|
||||
let a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d);
|
||||
let h = (r1 * r1 - a * a).max(0.0).sqrt();
|
||||
let m = c1 + dv * (a / d);
|
||||
let n = dv.perp() / d;
|
||||
vec![m + n * h, m - n * h]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Signed curvature at the start of a segment (left turns positive).
|
||||
fn curvature(s: &Seg2) -> f64 {
|
||||
match *s {
|
||||
Seg2::Line { .. } => 0.0,
|
||||
Seg2::Arc { radius, sweep, .. } => sweep.signum() / radius.max(1e-12),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find all closed profiles of a sketch. Curves are split where other curves end on them or
|
||||
/// cross them, like a drawing would be read.
|
||||
pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
||||
let tol = MERGE_TOL * 10.0;
|
||||
// Analytic shapes of the profile curves.
|
||||
let mut shapes: Vec<(usize, Shape)> = Vec::new();
|
||||
for (ci, c) in sk.curves.iter().enumerate() {
|
||||
if c.construction {
|
||||
continue;
|
||||
}
|
||||
match c.kind {
|
||||
CurveKind::Circle { .. } => {
|
||||
let segs = sk.segs(ci);
|
||||
if !segs.is_empty() {
|
||||
loops.push((Loop2 { segs }, vec![c.id.clone()], usize::MAX - ci));
|
||||
CurveKind::Line { a, b } => {
|
||||
if let (Some(a), Some(b)) = (sk.point(a), sk.point(b))
|
||||
&& a.dist(b) > MERGE_TOL
|
||||
{
|
||||
shapes.push((ci, Shape::Line { a, b }));
|
||||
}
|
||||
}
|
||||
CurveKind::Line { a, b } | CurveKind::Arc { a, b, .. } => {
|
||||
let (u, v) = (find(&mut parent, a), find(&mut parent, b));
|
||||
if u == v {
|
||||
continue;
|
||||
CurveKind::Circle { c, r } => {
|
||||
if let Some(c) = sk.point(c) {
|
||||
shapes.push((ci, Shape::Round { c, r, start: 0.0, sweep: TAU }));
|
||||
}
|
||||
if let Some(seg) = sk.segs(ci).into_iter().next()
|
||||
&& seg.length() > MERGE_TOL
|
||||
{
|
||||
edges.push(Edge { curve: ci, seg, u, v });
|
||||
}
|
||||
CurveKind::Arc { .. } => {
|
||||
if let Some(Seg2::Arc { center, radius, start, sweep }) = sk.segs(ci).into_iter().next() {
|
||||
shapes.push((ci, Shape::Round { c: center, r: radius, start, sweep }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if edges.len() > MAX_EDGES {
|
||||
if shapes.len() > MAX_EDGES {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Split parameters per shape: end points of other curves on it, and crossings.
|
||||
let mut ends: Vec<Vec2> = Vec::new();
|
||||
for (_, sh) in &shapes {
|
||||
match *sh {
|
||||
Shape::Line { a, b } => ends.extend([a, b]),
|
||||
Shape::Round { sweep, .. } if sweep < TAU - 1e-12 => ends.extend([sh.point(0.0), sh.point(sweep)]),
|
||||
Shape::Round { .. } => {}
|
||||
}
|
||||
}
|
||||
let mut cuts: Vec<Vec<f64>> = vec![Vec::new(); shapes.len()];
|
||||
for (i, (_, sh)) in shapes.iter().enumerate() {
|
||||
for p in &ends {
|
||||
if let Some(t) = sh.param_on(*p, tol)
|
||||
&& let Some(c) = cuts.get_mut(i)
|
||||
{
|
||||
c.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if shapes.len() <= 2000 {
|
||||
for i in 0..shapes.len() {
|
||||
for j in (i + 1)..shapes.len() {
|
||||
let (Some((_, x)), Some((_, y))) = (shapes.get(i), shapes.get(j)) else { continue };
|
||||
let mut pts = intersections(x, y);
|
||||
// A tangent touch gives two nearly equal roots: one point.
|
||||
if let [p, q] = pts[..]
|
||||
&& p.dist(q) < tol * 100.0
|
||||
{
|
||||
pts = vec![(p + q) * 0.5];
|
||||
}
|
||||
for p in pts {
|
||||
// Crossings at (or next to) curve ends are already cuts.
|
||||
if ends.iter().any(|e| e.dist(p) < tol * 100.0) {
|
||||
continue;
|
||||
}
|
||||
let on_x = x.param_on(p, tol).or_else(|| end_param(x, p, tol));
|
||||
let on_y = y.param_on(p, tol).or_else(|| end_param(y, p, tol));
|
||||
if let (Some(tx), Some(ty)) = (on_x, on_y) {
|
||||
if x.param_on(p, tol).is_some()
|
||||
&& let Some(c) = cuts.get_mut(i)
|
||||
{
|
||||
c.push(tx);
|
||||
}
|
||||
if y.param_on(p, tol).is_some()
|
||||
&& let Some(c) = cuts.get_mut(j)
|
||||
{
|
||||
c.push(ty);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Pieces → edges between merged nodes.
|
||||
let mut nodes: Vec<Vec2> = Vec::new();
|
||||
let mut node = |p: Vec2| -> usize {
|
||||
if let Some(i) = nodes.iter().position(|q| q.dist(p) <= tol) {
|
||||
return i;
|
||||
}
|
||||
nodes.push(p);
|
||||
nodes.len() - 1
|
||||
};
|
||||
let mut loops: Vec<(Loop2, Vec<String>, usize)> = Vec::new(); // (ccw loop, curve ids, component)
|
||||
let mut edges: Vec<Edge> = Vec::new();
|
||||
for (k, (ci, sh)) in shapes.iter().enumerate() {
|
||||
let mut ts = cuts.get(k).cloned().unwrap_or_default();
|
||||
ts.sort_by(f64::total_cmp);
|
||||
// Cuts closer than a few tolerances are one cut (tangent touches give near-double roots).
|
||||
let scale = match *sh {
|
||||
Shape::Line { a, b } => a.dist(b),
|
||||
Shape::Round { r, .. } => r,
|
||||
};
|
||||
let close = (tol * 100.0 / scale.max(1e-12)).max(1e-9);
|
||||
ts.dedup_by(|a, b| (*a - *b).abs() < close);
|
||||
if let (Shape::Round { sweep, .. }, Some(&first), Some(&last)) = (*sh, ts.first(), ts.last())
|
||||
&& sweep >= TAU - 1e-12
|
||||
&& ts.len() > 1
|
||||
&& (first + TAU - last).abs() < close
|
||||
{
|
||||
ts.pop();
|
||||
}
|
||||
let id = sk.curves.get(*ci).map(|c| c.id.clone()).unwrap_or_default();
|
||||
match *sh {
|
||||
Shape::Line { a, b } => {
|
||||
let mut ps = vec![a];
|
||||
ps.extend(ts.iter().map(|t| sh.point(*t)));
|
||||
ps.push(b);
|
||||
for w in ps.windows(2) {
|
||||
let (p, q) = (w[0], w[1]);
|
||||
if p.dist(q) > MERGE_TOL {
|
||||
let (u, v) = (node(p), node(q));
|
||||
edges.push(Edge { seg: Seg2::Line { a: p, b: q }, u, v, id: id.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Shape::Round { c, r, start, sweep } => {
|
||||
let full = sweep >= TAU - 1e-12;
|
||||
if full && ts.is_empty() {
|
||||
loops.push((Loop2::circle(c, r), vec![id.clone()], usize::MAX - *ci));
|
||||
continue;
|
||||
}
|
||||
let mut bounds: Vec<f64> =
|
||||
if full { ts.clone() } else { std::iter::once(0.0).chain(ts.iter().copied()).chain(std::iter::once(sweep)).collect() };
|
||||
if full {
|
||||
let first = bounds.first().copied().unwrap_or(0.0);
|
||||
bounds.push(first + TAU);
|
||||
}
|
||||
for w in bounds.windows(2) {
|
||||
let (t0, t1) = (w[0], w[1]);
|
||||
if (t1 - t0) * r <= MERGE_TOL {
|
||||
continue;
|
||||
}
|
||||
let seg = Seg2::Arc { center: c, radius: r, start: start + t0, sweep: t1 - t0 };
|
||||
let (u, v) = (node(seg.start()), node(seg.end()));
|
||||
edges.push(Edge { seg, u, v, id: id.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let n = nodes.len();
|
||||
// Prune dangling edges.
|
||||
let mut alive = vec![true; edges.len()];
|
||||
loop {
|
||||
@@ -190,27 +404,34 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
||||
break;
|
||||
};
|
||||
segs.push(seg);
|
||||
if let Some(e) = edges.get(h / 2)
|
||||
&& let Some(c) = sk.curves.get(e.curve)
|
||||
{
|
||||
ids.push(c.id.clone());
|
||||
if let Some(e) = edges.get(h / 2) {
|
||||
ids.push(e.id.clone());
|
||||
}
|
||||
let back = (-seg.end_tangent()).angle();
|
||||
let twin = h ^ 1;
|
||||
let mut best: Option<(f64, usize)> = None;
|
||||
// First clockwise turn from the way back. Directions are compared a short way
|
||||
// along each segment (curvature decides between equal tangents, e.g. a line tangent
|
||||
// to an arc, or an arc leaving back along the incoming line).
|
||||
let kt = half(twin).map(|(ts, _, _)| curvature(&ts)).unwrap_or(0.0);
|
||||
let mut best: Option<(f64, f64, usize)> = None;
|
||||
for &o in outgoing.get(to).map(Vec::as_slice).unwrap_or(&[]) {
|
||||
if o == twin {
|
||||
continue;
|
||||
}
|
||||
let Some((os, _, _)) = half(o) else { continue };
|
||||
let mut d = (back - os.start_tangent().angle()) % TAU;
|
||||
let mut raw = (back - os.start_tangent().angle()).rem_euclid(TAU);
|
||||
if raw > TAU - 1e-9 {
|
||||
raw = 0.0;
|
||||
}
|
||||
let mut d = raw + (kt - curvature(&os)) * 1e-7;
|
||||
if d <= 1e-12 {
|
||||
d += TAU;
|
||||
}
|
||||
if best.is_none_or(|(bd, _)| d < bd) {
|
||||
best = Some((d, o));
|
||||
if best.is_none_or(|(bd, _, _)| d < bd) {
|
||||
best = Some((d, 0.0, o));
|
||||
}
|
||||
}
|
||||
let best = best.map(|(d, _, o)| (d, o));
|
||||
let Some((_, next)) = best else {
|
||||
ok = false;
|
||||
break;
|
||||
@@ -271,3 +492,107 @@ pub fn find_profiles(sk: &Sketch) -> Vec<Profile> {
|
||||
out.sort_by(|a, b| first_curve(a).cmp(&first_curve(b)).then(b.area.total_cmp(&a.area)));
|
||||
out
|
||||
}
|
||||
|
||||
fn same_seg(a: &Seg2, b: &Seg2, tol: f64) -> bool {
|
||||
match (*a, *b) {
|
||||
(Seg2::Line { a: p, b: q }, Seg2::Line { a: r, b: s }) => p.dist(r) <= tol && q.dist(s) <= tol,
|
||||
(Seg2::Arc { center: c1, radius: r1, .. }, Seg2::Arc { center: c2, radius: r2, .. }) => {
|
||||
c1.dist(c2) <= tol
|
||||
&& (r1 - r2).abs() <= tol
|
||||
&& a.start().dist(b.start()) <= tol
|
||||
&& a.end().dist(b.end()) <= tol
|
||||
&& a.mid().dist(b.mid()) <= tol
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Union of regions that touch along shared boundary segments (one extrude of several adjacent
|
||||
/// profiles makes one body). Disjoint regions stay separate.
|
||||
pub fn merge_regions(regions: &[Region2]) -> Vec<Region2> {
|
||||
let tol = MERGE_TOL * 10.0;
|
||||
// Every boundary segment oriented with the region on its left.
|
||||
let mut segs: Vec<Seg2> = Vec::new();
|
||||
for r in regions {
|
||||
segs.extend(r.outer.ccw().segs);
|
||||
for h in &r.holes {
|
||||
segs.extend(h.ccw().reversed().segs);
|
||||
}
|
||||
}
|
||||
if segs.len() > MAX_EDGES {
|
||||
return regions.to_vec();
|
||||
}
|
||||
// Drop pairs that run against each other (shared boundaries).
|
||||
let mut alive = vec![true; segs.len()];
|
||||
let mut shared = false;
|
||||
for i in 0..segs.len() {
|
||||
if !alive.get(i).copied().unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
for j in (i + 1)..segs.len() {
|
||||
if alive.get(j).copied().unwrap_or(false)
|
||||
&& let (Some(a), Some(b)) = (segs.get(i), segs.get(j))
|
||||
&& same_seg(a, &b.reversed(), tol)
|
||||
{
|
||||
alive[i] = false;
|
||||
alive[j] = false;
|
||||
shared = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !shared {
|
||||
return regions.to_vec();
|
||||
}
|
||||
let rest: Vec<Seg2> = segs.iter().zip(&alive).filter(|(_, a)| **a).map(|(s, _)| *s).collect();
|
||||
// Chain into loops, taking the sharpest left turn where several continue.
|
||||
let mut used = vec![false; rest.len()];
|
||||
let mut loops: Vec<Loop2> = Vec::new();
|
||||
for start in 0..rest.len() {
|
||||
if used.get(start).copied().unwrap_or(true) {
|
||||
continue;
|
||||
}
|
||||
let mut lp = Vec::new();
|
||||
let mut cur = start;
|
||||
for _ in 0..=rest.len() {
|
||||
if let Some(u) = used.get_mut(cur) {
|
||||
*u = true;
|
||||
}
|
||||
let Some(seg) = rest.get(cur).copied() else { break };
|
||||
lp.push(seg);
|
||||
let end = seg.end();
|
||||
if let Some(first) = rest.get(start)
|
||||
&& end.dist(first.start()) <= tol
|
||||
&& lp.len() > 1
|
||||
{
|
||||
break;
|
||||
}
|
||||
let back = (-seg.end_tangent()).angle();
|
||||
let next = (0..rest.len())
|
||||
.filter(|k| !used.get(*k).copied().unwrap_or(true) && rest.get(*k).is_some_and(|s| s.start().dist(end) <= tol))
|
||||
.min_by(|x, y| {
|
||||
let d = |k: usize| rest.get(k).map(|s| (back - s.start_tangent().angle()).rem_euclid(TAU)).unwrap_or(TAU);
|
||||
d(*x).total_cmp(&d(*y))
|
||||
});
|
||||
match next {
|
||||
Some(k) => cur = k,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if !lp.is_empty() && lp.first().zip(lp.last()).is_some_and(|(f, l)| l.end().dist(f.start()) <= tol) {
|
||||
loops.push(Loop2 { segs: lp });
|
||||
}
|
||||
}
|
||||
let outers: Vec<&Loop2> = loops.iter().filter(|l| l.signed_area() > 0.0).collect();
|
||||
let holes: Vec<&Loop2> = loops.iter().filter(|l| l.signed_area() < 0.0).collect();
|
||||
let mut out: Vec<Region2> = outers.iter().map(|o| Region2 { outer: (*o).clone(), holes: Vec::new() }).collect();
|
||||
for h in holes {
|
||||
let probe = h.segs.first().map(Seg2::mid).unwrap_or_default();
|
||||
// Innermost containing outer loop.
|
||||
let best = out.iter_mut().filter(|r| r.outer.contains(probe)).min_by(|a, b| a.outer.signed_area().total_cmp(&b.outer.signed_area()));
|
||||
if let Some(r) = best {
|
||||
r.holes.push(h.clone());
|
||||
}
|
||||
}
|
||||
if out.is_empty() { regions.to_vec() } else { out }
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use solvecraft_geom::Vec2;
|
||||
use crate::linalg::{Mat, determined_vars, solve as lin_solve};
|
||||
use crate::model::{ConstraintKind, CurveKind, Sketch};
|
||||
|
||||
const MAX_ITERS: usize = 200;
|
||||
const MAX_ITERS: usize = 500;
|
||||
/// Converged when every residual is below this (mm).
|
||||
const TOL: f64 = 1e-9;
|
||||
/// A solution is accepted when every residual is below this (mm).
|
||||
@@ -254,21 +254,24 @@ fn residuals(s: &State, k: &ConstraintKind, out: &mut Vec<f64>) {
|
||||
}
|
||||
Radius { c, value } => out.push(s.round(c).1 - value),
|
||||
Diameter { c, value } => out.push(2.0 * s.round(c).1 - value),
|
||||
Angle { a, b, value } => {
|
||||
Angle { a, b, value, flip } => {
|
||||
let (a0, a1) = s.line(a);
|
||||
let (b0, b1) = s.line(b);
|
||||
let (da, db) = (a1 - a0, b1 - b0);
|
||||
let ang = da.cross(db).atan2(da.dot(db));
|
||||
let scale = 0.5 * (da.len() + db.len());
|
||||
out.push(wrap_angle(ang - value) * scale.max(1e-6));
|
||||
let target = if flip { value + std::f64::consts::PI } else { value };
|
||||
out.push(wrap_angle(ang - target) * scale.max(1e-6));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One residual block: implicit arc radius equality or a user constraint.
|
||||
/// One residual block: implicit arc radius equality, a user constraint, or a tangency at a known
|
||||
/// touch point (better conditioned than the distance form when an end point is on the curve).
|
||||
enum Block<'a> {
|
||||
Arc { c: usize, a: usize, b: usize },
|
||||
User(usize, &'a ConstraintKind),
|
||||
TangentAt(usize, &'a ConstraintKind, usize),
|
||||
}
|
||||
|
||||
fn block_residuals(s: &State, b: &Block, out: &mut Vec<f64>) {
|
||||
@@ -278,9 +281,45 @@ fn block_residuals(s: &State, b: &Block, out: &mut Vec<f64>) {
|
||||
out.push(cc.dist(s.p(*b)) - cc.dist(s.p(*a)));
|
||||
}
|
||||
Block::User(_, k) => residuals(s, k, out),
|
||||
Block::TangentAt(_, k, p) => {
|
||||
let ConstraintKind::Tangent { a, b } = **k else { return };
|
||||
let pt = s.p(*p);
|
||||
if s.is_line(a) || s.is_line(b) {
|
||||
let (l, c) = if s.is_line(a) { (a, b) } else { (b, a) };
|
||||
let (p0, p1) = s.line(l);
|
||||
let (cc, _) = s.round(c);
|
||||
let d = p1 - p0;
|
||||
// The radius to the touch point is perpendicular to the line.
|
||||
out.push((pt - cc).dot(d) / d.len().max(1e-12));
|
||||
} else {
|
||||
let (c1, _) = s.round(a);
|
||||
let (c2, _) = s.round(b);
|
||||
let (u, v) = (pt - c1, pt - c2);
|
||||
// Both centres on one line through the touch point.
|
||||
out.push(u.cross(v) / (u.len() + v.len()).max(1e-12));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A point where a tangency must happen: an end point shared by the two curves, or a line end
|
||||
/// point constrained onto the round curve.
|
||||
fn touch_point(sk: &Sketch, k: &ConstraintKind) -> Option<usize> {
|
||||
let ConstraintKind::Tangent { a, b } = *k else { return None };
|
||||
let ends = |c: usize| -> Vec<usize> {
|
||||
match sk.curves.get(c).map(|c| &c.kind) {
|
||||
Some(CurveKind::Line { a, b }) | Some(CurveKind::Arc { a, b, .. }) => vec![*a, *b],
|
||||
_ => Vec::new(),
|
||||
}
|
||||
};
|
||||
let (ea, eb) = (ends(a), ends(b));
|
||||
if let Some(p) = ea.iter().find(|p| eb.contains(p)) {
|
||||
return Some(*p);
|
||||
}
|
||||
let on = |p: usize, c: usize| sk.constraints.iter().any(|x| x.kind == ConstraintKind::PointOnCurve { p, c });
|
||||
ea.iter().find(|p| on(**p, b)).or_else(|| eb.iter().find(|p| on(**p, a))).copied()
|
||||
}
|
||||
|
||||
/// Variables a block depends on.
|
||||
fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec<usize> {
|
||||
let mut v = Vec::new();
|
||||
@@ -345,6 +384,13 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec<usize> {
|
||||
}
|
||||
Fix { .. } => {}
|
||||
},
|
||||
Block::TangentAt(_, k, p) => {
|
||||
pt(&mut v, *p);
|
||||
if let ConstraintKind::Tangent { a, b } = **k {
|
||||
curve(&mut v, a);
|
||||
curve(&mut v, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
v.sort_unstable();
|
||||
v.dedup();
|
||||
@@ -517,7 +563,10 @@ pub fn solve(sk: &mut Sketch) -> SolveReport {
|
||||
}
|
||||
}
|
||||
for (i, c) in sk.constraints.iter().enumerate() {
|
||||
let bl = Block::User(i, &c.kind);
|
||||
let bl = match touch_point(sk, &c.kind) {
|
||||
Some(p) => Block::TangentAt(i, &c.kind, p),
|
||||
None => Block::User(i, &c.kind),
|
||||
};
|
||||
let vars = block_vars(sk, &lay, &bl);
|
||||
blocks.push((bl, vars));
|
||||
}
|
||||
@@ -569,6 +618,7 @@ pub fn solve(sk: &mut Sketch) -> SolveReport {
|
||||
let b2 = match b {
|
||||
Block::Arc { c, a, b } => Block::Arc { c: *c, a: *a, b: *b },
|
||||
Block::User(i, k) => Block::User(*i, k),
|
||||
Block::TangentAt(i, k, p) => Block::TangentAt(*i, k, *p),
|
||||
};
|
||||
(b2, v.clone())
|
||||
})
|
||||
@@ -583,7 +633,7 @@ pub fn solve(sk: &mut Sketch) -> SolveReport {
|
||||
let mut failing: Vec<String> = Vec::new();
|
||||
for (row, v) in r.iter().enumerate() {
|
||||
if !(v.abs() < ACCEPT)
|
||||
&& let Some((Block::User(ci, _), _)) = owner.get(row).and_then(|bi| all_blocks.get(*bi))
|
||||
&& let Some((Block::User(ci, _) | Block::TangentAt(ci, _, _), _)) = owner.get(row).and_then(|bi| all_blocks.get(*bi))
|
||||
&& let Some(c) = sk.constraints.get(*ci)
|
||||
&& !failing.contains(&c.id)
|
||||
{
|
||||
|
||||
@@ -121,7 +121,7 @@ fn line_tangent_to_circle_and_angles() {
|
||||
let (a, _) = line_pts(&sk, l);
|
||||
assert!((a.y.abs() - 10.0).abs() < 1e-7, "{a:?}");
|
||||
let m = sk.add_line(v(0.0, 0.0), v(10.0, 3.0), Some(0), None, None).unwrap();
|
||||
sk.add_constraint(Angle { a: l, b: m, value: PI / 6.0 }, None).unwrap();
|
||||
sk.add_constraint(Angle { a: l, b: m, value: PI / 6.0, flip: false }, None).unwrap();
|
||||
sk.add_constraint(Length { l: m, value: 8.0 }, None).unwrap();
|
||||
assert!(solve(&mut sk).ok());
|
||||
let (p, q) = line_pts(&sk, m);
|
||||
@@ -250,3 +250,54 @@ fn hostile_values_rejected() {
|
||||
assert!(sk.add_constraint(ConstraintKind::Radius { c, value: f64::NAN }, None).is_err());
|
||||
assert!(sk.add_constraint(ConstraintKind::Radius { c, value: -2.0 }, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_split_at_crossings_and_touch_points() {
|
||||
// A line across a circle: two half discs.
|
||||
let mut sk = Sketch::new();
|
||||
sk.add_circle(v(0.0, 0.0), 10.0, None, None).unwrap();
|
||||
sk.add_line(v(-15.0, 0.0), v(15.0, 0.0), None, None, None).unwrap();
|
||||
let ps = find_profiles(&sk);
|
||||
assert_eq!(ps.len(), 2, "{ps:?}");
|
||||
for p in &ps {
|
||||
assert!((p.area - PI * 50.0).abs() < 1e-6, "{}", p.area);
|
||||
}
|
||||
// Belt: two circles joined by two tangent lines whose ends sit on the circles.
|
||||
let mut b = Sketch::new();
|
||||
let c1 = b.add_circle(v(0.0, 0.0), 20.0, Some(0), None).unwrap();
|
||||
let c2 = b.add_circle(v(50.0, 0.0), 10.0, None, None).unwrap();
|
||||
let l1 = b.add_line(v(3.0, 19.0), v(47.0, 13.0), None, None, None).unwrap();
|
||||
let l2 = b.add_line(v(1.0, -22.0), v(44.0, -8.0), None, None, None).unwrap();
|
||||
use ConstraintKind::*;
|
||||
for (l, end, c) in [(l1, "start", c1), (l1, "end", c2), (l2, "start", c1), (l2, "end", c2)] {
|
||||
let p = b.resolve_point(&format!("{}.{end}", b.curves[l].id)).unwrap();
|
||||
b.add_constraint(PointOnCurve { p, c }, None).unwrap();
|
||||
}
|
||||
for (l, c) in [(l1, c1), (l1, c2), (l2, c1), (l2, c2)] {
|
||||
b.add_constraint(Tangent { a: l, b: c }, None).unwrap();
|
||||
}
|
||||
b.add_constraint(Radius { c: c1, value: 20.0 }, None).unwrap();
|
||||
b.add_constraint(Radius { c: c2, value: 10.0 }, None).unwrap();
|
||||
let r = solve(&mut b);
|
||||
assert!(r.ok(), "{r:?}");
|
||||
let ps = find_profiles(&b);
|
||||
assert_eq!(ps.len(), 3, "two discs and the band between them: {:?}", ps.iter().map(|p| p.area).collect::<Vec<_>>());
|
||||
let total: f64 = ps.iter().map(|p| p.area).sum();
|
||||
assert!(total > PI * 500.0, "{total}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merging_adjacent_regions() {
|
||||
let a =
|
||||
solvecraft_geom::Region2 { outer: solvecraft_geom::Loop2::polygon(&[v(0.0, 0.0), v(10.0, 0.0), v(10.0, 10.0), v(0.0, 10.0)]), holes: vec![] };
|
||||
let b = solvecraft_geom::Region2 {
|
||||
outer: solvecraft_geom::Loop2::polygon(&[v(10.0, 0.0), v(20.0, 0.0), v(20.0, 10.0), v(10.0, 10.0)]),
|
||||
holes: vec![],
|
||||
};
|
||||
let c = solvecraft_geom::Region2 { outer: solvecraft_geom::Loop2::polygon(&[v(50.0, 0.0), v(60.0, 0.0), v(60.0, 10.0)]), holes: vec![] };
|
||||
let m = merge_regions(&[a, b, c]);
|
||||
assert_eq!(m.len(), 2);
|
||||
let mut areas: Vec<f64> = m.iter().map(|r| r.area()).collect();
|
||||
areas.sort_by(f64::total_cmp);
|
||||
assert!((areas[0] - 50.0).abs() < 1e-9 && (areas[1] - 200.0).abs() < 1e-9, "{areas:?}");
|
||||
}
|
||||
+16
-16
@@ -2,36 +2,36 @@
|
||||
|
||||
Generated by `cargo xtask oracle`. Each case is a part built in Fusion (recipe and measurements kept locally in `plan/fusion/oracle/`, not committed). SolveCraft replays the recipe through its command engine and compares body count, volume and area (relative tolerance 1e-3) and face/edge/vertex counts (exact).
|
||||
|
||||
**2 / 29 cases pass** (16 need features not implemented yet).
|
||||
**12 / 29 cases pass** (10 need features not implemented yet).
|
||||
|
||||
| Case | Result | Notes |
|
||||
|---|---|---|
|
||||
| 01-box | pass | |
|
||||
| 02-cylinder | pass | |
|
||||
| 03-box-through-hole | FAIL | step 11 (Extrude): invalid parameters for `Extrude`: `distance` must be a number or an expression |
|
||||
| 04-revolve-vase | not yet | recipe: sketch entity type `arc_three_point` is not supported yet |
|
||||
| 05-taper-extrude | FAIL | volume_mm3: got 48000.0 want 61814.82596; area_mm2: got 8000.0 want 9676.91517 |
|
||||
| 06-symmetric-extrude | FAIL | volume_mm3: got 15000.0 want 30000.0; area_mm2: got 4100.0 want 6200.0 |
|
||||
| 03-box-through-hole | pass | |
|
||||
| 04-revolve-vase | pass | |
|
||||
| 05-taper-extrude | pass | |
|
||||
| 06-symmetric-extrude | pass | |
|
||||
| 07-fillet-all-edges | FAIL | step 8 (FusionFilletEdgesCommand): not supported yet: the faces at the edge ends must be planar |
|
||||
| 08-chamfer | FAIL | step 8 (FusionChamferCommand): not supported yet: the faces at the edge ends must be perpendicular to the edge |
|
||||
| 09-shell | not yet | recipe: feature op `shell` is not supported yet |
|
||||
| 10-rect-pattern-holes | not yet | recipe: feature op `rectangular_pattern` is not supported yet |
|
||||
| 11-circular-pattern-flange | not yet | recipe: feature op `circular_pattern` is not supported yet |
|
||||
| 12-mirror-feature | not yet | recipe: feature op `mirror` is not supported yet |
|
||||
| 13-combine-join | not yet | recipe: feature op `combine` is not supported yet |
|
||||
| 14-combine-cut | not yet | recipe: sketch entity type `arc_three_point` is not supported yet |
|
||||
| 15-combine-intersect | not yet | recipe: sketch entity type `arc_three_point` is not supported yet |
|
||||
| 16-sweep | not yet | recipe: sketch entity type `arc_three_point` is not supported yet |
|
||||
| 13-combine-join | FAIL | step 12 (FusionCombineCommand): boolean Union: kernel internal error: boolean: This shell is not oriented and closed. |
|
||||
| 14-combine-cut | FAIL | step 13 (FusionCombineCommand): boolean Cut: the operation failed: no result |
|
||||
| 15-combine-intersect | FAIL | step 13 (FusionCombineCommand): boolean Intersect: the operation failed: no result |
|
||||
| 16-sweep | not yet | recipe: feature op `sweep` is not supported yet |
|
||||
| 17-loft | not yet | recipe: feature op `construction_plane` is not supported yet |
|
||||
| 18-hole-features | not yet | recipe: feature op `hole` is not supported yet |
|
||||
| 19-l-bracket-constrained | FAIL | step 8 (ConstraintHorizontalVertical): invalid parameters for `ConstraintHorizontalVertical`: `line` must be a curve id |
|
||||
| 20-slot-constrained | not yet | recipe: sketch entity type `arc_center_start_sweep` is not supported yet |
|
||||
| 19-l-bracket-constrained | pass | |
|
||||
| 20-slot-constrained | pass | |
|
||||
| 21-draft | not yet | recipe: feature op `draft` is not supported yet |
|
||||
| 22-split-body | not yet | recipe: feature op `construction_plane` is not supported yet |
|
||||
| 23-bracket-multifeature | not yet | recipe: feature op `construction_plane` is not supported yet |
|
||||
| 24-user-parameters | FAIL | step 11 (ConstraintCoincident): invalid parameters for `ConstraintCoincident`: `a` must be a point reference |
|
||||
| 25-sketch-triangle | FAIL | step 5 (ConstraintCoincident): invalid parameters for `ConstraintCoincident`: `a` must be a point reference |
|
||||
| 26-sketch-belt | not yet | recipe: constraint `horizontal_points` is not supported yet |
|
||||
| 27-sketch-trapezoid | FAIL | step 9 (ConstraintHorizontalVertical): invalid parameters for `ConstraintHorizontalVertical`: `line` must be a curve id |
|
||||
| 24-user-parameters | pass | |
|
||||
| 25-sketch-triangle | pass | |
|
||||
| 26-sketch-belt | pass | |
|
||||
| 27-sketch-trapezoid | pass | |
|
||||
| 28-blind-pocket-fillets | FAIL | step 15 (FusionFilletEdgesCommand): not supported yet: the blend is larger than the neighbouring faces |
|
||||
| 29-two-sided-and-partial-revolve | FAIL | step 7 (Extrude): invalid parameters for `Extrude`: `distance` must be a number or an expression |
|
||||
| 29-two-sided-and-partial-revolve | FAIL | step 14 (Revolve): boolean Cut: the operation failed: no result |
|
||||
Reference in new issue
Block a user