feat(model): add exact solid modelling tools

This commit is contained in:
Hakan Seven committed 2026-08-23 22:53:31 +03:00
1 parent fb2ea1e831
commit 06cd0ec6a9
51 files changed
+1487 -574

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
// Boolean operations on 3D solids (Design group). The actual CSG runs in
// Boolean operations on 3D solids. The actual CSG runs in
// `App::solid_boolean` (src/app/model_ops.rs) using the kernel on the
// cached B-reps; this module just names the operations.
/// Which boolean a Design-group tool performs on the two selected solids.
/// Which boolean operation to apply to selected solids.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum BoolOp {
Union,
+146
View File
@@ -0,0 +1,146 @@
use acadrust::Handle;
use glam::DVec3;
use crate::command::{
CadCommand, CmdResult, DynAnchor, DynFieldSpec, DynGuide, DynRole, DynSpec,
};
#[derive(Clone, Copy)]
pub enum EdgeOperation {
Fillet,
Chamfer,
}
pub struct SolidEdgeCommand {
operation: EdgeOperation,
target: Option<Handle>,
picked: Option<(Handle, DVec3)>,
default_value: f64,
}
impl SolidEdgeCommand {
pub fn new(operation: EdgeOperation, target: Option<Handle>) -> Self {
Self {
operation,
target,
picked: None,
default_value: 1.0,
}
}
fn finish(&self, value: f64) -> CmdResult {
let Some((handle, pick)) = self.picked else {
return CmdResult::Cancel;
};
CmdResult::SolidEdgeBlend {
handle,
pick,
value,
fillet: matches!(self.operation, EdgeOperation::Fillet),
}
}
}
impl CadCommand for SolidEdgeCommand {
fn name(&self) -> &'static str {
match self.operation {
EdgeOperation::Fillet => "SOLIDFILLET",
EdgeOperation::Chamfer => "SOLIDCHAMFER",
}
}
fn prompt(&self) -> String {
if self.picked.is_none() {
return crate::t!("Select a solid edge:").into_owned();
}
match self.operation {
EdgeOperation::Fillet => {
crate::tf!("Specify fillet radius <{:.3}>:", self.default_value).into_owned()
}
EdgeOperation::Chamfer => {
crate::tf!("Specify chamfer distance <{:.3}>:", self.default_value).into_owned()
}
}
}
fn needs_entity_pick(&self) -> bool {
self.picked.is_none()
}
fn entity_pick_includes_fills(&self) -> bool {
true
}
fn entity_pick_uses_surface_point(&self) -> bool {
true
}
fn entity_pick_highlights_hover(&self) -> bool {
true
}
fn on_entity_pick(&mut self, handle: Handle, point: DVec3) -> CmdResult {
if handle.is_null() || self.target.is_some_and(|target| target != handle) {
return CmdResult::NeedPoint;
}
self.picked = Some((handle, point));
CmdResult::NeedPoint
}
fn on_point(&mut self, point: DVec3) -> CmdResult {
let Some((_, pick)) = self.picked else {
return CmdResult::NeedPoint;
};
let value = point.distance(pick);
if value > 0.0 && value.is_finite() {
self.finish(value)
} else {
CmdResult::NeedPoint
}
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
if self.picked.is_none() {
return None;
}
let value = text.trim().parse::<f64>().ok()?;
(value > 0.0 && value.is_finite()).then(|| self.finish(value))
}
fn on_enter(&mut self) -> CmdResult {
if self.picked.is_some() {
self.finish(self.default_value)
} else {
CmdResult::Cancel
}
}
fn wants_text_input(&self) -> bool {
self.picked.is_some()
}
fn dyn_commit_as_text(&self) -> bool {
self.picked.is_some()
}
fn dyn_spec(&self) -> Option<DynSpec> {
let (_, pick) = self.picked?;
Some(DynSpec {
anchor: DynAnchor::Point(pick),
fields: vec![DynFieldSpec::new(match self.operation {
EdgeOperation::Fillet => DynRole::Radius,
EdgeOperation::Chamfer => DynRole::Distance,
})],
guide: DynGuide::Radius,
ref_point: None,
})
}
fn dyn_live_value(&self, cursor: DVec3) -> Option<f64> {
self.picked.map(|(_, pick)| cursor.distance(pick))
}
}
inventory::submit!(crate::command::CommandRegistration {
names: &["SOLIDFILLET", "SOLIDCHAMFER"]
});
+37 -17
View File
@@ -1,11 +1,7 @@
// Model module — 3D solid modelling.
//
// Model group : create primitive solids (box, cylinder, cone, sphere, …)
// as ACIS Solid3D entities via acadrust's primitive builders.
// Design group : combine solids with the kernel boolean operations
// (union / subtract / intersect).
// Solid creation and kernel modelling tools.
pub mod boolean_cmd;
pub mod edge_cmd;
pub mod primitive_cmd;
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
@@ -16,11 +12,20 @@ const BOX_ICON: &[u8] = include_bytes!("../../../assets/icons/box3d.svg");
const CYLINDER_ICON: &[u8] = include_bytes!("../../../assets/icons/cylinder3d.svg");
const CONE_ICON: &[u8] = include_bytes!("../../../assets/icons/cone3d.svg");
const SPHERE_ICON: &[u8] = include_bytes!("../../../assets/icons/sphere3d.svg");
const PYRAMID_ICON: &[u8] = include_bytes!("../../../assets/icons/pyramid3d.svg");
const WEDGE_ICON: &[u8] = include_bytes!("../../../assets/icons/wedge3d.svg");
const TORUS_ICON: &[u8] = include_bytes!("../../../assets/icons/torus3d.svg");
const POLYSOLID_ICON: &[u8] = include_bytes!("../../../assets/icons/polysolid.svg");
const EXTRUDE_ICON: &[u8] = include_bytes!("../../../assets/icons/extrude.svg");
const REVOLVE_ICON: &[u8] = include_bytes!("../../../assets/icons/revolve.svg");
const LOFT_ICON: &[u8] = include_bytes!("../../../assets/icons/loft.svg");
const SWEEP_ICON: &[u8] = include_bytes!("../../../assets/icons/sweep.svg");
const PRESSPULL_ICON: &[u8] = include_bytes!("../../../assets/icons/presspull.svg");
const UNION_ICON: &[u8] = include_bytes!("../../../assets/icons/union.svg");
const SUBTRACT_ICON: &[u8] = include_bytes!("../../../assets/icons/subtract.svg");
const INTERSECT_ICON: &[u8] = include_bytes!("../../../assets/icons/intersect.svg");
const FILLET_ICON: &[u8] = include_bytes!("../../../assets/icons/fillet.svg");
const CHAMFER_ICON: &[u8] = include_bytes!("../../../assets/icons/chamfer.svg");
/// Helper to declare a ribbon tool that fires a named command.
fn tool(id: &'static str, label: &'static str, icon: &'static [u8]) -> ToolDef {
@@ -37,7 +42,7 @@ impl CadModule for ModelModule {
"model"
}
fn title(&self) -> &'static str {
"Model"
"Modelling"
}
fn ribbon_groups(&self) -> &[RibbonGroup] {
@@ -45,31 +50,46 @@ impl CadModule for ModelModule {
GROUPS.get_or_init(|| {
vec![
RibbonGroup {
title: "Model",
title: "Create",
tools: vec![
RibbonItem::LargeTool(tool("BOX", "Box", BOX_ICON)),
RibbonItem::LargeTool(tool("CYLINDER", "Cylinder", CYLINDER_ICON)),
RibbonItem::LargeTool(tool("CONE", "Cone", CONE_ICON)),
RibbonItem::LargeTool(tool("SPHERE", "Sphere", SPHERE_ICON)),
RibbonItem::Dropdown {
id: "MODEL_MORE",
icon: IconKind::Svg(WEDGE_ICON),
RibbonItem::LargeDropdown {
id: "MODEL_PRIMITIVES",
label: "Box",
icon: IconKind::Svg(BOX_ICON),
items: vec![
("BOX", "Box", IconKind::Svg(BOX_ICON)),
("CYLINDER", "Cylinder", IconKind::Svg(CYLINDER_ICON)),
("CONE", "Cone", IconKind::Svg(CONE_ICON)),
("SPHERE", "Sphere", IconKind::Svg(SPHERE_ICON)),
("PYRAMID", "Pyramid", IconKind::Svg(PYRAMID_ICON)),
("WEDGE", "Wedge", IconKind::Svg(WEDGE_ICON)),
("TORUS", "Torus", IconKind::Svg(TORUS_ICON)),
("POLYSOLID", "Polysolid", IconKind::Svg(POLYSOLID_ICON)),
],
default: "WEDGE",
default: "BOX",
},
RibbonItem::LargeTool(tool("EXTRUDE", "Extrude", EXTRUDE_ICON)),
RibbonItem::LargeTool(tool("REVOLVE", "Revolve", REVOLVE_ICON)),
RibbonItem::LargeTool(tool("LOFT", "Loft", LOFT_ICON)),
RibbonItem::LargeTool(tool("SWEEP", "Sweep", SWEEP_ICON)),
RibbonItem::LargeTool(tool("PRESSPULL", "Presspull", PRESSPULL_ICON)),
],
},
RibbonGroup {
title: "Design",
title: "Boolean",
tools: vec![
RibbonItem::LargeTool(tool("UNION", "Union", UNION_ICON)),
RibbonItem::LargeTool(tool("SUBTRACT", "Subtract", SUBTRACT_ICON)),
RibbonItem::LargeTool(tool("INTERSECT", "Intersect", INTERSECT_ICON)),
],
},
RibbonGroup {
title: "Edges",
tools: vec![
RibbonItem::LargeTool(tool("SOLIDFILLET", "Fillet", FILLET_ICON)),
RibbonItem::LargeTool(tool("SOLIDCHAMFER", "Chamfer", CHAMFER_ICON)),
],
},
]
})
}
+64 -36
View File
@@ -1,22 +1,15 @@
// 3D primitive creation — BOX / CYLINDER / CONE / SPHERE / WEDGE / PYRAMID /
// TORUS. Each is placed CAD-style with a few clicks (planar footprint first,
// then a height value), then built as a real ACIS `Solid3D` via acadrust's
// `acis::primitives` builders. `Scene::add_entity` tessellates the SAT B-rep
// into the 3D mesh pipeline, so the solid renders, selects, and saves to DXF.
//
// A matching the kernel `Solid` is cached on the scene (see model/mod.rs) when the
// entity is committed, so the Design-group boolean tools can combine it.
// Interactive kernel primitives stored as ACIS Solid3D entities.
use acadrust::entities::Solid3D;
use acadrust::objects::SolidHistoryOperation;
use acadrust::{primitives, EntityType};
use glam::DVec3;
use crate::t;
use acadrust::EntityType;
use cadkernel::brep::Body;
use glam::DVec3;
use crate::command::{CadCommand, CmdResult, WorkingPlane};
use crate::scene::model::solid_model;
use crate::scene::model::wire_model::WireModel;
use crate::t;
/// Which primitive a `PrimitiveCommand` builds.
#[derive(Clone, Copy, PartialEq, Eq)]
@@ -26,6 +19,7 @@ pub enum Shape {
Cylinder,
Cone,
Sphere,
Pyramid,
Torus,
}
@@ -37,6 +31,7 @@ impl Shape {
"CYLINDER" => Shape::Cylinder,
"CONE" => Shape::Cone,
"SPHERE" => Shape::Sphere,
"PYRAMID" | "PYR" => Shape::Pyramid,
"TORUS" => Shape::Torus,
_ => return None,
})
@@ -48,6 +43,7 @@ impl Shape {
Shape::Cylinder => "CYLINDER",
Shape::Cone => "CONE",
Shape::Sphere => "SPHERE",
Shape::Pyramid => "PYRAMID",
Shape::Torus => "TORUS",
}
}
@@ -129,12 +125,11 @@ impl PrimitiveCommand {
.to_cols_array()
}
/// Build both the acadrust `Solid3D` (ACIS, for persistence) and the
/// kernel `Body` (rendering + booleans) from the footprint + `height`.
fn build(&self, height: f64) -> Option<(EntityType, Body, SolidHistoryOperation)> {
/// Build the persistent kernel body and its history node.
fn build(&self, height: f64) -> Option<(Body, SolidHistoryOperation)> {
use crate::scene::model::solid_history;
let (doc, solid, history) = match self.shape {
let (solid, history) = match self.shape {
Shape::Box | Shape::Wedge => {
let (a, b) = (self.pts[0], self.pts[1]);
let length = (b.x - a.x).abs();
@@ -150,7 +145,6 @@ impl PrimitiveCommand {
a.z + height / 2.0,
];
(
primitives::build_box(center, length, width, height),
solid_model::box_solid(center, length, width, height),
solid_history::box_op(
self.history_transform(origin),
@@ -162,7 +156,6 @@ impl PrimitiveCommand {
} else {
let origin = [a.x.min(b.x), a.y.min(b.y), a.z];
(
primitives::build_wedge(origin, length, width, height),
solid_model::wedge_solid(origin, length, width, height),
solid_history::wedge_op(
self.history_transform(DVec3::from_array(origin)),
@@ -182,7 +175,6 @@ impl PrimitiveCommand {
let center = [c.x, c.y, c.z];
if self.shape == Shape::Cylinder {
(
primitives::build_cylinder(center, r, height),
solid_model::cylinder_solid(center, r, height),
solid_history::cylinder_op(
self.history_transform(c),
@@ -192,7 +184,6 @@ impl PrimitiveCommand {
)
} else {
(
primitives::build_cone(center, r, height),
solid_model::cone_solid(center, r, height),
solid_history::cone_op(
self.history_transform(c),
@@ -210,11 +201,21 @@ impl PrimitiveCommand {
}
let center = [c.x, c.y, c.z];
(
primitives::build_sphere(center, r),
solid_model::sphere_solid(center, r),
solid_history::sphere_op(self.history_transform(c), r),
)
}
Shape::Pyramid => {
let c = self.pts[0];
let r = (self.pts[1] - c).length();
if r < 1e-6 || height < 1e-6 {
return None;
}
(
solid_model::pyramid_solid([c.x, c.y, c.z], r, height, 4),
solid_history::pyramid_op(self.history_transform(c), r, height, 4),
)
}
Shape::Torus => {
let c = self.pts[0];
let first = (self.pts[1] - c).length();
@@ -228,7 +229,6 @@ impl PrimitiveCommand {
let minor = (outer - inner) * 0.5;
let center = [c.x, c.y, c.z];
(
primitives::build_torus(center, major, minor),
solid_model::torus_solid(center, major, minor),
solid_history::torus_op(
self.history_transform(c),
@@ -239,14 +239,12 @@ impl PrimitiveCommand {
}
};
let solid = solid?;
let mut s3d = Solid3D::new();
s3d.set_sat_document(&doc);
Some((EntityType::Solid3D(s3d), solid, history))
Some((solid, history))
}
fn commit(&self, height: f64) -> CmdResult {
match self.build(height) {
Some((entity, solid, history)) => {
Some((solid, history)) => {
// Built upright in its own frame, then put on the working
// plane — the same move `place_entity` makes for the ACIS
// copy, so the two stay on top of each other.
@@ -262,11 +260,21 @@ impl PrimitiveCommand {
],
);
match placed {
Some(placed) => CmdResult::CommitSolid {
entity: self.plane.place_entity(entity),
solid: Box::new(placed),
history,
},
Some(placed) => {
let Some(document) =
crate::scene::convert::acis_export::solid_to_sat(&placed)
else {
return CmdResult::Cancel;
};
let mut entity = Solid3D::new();
entity.set_sat_document(&document);
entity.wires = solid_model::edge_wires(&placed);
CmdResult::CommitSolid {
entity: EntityType::Solid3D(entity),
solid: Box::new(placed),
history,
}
}
None => CmdResult::Cancel,
}
}
@@ -390,7 +398,15 @@ impl CadCommand for PrimitiveCommand {
fn footprint_wire(shape: Shape, pts: &[DVec3]) -> WireModel {
let mut points: Vec<[f32; 3]> = Vec::new();
if shape.radial() {
if shape == Shape::Pyramid {
let center = pts[0];
let radius = (pts[1] - center).length();
let corners: [DVec3; 4] = std::array::from_fn(|index| {
let angle = index as f64 * std::f64::consts::FRAC_PI_2;
center + DVec3::new(radius * angle.cos(), radius * angle.sin(), 0.0)
});
push_loop(&mut points, &corners);
} else if shape.radial() {
let c = pts[0];
let r = (pts[1] - c).length();
circle_points(&mut points, c, r);
@@ -446,9 +462,21 @@ fn height_wire(shape: Shape, pts: &[DVec3], height: f64) -> WireModel {
push_segment(&mut points, low[i], high[i]);
}
}
Shape::Cylinder | Shape::Cone => {
Shape::Cylinder | Shape::Cone | Shape::Pyramid => {
let center = pts[0];
let radius = (pts[1] - center).length();
if shape == Shape::Pyramid {
let base: [DVec3; 4] = std::array::from_fn(|index| {
let angle = index as f64 * std::f64::consts::FRAC_PI_2;
center + DVec3::new(radius * angle.cos(), radius * angle.sin(), 0.0)
});
push_loop(&mut points, &base);
let apex = center + DVec3::Z * height;
for corner in base {
push_segment(&mut points, corner, apex);
}
return wire("primitive_height_preview", points);
}
push_circle(&mut points, center, radius);
if shape == Shape::Cylinder {
push_circle(&mut points, center + DVec3::Z * height, radius);
@@ -506,7 +534,7 @@ fn circle_points(out: &mut Vec<[f32; 3]>, c: DVec3, r: f64) {
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration {
names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "TORUS"]
names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "PYRAMID", "PYR", "TORUS"]
});
fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel {
@@ -522,9 +550,9 @@ fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel {
render_instance: None,
pick_tris: Vec::new(),
pick_tris_low: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
dash_from_start: false,
dash_align_end: None,
text_verts: Vec::new(),
name: name.into(),
points,
points_low: Vec::new(),