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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-05-31 19:27:06 +03:00
commit 7bf9ce31ae
17 changed files with 1095 additions and 397 deletions

View file

@ -125,6 +125,16 @@ impl OpenCADStudio {
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
}
CmdResult::CommitSolid { entity, solid } => {
let label = self.history_label_from_active_cmd(i, "SOLID");
self.push_undo_snapshot(i, label);
self.add_model_solid(entity, *solid);
self.tabs[i].dirty = true;
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
self.tabs[i].snap_result = None;
self.restore_pre_cmd_tangent();
}
CmdResult::CommitAndEditText(entity) => {
let label = self.history_label_from_active_cmd(i, "ENTITY");
self.push_undo_snapshot(i, label);

View file

@ -1190,6 +1190,22 @@ impl OpenCADStudio {
}
}
// ── Model commands (3D primitives) ─────────────────────────────
"BOX" | "WEDGE" | "CYLINDER" | "CONE" | "SPHERE" | "TORUS" => {
use crate::modules::model::primitive_cmd::PrimitiveCommand;
let new_cmd = PrimitiveCommand::new(cmd);
self.command_line.push_info(&new_cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(new_cmd));
}
// ── Design commands (solid booleans) ───────────────────────────
"UNION" | "SUBTRACT" | "INTERSECT" => {
use crate::modules::model::boolean_cmd::BoolOp;
if let Some(op) = BoolOp::from_id(cmd) {
return self.solid_boolean(op);
}
}
// ── Annotate commands ──────────────────────────────────────────
"TEXT" | "T" | "DT" => {
use crate::modules::annotate::text::TextCommand;
@ -5038,32 +5054,8 @@ impl OpenCADStudio {
}
}
// ── 3D Primitive — BOX ────────────────────────────────────────
"BOX" => {
use crate::modules::insert::solid3d_cmds::BoxCommand;
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
let cmd = BoxCommand::new(color);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// ── 3D Primitive — SPHERE ─────────────────────────────────────
"SPHERE" => {
use crate::modules::insert::solid3d_cmds::SphereCommand;
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
let cmd = SphereCommand::new(color);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// ── 3D Primitive — CYLINDER ───────────────────────────────────
"CYLINDER" => {
use crate::modules::insert::solid3d_cmds::CylinderCommand;
let color = self.tabs[i].scene.layer_color(&self.tabs[i].active_layer);
let cmd = CylinderCommand::new(color);
self.command_line.push_info(&cmd.prompt());
self.tabs[i].active_cmd = Some(Box::new(cmd));
}
// BOX / SPHERE / CYLINDER / CONE / WEDGE / TORUS are handled by the
// Model-tab primitive command above (with truck boolean caching).
// ── EXTRUDE ────────────────────────────────────────────────────
"EXTRUDE" | "EXT" => {

View file

@ -5,6 +5,7 @@ mod helpers;
mod history;
mod layers;
mod mtext_editor;
mod model_ops;
mod properties;
mod text_inline;
mod update;

75
src/app/model_ops.rs Normal file
View file

@ -0,0 +1,75 @@
// 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::{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_model_solid(&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_model_solid(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<Message> {
let i = self.active_tab;
// Selected entities that have a cached truck B-rep.
let handles: Vec<Handle> = self.tabs[i]
.scene
.selected
.iter()
.copied()
.filter(|h| self.tabs[i].scene.model_solids.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.model_solids[&handles[0]].clone();
let b = self.tabs[i].scene.model_solids[&handles[1]].clone();
let kind = match op {
BoolOp::Union => Bool::Union,
BoolOp::Subtract => Bool::Subtract,
BoolOp::Intersect => Bool::Intersect,
};
let Some(result) = model_solid::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 = model_solid::edge_wires(&result);
let handle = self.add_model_solid(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()
}
}