feat(model): 3D solid modelling tab — primitives + boolean ops

Rename the Home tab to "Draw" and add a Model tab beside it with two
groups:

- Model: BOX / CYLINDER / CONE / SPHERE / WEDGE / TORUS, placed with a
  footprint + height. Each is built as a real ACIS Solid3D (acadrust
  primitive builders) plus a truck B-rep, tessellated into the shaded
  mesh pipeline, with edge wires for picking + wireframe.
- Design: UNION / SUBTRACT / INTERSECT via truck-shapeops.

A session-only Scene.model_solids cache holds each solid's truck B-rep
so the boolean tools can combine and chain them. Supersedes the old
command-line BOX/SPHERE/CYLINDER (insert::solid3d_cmds keeps EXTRUDE/
REVOLVE/SWEEP/LOFT).

Known limitations: booleans need solids created this session; curved
ACIS primitives render in-session but not after reload (the SAT
tessellator handles only planar caps); geometry assumes world_offset 0.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-05-31 19:27:06 +03:00
1 parent 4acb820e01
commit 7bf9ce31ae
17 files changed
+1095 -397

No files matched your search

+10 -345
View File
@@ -1,352 +1,20 @@
// 3D solid primitive commands — BOX, SPHERE, CYLINDER
// and 2D→3D extrusion commands — EXTRUDE, REVOLVE.
// 2D→3D modelling commands — EXTRUDE, REVOLVE, SWEEP, LOFT.
//
// All create a minimal Solid3D entity (empty ACIS, just as a document
// placeholder to hold the handle) and a MeshModel built with truck.
// The mesh is manually inserted into scene.meshes so it renders immediately.
//
// Round-trip limitation: saving and reopening the file will not restore
// the mesh because the ACIS data is empty. Full ACIS generation requires
// a separate step (out of scope here).
//
// Coordinate convention in OpenCADStudio: the viewport is Y-up (OpenGL style),
// so screen X→DXF X, screen Z→DXF Y, screen Y→DXF Z (height).
// truck works in standard math coordinates; we map accordingly.
// Each picks profile/path entities and emits a `CmdResult` whose handler
// builds a truck solid and inserts its MeshModel into scene.meshes under a
// minimal placeholder Solid3D entity (empty ACIS — saving will not restore
// the mesh). The standalone primitives (BOX/CYLINDER/CONE/SPHERE/WEDGE/TORUS)
// live in the Model tab (`modules::model::primitive_cmd`).
use acadrust::{entities::Solid3D, EntityType};
use glam::Vec3;
use truck_modeling::builder;
use truck_modeling::{Point3, Rad, Vector3 as TruckVec3};
use crate::command::{CadCommand, CmdResult};
use crate::scene::mesh_model::MeshModel;
use crate::scene::truck_tess;
// ── Tessellation helper ────────────────────────────────────────────────────
fn solid_to_mesh(solid: &truck_modeling::Solid, color: [f32; 4], name: &str) -> Option<MeshModel> {
match truck_tess::tessellate_solid(solid, [0.0; 3]) {
truck_tess::TruckTessResult::Mesh {
verts,
normals,
indices,
} => Some(MeshModel {
name: name.to_string(),
verts,
normals,
indices,
color,
selected: false,
}),
_ => None,
}
}
fn shell_to_mesh(shell: &truck_modeling::Shell, color: [f32; 4], name: &str) -> Option<MeshModel> {
match truck_tess::tessellate_shell(shell, [0.0; 3]) {
truck_tess::TruckTessResult::Mesh {
verts,
normals,
indices,
} => Some(MeshModel {
name: name.to_string(),
verts,
normals,
indices,
color,
selected: false,
}),
_ => None,
}
}
// ── BOX command ────────────────────────────────────────────────────────────
pub struct BoxCommand {
step: BoxStep,
p1: Vec3,
p2: Vec3,
color: [f32; 4],
}
#[derive(PartialEq)]
enum BoxStep {
Corner1,
Corner2,
Height,
}
impl BoxCommand {
pub fn new(color: [f32; 4]) -> Self {
Self {
step: BoxStep::Corner1,
p1: Vec3::ZERO,
p2: Vec3::ZERO,
color,
}
}
}
impl CadCommand for BoxCommand {
fn name(&self) -> &'static str {
"BOX"
}
fn prompt(&self) -> String {
match self.step {
BoxStep::Corner1 => "BOX First corner:".into(),
BoxStep::Corner2 => "BOX Opposite corner (XY):".into(),
BoxStep::Height => "BOX Height:".into(),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match self.step {
BoxStep::Corner1 => {
self.p1 = pt;
self.step = BoxStep::Corner2;
CmdResult::NeedPoint
}
BoxStep::Corner2 => {
self.p2 = pt;
self.step = BoxStep::Height;
CmdResult::NeedPoint
}
BoxStep::Height => commit_box(
self.p1,
self.p2,
(pt.y - self.p1.y).abs().max(1e-4),
self.color,
),
}
}
fn wants_text_input(&self) -> bool {
self.step == BoxStep::Height
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
text.trim()
.parse::<f32>()
.ok()
.filter(|&h| h.abs() > 1e-6)
.map(|h| commit_box(self.p1, self.p2, h.abs(), self.color))
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
fn commit_box(p1: Vec3, p2: Vec3, height: f32, color: [f32; 4]) -> CmdResult {
// Map OpenCADStudio coords to truck: x→x, z→y, y→z
let x0 = p1.x.min(p2.x) as f64;
let y0 = p1.z.min(p2.z) as f64;
let x1 = p1.x.max(p2.x) as f64;
let y1 = p1.z.max(p2.z) as f64;
let z0 = p1.y as f64;
let h = height as f64;
// Build face at z=z0, then sweep to z0+h.
let v00 = builder::vertex(Point3::new(x0, y0, z0));
let v10 = builder::vertex(Point3::new(x1, y0, z0));
let v11 = builder::vertex(Point3::new(x1, y1, z0));
let v01 = builder::vertex(Point3::new(x0, y1, z0));
let e0 = builder::line(&v00, &v10);
let e1 = builder::line(&v10, &v11);
let e2 = builder::line(&v11, &v01);
let e3 = builder::line(&v01, &v00);
let wire: truck_modeling::Wire = [e0, e1, e2, e3].into_iter().collect();
let face = match builder::try_attach_plane(&[wire]) {
Ok(f) => f,
Err(_) => return CmdResult::Cancel,
};
// tsweep on Face → Solid
let solid = builder::tsweep(&face, TruckVec3::new(0.0, 0.0, h));
CmdResult::CommitSolid3D {
mesh_fn: Box::new(move |name| solid_to_mesh(&solid, color, &name)),
}
}
// ── SPHERE command ─────────────────────────────────────────────────────────
pub struct SphereCommand {
step: SphereStep,
center: Vec3,
color: [f32; 4],
}
#[derive(PartialEq)]
enum SphereStep {
Center,
Radius,
}
impl SphereCommand {
pub fn new(color: [f32; 4]) -> Self {
Self {
step: SphereStep::Center,
center: Vec3::ZERO,
color,
}
}
}
impl CadCommand for SphereCommand {
fn name(&self) -> &'static str {
"SPHERE"
}
fn prompt(&self) -> String {
match self.step {
SphereStep::Center => "SPHERE Center:".into(),
SphereStep::Radius => "SPHERE Radius:".into(),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match self.step {
SphereStep::Center => {
self.center = pt;
self.step = SphereStep::Radius;
CmdResult::NeedPoint
}
SphereStep::Radius => {
commit_sphere(self.center, (pt - self.center).length(), self.color)
}
}
}
fn wants_text_input(&self) -> bool {
self.step == SphereStep::Radius
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
text.trim()
.parse::<f32>()
.ok()
.filter(|&r| r > 1e-6)
.map(|r| commit_sphere(self.center, r, self.color))
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
fn commit_sphere(center: Vec3, radius: f32, color: [f32; 4]) -> CmdResult {
let cx = center.x as f64;
let cy = center.z as f64;
let cz = center.y as f64;
let r = radius as f64;
// Build a half-circle arc wire from north pole to south pole (XZ plane).
let north = builder::vertex(Point3::new(cx, cy, cz + r));
let south = builder::vertex(Point3::new(cx, cy, cz - r));
let east = Point3::new(cx + r, cy, cz);
let arc = builder::circle_arc(&north, &south, east);
// rsweep the wire around the Z axis for a full revolution → Shell.
let wire: truck_modeling::Wire = std::iter::once(arc).collect();
let axis_pt = Point3::new(cx, cy, cz);
let axis = TruckVec3::new(0.0, 0.0, 1.0);
let shell = builder::rsweep(&wire, axis_pt, axis, Rad(std::f64::consts::TAU));
CmdResult::CommitSolid3D {
mesh_fn: Box::new(move |name| shell_to_mesh(&shell, color, &name)),
}
}
// ── CYLINDER command ───────────────────────────────────────────────────────
pub struct CylinderCommand {
step: CylStep,
center: Vec3,
radius: f32,
color: [f32; 4],
}
#[derive(PartialEq)]
enum CylStep {
Center,
Radius,
Height,
}
impl CylinderCommand {
pub fn new(color: [f32; 4]) -> Self {
Self {
step: CylStep::Center,
center: Vec3::ZERO,
radius: 1.0,
color,
}
}
}
impl CadCommand for CylinderCommand {
fn name(&self) -> &'static str {
"CYLINDER"
}
fn prompt(&self) -> String {
match self.step {
CylStep::Center => "CYLINDER Center of base:".into(),
CylStep::Radius => "CYLINDER Radius:".into(),
CylStep::Height => "CYLINDER Height:".into(),
}
}
fn on_point(&mut self, pt: Vec3) -> CmdResult {
match self.step {
CylStep::Center => {
self.center = pt;
self.step = CylStep::Radius;
CmdResult::NeedPoint
}
CylStep::Radius => {
self.radius = (pt - self.center).length().max(1e-4);
self.step = CylStep::Height;
CmdResult::NeedPoint
}
CylStep::Height => commit_cylinder(
self.center,
self.radius,
(pt.y - self.center.y).abs().max(1e-4),
self.color,
),
}
}
fn wants_text_input(&self) -> bool {
matches!(self.step, CylStep::Radius | CylStep::Height)
}
fn on_text_input(&mut self, text: &str) -> Option<CmdResult> {
let v = text.trim().parse::<f32>().ok().filter(|&v| v > 1e-6)?;
match self.step {
CylStep::Radius => {
self.radius = v;
self.step = CylStep::Height;
Some(CmdResult::NeedPoint)
}
CylStep::Height => Some(commit_cylinder(self.center, self.radius, v, self.color)),
_ => None,
}
}
fn on_enter(&mut self) -> CmdResult {
CmdResult::Cancel
}
}
fn commit_cylinder(center: Vec3, radius: f32, height: f32, color: [f32; 4]) -> CmdResult {
let cx = center.x as f64;
let cy = center.z as f64;
let cz = center.y as f64;
let r = radius as f64;
let h = height as f64;
// Build circle face at z=cz, sweep upward.
let right = builder::vertex(Point3::new(cx + r, cy, cz));
let left = builder::vertex(Point3::new(cx - r, cy, cz));
let top_t = Point3::new(cx, cy + r, cz);
let bot_t = Point3::new(cx, cy - r, cz);
let upper = builder::circle_arc(&right, &left, top_t);
let lower = builder::circle_arc(&left, &right, bot_t);
let wire: truck_modeling::Wire = [upper, lower].into_iter().collect();
let face = match builder::try_attach_plane(&[wire]) {
Ok(f) => f,
Err(_) => return CmdResult::Cancel,
};
let solid = builder::tsweep(&face, TruckVec3::new(0.0, 0.0, h));
CmdResult::CommitSolid3D {
mesh_fn: Box::new(move |name| solid_to_mesh(&solid, color, &name)),
}
}
// BOX / SPHERE / CYLINDER (and CONE / WEDGE / TORUS) now live in the Model tab
// (`modules::model::primitive_cmd`), which builds them as truck B-reps cached
// for the Design-group boolean tools. EXTRUDE / REVOLVE / SWEEP / LOFT remain
// here as 2D→3D operations.
// ── EXTRUDE command ────────────────────────────────────────────────────────
@@ -661,10 +329,7 @@ pub fn empty_solid3d() -> EntityType {
// ── Autocomplete registry ─────────────────────────────────
inventory::submit!(crate::command::CommandRegistration { names: &["BOX"] }); // BoxCommand
inventory::submit!(crate::command::CommandRegistration { names: &["CYLINDER"] }); // CylinderCommand
inventory::submit!(crate::command::CommandRegistration { names: &["EXT", "EXTRUDE"] }); // ExtrudeCommand
inventory::submit!(crate::command::CommandRegistration { names: &["LOFT"] }); // LoftCommand
inventory::submit!(crate::command::CommandRegistration { names: &["REV", "REVOLVE"] }); // RevolveCommand
inventory::submit!(crate::command::CommandRegistration { names: &["SPHERE"] }); // SphereCommand
inventory::submit!(crate::command::CommandRegistration { names: &["SWEEP"] }); // SweepCommand