diff --git a/apps/solvecraft-cli/src/main.rs b/apps/solvecraft-cli/src/main.rs index 86fabbaf..a8ec7f56 100644 --- a/apps/solvecraft-cli/src/main.rs +++ b/apps/solvecraft-cli/src/main.rs @@ -22,7 +22,7 @@ use solvecraft_engine::Session; use solvecraft_engine::render::{StandardView, render_png}; const USAGE: &str = "usage: - solvecraft-cli run [--out FILE]... [--save FILE] [--quiet] + solvecraft-cli run [--out FILE]... [--save FILE] [--quiet] [--results] solvecraft-cli eval (alias: inspect) solvecraft-cli snapshot --out shot.png [--width W] [--height H] [--view iso|front|back|top|bottom|left|right] solvecraft-cli exec [json-params] @@ -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())?; diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index 3c81924f..745c9b3f 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -51,6 +51,8 @@ fn sketch_cmds(f: &Value, out: &mut Vec) -> 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) -> 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 { + 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 { + 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 { 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}})) } diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index b97335da..cb43f3b9 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -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, + /// 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, } /// 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), diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 0b27070b..00ecb259 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -328,6 +328,31 @@ fn apply_op(state: &mut ModelState, f: &Feature, tools: Vec, 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 { + 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 { 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, 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::, _>>()?; + 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, 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)?; diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 91725947..f557e152 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -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![], }, diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index cb5c496d..fddddc01 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -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 { fn extrude(s: &mut Session, p: &Value) -> Result { let cmd = "Extrude"; let sketch = feature_sketch(s, p, cmd)?; - let distance = req_expr(cmd, p, "distance")?; + 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 { 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 { fn revolve(s: &mut Session, p: &Value) -> Result { let cmd = "Revolve"; let sketch = feature_sketch(s, p, cmd)?; - let axis = match str_(p, "axis") { - Some(a @ ("X" | "Y" | "Z")) => AxisRef::World { axis: a.into() }, - Some(a @ ("x" | "y")) => AxisRef::SketchAxis { axis: a.into() }, - Some(id) => AxisRef::SketchLine { curve: id.into() }, - None => return Err(bad(cmd, "`axis` must be a sketch line id, x, y (sketch axes) or X, Y, Z")), + let 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")?; diff --git a/crates/engine/src/cmd/sketch.rs b/crates/engine/src/cmd/sketch.rs index a903dab7..b494447a 100644 --- a/crates/engine/src/cmd/sketch.rs +++ b/crates/engine/src/cmd/sketch.rs @@ -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 { // 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 { fn arc_center(s: &mut Session, p: &Value) -> Result { 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 `.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 { } fn c_symmetry(s: &mut Session, p: &Value) -> Result { let cmd = "ConstraintSymmetry"; - constrain(s, p, cmd, |sk| { - Ok(ConstraintKind::Symmetric { - p: point_ref(sk, p.get("a"), cmd, "a")?, - q: point_ref(sk, p.get("b"), cmd, "b")?, - l: curve_ref(sk, p.get("line"), cmd, "line")?, - }) - }) + 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 { + 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 { @@ -877,7 +938,14 @@ fn dimension(s: &mut Session, p: &Value) -> Result { 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, Option)> { if let Some(c) = sk.curve_index(r) { @@ -937,13 +1005,18 @@ fn dimension(s: &mut Session, p: &Value) -> Result { 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 { 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 } diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index 7a845b95..3eed4222 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -202,3 +202,97 @@ pub fn torus(center: Vec3, major: f64, minor: f64) -> Result { 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 { + 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::>>()?; + 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 { + 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 = std::iter::once(region.outer.ccw()).chain(region.holes.iter().map(|h| h.ccw().reversed())).collect(); + let loops1: Vec = loops0.iter().map(|l| offset_loop(l, d)).collect::>()?; + guard("tapered extrude", || { + let w0: Vec = loops0.iter().map(|l| wire(plane, l)).collect::>()?; + let w1: Vec = loops1.iter().map(|l| wire(&top_plane, l)).collect::>()?; + let mut faces: Vec = 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::>()) + .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}"))) + }) +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 17219801..7ff8a02c 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -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; diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 9f98e618..01d8a327 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -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"); +} diff --git a/crates/kernel/src/topo.rs b/crates/kernel/src/topo.rs index a0c58cb5..473db891 100644 --- a/crates/kernel/src/topo.rs +++ b/crates/kernel/src/topo.rs @@ -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> { + let mut out: Vec> = 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 { + 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>, 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 { 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> = HashMap::new(); let mut order: Vec = Vec::new(); for (fi, f) in solid.face_iter().enumerate() { @@ -160,11 +217,11 @@ pub fn seam_flags(b: &Body, mesh: &Mesh) -> Vec { } 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 { let solid = &*b.solid; let nf = solid.face_iter().count(); let tol = (b.size() * 1e-4).max(1e-6); - let mut pts: Vec> = vec![Vec::new(); nf]; - let mut nrm: Vec> = 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 = (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 = 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> = HashMap::new(); @@ -237,10 +290,9 @@ pub fn merged_topology(b: &Body, mesh: &Mesh) -> Result { } // Merge faces. let mut fp: Vec = (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 { let mut groups: BTreeMap = BTreeMap::new(); let mut face_types: BTreeMap = 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 { 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 = faces.iter().map(|f| find(&mut fp, *f)).collect(); let pair = match gs[..] { [a, b] if a == b => continue, diff --git a/crates/sketch/src/lib.rs b/crates/sketch/src/lib.rs index 70ac6c5b..14fc970b 100644 --- a/crates/sketch/src/lib.rs +++ b/crates/sketch/src/lib.rs @@ -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)] diff --git a/crates/sketch/src/model.rs b/crates/sketch/src/model.rs index 7bcea255..5163e88b 100644 --- a/crates/sketch/src/model.rs +++ b/crates/sketch/src/model.rs @@ -49,6 +49,10 @@ pub struct Curve { pub kind: CurveKind, #[serde(default)] pub construction: bool, + /// An arc drawn clockwise: stored counter-clockwise, but `.start` / `.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), diff --git a/crates/sketch/src/profiles.rs b/crates/sketch/src/profiles.rs index 20d32cff..6535584e 100644 --- a/crates/sketch/src/profiles.rs +++ b/crates/sketch/src/profiles.rs @@ -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 { + 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 { - // Node ids: point indices merged by position. - let n = sk.points.len(); - let mut parent: Vec = (0..n).collect(); - let mut order: Vec = (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 { + 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, usize)> = Vec::new(); // (ccw loop, curve ids, component) - let mut edges: Vec = Vec::new(); +/// Intersection points of two shapes (as circles/infinite lines; callers filter by range). +fn intersections(x: &Shape, y: &Shape) -> Vec { + 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 { + 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 = 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![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 = 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, usize)> = Vec::new(); // (ccw loop, curve ids, component) + let mut edges: Vec = 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 = + 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 { 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 { 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 { + let tol = MERGE_TOL * 10.0; + // Every boundary segment oriented with the region on its left. + let mut segs: Vec = 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 = 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 = 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 = 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 } +} diff --git a/crates/sketch/src/solver.rs b/crates/sketch/src/solver.rs index 33a52f78..4f3139a4 100644 --- a/crates/sketch/src/solver.rs +++ b/crates/sketch/src/solver.rs @@ -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) { } 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) { @@ -278,9 +281,45 @@ fn block_residuals(s: &State, b: &Block, out: &mut Vec) { 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 { + let ConstraintKind::Tangent { a, b } = *k else { return None }; + let ends = |c: usize| -> Vec { + 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 { let mut v = Vec::new(); @@ -345,6 +384,13 @@ fn block_vars(sk: &Sketch, lay: &Layout, b: &Block) -> Vec { } 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 = 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) { diff --git a/crates/sketch/src/tests.rs b/crates/sketch/src/tests.rs index 947c4b67..260d182f 100644 --- a/crates/sketch/src/tests.rs +++ b/crates/sketch/src/tests.rs @@ -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::>()); + 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 = 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:?}"); +} diff --git a/docs/oracle.md b/docs/oracle.md index 0d9395db..1afcec2b 100644 --- a/docs/oracle.md +++ b/docs/oracle.md @@ -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 |