forked from 1b-agent/opencadstudio-upstream
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:
1 parent
4acb820e01
commit
7bf9ce31ae
17 files changed
+1095
-397
No files matched your search
@@ -23,7 +23,7 @@ impl CadModule for HomeModule {
|
||||
"home"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"Home"
|
||||
"Draw"
|
||||
}
|
||||
|
||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||
|
||||
@@ -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
|
||||
@@ -134,6 +134,7 @@ pub trait CadModule: Send + Sync {
|
||||
pub mod annotate;
|
||||
pub mod home;
|
||||
pub mod insert;
|
||||
pub mod model;
|
||||
pub mod layout;
|
||||
pub mod manage;
|
||||
pub mod view;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Boolean operations on 3D solids (Design group). The actual CSG runs in
|
||||
// `App::solid_boolean` (src/app/model_ops.rs) using truck-shapeops on the
|
||||
// session-cached truck B-reps; this module just names the operations.
|
||||
|
||||
/// Which boolean a Design-group tool performs on the two selected solids.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub enum BoolOp {
|
||||
Union,
|
||||
Subtract,
|
||||
Intersect,
|
||||
}
|
||||
|
||||
impl BoolOp {
|
||||
pub fn from_id(id: &str) -> Option<BoolOp> {
|
||||
Some(match id {
|
||||
"UNION" => BoolOp::Union,
|
||||
"SUBTRACT" => BoolOp::Subtract,
|
||||
"INTERSECT" => BoolOp::Intersect,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["UNION", "SUBTRACT", "INTERSECT"]
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// 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 truck boolean operations
|
||||
// (union / subtract / intersect).
|
||||
|
||||
pub mod boolean_cmd;
|
||||
pub mod primitive_cmd;
|
||||
|
||||
use crate::modules::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
|
||||
|
||||
pub struct ModelModule;
|
||||
|
||||
/// Helper to declare a ribbon tool that fires a named command.
|
||||
fn tool(id: &'static str, label: &'static str, glyph: &'static str) -> ToolDef {
|
||||
ToolDef {
|
||||
id,
|
||||
label,
|
||||
icon: IconKind::Glyph(glyph),
|
||||
event: ModuleEvent::Command(id.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
impl CadModule for ModelModule {
|
||||
fn id(&self) -> &'static str {
|
||||
"model"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"Model"
|
||||
}
|
||||
|
||||
fn ribbon_groups(&self) -> Vec<RibbonGroup> {
|
||||
vec![
|
||||
RibbonGroup {
|
||||
title: "Model",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(tool("BOX", "Box", "▦")),
|
||||
RibbonItem::LargeTool(tool("CYLINDER", "Cylinder", "⬭")),
|
||||
RibbonItem::LargeTool(tool("CONE", "Cone", "△")),
|
||||
RibbonItem::LargeTool(tool("SPHERE", "Sphere", "◯")),
|
||||
RibbonItem::Dropdown {
|
||||
id: "MODEL_MORE",
|
||||
icon: IconKind::Glyph("◰"),
|
||||
items: vec![
|
||||
("WEDGE", "Wedge", IconKind::Glyph("◣")),
|
||||
("TORUS", "Torus", IconKind::Glyph("◎")),
|
||||
],
|
||||
default: "WEDGE",
|
||||
},
|
||||
],
|
||||
},
|
||||
RibbonGroup {
|
||||
title: "Design",
|
||||
tools: vec![
|
||||
RibbonItem::LargeTool(tool("UNION", "Union", "⊕")),
|
||||
RibbonItem::LargeTool(tool("SUBTRACT", "Subtract", "⊖")),
|
||||
RibbonItem::LargeTool(tool("INTERSECT", "Intersect", "⊗")),
|
||||
],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
// 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 truck `Solid` is cached on the scene (see model/mod.rs) when the
|
||||
// entity is committed, so the Design-group boolean tools can combine it.
|
||||
|
||||
use acadrust::entities::Solid3D;
|
||||
use acadrust::{primitives, EntityType};
|
||||
use glam::Vec3;
|
||||
use truck_modeling::Solid;
|
||||
|
||||
use crate::command::{CadCommand, CmdResult};
|
||||
use crate::scene::model_solid;
|
||||
use crate::scene::wire_model::WireModel;
|
||||
|
||||
/// Which primitive a `PrimitiveCommand` builds.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Shape {
|
||||
Box,
|
||||
Wedge,
|
||||
Cylinder,
|
||||
Cone,
|
||||
Sphere,
|
||||
Torus,
|
||||
}
|
||||
|
||||
impl Shape {
|
||||
fn from_id(id: &str) -> Option<Shape> {
|
||||
Some(match id {
|
||||
"BOX" => Shape::Box,
|
||||
"WEDGE" => Shape::Wedge,
|
||||
"CYLINDER" => Shape::Cylinder,
|
||||
"CONE" => Shape::Cone,
|
||||
"SPHERE" => Shape::Sphere,
|
||||
"TORUS" => Shape::Torus,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Shape::Box => "BOX",
|
||||
Shape::Wedge => "WEDGE",
|
||||
Shape::Cylinder => "CYLINDER",
|
||||
Shape::Cone => "CONE",
|
||||
Shape::Sphere => "SPHERE",
|
||||
Shape::Torus => "TORUS",
|
||||
}
|
||||
}
|
||||
/// True for footprints picked as a centre + radius (round shapes); false
|
||||
/// for corner-to-corner footprints (box/wedge).
|
||||
fn radial(self) -> bool {
|
||||
!matches!(self, Shape::Box | Shape::Wedge)
|
||||
}
|
||||
/// Whether a height value is collected after the footprint.
|
||||
fn needs_height(self) -> bool {
|
||||
!matches!(self, Shape::Sphere | Shape::Torus)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PrimitiveCommand {
|
||||
shape: Shape,
|
||||
/// Footprint points collected so far (local/world XY, z = 0).
|
||||
pts: Vec<Vec3>,
|
||||
/// True once the footprint is set and we are collecting the height.
|
||||
height_step: bool,
|
||||
}
|
||||
|
||||
impl PrimitiveCommand {
|
||||
pub fn new(id: &str) -> Self {
|
||||
Self {
|
||||
shape: Shape::from_id(id).unwrap_or(Shape::Box),
|
||||
pts: Vec::new(),
|
||||
height_step: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of footprint points the shape needs before the height step.
|
||||
fn footprint_pts(&self) -> usize {
|
||||
match self.shape {
|
||||
Shape::Torus => 3, // centre, major-radius, minor-radius
|
||||
_ => 2, // corner/corner or centre/radius
|
||||
}
|
||||
}
|
||||
|
||||
/// A reasonable default height when the user just presses Enter.
|
||||
fn default_height(&self) -> f64 {
|
||||
match self.shape {
|
||||
Shape::Box | Shape::Wedge => {
|
||||
let d = self.pts[1] - self.pts[0];
|
||||
(d.x.abs().max(d.y.abs())) as f64
|
||||
}
|
||||
_ => (self.pts[1] - self.pts[0]).length() as f64,
|
||||
}
|
||||
.max(1.0)
|
||||
}
|
||||
|
||||
/// Build both the acadrust `Solid3D` (ACIS, for persistence) and the truck
|
||||
/// `Solid` B-rep (rendering + booleans) from the footprint + `height`.
|
||||
fn build(&self, height: f64) -> Option<(EntityType, Solid)> {
|
||||
let (doc, solid) = match self.shape {
|
||||
Shape::Box | Shape::Wedge => {
|
||||
let (a, b) = (self.pts[0], self.pts[1]);
|
||||
let length = (b.x - a.x).abs() as f64;
|
||||
let width = (b.y - a.y).abs() as f64;
|
||||
if length < 1e-6 || width < 1e-6 || height < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
if self.shape == Shape::Box {
|
||||
let center = [
|
||||
(a.x + b.x) as f64 / 2.0,
|
||||
(a.y + b.y) as f64 / 2.0,
|
||||
height / 2.0,
|
||||
];
|
||||
(
|
||||
primitives::build_box(center, length, width, height),
|
||||
model_solid::box_solid(center, length, width, height),
|
||||
)
|
||||
} else {
|
||||
let origin = [a.x.min(b.x) as f64, a.y.min(b.y) as f64, 0.0];
|
||||
(
|
||||
primitives::build_wedge(origin, length, width, height),
|
||||
model_solid::wedge_solid(origin, length, width, height),
|
||||
)
|
||||
}
|
||||
}
|
||||
Shape::Cylinder | Shape::Cone => {
|
||||
let c = self.pts[0];
|
||||
let r = (self.pts[1] - c).length() as f64;
|
||||
if r < 1e-6 || height < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let center = [c.x as f64, c.y as f64, 0.0];
|
||||
if self.shape == Shape::Cylinder {
|
||||
(
|
||||
primitives::build_cylinder(center, r, height),
|
||||
model_solid::cylinder_solid(center, r, height),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
primitives::build_cone(center, r, height),
|
||||
model_solid::cone_solid(center, r, height),
|
||||
)
|
||||
}
|
||||
}
|
||||
Shape::Sphere => {
|
||||
let c = self.pts[0];
|
||||
let r = (self.pts[1] - c).length() as f64;
|
||||
if r < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let center = [c.x as f64, c.y as f64, 0.0];
|
||||
(
|
||||
primitives::build_sphere(center, r),
|
||||
model_solid::sphere_solid(center, r),
|
||||
)
|
||||
}
|
||||
Shape::Torus => {
|
||||
let c = self.pts[0];
|
||||
let major = (self.pts[1] - c).length() as f64;
|
||||
let minor = (self.pts[2] - self.pts[1]).length() as f64;
|
||||
if major < 1e-6 || minor < 1e-6 {
|
||||
return None;
|
||||
}
|
||||
let center = [c.x as f64, c.y as f64, 0.0];
|
||||
(
|
||||
primitives::build_torus(center, major, minor),
|
||||
model_solid::torus_solid(center, major, minor),
|
||||
)
|
||||
}
|
||||
};
|
||||
let mut s3d = Solid3D::new();
|
||||
s3d.set_sat_document(&doc);
|
||||
// Edge wires make the solid click-pickable and draw a wireframe over
|
||||
// the shaded mesh.
|
||||
s3d.wires = model_solid::edge_wires(&solid);
|
||||
Some((EntityType::Solid3D(s3d), solid))
|
||||
}
|
||||
|
||||
fn commit(&self, height: f64) -> CmdResult {
|
||||
match self.build(height) {
|
||||
Some((entity, solid)) => CmdResult::CommitSolid {
|
||||
entity,
|
||||
solid: Box::new(solid),
|
||||
},
|
||||
None => CmdResult::Cancel,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CadCommand for PrimitiveCommand {
|
||||
fn name(&self) -> &'static str {
|
||||
self.shape.name()
|
||||
}
|
||||
|
||||
fn prompt(&self) -> String {
|
||||
let n = self.shape.name();
|
||||
if self.height_step {
|
||||
return format!("{n} Specify height <Enter for default>:");
|
||||
}
|
||||
match (self.shape.radial(), self.pts.len()) {
|
||||
(false, 0) => format!("{n} Specify first corner:"),
|
||||
(false, _) => format!("{n} Specify opposite corner:"),
|
||||
(true, 0) => format!("{n} Specify center point:"),
|
||||
(true, 1) => format!("{n} Specify radius:"),
|
||||
(true, _) => format!("{n} Specify tube radius:"),
|
||||
}
|
||||
}
|
||||
|
||||
fn on_point(&mut self, pt: Vec3) -> CmdResult {
|
||||
if self.height_step {
|
||||
// A click in the ground plane has no Z; use its distance from the
|
||||
// footprint centre as the height magnitude.
|
||||
let h = (pt - self.pts[0]).length() as f64;
|
||||
return self.commit(h.max(1e-6));
|
||||
}
|
||||
self.pts.push(pt);
|
||||
if self.pts.len() < self.footprint_pts() {
|
||||
return CmdResult::NeedPoint;
|
||||
}
|
||||
// Footprint complete.
|
||||
if self.shape.needs_height() {
|
||||
self.height_step = true;
|
||||
CmdResult::NeedPoint
|
||||
} else {
|
||||
self.commit(0.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn on_enter(&mut self) -> CmdResult {
|
||||
if self.height_step {
|
||||
let h = self.default_height();
|
||||
return self.commit(h);
|
||||
}
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn on_escape(&mut self) -> CmdResult {
|
||||
CmdResult::Cancel
|
||||
}
|
||||
|
||||
fn wants_text_input(&self) -> bool {
|
||||
self.height_step
|
||||
}
|
||||
|
||||
fn on_text_input(&mut self, raw: &str) -> Option<CmdResult> {
|
||||
if !self.height_step {
|
||||
return None;
|
||||
}
|
||||
let h: f64 = raw.trim().parse().ok().filter(|v| *v > 0.0)?;
|
||||
Some(self.commit(h))
|
||||
}
|
||||
|
||||
fn on_mouse_move(&mut self, pt: Vec3) -> Option<WireModel> {
|
||||
if self.height_step || self.pts.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut foot = self.pts.clone();
|
||||
foot.push(pt);
|
||||
Some(footprint_wire(self.shape, &foot))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Footprint preview ───────────────────────────────────────────────────────
|
||||
|
||||
fn footprint_wire(shape: Shape, pts: &[Vec3]) -> WireModel {
|
||||
let mut points: Vec<[f32; 3]> = Vec::new();
|
||||
if shape.radial() {
|
||||
let c = pts[0];
|
||||
let r = (pts[1] - c).length();
|
||||
circle_points(&mut points, c, r);
|
||||
if shape == Shape::Torus && pts.len() >= 3 {
|
||||
// outer ring at major + minor for a quick torus hint
|
||||
let minor = (pts[2] - pts[1]).length();
|
||||
points.push([f32::NAN; 3]);
|
||||
circle_points(&mut points, c, r + minor);
|
||||
}
|
||||
} else {
|
||||
let (a, b) = (pts[0], pts[1]);
|
||||
points.extend_from_slice(&[
|
||||
[a.x, a.y, 0.0],
|
||||
[b.x, a.y, 0.0],
|
||||
[b.x, b.y, 0.0],
|
||||
[a.x, b.y, 0.0],
|
||||
[a.x, a.y, 0.0],
|
||||
]);
|
||||
}
|
||||
wire("primitive_preview", points)
|
||||
}
|
||||
|
||||
fn circle_points(out: &mut Vec<[f32; 3]>, c: Vec3, r: f32) {
|
||||
const SEG: usize = 48;
|
||||
for i in 0..=SEG {
|
||||
let t = i as f32 / SEG as f32 * std::f32::consts::TAU;
|
||||
out.push([c.x + r * t.cos(), c.y + r * t.sin(), 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Autocomplete registry ─────────────────────────────────
|
||||
inventory::submit!(crate::command::CommandRegistration {
|
||||
names: &["BOX", "WEDGE", "CYLINDER", "CONE", "SPHERE", "TORUS"]
|
||||
});
|
||||
|
||||
fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel {
|
||||
WireModel {
|
||||
name: name.into(),
|
||||
points,
|
||||
color: WireModel::CYAN,
|
||||
selected: false,
|
||||
pattern_length: 0.0,
|
||||
pattern: [0.0; 8],
|
||||
line_weight_px: 1.0,
|
||||
snap_pts: vec![],
|
||||
tangent_geoms: vec![],
|
||||
aci: 0,
|
||||
key_vertices: vec![],
|
||||
aabb: WireModel::UNBOUNDED_AABB,
|
||||
plinegen: true,
|
||||
vp_scissor: None,
|
||||
fill_tris: vec![],
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use crate::modules::CadModule;
|
||||
pub fn all_modules() -> Vec<Box<dyn CadModule>> {
|
||||
vec![
|
||||
Box::new(super::home::HomeModule),
|
||||
Box::new(super::model::ModelModule),
|
||||
Box::new(super::insert::InsertModule),
|
||||
Box::new(super::annotate::AnnotateModule),
|
||||
Box::new(super::view::ViewModule),
|
||||
|
||||
Reference in new issue
Block a user