forked from 1b-agent/opencadstudio-upstream
The codec and kernel repositories and crates are renamed opencadcodec and opencadkernel. Inside OCS they are depended on under short keys, so code reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node graph engine). File-format and protocol spellings stay as they were: the ACIS product id `acadrust`, the ACADRUST_* switches and the plugin version keys `acadrust_version` / `acadrust_source`. A button left of MCP on the command line opens a translucent node graph over the viewport, with a palette of object, input, math, logic, list, text and geometry nodes added by click or drag. An object node owns drawing entities and its rows are the Properties panel's own rows for them: the single-object page is extracted from refresh_properties as entity_property_sections, values use the control protocol's property_json, and writes go through set_property_value with a property target override, so the panel, MCP and the graph share one read/write path. A list reaching an object row makes one entity per element. Each graph action is one undo step; the canvas pans and zooms. Co-Authored-By: Claude Opus 5.5 <[email protected]>
446 lines
17 KiB
Rust
446 lines
17 KiB
Rust
// Triangle mesh model — produced by the kernel Shell/Solid tessellation.
|
||
//
|
||
// Stored alongside WireModels in the scene; rendered by the mesh pipeline
|
||
// (wgpu TriangleList with depth test, flat normals).
|
||
|
||
/// A tessellated triangle mesh ready to upload to the GPU.
|
||
#[derive(Clone, Debug)]
|
||
#[allow(dead_code)]
|
||
pub struct MeshModel {
|
||
/// Unique identifier (entity handle value as decimal string).
|
||
pub name: String,
|
||
/// World-space vertex positions (high half of the double-single pair).
|
||
pub verts: Vec<[f32; 3]>,
|
||
/// Low residual paired with `verts` so meshes stay precise at UTM scale.
|
||
/// Empty = all-zero (legacy / interactive meshes near the origin).
|
||
pub verts_low: Vec<[f32; 3]>,
|
||
/// Per-vertex normals (may be empty if not available).
|
||
pub normals: Vec<[f32; 3]>,
|
||
/// Triangle indices into `verts` (every 3 values = one triangle).
|
||
pub indices: Vec<u32>,
|
||
/// Optional AcDbMaterial handle per triangle. Empty means the whole mesh
|
||
/// uses `MeshLodSet::material`; otherwise each entry aligns with one
|
||
/// `indices` triplet and overrides the entity material for that face.
|
||
pub triangle_material_handles: Vec<Option<codec::Handle>>,
|
||
/// Optional ACIS face colour per triangle, aligned with `indices` triplets.
|
||
pub triangle_colors: Vec<Option<[f32; 4]>>,
|
||
/// RGBA colour in [0, 1].
|
||
pub color: [f32; 4],
|
||
/// Whether this mesh is currently selected.
|
||
pub selected: bool,
|
||
}
|
||
|
||
/// Bundle of mesh tessellations at different sampling densities, picked
|
||
/// per frame by the render pipeline based on the projected pixel size of
|
||
/// `world_aabb`. Phase 3.4 LOD ladder:
|
||
///
|
||
/// | LOD | Source | Use when projected diagonal |
|
||
/// |-----|------------|------------------------------|
|
||
/// | 0 | HIGH | > 200 px |
|
||
/// | 1 | MID (½) | 50–200 px |
|
||
/// | 2 | LOW (¼) | < 50 px |
|
||
///
|
||
/// `lods` holds up to one MeshModel per LOD level (high → low). Empty
|
||
/// slots fall back to the nearest available LOD at render time.
|
||
/// Kernel-owned source for a view-dependent silhouette.
|
||
#[derive(Clone, Debug)]
|
||
pub struct CurvedGen {
|
||
pub source: kernel::brep::mesh::SilhouetteSource,
|
||
}
|
||
|
||
#[derive(Clone, Copy, Debug, Default)]
|
||
pub struct MeshMetrics {
|
||
pub vertices: usize,
|
||
pub triangles: usize,
|
||
pub surface_area: f64,
|
||
pub volume: f64,
|
||
pub centroid: [f64; 3],
|
||
pub moment_of_inertia: [f64; 3],
|
||
pub principal_directions: [f64; 9],
|
||
pub principal_moments: [f64; 3],
|
||
pub product_of_inertia: [f64; 3],
|
||
pub radii_of_gyration: [f64; 3],
|
||
}
|
||
|
||
impl MeshMetrics {
|
||
fn apply_mass_properties(&mut self, properties: kernel::brep::MassProperties) {
|
||
self.volume = properties.volume;
|
||
self.centroid = properties.centroid;
|
||
self.moment_of_inertia = properties.moment_of_inertia;
|
||
self.principal_directions = properties.principal_directions;
|
||
self.principal_moments = properties.principal_moments;
|
||
self.product_of_inertia = properties.product_of_inertia;
|
||
self.radii_of_gyration = properties.radii_of_gyration;
|
||
}
|
||
|
||
pub fn translate(&mut self, delta: [f64; 3]) {
|
||
if self.volume > 1e-18 {
|
||
self.apply_mass_properties(
|
||
kernel::brep::MassProperties {
|
||
volume: self.volume,
|
||
centroid: self.centroid,
|
||
moment_of_inertia: self.moment_of_inertia,
|
||
principal_directions: self.principal_directions,
|
||
principal_moments: self.principal_moments,
|
||
product_of_inertia: self.product_of_inertia,
|
||
radii_of_gyration: self.radii_of_gyration,
|
||
}
|
||
.translated(delta),
|
||
);
|
||
} else {
|
||
for axis in 0..3 {
|
||
self.centroid[axis] += delta[axis];
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct MeshLodSet {
|
||
pub lods: Vec<MeshModel>,
|
||
/// Effective AcDbMaterial resolved from entity/layer/INSERT inheritance.
|
||
/// Geometry remains usable when it is absent; the renderer then keeps the
|
||
/// per-mesh entity colour.
|
||
pub material: Option<super::material_model::MeshMaterial>,
|
||
/// Face-level ACIS material overrides, resolved from each LOD's
|
||
/// `triangle_material_handles`. Only handles actually referenced by the
|
||
/// tessellation are retained.
|
||
pub face_materials:
|
||
rustc_hash::FxHashMap<codec::Handle, super::material_model::MeshMaterial>,
|
||
/// Effective AcDbVisualStyle override resolved from the entity's full,
|
||
/// face and edge style handles.
|
||
pub visual_style: Option<super::visual_style_model::MeshVisualStyle>,
|
||
/// True only when every source face produced triangles. False keeps
|
||
/// downstream solid-edit code from treating a display-only partial shell
|
||
/// as a closed, valid solid.
|
||
pub complete: bool,
|
||
/// Feature-edge line list (LOD-independent): pairs of endpoints, high half
|
||
/// of the double-single. Populated for ACIS solids (the B-rep face-boundary
|
||
/// edges) so their wireframe shows real edges rather than the triangulation.
|
||
/// Empty for plain meshes — those fall back to triangle edges at batch time.
|
||
pub edge_verts: Vec<[f32; 3]>,
|
||
/// Low residual paired with `edge_verts`.
|
||
pub edge_verts_low: Vec<[f32; 3]>,
|
||
/// Kernel sources for per-frame silhouettes.
|
||
pub curved_gens: Vec<CurvedGen>,
|
||
/// Geometry measurements calculated once from the highest available LOD.
|
||
/// Properties can read these without re-parsing or re-tessellating ACIS on
|
||
/// the UI thread.
|
||
pub metrics: MeshMetrics,
|
||
/// World XY AABB `[min_x, min_y, max_x, max_y]` of the mesh — used
|
||
/// by the per-frame LOD selector to compute the projected pixel
|
||
/// diagonal.
|
||
pub world_aabb: [f32; 4],
|
||
/// World Z extent `[min_z, max_z]`. With `world_aabb` this is the full 3D
|
||
/// box, which the pick path projects to a screen rect to skip solids whose
|
||
/// footprint isn't under the cursor (O(solids) instead of ray-testing every
|
||
/// triangle). `verts` carry only the high half of the double-single
|
||
/// position, so the bound is f32-precise — fine for a conservative cull.
|
||
pub z_aabb: [f32; 2],
|
||
/// Immutable block-local geometry shared by every INSERT instance of this
|
||
/// block entity. Top-level meshes leave this empty.
|
||
pub instance_source: Option<std::sync::Arc<MeshInstanceSource>>,
|
||
/// Accumulated block-local → world transform for this rendered instance.
|
||
pub instance_transform: Option<codec::types::Transform>,
|
||
/// Parent INSERT selected for this rendered block instance.
|
||
pub instance_handle: Option<codec::Handle>,
|
||
/// Effective colour after INSERT inheritance, without copying the mesh.
|
||
pub instance_color: Option<[f32; 4]>,
|
||
/// Precise world bounds used by the interaction index.
|
||
pub instance_aabb: Option<[f64; 6]>,
|
||
}
|
||
|
||
#[derive(Clone, Debug)]
|
||
pub struct MeshInstanceSource {
|
||
pub handle: codec::Handle,
|
||
pub lods: Vec<MeshModel>,
|
||
pub edge_verts: Vec<[f32; 3]>,
|
||
pub edge_verts_low: Vec<[f32; 3]>,
|
||
pub curved_gens: Vec<CurvedGen>,
|
||
}
|
||
|
||
/// 3D bounds of every LOD's vertices: `([min_x, min_y, max_x, max_y], [min_z, max_z])`.
|
||
pub fn compute_mesh_aabb(lods: &[MeshModel]) -> ([f32; 4], [f32; 2]) {
|
||
let (mut min_x, mut min_y, mut min_z) = (f32::INFINITY, f32::INFINITY, f32::INFINITY);
|
||
let (mut max_x, mut max_y, mut max_z) =
|
||
(f32::NEG_INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY);
|
||
for lod in lods {
|
||
for &[x, y, z] in &lod.verts {
|
||
if !x.is_finite() || !y.is_finite() || !z.is_finite() {
|
||
continue;
|
||
}
|
||
min_x = min_x.min(x);
|
||
min_y = min_y.min(y);
|
||
min_z = min_z.min(z);
|
||
max_x = max_x.max(x);
|
||
max_y = max_y.max(y);
|
||
max_z = max_z.max(z);
|
||
}
|
||
}
|
||
([min_x, min_y, max_x, max_y], [min_z, max_z])
|
||
}
|
||
|
||
fn compute_mesh_metrics(lods: &[MeshModel]) -> MeshMetrics {
|
||
let Some(mesh) = lods.iter().find(|mesh| !mesh.indices.is_empty()) else {
|
||
return MeshMetrics::default();
|
||
};
|
||
let kernel_mesh = kernel::brep::Mesh {
|
||
positions: mesh
|
||
.verts
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, high)| {
|
||
let low = mesh.verts_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
[
|
||
high[0] as f64 + low[0] as f64,
|
||
high[1] as f64 + low[1] as f64,
|
||
high[2] as f64 + low[2] as f64,
|
||
]
|
||
})
|
||
.collect(),
|
||
normals: Vec::new(),
|
||
triangles: mesh
|
||
.indices
|
||
.chunks_exact(3)
|
||
.map(|triangle| {
|
||
[
|
||
triangle[0] as usize,
|
||
triangle[1] as usize,
|
||
triangle[2] as usize,
|
||
]
|
||
})
|
||
.collect(),
|
||
};
|
||
let surface = kernel_mesh.surface_properties();
|
||
let mut metrics = MeshMetrics {
|
||
vertices: mesh.verts.len(),
|
||
triangles: mesh.indices.len() / 3,
|
||
surface_area: surface.map_or(0.0, |value| value.0),
|
||
centroid: surface.map_or([0.0; 3], |value| value.1),
|
||
..MeshMetrics::default()
|
||
};
|
||
if let Some(properties) = kernel_mesh.inertial_properties() {
|
||
metrics.apply_mass_properties(properties);
|
||
}
|
||
metrics
|
||
}
|
||
|
||
impl MeshLodSet {
|
||
pub fn apply_mass_properties(&mut self, properties: kernel::brep::MassProperties) {
|
||
self.metrics.apply_mass_properties(properties);
|
||
}
|
||
|
||
/// Build a set from its LODs, computing the 3D AABB.
|
||
pub fn from_lods(lods: Vec<MeshModel>) -> Self {
|
||
let (world_aabb, z_aabb) = compute_mesh_aabb(&lods);
|
||
let metrics = compute_mesh_metrics(&lods);
|
||
Self {
|
||
lods,
|
||
material: None,
|
||
face_materials: rustc_hash::FxHashMap::default(),
|
||
visual_style: None,
|
||
complete: true,
|
||
edge_verts: Vec::new(),
|
||
edge_verts_low: Vec::new(),
|
||
curved_gens: Vec::new(),
|
||
metrics,
|
||
world_aabb,
|
||
z_aabb,
|
||
instance_source: None,
|
||
instance_transform: None,
|
||
instance_handle: None,
|
||
instance_color: None,
|
||
instance_aabb: None,
|
||
}
|
||
}
|
||
|
||
/// Wrap a single MeshModel as a one-LOD set. Used by interactive
|
||
/// commands that only produce one tessellation (e.g. the kernel-based
|
||
/// BOX/CYLINDER creation). The LOD selector will pick slot 0 for
|
||
/// every zoom level.
|
||
pub fn from_single(mesh: MeshModel) -> Self {
|
||
Self::from_lods(vec![mesh])
|
||
}
|
||
|
||
/// Recompute `world_aabb` / `z_aabb` after the LODs' vertices were rewritten
|
||
/// (relative-to-eye re-split, INSERT transform).
|
||
pub fn recompute_aabb(&mut self) {
|
||
let (xy, z) = compute_mesh_aabb(&self.lods);
|
||
self.world_aabb = xy;
|
||
self.z_aabb = z;
|
||
}
|
||
|
||
pub fn prepare_instance_source(&mut self, handle: codec::Handle) {
|
||
self.instance_source = Some(std::sync::Arc::new(MeshInstanceSource {
|
||
handle,
|
||
lods: self.lods.clone(),
|
||
edge_verts: self.edge_verts.clone(),
|
||
edge_verts_low: self.edge_verts_low.clone(),
|
||
curved_gens: self.curved_gens.clone(),
|
||
}));
|
||
self.instance_transform = None;
|
||
self.instance_handle = None;
|
||
self.instance_color = None;
|
||
self.instance_aabb = None;
|
||
}
|
||
|
||
pub fn geometry_lods(&self) -> &[MeshModel] {
|
||
self.instance_source
|
||
.as_ref()
|
||
.map_or(self.lods.as_slice(), |source| source.lods.as_slice())
|
||
}
|
||
|
||
pub fn geometry_edges(&self) -> (&[[f32; 3]], &[[f32; 3]]) {
|
||
self.instance_source.as_ref().map_or(
|
||
(self.edge_verts.as_slice(), self.edge_verts_low.as_slice()),
|
||
|source| (
|
||
source.edge_verts.as_slice(),
|
||
source.edge_verts_low.as_slice(),
|
||
),
|
||
)
|
||
}
|
||
|
||
pub fn entity_handle(&self) -> Option<codec::Handle> {
|
||
self.instance_handle.or_else(|| {
|
||
self.lods
|
||
.first()
|
||
.and_then(|mesh| mesh.name.parse::<u64>().ok())
|
||
.map(codec::Handle::new)
|
||
})
|
||
}
|
||
|
||
pub fn display_color(&self) -> Option<[f32; 4]> {
|
||
self.instance_color.or_else(|| {
|
||
self.geometry_lods()
|
||
.iter()
|
||
.find(|mesh| !mesh.indices.is_empty())
|
||
.map(|mesh| mesh.color)
|
||
})
|
||
}
|
||
}
|
||
|
||
/// Max B-rep edge pairs read from one solid for snapping. Curved-edge
|
||
/// tessellations fan out into thousands of joints; the cap keeps the
|
||
/// interaction index and the per-move snap scan bounded.
|
||
pub const MAX_SOLID_SNAP_EDGES: usize = 2048;
|
||
|
||
/// Distinct B-rep edge segments from a high/low double-single line list
|
||
/// (mesh `edge_verts` pairs + `edge_verts_low`), feeding solid vertex
|
||
/// (endpoints) and edge-midpoint (centres) snaps.
|
||
///
|
||
/// Degenerate (zero-length) and non-finite pairs are dropped, and identical
|
||
/// pairs collapse regardless of endpoint order. Past the cap the output is
|
||
/// stride-sampled so coverage degrades gracefully instead of dropping every
|
||
/// edge past some cutoff.
|
||
pub fn solid_edge_segments(
|
||
edge_verts: &[[f32; 3]],
|
||
edge_verts_low: &[[f32; 3]],
|
||
) -> Vec<(glam::DVec3, glam::DVec3)> {
|
||
fn point(high: [f32; 3], low: [f32; 3]) -> Option<glam::DVec3> {
|
||
let p = glam::DVec3::new(
|
||
high[0] as f64 + low[0] as f64,
|
||
high[1] as f64 + low[1] as f64,
|
||
high[2] as f64 + low[2] as f64,
|
||
);
|
||
p.is_finite().then_some(p)
|
||
}
|
||
fn key(p: &glam::DVec3) -> [u64; 3] {
|
||
[p.x.to_bits(), p.y.to_bits(), p.z.to_bits()]
|
||
}
|
||
let mut seen = std::collections::HashSet::new();
|
||
let mut out = Vec::new();
|
||
for index in 0..edge_verts.len() / 2 {
|
||
let high = [edge_verts[2 * index], edge_verts[2 * index + 1]];
|
||
let low = [
|
||
edge_verts_low.get(2 * index).copied().unwrap_or([0.0; 3]),
|
||
edge_verts_low
|
||
.get(2 * index + 1)
|
||
.copied()
|
||
.unwrap_or([0.0; 3]),
|
||
];
|
||
let (Some(a), Some(b)) = (point(high[0], low[0]), point(high[1], low[1])) else {
|
||
continue;
|
||
};
|
||
if a.distance_squared(b) <= 1e-12 {
|
||
continue;
|
||
}
|
||
let (ka, kb) = (key(&a), key(&b));
|
||
let ordered = if ka <= kb { (ka, kb) } else { (kb, ka) };
|
||
if !seen.insert(ordered) {
|
||
continue;
|
||
}
|
||
out.push((a, b));
|
||
}
|
||
if out.len() > MAX_SOLID_SNAP_EDGES {
|
||
let step = out.len().div_ceil(MAX_SOLID_SNAP_EDGES);
|
||
out = out.into_iter().step_by(step).collect();
|
||
}
|
||
out
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod solid_snap_tests {
|
||
use super::solid_edge_segments;
|
||
|
||
fn pt(x: f32, y: f32, z: f32) -> [f32; 3] {
|
||
[x, y, z]
|
||
}
|
||
|
||
#[test]
|
||
fn shared_corners_keep_both_segments() {
|
||
// Two edges sharing the corner (1,0,0): both pairs survive, the
|
||
// corner itself dedups later at attach time, not here.
|
||
let edges = vec![
|
||
pt(0.0, 0.0, 0.0),
|
||
pt(1.0, 0.0, 0.0),
|
||
pt(1.0, 0.0, 0.0),
|
||
pt(1.0, 1.0, 0.0),
|
||
];
|
||
let low = vec![[0.0f32; 3]; 4];
|
||
let segs = solid_edge_segments(&edges, &low);
|
||
assert_eq!(segs.len(), 2, "shared corner collapsed segments: {segs:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn degenerate_and_non_finite_pairs_are_dropped() {
|
||
let edges = vec![
|
||
pt(0.0, 0.0, 0.0),
|
||
pt(f32::NAN, 0.0, 0.0),
|
||
pt(1.0, 0.0, 0.0),
|
||
pt(1.0, 0.0, 0.0), // degenerate: zero length
|
||
pt(2.0, 0.0, 0.0),
|
||
pt(3.0, 0.0, f32::INFINITY),
|
||
pt(4.0, 0.0, 0.0),
|
||
pt(5.0, 0.0, 0.0),
|
||
];
|
||
let low = vec![[0.0f32; 3]; 8];
|
||
let segs = solid_edge_segments(&edges, &low);
|
||
assert_eq!(segs.len(), 1, "expected only the valid pair: {segs:?}");
|
||
assert!(segs[0].0.is_finite() && segs[0].1.is_finite());
|
||
}
|
||
|
||
#[test]
|
||
fn output_is_capped_with_coverage() {
|
||
let mut edges = Vec::new();
|
||
for k in 0..2200 {
|
||
let x = 100.0 + k as f32;
|
||
edges.push(pt(x, 0.0, 0.0));
|
||
edges.push(pt(x, 1.0, 0.0));
|
||
}
|
||
let low = vec![[0.0f32; 3]; 4400];
|
||
let segs = solid_edge_segments(&edges, &low);
|
||
assert!(
|
||
segs.len() <= super::MAX_SOLID_SNAP_EDGES,
|
||
"cap exceeded: {}",
|
||
segs.len()
|
||
);
|
||
assert!(
|
||
segs.len() >= super::MAX_SOLID_SNAP_EDGES / 2,
|
||
"stride sampling dropped too much coverage: {}",
|
||
segs.len()
|
||
);
|
||
// The first pair survives sampling (index 0 is kept).
|
||
assert_eq!(segs[0].0, glam::DVec3::new(100.0, 0.0, 0.0));
|
||
}
|
||
}
|