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:
parent
4acb820e01
commit
7bf9ce31ae
17 changed files with 1095 additions and 397 deletions
|
|
@ -10,6 +10,7 @@ pub mod hatch_patterns;
|
|||
pub mod hit_test;
|
||||
pub mod image_model;
|
||||
pub mod mesh_model;
|
||||
pub mod model_solid;
|
||||
pub mod object;
|
||||
pub mod paper_canvas;
|
||||
pub mod pipeline;
|
||||
|
|
@ -614,6 +615,11 @@ pub struct Scene {
|
|||
pub hatches: HashMap<Handle, HatchModel>,
|
||||
/// GPU render data for solid meshes (truck Shell/Solid tessellation).
|
||||
pub meshes: HashMap<Handle, MeshLodSet>,
|
||||
/// Live truck B-reps for solids created this session by the Model tab,
|
||||
/// keyed by entity handle. Backs the Design-group boolean tools (a solid
|
||||
/// must be here to be combined). Not persisted — rebuilt only by creating
|
||||
/// or combining primitives in-session.
|
||||
pub model_solids: HashMap<Handle, truck_modeling::Solid>,
|
||||
/// GPU render data for raster images (RasterImage entities), keyed by handle.
|
||||
pub images: HashMap<Handle, ImageModel>,
|
||||
/// The viewport that is currently "entered" (MSPACE mode).
|
||||
|
|
@ -703,6 +709,7 @@ impl Scene {
|
|||
current_layout: "Model".to_string(),
|
||||
hatches: HashMap::new(),
|
||||
meshes: HashMap::new(),
|
||||
model_solids: HashMap::new(),
|
||||
images: HashMap::new(),
|
||||
active_viewport: None,
|
||||
bg_color: [0.11, 0.11, 0.11, 1.0],
|
||||
|
|
@ -833,6 +840,23 @@ impl Scene {
|
|||
}
|
||||
|
||||
/// Re-evaluate every cached mesh's color through `render_style` so a
|
||||
/// Register a Model-tab solid: cache its truck B-rep (for boolean ops) and
|
||||
/// tessellate it into the shaded mesh pipeline under `handle`. The solid is
|
||||
/// in the same offset-relative frame the mesh pipeline uses, so the mesh is
|
||||
/// stored as-is (Model-tab geometry is authored at world_offset 0).
|
||||
pub fn register_model_solid(&mut self, handle: Handle, solid: truck_modeling::Solid) {
|
||||
let color = self
|
||||
.document
|
||||
.get_entity(handle)
|
||||
.map(|e| self.render_style(e).0)
|
||||
.unwrap_or([0.8, 0.8, 0.85, 1.0]);
|
||||
if let Some(set) = crate::scene::model_solid::mesh_from_solid(&solid, color) {
|
||||
self.meshes.insert(handle, set);
|
||||
}
|
||||
self.model_solids.insert(handle, solid);
|
||||
self.bump_geometry();
|
||||
}
|
||||
|
||||
/// `BACKGROUND` change picks up the new `adapt_to_bg` result without
|
||||
/// re-tessellating ACIS geometry. Caller must bump `geometry_epoch`
|
||||
/// afterwards so the GPU re-uploads the now-updated colour data.
|
||||
|
|
@ -2711,6 +2735,7 @@ impl Scene {
|
|||
for h in &to_remove {
|
||||
self.hatches.remove(h);
|
||||
self.meshes.remove(h);
|
||||
self.model_solids.remove(h);
|
||||
self.document.remove_entity(*h);
|
||||
}
|
||||
|
||||
|
|
@ -4070,6 +4095,7 @@ impl Scene {
|
|||
self.selected.remove(&h);
|
||||
self.hatches.remove(&h);
|
||||
self.meshes.remove(&h);
|
||||
self.model_solids.remove(&h);
|
||||
}
|
||||
// Remove erased handles from all groups; delete groups that become empty.
|
||||
let group_dict_handle = self.document.header.acad_group_dict_handle;
|
||||
|
|
|
|||
216
src/scene/model_solid.rs
Normal file
216
src/scene/model_solid.rs
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
// truck B-rep construction for the Model tab's primitives, plus tessellation
|
||||
// into the renderer's `MeshLodSet`. Each builder follows truck's own example
|
||||
// recipes (tsweep / rsweep / cone / try_attach_plane), oriented Z-up with the
|
||||
// footprint on the z = `base_z` plane to match acadrust's `acis::primitives`.
|
||||
//
|
||||
// The resulting truck `Solid` is cached per entity handle on the Scene so the
|
||||
// Design-group boolean tools can run truck-shapeops on it.
|
||||
|
||||
use truck_modeling::{builder, Point3, Rad, Solid, Vector3, Wire};
|
||||
|
||||
use crate::scene::mesh_model::{MeshLodSet, MeshModel};
|
||||
|
||||
const FULL: f64 = 7.0; // > 2π → builder closes the revolution
|
||||
|
||||
/// Axis-aligned box from its center and full extents.
|
||||
pub fn box_solid(center: [f64; 3], length: f64, width: f64, height: f64) -> Solid {
|
||||
let min = Point3::new(
|
||||
center[0] - length / 2.0,
|
||||
center[1] - width / 2.0,
|
||||
center[2] - height / 2.0,
|
||||
);
|
||||
let v = builder::vertex(min);
|
||||
let e = builder::tsweep(&v, Vector3::new(length, 0.0, 0.0));
|
||||
let f = builder::tsweep(&e, Vector3::new(0.0, width, 0.0));
|
||||
builder::tsweep(&f, Vector3::new(0.0, 0.0, height))
|
||||
}
|
||||
|
||||
/// Right triangular prism (wedge): right-triangle cross-section in XZ,
|
||||
/// extruded along Y. `origin` is the min corner, ramp rising in +X/+Z.
|
||||
pub fn wedge_solid(origin: [f64; 3], length: f64, width: f64, height: f64) -> Solid {
|
||||
let o = Point3::new(origin[0], origin[1], origin[2]);
|
||||
let a = builder::vertex(o);
|
||||
let b = builder::vertex(Point3::new(o.x + length, o.y, o.z));
|
||||
let c = builder::vertex(Point3::new(o.x, o.y, o.z + height));
|
||||
let wire: Wire = vec![
|
||||
builder::line(&a, &b),
|
||||
builder::line(&b, &c),
|
||||
builder::line(&c, &a),
|
||||
]
|
||||
.into();
|
||||
let face = builder::try_attach_plane(&[wire]).expect("wedge profile");
|
||||
builder::tsweep(&face, Vector3::new(0.0, width, 0.0))
|
||||
}
|
||||
|
||||
/// Solid cylinder: disk on the z = base plane, extruded up by `height`.
|
||||
pub fn cylinder_solid(center: [f64; 3], radius: f64, height: f64) -> Solid {
|
||||
let v = builder::vertex(Point3::new(center[0] + radius, center[1], center[2]));
|
||||
let circle = builder::rsweep(
|
||||
&v,
|
||||
Point3::new(center[0], center[1], center[2]),
|
||||
Vector3::unit_z(),
|
||||
Rad(FULL),
|
||||
);
|
||||
let disk = builder::try_attach_plane(&[circle]).expect("cylinder cap");
|
||||
builder::tsweep(&disk, Vector3::new(0.0, 0.0, height))
|
||||
}
|
||||
|
||||
/// Solid cone: profile (apex → rim → base-center) revolved about the axis.
|
||||
pub fn cone_solid(center: [f64; 3], radius: f64, height: f64) -> Solid {
|
||||
let cx = center[0];
|
||||
let cy = center[1];
|
||||
let cz = center[2];
|
||||
let apex = builder::vertex(Point3::new(cx, cy, cz + height));
|
||||
let rim = builder::vertex(Point3::new(cx + radius, cy, cz));
|
||||
let base = builder::vertex(Point3::new(cx, cy, cz));
|
||||
let wire: Wire = vec![builder::line(&apex, &rim), builder::line(&rim, &base)].into();
|
||||
let shell = builder::cone(&wire, Vector3::unit_z(), Rad(FULL));
|
||||
Solid::new(vec![shell])
|
||||
}
|
||||
|
||||
/// Solid sphere: meridian semicircle revolved about the polar (Z) axis.
|
||||
pub fn sphere_solid(center: [f64; 3], radius: f64) -> Solid {
|
||||
let c = Point3::new(center[0], center[1], center[2]);
|
||||
let top = builder::vertex(Point3::new(c.x, c.y, c.z + radius));
|
||||
// Rotate the top point about the X axis by π → meridian from +Z to −Z.
|
||||
let meridian: Wire = builder::rsweep(&top, c, Vector3::unit_x(), Rad(std::f64::consts::PI));
|
||||
let shell = builder::cone(&meridian, Vector3::unit_z(), Rad(FULL));
|
||||
Solid::new(vec![shell])
|
||||
}
|
||||
|
||||
/// Solid torus in the z = base plane (tube revolved about the Z axis).
|
||||
pub fn torus_solid(center: [f64; 3], major: f64, minor: f64) -> Solid {
|
||||
let c = Point3::new(center[0], center[1], center[2]);
|
||||
let v = builder::vertex(Point3::new(c.x + major, c.y, c.z + minor));
|
||||
let tube = builder::rsweep(
|
||||
&v,
|
||||
Point3::new(c.x + major, c.y, c.z),
|
||||
Vector3::unit_y(),
|
||||
Rad(FULL),
|
||||
);
|
||||
let shell = builder::rsweep(&tube, c, Vector3::unit_z(), Rad(FULL));
|
||||
Solid::new(vec![shell])
|
||||
}
|
||||
|
||||
// ── Edge extraction (pick geometry + wireframe overlay) ─────────────────────
|
||||
|
||||
/// Tessellate the solid's B-rep edges into acadrust `Wire`s. Stored on the
|
||||
/// `Solid3D`/result entity so it is click-pickable (the renderer's wire
|
||||
/// fallback draws these as a wireframe over the shaded mesh, and hit-testing
|
||||
/// uses their points).
|
||||
pub fn edge_wires(solid: &Solid) -> Vec<acadrust::entities::Wire> {
|
||||
use crate::scene::truck_tess::{tessellate_edge, TruckTessResult};
|
||||
use acadrust::types::Vector3;
|
||||
let mut wires = Vec::new();
|
||||
for shell in solid.boundaries() {
|
||||
for edge in shell.edge_iter() {
|
||||
if let TruckTessResult::Lines(pts) = tessellate_edge(&edge, [0.0; 3]) {
|
||||
if pts.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let pts3: Vec<Vector3> = pts
|
||||
.iter()
|
||||
.map(|p| Vector3::new(p[0] as f64, p[1] as f64, p[2] as f64))
|
||||
.collect();
|
||||
wires.push(acadrust::entities::Wire::from_points(pts3));
|
||||
}
|
||||
}
|
||||
}
|
||||
wires
|
||||
}
|
||||
|
||||
// ── Boolean operations (truck-shapeops) ─────────────────────────────────────
|
||||
|
||||
/// Which CSG to apply. Mirrors `model::boolean_cmd::BoolOp` but kept local so
|
||||
/// this scene module has no dependency on the UI module.
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Bool {
|
||||
Union,
|
||||
Subtract,
|
||||
Intersect,
|
||||
}
|
||||
|
||||
const BOOL_TOL: f64 = 0.05;
|
||||
|
||||
/// Combine two solids. `Subtract` removes `b` from `a`. Returns `None` when the
|
||||
/// operation fails (e.g. the solids don't actually overlap).
|
||||
pub fn boolean(op: Bool, a: &Solid, b: &Solid) -> Option<Solid> {
|
||||
match op {
|
||||
Bool::Union => truck_shapeops::or(a, b, BOOL_TOL),
|
||||
Bool::Intersect => truck_shapeops::and(a, b, BOOL_TOL),
|
||||
Bool::Subtract => {
|
||||
let mut bn = b.clone();
|
||||
bn.not();
|
||||
truck_shapeops::and(a, &bn, BOOL_TOL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tessellation ────────────────────────────────────────────────────────────
|
||||
|
||||
/// Tessellate a truck `Solid` into a single-LOD `MeshLodSet` (world-space,
|
||||
/// before world_offset is applied by the caller).
|
||||
pub fn mesh_from_solid(solid: &Solid, color: [f32; 4]) -> Option<MeshLodSet> {
|
||||
use crate::scene::truck_tess::{tessellate_solid, TruckTessResult};
|
||||
match tessellate_solid(solid, [0.0; 3]) {
|
||||
TruckTessResult::Mesh {
|
||||
verts,
|
||||
normals,
|
||||
indices,
|
||||
} if !indices.is_empty() => {
|
||||
let mesh = MeshModel {
|
||||
name: String::new(),
|
||||
verts,
|
||||
normals,
|
||||
indices,
|
||||
color,
|
||||
selected: false,
|
||||
};
|
||||
Some(MeshLodSet::from_single(mesh))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tri_count(s: &Solid) -> usize {
|
||||
mesh_from_solid(s, [0.7, 0.7, 0.7, 1.0])
|
||||
.map(|m| m.lods[0].indices.len() / 3)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_primitives_triangulate() {
|
||||
let c = [0.0, 0.0, 0.0];
|
||||
assert!(tri_count(&box_solid(c, 10.0, 10.0, 10.0)) >= 12, "box");
|
||||
assert!(tri_count(&wedge_solid(c, 10.0, 10.0, 10.0)) >= 6, "wedge");
|
||||
assert!(tri_count(&cylinder_solid(c, 5.0, 12.0)) > 20, "cylinder");
|
||||
assert!(tri_count(&cone_solid(c, 5.0, 12.0)) > 10, "cone");
|
||||
assert!(tri_count(&sphere_solid(c, 5.0)) > 50, "sphere");
|
||||
assert!(tri_count(&torus_solid(c, 8.0, 2.0)) > 50, "torus");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn booleans_produce_solids() {
|
||||
let a = box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0);
|
||||
let b = box_solid([5.0, 5.0, 5.0], 10.0, 10.0, 10.0);
|
||||
for (op, label) in [
|
||||
(Bool::Union, "union"),
|
||||
(Bool::Subtract, "subtract"),
|
||||
(Bool::Intersect, "intersect"),
|
||||
] {
|
||||
let r = boolean(op, &a, &b);
|
||||
let n = r.as_ref().map(tri_count).unwrap_or(0);
|
||||
eprintln!("{label}: tris={n}");
|
||||
assert!(r.is_some() && n > 0, "{label} produced nothing");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn box_exposes_edges() {
|
||||
assert!(edge_wires(&box_solid([0.0, 0.0, 0.0], 10.0, 10.0, 10.0)).len() >= 12);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue