// 3D solid modelling support on the App: committing Model-tab primitives, // and the Design-group boolean operations (truck-shapeops) over the scene's // session-cached truck B-reps. use acadrust::entities::Solid3D; use acadrust::{EntityType, Handle}; use iced::Task; use truck_modeling::Solid; use super::Message; use crate::modules::model::boolean_cmd::BoolOp; use crate::scene::model::solid_model::{self, Bool}; impl super::OpenCADStudio { /// Commit a Model-tab solid: add its acadrust entity to the document, then /// register the truck B-rep (caches it for booleans + tessellates it into /// the shaded mesh pipeline). Returns the new entity handle. pub(super) fn add_solid_model(&mut self, entity: EntityType, solid: Solid) -> Handle { let i = self.active_tab; let Some(handle) = self.commit_entity_handle(entity) else { return Handle::NULL; }; self.tabs[i].scene.register_solid_model(handle, solid); handle } /// Run a boolean (`union` / `subtract` / `intersect`) on exactly two /// selected solids whose truck B-reps are in the session cache. pub(super) fn solid_boolean(&mut self, op: BoolOp) -> Task { let i = self.active_tab; // Selected entities that have a cached truck B-rep. let handles: Vec = self.tabs[i] .scene .selected .iter() .copied() .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 2 { self.command_line .push_error("Boolean: select exactly two solids created this session."); return Task::none(); } let a = self.tabs[i].scene.solid_models[&handles[0]].clone(); let b = self.tabs[i].scene.solid_models[&handles[1]].clone(); let kind = match op { BoolOp::Union => Bool::Union, BoolOp::Subtract => Bool::Subtract, BoolOp::Intersect => Bool::Intersect, }; let Some(result) = solid_model::boolean(kind, &a, &b) else { self.command_line .push_error("Boolean failed — the solids may not overlap."); return Task::none(); }; self.push_undo_snapshot(i, "BOOLEAN"); // Remove the two operands (entity + mesh + cached B-rep). self.tabs[i].scene.erase_entities(&handles); // The result is freshly combined geometry with no ACIS parametrisation, // so it lives as a Solid3D whose render/boolean data is the injected // truck mesh + cached B-rep; its edge wires make it pickable. let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&result); let handle = self.add_solid_model(EntityType::Solid3D(s3d), result); self.tabs[i].scene.deselect_all(); if !handle.is_null() { self.tabs[i].scene.select_entity(handle, false); } self.tabs[i].dirty = true; self.refresh_properties(); Task::none() } /// Slice the one selected solid with an axis-aligned plane (axis 0/1/2 = /// X/Y/Z at `value`), keeping the lower side when `keep_low` is true. The /// kept half is the intersection of the solid with a half-space box, reusing /// the same truck-shapeops path as the boolean tools. pub(super) fn solid_slice(&mut self, axis: usize, value: f64, keep_low: bool) -> Task { let i = self.active_tab; let handles: Vec = self.tabs[i] .scene .selected .iter() .copied() .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { self.command_line .push_error("SLICE: select exactly one solid created this session."); return Task::none(); } let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); // Bounding box from the solid's edge wires. let wires = solid_model::edge_wires(&solid); let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]); for w in &wires { for p in &w.points { let c = [p.x, p.y, p.z]; for k in 0..3 { min[k] = min[k].min(c[k]); max[k] = max[k].max(c[k]); } } } if min[0] > max[0] { self.command_line .push_error("SLICE: could not determine the solid's extent."); return Task::none(); } // Generous margin so the box fully spans the solid in the free axes. let m = [ (max[0] - min[0]).max(1.0), (max[1] - min[1]).max(1.0), (max[2] - min[2]).max(1.0), ]; let mut lo = [min[0] - m[0], min[1] - m[1], min[2] - m[2]]; let mut hi = [max[0] + m[0], max[1] + m[1], max[2] + m[2]]; if keep_low { hi[axis] = value; } else { lo[axis] = value; } if hi[axis] <= lo[axis] { self.command_line .push_error("SLICE: the plane does not cross the solid on the kept side."); return Task::none(); } let center = [ (lo[0] + hi[0]) / 2.0, (lo[1] + hi[1]) / 2.0, (lo[2] + hi[2]) / 2.0, ]; let halfspace = solid_model::box_solid(center, hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2]); let Some(result) = solid_model::boolean(Bool::Intersect, &solid, &halfspace) else { self.command_line .push_error("SLICE failed — the plane may not cross the solid."); return Task::none(); }; self.push_undo_snapshot(i, "SLICE"); self.tabs[i].scene.erase_entities(&handles); let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&result); let handle = self.add_solid_model(EntityType::Solid3D(s3d), result); self.tabs[i].scene.deselect_all(); if !handle.is_null() { self.tabs[i].scene.select_entity(handle, false); } self.tabs[i].dirty = true; self.refresh_properties(); let ax = ["X", "Y", "Z"][axis]; self.command_line.push_output(&format!( "SLICE: cut at {ax}={value}, kept the {} half.", if keep_low { "lower" } else { "upper" } )); Task::none() } /// INTERFERE — create a solid from the overlap of the two selected solids, /// leaving the originals in place (a non-destructive boolean intersect). pub(super) fn solid_interfere(&mut self) -> Task { let i = self.active_tab; let handles: Vec = self.tabs[i] .scene .selected .iter() .copied() .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 2 { self.command_line .push_error("INTERFERE: select exactly two solids created this session."); return Task::none(); } let a = self.tabs[i].scene.solid_models[&handles[0]].clone(); let b = self.tabs[i].scene.solid_models[&handles[1]].clone(); match solid_model::boolean(Bool::Intersect, &a, &b) { Some(result) => { self.push_undo_snapshot(i, "INTERFERE"); // Keep both originals; add the interference solid. let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&result); self.add_solid_model(EntityType::Solid3D(s3d), result); self.tabs[i].dirty = true; self.refresh_properties(); self.command_line .push_output("INTERFERE: created an interference solid from the overlap."); } None => self .command_line .push_output("INTERFERE: the selected solids do not overlap."), } Task::none() } /// 3DROTATE — rotate the one selected solid about the X/Y/Z axis (0/1/2) /// through its centre by `angle_deg` degrees. Rotation preserves the solid's /// orientation, so it reuses the cached truck B-rep directly. pub(super) fn solid_rotate3d(&mut self, axis: usize, angle_deg: f64) -> Task { use truck_modeling::{builder, Point3, Rad, Vector3 as TVec3}; let i = self.active_tab; let handles: Vec = self.tabs[i] .scene .selected .iter() .copied() .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { self.command_line .push_error("3DROTATE: select exactly one solid created this session."); return Task::none(); } let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); let wires = solid_model::edge_wires(&solid); let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]); for w in &wires { for p in &w.points { let c = [p.x, p.y, p.z]; for k in 0..3 { min[k] = min[k].min(c[k]); max[k] = max[k].max(c[k]); } } } if min[0] > max[0] { self.command_line .push_error("3DROTATE: could not determine the solid's extent."); return Task::none(); } let origin = Point3::new( (min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0, (min[2] + max[2]) / 2.0, ); let axis_v = match axis { 0 => TVec3::unit_x(), 1 => TVec3::unit_y(), _ => TVec3::unit_z(), }; let rotated = builder::rotated(&solid, origin, axis_v, Rad(angle_deg.to_radians())); self.push_undo_snapshot(i, "3DROTATE"); self.tabs[i].scene.erase_entities(&handles); let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&rotated); let handle = self.add_solid_model(EntityType::Solid3D(s3d), rotated); self.tabs[i].scene.deselect_all(); if !handle.is_null() { self.tabs[i].scene.select_entity(handle, false); } self.tabs[i].dirty = true; self.refresh_properties(); self.command_line.push_output(&format!( "3DROTATE: rotated {angle_deg}° about the {} axis.", ["X", "Y", "Z"][axis] )); Task::none() } /// POLYSOLID — build a wall-like solid from a selected polyline: one box per /// segment (oriented along it, `width` wide, `height` tall) unioned together, /// reusing the box primitive + the boolean union path. pub(super) fn solid_polysolid(&mut self, width: f64, height: f64) -> Task { use truck_modeling::{builder, Point3, Rad, Vector3 as TVec3}; let i = self.active_tab; let found: Option<(Handle, Vec<[f64; 2]>, bool)> = self.tabs[i] .scene .selected_entities() .iter() .find_map(|(h, e)| match e { EntityType::LwPolyline(pl) => Some(( *h, pl.vertices .iter() .map(|v| [v.location.x, v.location.y]) .collect(), pl.is_closed, )), _ => None, }); let Some((handle, pts, closed)) = found else { self.command_line .push_error("POLYSOLID: select a polyline first."); return Task::none(); }; let mut segs: Vec<([f64; 2], [f64; 2])> = pts.windows(2).map(|w| (w[0], w[1])).collect(); if closed && pts.len() > 2 { segs.push((pts[pts.len() - 1], pts[0])); } let mut acc: Option = None; let mut used = 0usize; for (a, b) in &segs { let (dx, dy) = (b[0] - a[0], b[1] - a[1]); let len = (dx * dx + dy * dy).sqrt(); if len < 1e-9 { continue; } let angle = dy.atan2(dx); // Local box: X 0..len, Y -w/2..w/2, Z 0..h, then orient to the segment. let bx = solid_model::box_solid([len / 2.0, 0.0, height / 2.0], len, width, height); let bx = builder::rotated(&bx, Point3::new(0.0, 0.0, 0.0), TVec3::unit_z(), Rad(angle)); let bx = builder::translated(&bx, TVec3::new(a[0], a[1], 0.0)); acc = Some(match acc { None => bx, Some(prev) => solid_model::boolean(Bool::Union, &prev, &bx).unwrap_or(prev), }); used += 1; } let Some(result) = acc else { self.command_line .push_error("POLYSOLID: the polyline has no usable segments."); return Task::none(); }; self.push_undo_snapshot(i, "POLYSOLID"); self.tabs[i].scene.erase_entities(&[handle]); let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&result); let h = self.add_solid_model(EntityType::Solid3D(s3d), result); self.tabs[i].scene.deselect_all(); if !h.is_null() { self.tabs[i].scene.select_entity(h, false); } self.tabs[i].dirty = true; self.refresh_properties(); self.command_line.push_output(&format!( "POLYSOLID: built a wall solid from {used} segment(s) (width {width}, height {height})." )); Task::none() } /// 3DMIRROR — add a mirrored copy of the one selected solid across the plane /// perpendicular to the X/Y/Z axis (0/1/2) through its centre, keeping the /// original. Reflection reverses face orientation, so `Solid::not()` restores /// outward normals (the same inversion the boolean subtraction relies on). pub(super) fn solid_mirror3d(&mut self, axis: usize) -> Task { use truck_modeling::{builder, Matrix4, Vector3 as TVec3}; let i = self.active_tab; let handles: Vec = self.tabs[i] .scene .selected .iter() .copied() .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { self.command_line .push_error("3DMIRROR: select exactly one solid created this session."); return Task::none(); } let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); let wires = solid_model::edge_wires(&solid); let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]); for w in &wires { for p in &w.points { let c = [p.x, p.y, p.z]; for k in 0..3 { min[k] = min[k].min(c[k]); max[k] = max[k].max(c[k]); } } } if min[0] > max[0] { self.command_line .push_error("3DMIRROR: could not determine the solid's extent."); return Task::none(); } let c = TVec3::new( (min[0] + max[0]) / 2.0, (min[1] + max[1]) / 2.0, (min[2] + max[2]) / 2.0, ); let s = match axis { 0 => TVec3::new(-1.0, 1.0, 1.0), 1 => TVec3::new(1.0, -1.0, 1.0), _ => TVec3::new(1.0, 1.0, -1.0), }; // Reflect about the plane through the centre: T(c) · scale · T(-c). let mat = Matrix4::from_translation(c) * Matrix4::from_nonuniform_scale(s.x, s.y, s.z) * Matrix4::from_translation(-c); let mut reflected = builder::transformed(&solid, mat); reflected.not(); // restore outward orientation after the reflection self.push_undo_snapshot(i, "3DMIRROR"); let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&reflected); let h = self.add_solid_model(EntityType::Solid3D(s3d), reflected); self.tabs[i].scene.deselect_all(); if !h.is_null() { self.tabs[i].scene.select_entity(h, false); } self.tabs[i].dirty = true; self.refresh_properties(); self.command_line.push_output(&format!( "3DMIRROR: added a mirror across the {} plane.", ["X", "Y", "Z"][axis] )); Task::none() } /// 3DALIGN — move/rotate the one selected solid so its three source points /// land on the three destination points. The frame-to-frame transform is /// computed in glam (`M = D · S⁻¹`) and handed to truck as a raw matrix; both /// frames are right-handed so the result is a pure rotation+translation. pub(super) fn solid_align3d( &mut self, src: [[f64; 3]; 3], dst: [[f64; 3]; 3], ) -> Task { use truck_modeling::{builder, Matrix4}; let i = self.active_tab; let handles: Vec = self.tabs[i] .scene .selected .iter() .copied() .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { self.command_line .push_error("3DALIGN: select exactly one solid created this session."); return Task::none(); } // Build a right-handed frame (origin + orthonormal axes) from 3 points. let frame = |p: [[f64; 3]; 3]| -> Option { let p1 = glam::DVec3::from_array(p[0]); let p2 = glam::DVec3::from_array(p[1]); let p3 = glam::DVec3::from_array(p[2]); let x = (p2 - p1).normalize_or_zero(); let z = (p2 - p1).cross(p3 - p1).normalize_or_zero(); if x.length_squared() < 1e-12 || z.length_squared() < 1e-12 { return None; // coincident or collinear points } let y = z.cross(x); Some(glam::DMat4::from_cols( x.extend(0.0), y.extend(0.0), z.extend(0.0), p1.extend(1.0), )) }; let (Some(s), Some(d)) = (frame(src), frame(dst)) else { self.command_line .push_error("3DALIGN: each point triple must be non-coincident and non-collinear."); return Task::none(); }; let a = (d * s.inverse()).to_cols_array(); // column-major [f64; 16] let mat = Matrix4::new( a[0], a[1], a[2], a[3], a[4], a[5], a[6], a[7], a[8], a[9], a[10], a[11], a[12], a[13], a[14], a[15], ); let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); let aligned = builder::transformed(&solid, mat); self.push_undo_snapshot(i, "3DALIGN"); self.tabs[i].scene.erase_entities(&handles); let mut s3d = Solid3D::new(); s3d.wires = solid_model::edge_wires(&aligned); let h = self.add_solid_model(EntityType::Solid3D(s3d), aligned); self.tabs[i].scene.deselect_all(); if !h.is_null() { self.tabs[i].scene.select_entity(h, false); } self.tabs[i].dirty = true; self.refresh_properties(); self.command_line .push_output("3DALIGN: aligned the solid to the destination points."); Task::none() } /// SECTION — draw the cross-section outline where an axis-aligned plane /// (X/Y/Z = `axis` at `value`) cuts the one selected solid, as Line entities. /// Reuses the mesh-interference analyzer to find the cut segments. #[cfg(feature = "solid3d")] pub(super) fn solid_section(&mut self, axis: usize, value: f64) -> Task { use acadrust::types::Vector3; use acadrust::Line; use truck_meshalgo::analyzers::Collision; use truck_meshalgo::tessellation::{MeshableShape, MeshedShape}; use truck_modeling::{builder, Point3, Shell, Wire}; let i = self.active_tab; let handles: Vec = self.tabs[i] .scene .selected .iter() .copied() .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.len() != 1 { self.command_line .push_error("SECTION: select exactly one solid created this session."); return Task::none(); } let solid = self.tabs[i].scene.solid_models[&handles[0]].clone(); let wires = solid_model::edge_wires(&solid); let (mut min, mut max) = ([f64::MAX; 3], [f64::MIN; 3]); for w in &wires { for p in &w.points { let c = [p.x, p.y, p.z]; for k in 0..3 { min[k] = min[k].min(c[k]); max[k] = max[k].max(c[k]); } } } if min[0] > max[0] { self.command_line .push_error("SECTION: could not determine the solid's extent."); return Task::none(); } // Margin so the cutting plane fully spans the solid in the free axes. let m = [ (max[0] - min[0]).max(1.0) * 0.1, (max[1] - min[1]).max(1.0) * 0.1, (max[2] - min[2]).max(1.0) * 0.1, ]; let lo = [min[0] - m[0], min[1] - m[1], min[2] - m[2]]; let hi = [max[0] + m[0], max[1] + m[1], max[2] + m[2]]; let corners: [Point3; 4] = match axis { 0 => [ Point3::new(value, lo[1], lo[2]), Point3::new(value, hi[1], lo[2]), Point3::new(value, hi[1], hi[2]), Point3::new(value, lo[1], hi[2]), ], 1 => [ Point3::new(lo[0], value, lo[2]), Point3::new(hi[0], value, lo[2]), Point3::new(hi[0], value, hi[2]), Point3::new(lo[0], value, hi[2]), ], _ => [ Point3::new(lo[0], lo[1], value), Point3::new(hi[0], lo[1], value), Point3::new(hi[0], hi[1], value), Point3::new(lo[0], hi[1], value), ], }; let v: Vec<_> = corners.iter().map(|p| builder::vertex(*p)).collect(); let wire: Wire = vec![ builder::line(&v[0], &v[1]), builder::line(&v[1], &v[2]), builder::line(&v[2], &v[3]), builder::line(&v[3], &v[0]), ] .into_iter() .collect(); let Ok(face) = builder::try_attach_plane(&[wire]) else { self.command_line .push_error("SECTION: could not build the cutting plane."); return Task::none(); }; let shell: Shell = std::iter::once(face).collect(); let tol = 0.02; let solid_mesh = solid.triangulation(tol).to_polygon(); let plane_mesh = shell.triangulation(tol).to_polygon(); let segs = solid_mesh.extract_interference(&plane_mesh); if segs.is_empty() { self.command_line .push_output("SECTION: the plane does not cross the solid."); return Task::none(); } self.push_undo_snapshot(i, "SECTION"); for (p1, p2) in &segs { let line = Line::from_points( Vector3::new(p1.x, p1.y, p1.z), Vector3::new(p2.x, p2.y, p2.z), ); self.tabs[i].scene.add_entity(EntityType::Line(line)); } self.tabs[i].dirty = true; self.refresh_properties(); self.command_line.push_output(&format!( "SECTION: created {} section line(s) at {}={value}.", segs.len(), ["X", "Y", "Z"][axis] )); Task::none() } /// Without `solid3d` (e.g. wasm) there is no mesh-interference kernel. #[cfg(not(feature = "solid3d"))] pub(super) fn solid_section(&mut self, _axis: usize, _value: f64) -> Task { self.command_line .push_error("SECTION: solid modelling is unavailable in this build."); Task::none() } /// PYRAMID — create an `n`-sided pyramid (regular polygon base of the given /// circumradius, apex at `height`) as a tessellated mesh, reusing the same /// face→Shell→mesh path as LOFT. Each face is independent, so no closed-shell /// topology is required; the windings give outward normals. pub(super) fn solid_pyramid( &mut self, radius: f64, height: f64, sides: usize, ) -> Task { use crate::modules::insert::solid3d_cmds::empty_solid3d; use crate::scene::convert::truck_tess; use crate::scene::model::mesh_model::MeshModel; use truck_modeling::{builder, Point3, Shell, Wire}; let i = self.active_tab; let n = sides.max(3); if radius <= 0.0 || height <= 0.0 { self.command_line .push_error("PYRAMID: radius and height must be positive."); return Task::none(); } let corners: Vec = (0..n) .map(|k| { let a = std::f64::consts::TAU * k as f64 / n as f64; Point3::new(radius * a.cos(), radius * a.sin(), 0.0) }) .collect(); let apex = builder::vertex(Point3::new(0.0, 0.0, height)); let mut faces = Vec::new(); // Base: reversed winding so the normal points down (outward at the base). { let bv: Vec<_> = corners.iter().rev().map(|p| builder::vertex(*p)).collect(); let wire: Wire = (0..n) .map(|k| builder::line(&bv[k], &bv[(k + 1) % n])) .collect(); if let Ok(f) = builder::try_attach_plane(&[wire]) { faces.push(f); } } // Side triangles [corner k, corner k+1, apex] → outward normals. let sv: Vec<_> = corners.iter().map(|p| builder::vertex(*p)).collect(); for k in 0..n { let k1 = (k + 1) % n; let wire: Wire = vec![ builder::line(&sv[k], &sv[k1]), builder::line(&sv[k1], &apex), builder::line(&apex, &sv[k]), ] .into_iter() .collect(); if let Ok(f) = builder::try_attach_plane(&[wire]) { faces.push(f); } } if faces.len() < n + 1 { self.command_line .push_error("PYRAMID: could not build all faces."); return Task::none(); } let shell = Shell::from(faces); let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer); let truck_tess::TruckTessResult::Mesh { verts, verts_low, normals, indices, } = truck_tess::tessellate_shell(&shell) else { self.command_line .push_error("PYRAMID: tessellation failed."); return Task::none(); }; if verts.is_empty() { self.command_line .push_error("PYRAMID: tessellation produced no geometry."); return Task::none(); } self.push_undo_snapshot(i, "PYRAMID"); let new_handle = self.tabs[i].scene.add_entity(empty_solid3d()); let mesh = MeshModel { name: format!("{}", new_handle.value()), verts, verts_low, normals, indices, color, selected: false, }; self.tabs[i] .scene .meshes .insert(new_handle, crate::scene::MeshLodSet::from_single(mesh)); self.tabs[i].dirty = true; self.refresh_properties(); self.command_line.push_output(&format!( "PYRAMID: created a {n}-sided pyramid (radius {radius}, height {height})." )); Task::none() } /// SPLINEFIT — replace the selected polyline with a cubic spline that passes /// through its vertices. Control points come from the Catmull-Rom → cubic /// Bézier formula (the curve provably interpolates each vertex), with a /// clamped piecewise-Bézier knot vector the spline renderer reads directly. pub(super) fn fit_spline(&mut self) -> Task { use acadrust::entities::Spline; use acadrust::types::Vector3; let i = self.active_tab; let found: Option<(Handle, Vec<[f64; 3]>)> = self.tabs[i] .scene .selected_entities() .iter() .find_map(|(h, e)| match e { EntityType::LwPolyline(pl) => Some(( *h, pl.vertices .iter() .map(|v| [v.location.x, v.location.y, 0.0]) .collect(), )), _ => None, }); let Some((handle, fit)) = found else { self.command_line .push_error("SPLINEFIT: select a polyline to fit a spline through."); return Task::none(); }; if fit.len() < 3 { self.command_line .push_error("SPLINEFIT: need at least 3 points."); return Task::none(); } let n = fit.len(); let m = n - 1; // Bézier segments let p = |k: usize| glam::DVec3::new(fit[k][0], fit[k][1], fit[k][2]); // Catmull-Rom → cubic Bézier control points: [P0, b1,b2,P1, b1,b2,P2, …]. let mut ctrl: Vec = Vec::with_capacity(3 * m + 1); ctrl.push(Vector3::new(fit[0][0], fit[0][1], 0.0)); for seg in 0..m { let p0 = p(seg); let p1 = p(seg + 1); let prev = if seg > 0 { p(seg - 1) } else { p0 }; let next = if seg + 2 <= m { p(seg + 2) } else { p1 }; let b1 = p0 + (p1 - prev) / 6.0; let b2 = p1 - (next - p0) / 6.0; ctrl.push(Vector3::new(b1.x, b1.y, 0.0)); ctrl.push(Vector3::new(b2.x, b2.y, 0.0)); ctrl.push(Vector3::new(p1.x, p1.y, 0.0)); } // Clamped piecewise-Bézier knots (degree 3): len == ctrl.len()+degree+1. let mut knots: Vec = vec![0.0; 4]; for s in 1..m { knots.extend_from_slice(&[s as f64, s as f64, s as f64]); } knots.extend_from_slice(&[m as f64; 4]); let mut spl = Spline::new(); spl.degree = 3; spl.control_points = ctrl; spl.knots = knots; spl.fit_points = fit.iter().map(|q| Vector3::new(q[0], q[1], 0.0)).collect(); // flags.rational defaults to false (non-rational) — exactly what we want. self.push_undo_snapshot(i, "SPLINEFIT"); self.tabs[i].scene.erase_entities(&[handle]); self.tabs[i].scene.add_entity(EntityType::Spline(spl)); self.tabs[i].dirty = true; self.refresh_properties(); self.command_line .push_output(&format!("SPLINEFIT: fit a spline through {n} points.")); Task::none() } /// FLATSHOT — project the selected solid's edges onto the XY plane (Z=0) as /// Line entities, giving a flattened 2D shot of the model. Reuses the cached /// solid's edge wires (the same source SECTION uses). pub(super) fn solid_flatshot(&mut self) -> Task { use acadrust::types::Vector3; use acadrust::Line; let i = self.active_tab; let handles: Vec = self.tabs[i] .scene .selected .iter() .copied() .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.is_empty() { self.command_line .push_error("FLATSHOT: select a solid created this session."); return Task::none(); } self.push_undo_snapshot(i, "FLATSHOT"); let mut n = 0usize; for h in &handles { let solid = self.tabs[i].scene.solid_models[h].clone(); for w in solid_model::edge_wires(&solid) { for seg in w.points.windows(2) { let line = Line::from_points( Vector3::new(seg[0].x, seg[0].y, 0.0), Vector3::new(seg[1].x, seg[1].y, 0.0), ); self.tabs[i].scene.add_entity(EntityType::Line(line)); n += 1; } } } self.tabs[i].dirty = true; self.refresh_properties(); self.command_line .push_output(&format!("FLATSHOT: created {n} projected edge(s) at Z=0.")); Task::none() } /// CONVTOSURFACE — convert the selected solid(s) into Surface entities, /// carrying the solid's edge wires (reuses the cached B-rep's edge wires). pub(super) fn solid_convtosurface(&mut self) -> Task { use acadrust::entities::{Surface, SurfaceKind, Wire as AWire}; use acadrust::types::Vector3; let i = self.active_tab; let handles: Vec = self.tabs[i] .scene .selected .iter() .copied() .filter(|h| self.tabs[i].scene.solid_models.contains_key(h)) .collect(); if handles.is_empty() { self.command_line .push_error("CONVTOSURFACE: select a solid created this session."); return Task::none(); } let mut surfaces: Vec = Vec::new(); for h in &handles { let solid = self.tabs[i].scene.solid_models[h].clone(); let awires: Vec = solid_model::edge_wires(&solid) .into_iter() .map(|w| { let mut aw = AWire::new(); aw.points = w .points .iter() .map(|p| Vector3::new(p.x, p.y, p.z)) .collect(); aw }) .collect(); let mut surf = Surface::new(SurfaceKind::Generic); surf.wires = awires; surf.common.layer = self.tabs[i].active_layer.clone(); surfaces.push(surf); } self.push_undo_snapshot(i, "CONVTOSURFACE"); self.tabs[i].scene.erase_entities(&handles); let n = surfaces.len(); for surf in surfaces { self.tabs[i].scene.add_entity(EntityType::Surface(surf)); } self.tabs[i].dirty = true; self.refresh_properties(); self.command_line .push_output(&format!("CONVTOSURFACE: converted {n} solid(s) to surface(s).")); Task::none() } }