diff --git a/.gitignore b/.gitignore index e30dd0a8..6ddd1c8b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ .DS_Store *.swp /.claude/worktrees/ +/target-snapshot diff --git a/apps/solvecraft-cli/src/recipe.rs b/apps/solvecraft-cli/src/recipe.rs index 802ea87e..ec4cc1fa 100644 --- a/apps/solvecraft-cli/src/recipe.rs +++ b/apps/solvecraft-cli/src/recipe.rs @@ -224,6 +224,22 @@ pub fn to_script(recipe: &Value) -> Result { let axis = f.get("axis").map(|a| json!({"origin": a.get("origin").cloned().unwrap_or(json!([0, 0, 0])), "dir": a.get("dir")})).unwrap_or(Value::Null); out.push(json!({"command": "PatternCircular", "params": {"features": f.get("features"), "axis": axis, "count": f.get("count"), "angle": f.get("total_angle").cloned().unwrap_or(json!(360)), "name": name}})); } + Some("loft") => { + let sections: Vec = f + .get("sections") + .and_then(Value::as_array) + .into_iter() + .flatten() + .map(|sec| json!({"sketch": sec.get("sketch"), "profiles": [sec]})) + .collect(); + out.push(json!({"command": "SolidLoft", "params": {"sections": sections, "operation": op(f), "name": name, "body_names": bodies}})); + } + Some("sweep") => { + let path = f.get("path").cloned().unwrap_or(Value::Null); + out.push(json!({"command": "Sweep", "params": { + "sketch": profile_sketch(f), "profiles": profiles_of(f), "path_sketch": path.get("sketch"), "path": path.get("curves"), + "operation": op(f), "name": name, "body_names": bodies}})); + } Some("shell") => { let faces: Vec = f.get("faces_removed").and_then(Value::as_array).into_iter().flatten().filter_map(|x| x.get("point").cloned()).collect(); out.push(json!({"command": "FusionShellBodyCommand", "params": {"faces": faces, "thickness": f.get("inside_thickness"), "name": name}})); diff --git a/crates/doc/src/document.rs b/crates/doc/src/document.rs index a24dfc23..74e3f095 100644 --- a/crates/doc/src/document.rs +++ b/crates/doc/src/document.rs @@ -228,6 +228,25 @@ pub enum FeatureKind { #[serde(default)] hole: HoleKind, }, + /// Ruled loft through profiles of several sketches, in order. + Loft { + sections: Vec, + #[serde(default)] + operation: Operation, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + targets: Vec, + }, + /// Sweep profiles along a chain of sketch curves. + Sweep { + sketch: u64, + profiles: ProfileSel, + path_sketch: u64, + path: Vec, + #[serde(default)] + operation: Operation, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + targets: Vec, + }, /// Hollow a body leaving walls of `thickness`, removing the faces at the given points. Shell { faces: Vec, @@ -263,6 +282,13 @@ pub enum FeatureKind { }, } +/// One loft section: profiles of a sketch. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct LoftSection { + pub sketch: u64, + pub profiles: ProfileSel, +} + /// Hole shapes. #[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] @@ -347,6 +373,8 @@ impl FeatureKind { FeatureKind::Mirror { .. } => "MirrorFeature", FeatureKind::ConstructionPlane { .. } => "ConstructionPlane", FeatureKind::Hole { .. } => "HoleFeature", + FeatureKind::Loft { .. } => "LoftFeature", + FeatureKind::Sweep { .. } => "SweepFeature", FeatureKind::Shell { .. } => "ShellFeature", FeatureKind::Draft { .. } => "DraftFeature", FeatureKind::Split { .. } => "SplitBodyFeature", @@ -371,6 +399,8 @@ impl FeatureKind { FeatureKind::Mirror { .. } => "Mirror", FeatureKind::ConstructionPlane { .. } => "Plane", FeatureKind::Hole { .. } => "Hole", + FeatureKind::Loft { .. } => "Loft", + FeatureKind::Sweep { .. } => "Sweep", FeatureKind::Shell { .. } => "Shell", FeatureKind::Draft { .. } => "Draft", FeatureKind::Split { .. } => "Split", @@ -414,6 +444,7 @@ impl FeatureKind { }, FeatureKind::Mirror { plane, .. } => plane_exprs(plane, &mut v), FeatureKind::Shell { thickness, .. } => v.push(thickness), + FeatureKind::Loft { .. } | FeatureKind::Sweep { .. } => {} FeatureKind::Draft { angle, neutral, .. } => { v.push(angle); plane_exprs(neutral, &mut v); @@ -565,6 +596,8 @@ impl Document { for f in &self.features { match &f.kind { FeatureKind::Extrude { sketch, .. } | FeatureKind::Revolve { sketch, .. } if *sketch == id => gone.push(f.id), + FeatureKind::Sweep { sketch, path_sketch, .. } if *sketch == id || *path_sketch == id => gone.push(f.id), + FeatureKind::Loft { sections, .. } if sections.iter().any(|s| s.sketch == id) => gone.push(f.id), _ => {} } } diff --git a/crates/doc/src/eval.rs b/crates/doc/src/eval.rs index 81c7f249..edfe6b55 100644 --- a/crates/doc/src/eval.rs +++ b/crates/doc/src/eval.rs @@ -613,6 +613,24 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - let regions: Vec = regions.iter().map(|r| grow_profile_for_coplanar(st, r, operation, &targets, &probe)).collect(); Ok(kernel::revolve(&ss.plane, ®ions, o, d, ang)?) } + FeatureKind::Loft { sections, .. } => { + let mut secs = Vec::new(); + for sec in sections { + let ss = st.sketch(sec.sketch).ok_or_else(|| DocError::Unknown(format!("sketch {} (it must come earlier in the timeline)", sec.sketch)))?; + let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, &sec.profiles)?); + let [r] = ®ions[..] else { return Err(DocError::Invalid("each loft section must be one profile".into())) }; + secs.push((ss.plane, r.outer.clone())); + } + Ok(vec![kernel::loft(&secs)?]) + } + FeatureKind::Sweep { sketch, profiles, path_sketch, path, .. } => { + let ss = st.sketch(*sketch).ok_or_else(|| DocError::Unknown(format!("sketch {sketch} (it must come earlier in the timeline)")))?; + let ps = st.sketch(*path_sketch).ok_or_else(|| DocError::Unknown(format!("sketch {path_sketch} (it must come earlier in the timeline)")))?; + let regions = solvecraft_sketch::merge_regions(&select_profiles(ss, profiles)?); + let start = regions.first().map(|r| ss.plane.to_world(r.centroid())).unwrap_or_default(); + let segs = path_segments(ps, path, start)?; + regions.iter().map(|r| kernel::sweep(&ss.plane, r, &segs).map_err(DocError::from)).collect() + } FeatureKind::Box { corner, length, width, height, .. } => { let s = Vec3::new(val(vals, length, Kind::Length)?, val(vals, width, Kind::Length)?, val(vals, height, Kind::Length)?); Ok(vec![kernel::box_solid(*corner, *corner + s)?]) @@ -698,6 +716,44 @@ fn feature_tools(vals: &BTreeMap, f: &Feature, st: &ModelState) - } } +/// A chain of sketch curves as 3D path segments, starting at the end nearest `start`. +fn path_segments(ps: &SolvedSketch, ids: &[String], start: Vec3) -> Result> { + if ids.is_empty() || ids.len() > 1000 { + return Err(DocError::Invalid("the path needs 1…1000 curves".into())); + } + let mut segs: Vec = Vec::new(); + for id in ids { + let ci = ps.sketch.curve_index(id).ok_or_else(|| DocError::Unknown(format!("path curve `{id}`")))?; + segs.extend(ps.sketch.segs(ci)); + } + // Orient the chain: the first segment starts at the end nearest `start`, each next one + // starts where the previous ended. + let w = |p: Vec2| ps.plane.to_world(p); + if let (Some(f), true) = (segs.first().copied(), segs.len() > 0) + && w(f.end()).dist(start) < w(f.start()).dist(start) + && (segs.len() == 1 || segs.get(1).is_some_and(|n| n.start().dist(f.start()) < 1e-6 || n.end().dist(f.start()) < 1e-6)) + { + segs[0] = f.reversed(); + } + for i in 1..segs.len() { + let prev_end = segs[i - 1].end(); + if segs[i].end().dist(prev_end) < segs[i].start().dist(prev_end) { + segs[i] = segs[i].reversed(); + } + if segs[i].start().dist(prev_end) > 1e-5 { + return Err(DocError::Invalid("the path curves are not connected end to end".into())); + } + } + let n = ps.plane.normal(); + Ok(segs + .iter() + .map(|s| match *s { + solvecraft_geom::Seg2::Line { a, b } => kernel::PathSeg::Line { a: w(a), b: w(b) }, + solvecraft_geom::Seg2::Arc { center, sweep, .. } => kernel::PathSeg::Arc { a: w(s.start()), center: w(center), axis: n * sweep.signum(), angle: sweep.abs() }, + }) + .collect()) +} + /// Operation and targets of a feature that makes tools. fn feature_op(f: &Feature) -> (Operation, Vec) { match &f.kind { @@ -707,6 +763,7 @@ fn feature_op(f: &Feature) -> (Operation, Vec) { | FeatureKind::Sphere { operation, .. } | FeatureKind::Torus { operation, .. } => (*operation, Vec::new()), FeatureKind::Hole { .. } => (Operation::Cut, Vec::new()), + FeatureKind::Loft { operation, targets, .. } | FeatureKind::Sweep { operation, targets, .. } => (*operation, targets.clone()), _ => (Operation::NewBody, Vec::new()), } } @@ -842,7 +899,10 @@ fn eval_feature(doc: &Document, vals: &BTreeMap, f: &Feature, st: st.sketches.push(SolvedSketch { feature: f.id, name: f.name.clone(), plane, sketch: sk, report, profiles }); Ok(()) } - FeatureKind::Extrude { operation, targets, .. } | FeatureKind::Revolve { operation, targets, .. } => { + FeatureKind::Extrude { operation, targets, .. } + | FeatureKind::Revolve { operation, targets, .. } + | FeatureKind::Loft { operation, targets, .. } + | FeatureKind::Sweep { operation, targets, .. } => { let tools = feature_tools(vals, f, st)?; apply_op(st, f, tools, *operation, targets) } diff --git a/crates/engine/src/cmd/features.rs b/crates/engine/src/cmd/features.rs index 3b2c071a..fe586c2b 100644 --- a/crates/engine/src/cmd/features.rs +++ b/crates/engine/src/cmd/features.rs @@ -18,6 +18,11 @@ pub static COMMANDS: &[CommandSpec] = &[ .at("SOLID", "CREATE") .icon("revolve") .params("axis: sketch line id | x | y (sketch axes) | X|Y|Z (world); angle?: expr (default 360 deg); sketch?, profiles?, operation?, targets?, name?, body_name?"), + CommandSpec::new("Sweep", "Sweep", sweep) + .at("SOLID", "CREATE") + .icon("sweep") + .params("sketch: profile sketch; profiles?; path_sketch: sketch; path: [curve ids in order]; operation?"), + CommandSpec::new("SolidLoft", "Loft", loft).at("SOLID", "CREATE").icon("loft").params("sections: [{sketch, profiles?}] (2 or more, in order); operation?"), CommandSpec::new("PrimitiveBox", "Box", prim_box).at("SOLID", "CREATE").icon("box").params("length, width, height: expr; corner?: [x,y,z] | center?: [x,y,z]; operation?"), CommandSpec::new("PrimitiveCylinder", "Cylinder", prim_cylinder).at("SOLID", "CREATE").icon("cylinder").params("radius | diameter, height: expr; base?: [x,y,z]; axis?: [x,y,z]; operation?"), CommandSpec::new("PrimitiveSphere", "Sphere", prim_sphere).at("SOLID", "CREATE").icon("sphere").params("radius | diameter: expr; center?: [x,y,z]; operation?"), @@ -571,3 +576,40 @@ fn draft(s: &mut Session, p: &Value) -> Result { let pull = p.get("pull").and_then(vec3).unwrap_or_else(|| pl.normal()); add_feature(s, p, FeatureKind::Draft { faces, angle, neutral, pull, body: str_(p, "body").map(str::to_string) }) } + +fn sketch_id(s: &Session, v: Option<&Value>, cmd: &str, what: &str) -> Result { + let key = match v { + Some(Value::Number(n)) => n.to_string(), + Some(Value::String(x)) => x.clone(), + _ => return Err(bad(cmd, format!("`{what}` must be a sketch id or name"))), + }; + match s.doc.find_feature(&key) { + Some(f) if matches!(f.kind, FeatureKind::Sketch { .. }) => Ok(f.id), + _ => Err(bad(cmd, format!("no sketch `{key}`"))), + } +} + +fn sweep(s: &mut Session, p: &Value) -> Result { + let cmd = "Sweep"; + let sketch = feature_sketch(s, p, cmd)?; + let path_sketch = sketch_id(s, p.get("path_sketch"), cmd, "path_sketch")?; + let path = string_list(p, "path"); + if path.is_empty() { + return Err(bad(cmd, "`path` must list curve ids")); + } + add_feature(s, p, FeatureKind::Sweep { sketch, profiles: profiles(p, cmd)?, path_sketch, path, operation: operation(p, cmd)?, targets: string_list(p, "targets") }) +} + +fn loft(s: &mut Session, p: &Value) -> Result { + let cmd = "SolidLoft"; + let list = p.get("sections").and_then(Value::as_array).ok_or_else(|| bad(cmd, "`sections` must be a list"))?; + if list.len() < 2 || list.len() > 50 { + return Err(bad(cmd, "a loft needs 2…50 sections")); + } + let mut sections = Vec::new(); + for sec in list { + let sketch = sketch_id(s, sec.get("sketch"), cmd, "sections[].sketch")?; + sections.push(solvecraft_doc::LoftSection { sketch, profiles: profiles(sec, cmd)? }); + } + add_feature(s, p, FeatureKind::Loft { sections, operation: operation(p, cmd)?, targets: string_list(p, "targets") }) +} diff --git a/crates/kernel/src/build.rs b/crates/kernel/src/build.rs index ea249c54..0a44a2b1 100644 --- a/crates/kernel/src/build.rs +++ b/crates/kernel/src/build.rs @@ -395,3 +395,246 @@ fn revolve_touching_axis(plane: &Plane, lp: &Loop2, axis_origin: Vec2, d: Vec2) Body::new(solid).map(Some) }) } + +/// Close a ruled shell (side faces between matching wires) with planar caps into a body. +fn cap_and_close(mut faces: Vec, w0: &[mt::Wire], w1: &[mt::Wire]) -> Result { + let bottom = builder::try_attach_plane(&w0.iter().map(|w| w.inverse()).collect::>()).map_err(|e| KernelError::Failed(format!("cap: {e}")))?; + let top = builder::try_attach_plane(w1).map_err(|e| KernelError::Failed(format!("cap: {e}")))?; + let mut last = String::new(); + for (b, t) in [(bottom.clone(), top.clone()), (bottom.inverse(), top.inverse()), (bottom.clone(), top.inverse()), (bottom.inverse(), top)] { + let mut all = std::mem::take(&mut faces); + let keep = all.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(), + } + faces = keep; + } + Err(KernelError::Failed(format!("closing the shell: {last}"))) +} + +/// Re-split a loop into exactly `n` segments: a full circle gets breaks at `angles` (or evenly), +/// other loops split their longest segments in half. +fn resample(lp: &Loop2, n: usize, angles: &[f64]) -> Loop2 { + let circle = lp.segs.iter().all(|s| matches!(s, Seg2::Arc { .. })) + && lp.segs.windows(2).all(|w| match (w[0], w[1]) { + (Seg2::Arc { center: c1, radius: r1, .. }, Seg2::Arc { center: c2, radius: r2, .. }) => c1.dist(c2) < 1e-9 && (r1 - r2).abs() < 1e-9, + _ => false, + }); + if circle && let Some(Seg2::Arc { center, radius, .. }) = lp.segs.first().copied() { + let mut a: Vec = if angles.len() == n { angles.to_vec() } else { (0..n).map(|i| std::f64::consts::TAU * i as f64 / n as f64).collect() }; + // Keep the given order but make them increase. + for i in 1..a.len() { + while a[i] <= a[i - 1] { + a[i] += std::f64::consts::TAU; + } + } + let segs = (0..n) + .map(|i| { + let s = a[i]; + let e = if i + 1 < n { a[i + 1] } else { a[0] + std::f64::consts::TAU }; + Seg2::Arc { center, radius, start: s, sweep: e - s } + }) + .collect(); + return Loop2 { segs }; + } + let mut segs = lp.segs.clone(); + while segs.len() < n { + let Some((k, _)) = segs.iter().enumerate().max_by(|a, b| a.1.length().total_cmp(&b.1.length())) else { break }; + let s = segs.remove(k); + let (p, q) = match s { + Seg2::Line { a, b } => { + let m = a.lerp(b, 0.5); + (Seg2::Line { a, b: m }, Seg2::Line { a: m, b }) + } + Seg2::Arc { center, radius, start, sweep } => { + (Seg2::Arc { center, radius, start, sweep: sweep / 2.0 }, Seg2::Arc { center, radius, start: start + sweep / 2.0, sweep: sweep / 2.0 }) + } + }; + segs.insert(k, q); + segs.insert(k, p); + } + Loop2 { segs } +} + +/// Ruled loft through two or more planar sections (outer loops only). +pub fn loft(sections: &[(Plane, Loop2)]) -> Result { + if sections.len() < 2 || sections.len() > 50 { + return Err(KernelError::Invalid("a loft needs 2…50 sections".into())); + } + let n = sections.iter().map(|(_, l)| l.segs.len()).max().unwrap_or(0); + if n == 0 || n > MAX_SEGS { + return Err(KernelError::Invalid("empty section".into())); + } + // Orient every section counter-clockwise about the first section's normal, then match + // segment counts and starting points to the section with the most segments. + let n0 = sections[0].0.normal(); + let oriented: Vec<(Plane, Loop2)> = sections + .iter() + .map(|(p, l)| { + let l = if p.normal().dot(n0) >= 0.0 { l.ccw() } else { l.ccw().reversed() }; + (*p, l) + }) + .collect(); + let lead = oriented.iter().max_by_key(|(_, l)| l.segs.len()).cloned().ok_or_else(|| KernelError::Invalid("no sections".into()))?; + let lead_c = lead.0.to_world(lead.1.centroid()); + let lead_dirs: Vec = lead.1.segs.iter().map(|s| lead.0.to_world(s.start()) - lead_c).collect(); + let mut wires: Vec = Vec::new(); + for (p, l) in &oriented { + let c = l.centroid(); + // Break angles: the lead section's vertex directions seen in this plane. + let angles: Vec = lead_dirs.iter().map(|d| Vec2::new(d.dot(p.x), d.dot(p.y)).angle()).collect(); + let mut r = resample(l, n, &angles); + // Start at the vertex closest in direction to the lead's first vertex. + if let Some(d0) = lead_dirs.first() { + let target = Vec2::new(d0.dot(p.x), d0.dot(p.y)).angle(); + let best = (0..r.segs.len()).min_by(|a, b| { + let ang = |k: usize| { + let v = r.segs[k].start() - c; + let d = (v.angle() - target).rem_euclid(std::f64::consts::TAU); + d.min(std::f64::consts::TAU - d) + }; + ang(*a).total_cmp(&ang(*b)) + }); + if let Some(k) = best { + r.segs.rotate_left(k); + } + } + wires.push(uniform_wire(p, &r)?); + } + guard("loft", || { + let mut faces: Vec = Vec::new(); + for w in wires.windows(2) { + let sides = builder::try_wire_homotopy(&w[0], &w[1]).map_err(|e| KernelError::Failed(format!("loft: {e}")))?; + faces.extend(sides.face_iter().cloned()); + } + let (Some(first), Some(last)) = (wires.first(), wires.last()) else { return Err(KernelError::Failed("loft".into())) }; + cap_and_close(faces, std::slice::from_ref(first), std::slice::from_ref(last)) + }) +} + +/// A 3D path segment for sweeps. +#[derive(Clone, Copy, Debug)] +pub enum PathSeg { + Line { a: Vec3, b: Vec3 }, + /// Arc around `center`, rotating by `angle` about `axis` (right hand) from `a`. + Arc { a: Vec3, center: Vec3, axis: Vec3, angle: f64 }, +} + +/// Sweep a planar region (outer loop) along a chain of path segments, keeping the profile +/// perpendicular to the path (lines translate it, arcs rotate it). +pub fn sweep(plane: &Plane, region: &Region2, path: &[PathSeg]) -> Result { + region_ok(region)?; + if path.is_empty() || path.len() > 1000 { + return Err(KernelError::Invalid("the path needs 1…1000 segments".into())); + } + let w0 = wire(plane, ®ion.outer.ccw())?; + guard("sweep", || { + let mut shell = mt::Shell::new(); + let mut cur = w0.clone(); + for seg in path { + let mut part: mt::Shell = match *seg { + PathSeg::Line { a, b } => builder::tsweep(&cur, v3(b - a)), + PathSeg::Arc { center, axis, angle, .. } => { + let ax = axis.normalized().ok_or_else(|| KernelError::Invalid("arc axis".into()))?; + builder::rsweep(&cur, p3(center), v3(ax), mt::Rad(angle)) + } + }; + let next = part.extract_boundaries().into_iter().find(|w| w.edge_iter().all(|e| !cur.edge_iter().any(|c| c.id() == e.id()))).ok_or_else(|| KernelError::Failed("sweep: lost the profile".into()))?; + shell.append(&mut part); + cur = next.inverse(); + } + let faces: Vec = shell.face_iter().cloned().collect(); + cap_and_close(faces, std::slice::from_ref(&w0), std::slice::from_ref(&cur)) + }) +} + +/// Cox–de Boor basis values N_{j,3}(u) for all j. +fn cubic_basis(knots: &[f64], n_ctrl: usize, u: f64) -> Vec { + let p = 3; + let m = knots.len(); + let mut n = vec![0.0; m.saturating_sub(1)]; + let last = n_ctrl.saturating_sub(1); + for (j, v) in n.iter_mut().enumerate() { + let (Some(&a), Some(&b)) = (knots.get(j), knots.get(j + 1)) else { continue }; + // Half-open spans, with the last span closed at the end. + if (a <= u && u < b) || (u >= b && (b - a) > 0.0 && j == last && (u - b).abs() < 1e-12) { + *v = 1.0; + } + } + for k in 1..=p { + for j in 0..m.saturating_sub(1 + k) { + let (Some(&tj), Some(&tjk), Some(&tj1), Some(&tjk1)) = (knots.get(j), knots.get(j + k), knots.get(j + 1), knots.get(j + k + 1)) else { continue }; + let left = if tjk - tj > 0.0 { (u - tj) / (tjk - tj) * n.get(j).copied().unwrap_or(0.0) } else { 0.0 }; + let right = if tjk1 - tj1 > 0.0 { (tjk1 - u) / (tjk1 - tj1) * n.get(j + 1).copied().unwrap_or(0.0) } else { 0.0 }; + if let Some(v) = n.get_mut(j) { + *v = left + right; + } + } + } + n.truncate(n_ctrl); + n +} + +/// Cubic B-spline through points at uniform parameters (clamped, knots by averaging). +fn interpolate_cubic(pts: &[Vec3]) -> Option> { + let m = pts.len(); + if m < 4 { + return None; + } + let u: Vec = (0..m).map(|i| i as f64 / (m - 1) as f64).collect(); + let mut knots = vec![0.0; 4]; + for j in 1..(m - 3) { + knots.push((j..j + 3).filter_map(|i| u.get(i)).sum::() / 3.0); + } + knots.extend([1.0; 4]); + // Dense solve of N · P = Q for each coordinate. + let mut a: Vec> = u.iter().map(|ui| cubic_basis(&knots, m, *ui)).collect(); + let mut q: Vec<[f64; 3]> = pts.iter().map(|p| [p.x, p.y, p.z]).collect(); + for col in 0..m { + let piv = (col..m).max_by(|x, y| a[*x][col].abs().total_cmp(&a[*y][col].abs()))?; + if a[piv][col].abs() < 1e-14 { + return None; + } + a.swap(col, piv); + q.swap(col, piv); + for r in 0..m { + if r == col { + continue; + } + let f = a[r][col] / a[col][col]; + if f == 0.0 { + continue; + } + for c in col..m { + let v = a[col][c]; + a[r][c] -= f * v; + } + let qc = q[col]; + for k in 0..3 { + q[r][k] -= f * qc[k]; + } + } + } + let ctrl: Vec = (0..m).map(|i| mt::Point3::new(q[i][0] / a[i][i], q[i][1] / a[i][i], q[i][2] / a[i][i])).collect(); + mt::BSplineCurve::try_new(mt::KnotVec::from(knots), ctrl).ok() +} + +/// A closed wire whose edges are cubic B-splines parameterised (nearly) by arc length, so that +/// ruled surfaces between sections pair points proportionally along each edge. +fn uniform_wire(plane: &Plane, lp: &Loop2) -> Result { + const SAMPLES: usize = 24; + let n = lp.segs.len(); + let verts: Vec = lp.segs.iter().map(|s| builder::vertex(p3(plane.to_world(s.start())))).collect(); + let mut edges = Vec::with_capacity(n); + for (i, s) in lp.segs.iter().enumerate() { + let (Some(a), Some(b)) = (verts.get(i), verts.get((i + 1) % n)) else { continue }; + let pts: Vec = (0..SAMPLES).map(|k| plane.to_world(s.point_at(k as f64 / (SAMPLES - 1) as f64))).collect(); + let curve = interpolate_cubic(&pts).ok_or_else(|| KernelError::Failed("loft curve".into()))?; + edges.push(mt::Edge::new(a, b, mt::Curve::BSplineCurve(curve))); + } + Ok(edges.into()) +} diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index 416c3176..e86b21a2 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -23,7 +23,7 @@ mod topo; pub use blend::{chamfer, fillet}; pub use body::{Body, EdgeInfo, FaceInfo}; -pub use build::{box_solid, cylinder, extrude, extrude_tapered, revolve, sphere, torus}; +pub use build::{PathSeg, box_solid, cylinder, extrude, extrude_tapered, loft, revolve, sphere, sweep, torus}; pub use measure::{BodyMeasure, measure}; pub use ops::{BoolOp, boolean, split_by_plane, transform, transform_matrix}; pub use polyhedron::{HalfSpace, convex_polyhedron, draft, shell}; diff --git a/crates/kernel/src/measure.rs b/crates/kernel/src/measure.rs index 58b7cd02..38bbfe33 100644 --- a/crates/kernel/src/measure.rs +++ b/crates/kernel/src/measure.rs @@ -40,13 +40,21 @@ fn face_sums(m: &Mesh, nf: usize) -> Vec<(f64, f64, Vec3)> { pub fn measure(b: &Body) -> Result { let size = b.size(); let fine = b.tessellate((size * 5e-5).max(1e-4))?; + let medium = b.tessellate((size * 2.5e-4).max(2e-4))?; let coarse = b.tessellate((size * 1e-3).max(1e-3))?; let nf = b.face_count(); - let (sf, sc) = (face_sums(&fine, nf), face_sums(&coarse, nf)); + let (sf, sm, sc) = (face_sums(&fine, nf), face_sums(&medium, nf), face_sums(&coarse, nf)); + let close = |x: f64, y: f64| (x - y).abs() <= 0.01 * x.abs().max(y.abs()) + 1e-9; let (mut area, mut v6, mut mom) = (0.0, 0.0, Vec3::ZERO); - for (f, c) in sf.iter().zip(&sc) { - let bad = (f.0 - c.0).abs() > 0.01 * c.0.max(f.0) + 1e-9; - let pick = if bad { c } else { f }; + for ((f, m), c) in sf.iter().zip(&sm).zip(&sc) { + // Trust the finest tessellation that agrees with the next coarser one. + let pick = if close(f.0, m.0) { + f + } else if close(m.0, c.0) { + m + } else { + f + }; area += pick.0; v6 += pick.1; mom += pick.2; diff --git a/crates/kernel/src/tests.rs b/crates/kernel/src/tests.rs index 05a6a18c..f2b1efd5 100644 --- a/crates/kernel/src/tests.rs +++ b/crates/kernel/src/tests.rs @@ -192,6 +192,30 @@ fn polyhedra_shell_and_draft() { assert!(shell(&cyl, &[Vec3::new(0.0, 0.0, 5.0)], 1.0).is_err(), "curved faces"); } +#[test] +fn loft_and_sweep() { + let sq = 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)]); + let top = Plane::XY.offset(40.0); + let l = loft(&[(Plane::XY, sq), (top, Loop2::circle(Vec2::ZERO, 10.0))]).unwrap(); + let m = measure(&l).unwrap(); + println!("loft volume {} faces {:?}", m.volume, m.merged); + assert!(m.volume > 314.0 * 40.0 && m.volume < 1600.0 * 40.0); + // A tube: up 30, quarter bend of radius 20, across 30. + let disc = Region2 { outer: Loop2::circle(Vec2::ZERO, 3.0), holes: vec![] }; + let path = [ + PathSeg::Line { a: Vec3::ZERO, b: Vec3::new(0.0, 0.0, 30.0) }, + PathSeg::Arc { a: Vec3::new(0.0, 0.0, 30.0), center: Vec3::new(20.0, 0.0, 30.0), axis: Vec3::new(0.0, 1.0, 0.0), angle: std::f64::consts::FRAC_PI_2 }, + PathSeg::Line { a: Vec3::new(20.0, 0.0, 50.0), b: Vec3::new(50.0, 0.0, 50.0) }, + ]; + let t = sweep(&Plane::XY, &disc, &path).unwrap(); + for tol in [0.1, 0.01, 0.003, 0.001] { + println!("tube tess {tol}: {}", t.tessellate(tol).unwrap().measure().volume); + } + let area = PI * 9.0; + let expect = area * (30.0 + 30.0 + 20.0 * std::f64::consts::FRAC_PI_2); + assert!(rel(measure(&t).unwrap().volume, expect) < 1e-3, "{} vs {expect}", measure(&t).unwrap().volume); +} + #[test] #[ignore] fn debug_convergence() { diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index 4634f821..f2198dbb 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -155,6 +155,17 @@ pub fn paint(p: &Painter, r: Rect, name: &str, ink: Color32, fill: Color32, acce pen.line(&[(7.0, 8.0), (7.0, 16.0)], a); pen.line(&[(17.0, 8.0), (17.0, 16.0)], a); } + "sweep" => { + pen.arc(20.0, 20.0, 14.0, std::f32::consts::FRAC_PI_2, std::f32::consts::PI, ink); + pen.circle(6.0, 20.0, 3.0, pen.fill, a); + pen.circle(20.0, 6.0, 3.0, pen.fill, a); + } + "loft" => { + pen.poly(&[(3.0, 20.0), (15.0, 20.0), (21.0, 15.0), (9.0, 15.0)], pen.fill, ink); + pen.circle(12.0, 6.0, 3.5, Color32::TRANSPARENT, a); + pen.line(&[(3.0, 20.0), (8.5, 6.0)], a); + pen.line(&[(21.0, 15.0), (15.5, 6.0)], a); + } "shell" => { pen.iso_box(3.0, 9.0, 10.0, 8.0, 9.0); pen.poly(&[(6.0, 9.0), (13.0, 5.5), (18.0, 8.0), (11.0, 11.5)], Color32::WHITE, a); diff --git a/crates/ui-egui/src/timeline.rs b/crates/ui-egui/src/timeline.rs index 76637592..9da7774a 100644 --- a/crates/ui-egui/src/timeline.rs +++ b/crates/ui-egui/src/timeline.rs @@ -24,6 +24,8 @@ fn icon_of(k: &FeatureKind) -> &'static str { FeatureKind::Mirror { .. } => "mirror", FeatureKind::ConstructionPlane { .. } => "plane", FeatureKind::Hole { .. } => "hole", + FeatureKind::Loft { .. } => "loft", + FeatureKind::Sweep { .. } => "sweep", FeatureKind::Shell { .. } => "shell", FeatureKind::Draft { .. } => "draft", FeatureKind::Split { .. } => "split",