Accelerate current-view DXF export and keep desktop exports responsive (#1738)

Thanks @crono2250.
This commit is contained in:
cronos authored and GitHub committed 2026-10-16 10:26:27 -04:00
1 parent 4bacd0cae0
commit bba03803b7
20 files changed
+1319 -53

No files matched your search

+18 -20
View File
@@ -1,6 +1,10 @@
//! A drawing of the visible bodies from a supplied viewport camera. No screen pixels, grid,
//! selection highlights or tessellation diagonals are exported.
#[path = "view_dxf_meshes.rs"]
mod meshes;
#[path = "view_dxf_progress.rs"]
mod progress;
#[path = "view_dxf_projection.rs"]
mod projection;
@@ -46,28 +50,17 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(path) = path {
solidcraft_io::vfs::check(path).map_err(|e| crate::EngineError::Other(e.to_string()))?;
}
let world = s.world_state();
let mut meshes = Vec::new();
let mut count = 0usize;
for body in world.bodies.iter().filter(|b| s.body_visible(&b.name)) {
let mesh = body.body.reference_display_mesh_angular(tolerance, solidcraft_kernel::DISPLAY_ANGLE)?;
count = count.saturating_add(mesh.triangles.len()).saturating_add(mesh.edges.iter().map(Vec::len).sum::<usize>());
if count > 1_000_000 {
return Err(bad(CMD, "view too complex to export; hide bodies or increase tolerance"));
}
if mesh.triangles.is_empty() {
return Err(bad(CMD, format!("{} has no exportable surface", body.name)));
}
meshes.push(mesh);
}
if meshes.is_empty() {
return Err(bad(CMD, "no visible bodies to export"));
}
let lines = projection::drawing(&meshes, &cam).map_err(|e| bad(CMD, e))?;
let started = std::time::Instant::now();
progress::Stage::new("dxf_meshes", 1).check().map_err(|e| bad(CMD, e))?;
let meshes = meshes::prepare(s, tolerance)?;
let prepare_ms = started.elapsed().as_secs_f64() * 1000.;
let drawing = projection::drawing(&meshes, &cam).map_err(|e| bad(CMD, e))?;
let writing = progress::Stage::new("dxf_write", 1);
writing.check().map_err(|e| bad(CMD, e))?;
let mut visible = 0;
let mut hidden = 0;
let mut entities = Vec::new();
for line in lines {
for line in drawing.lines {
if line.hidden && !dashed {
continue;
}
@@ -86,9 +79,14 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> {
layers.push(DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) });
}
let text = solidcraft_io::write_dxf_drawing(&layers, &entities);
writing.check().map_err(|e| bad(CMD, e))?;
let mut result = json!({"visible":visible,"hidden":hidden,"units":"mm","tolerance":tolerance,"projection":if cam.fov > 0.0 {"perspective"} else {"orthographic"}});
result["stats"] = json!({"bodies":meshes.placements.len(),"shared_meshes":meshes.meshes.len(),"triangles":meshes.triangles,
"mesh_bytes":meshes.bytes,"worker_limit":meshes::WORKERS,"segments":drawing.segments,"occlusion_work":drawing.work,
"prepare_ms":prepare_ms,"projection_ms":drawing.projection_ms,"occlusion_ms":drawing.occlusion_ms});
if let Some(path) = path {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(e.to_string()))?;
solidcraft_io::vfs::check(path).map_err(|e| crate::EngineError::Other(e.to_string()))?;
solidcraft_io::write_atomic(std::path::Path::new(path), text.as_bytes(), false).map_err(|e| crate::EngineError::Other(e.to_string()))?;
result["path"] = json!(path);
} else {
result["dxf"] = json!(text);
+207
View File
@@ -0,0 +1,207 @@
//! Export-only mesh instances. Keep one geometry buffer per unambiguous definition, while
//! retaining the current placed body's edge choices (tangent classification can vary after
//! placement). Never modify the document, fuse parts, or share across separate exports.
use std::collections::BTreeMap;
use std::mem::size_of;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use solidcraft_doc::{IDENTITY, Mat, ModelBody, mat_inverse, mat_mul};
use solidcraft_geom::{Aabb3, Mesh, Vec3};
use solidcraft_kernel::{KernelError, map_with_local_pools};
use super::{CMD, progress::Stage};
use crate::params::bad;
use crate::{Result, Session};
#[cfg(not(target_arch = "wasm32"))]
pub(super) const WORKERS: usize = 8;
#[cfg(target_arch = "wasm32")]
pub(super) const WORKERS: usize = 1;
const MESH_BYTES: usize = 512 * 1024 * 1024;
const MAX_BODIES: usize = 100_000;
pub(super) struct Placement {
pub mesh: usize,
pub transform: Mat,
pub visibility: Vec<bool>,
}
pub(super) struct Scene {
pub meshes: Vec<Arc<Mesh>>,
pub placements: Vec<Placement>,
pub triangles: usize,
pub bytes: usize,
}
struct Group {
first: usize,
inverse: Mat,
}
struct Item<'a> {
body: &'a ModelBody,
group: usize,
keep: bool,
transform: Mat,
}
struct Prepared {
mesh: Option<Arc<Mesh>>,
visibility: Vec<bool>,
triangles: usize,
}
pub(super) fn claim(counter: &AtomicUsize, amount: usize, limit: usize) -> bool {
let mut old = counter.load(Ordering::Relaxed);
loop {
let Some(next) = old.checked_add(amount).filter(|n| *n <= limit) else { return false };
match counter.compare_exchange_weak(old, next, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => return true,
Err(current) => old = current,
}
}
}
pub(super) fn buffer_bytes(m: &Mesh) -> usize {
m.positions.capacity() * size_of::<Vec3>()
+ m.normals.capacity() * size_of::<Vec3>()
+ m.triangles.capacity() * size_of::<[u32; 3]>()
+ m.tri_face.capacity() * size_of::<u32>()
+ m.edges.capacity() * size_of::<Vec<Vec3>>()
+ m.edges.iter().map(|e| e.capacity() * size_of::<Vec3>()).sum::<usize>()
+ m.seams.capacity()
+ m.smooth_edges.capacity()
+ m.sharp_edges.capacity()
+ m.edge_faces.capacity() * size_of::<Vec<u32>>()
+ m.edge_faces.iter().map(|e| e.capacity() * size_of::<u32>()).sum::<usize>()
}
fn rigid(m: &Mat) -> bool {
if m.iter().flatten().any(|v| !v.is_finite()) || m.iter().zip([0., 0., 0., 1.]).any(|(c, w)| (c[3] - w).abs() > 1e-10) {
return false;
}
let axes = [Vec3::new(m[0][0], m[0][1], m[0][2]), Vec3::new(m[1][0], m[1][1], m[1][2]), Vec3::new(m[2][0], m[2][1], m[2][2])];
let [x, y, z] = axes;
(x.len2() - 1.).abs() < 1e-9
&& (y.len2() - 1.).abs() < 1e-9
&& (z.len2() - 1.).abs() < 1e-9
&& x.dot(y).abs() < 1e-9
&& x.dot(z).abs() < 1e-9
&& y.dot(z).abs() < 1e-9
&& (x.cross(y).dot(z) - 1.).abs() < 1e-9
}
pub(super) fn prepare(s: &Session, tolerance: f64) -> Result<Scene> {
let world = s.world_state();
let state = s.model.state();
let mut definitions = BTreeMap::new();
for body in &state.bodies {
*definitions.entry((body.component, body.definition_name(), body.feature)).or_insert(0usize) += 1;
}
let mut by_definition = BTreeMap::new();
let mut groups: Vec<Group> = Vec::new();
let mut items = Vec::new();
for (index, body) in world.bodies.iter().enumerate().filter(|(_, b)| s.body_visible(&b.name)) {
if items.len() >= MAX_BODIES {
return Err(bad(CMD, "too many placed bodies to export"));
}
let key = (body.component, body.definition_name(), body.feature);
let unambiguous = definitions.get(&key) == Some(&1);
let reused = unambiguous
.then(|| by_definition.get(&key).copied())
.flatten()
.and_then(|g: usize| groups.get(g).map(|group| (g, mat_mul(&body.source_transform, &group.inverse))))
.filter(|(_, m)| rigid(m));
let (group, transform) = if let Some(reused) = reused {
reused
} else {
let g = groups.len();
groups.push(Group { first: index, inverse: mat_inverse(&body.source_transform).unwrap_or(IDENTITY) });
if unambiguous && mat_inverse(&body.source_transform).is_some() {
by_definition.entry(key).or_insert(g);
}
(g, IDENTITY)
};
let keep = groups.get(group).is_some_and(|g| g.first == index);
items.push(Item { body, group, keep, transform });
}
if items.is_empty() {
return Err(bad(CMD, "no visible bodies to export"));
}
let progress = Stage::new("dxf_meshes", items.len());
let bytes = AtomicUsize::new(0);
let results = map_with_local_pools(&items, WORKERS, |item| {
progress.check().map_err(|e| KernelError::Failed(e.into()))?;
let mut mesh = item.body.body.reference_display_mesh_angular(tolerance, solidcraft_kernel::DISPLAY_ANGLE)?;
if mesh.triangles.is_empty() {
return Err(KernelError::Invalid(format!("{} has no exportable surface", item.body.name)));
}
let visibility = (0..mesh.edges.len()).map(|i| mesh.shaded_edge_visible(i)).collect();
let triangles = mesh.triangles.len();
let mesh = if item.keep {
// These are owned export buffers, not the document's cached mesh. Picking and
// shading data are unused by vector occlusion; edge choices were captured above.
mesh.normals = Vec::new();
mesh.tri_face = Vec::new();
mesh.seams = Vec::new();
mesh.smooth_edges = Vec::new();
mesh.sharp_edges = Vec::new();
mesh.edge_faces = Vec::new();
if !claim(&bytes, buffer_bytes(&mesh), MESH_BYTES) {
return Err(KernelError::Invalid("view exceeds export memory budget; hide bodies or increase tolerance".into()));
}
Some(Arc::new(mesh))
} else {
None
};
progress.advance(1).map_err(|e| KernelError::Failed(e.into()))?;
Ok(Prepared { mesh, visibility, triangles })
});
let mut prepared = results.into_iter().collect::<solidcraft_kernel::Result<Vec<_>>>()?;
let mut meshes: Vec<Option<Arc<Mesh>>> = vec![None; groups.len()];
for (item, p) in items.iter().zip(&mut prepared) {
if let Some(mesh) = p.mesh.take()
&& let Some(slot) = meshes.get_mut(item.group)
{
*slot = Some(mesh);
}
}
let meshes = meshes.into_iter().map(|m| m.ok_or_else(|| bad(CMD, "missing export geometry"))).collect::<Result<Vec<_>>>()?;
let mut placements = Vec::with_capacity(items.len());
let mut triangles = 0usize;
for (item, p) in items.iter().zip(prepared) {
if meshes.get(item.group).is_none_or(|m| m.edges.len() != p.visibility.len()) {
return Err(bad(CMD, "placed edge identity changed"));
}
triangles = triangles.saturating_add(p.triangles);
placements.push(Placement { mesh: item.group, transform: item.transform, visibility: p.visibility });
}
Ok(Scene { meshes, placements, triangles, bytes: bytes.load(Ordering::Relaxed) })
}
pub(super) fn transformed_bounds(mesh: &Mesh, m: &Mat) -> Aabb3 {
let b = mesh.bounds();
let mut out = Aabb3::EMPTY;
for x in [b.min.x, b.max.x] {
for y in [b.min.y, b.max.y] {
for z in [b.min.z, b.max.z] {
out.add(solidcraft_doc::apply_point(m, Vec3::new(x, y, z)));
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_proper_rigid_placements_share_geometry() {
assert!(rigid(&solidcraft_doc::rigid(Vec3::new(1., 2., 3.), Vec3::ZERO, Vec3::Z, 0.7)));
let mut m = IDENTITY;
m[0][0] = -1.;
assert!(!rigid(&m));
m[0][0] = 2.;
assert!(!rigid(&m));
m[0][0] = f64::NAN;
assert!(!rigid(&m));
}
}
@@ -0,0 +1,35 @@
//! Stage counters shared by the export worker and its bounded computation workers.
use std::sync::{Arc, Mutex};
pub(super) const CANCELLED: &str = "DXF export cancelled";
pub(super) struct Stage {
watch: Option<Arc<dyn solidcraft_kernel::watch::Watch>>,
name: &'static str,
total: usize,
done: Mutex<usize>,
}
impl Stage {
pub fn new(name: &'static str, total: usize) -> Self {
let stage = Self { watch: solidcraft_kernel::watch::current(), name, total: total.max(1), done: Mutex::new(0) };
if let Some(watch) = &stage.watch {
watch.progress(name, 0, stage.total);
}
stage
}
pub fn check(&self) -> Result<(), &'static str> {
if self.watch.as_ref().is_some_and(|w| w.cancelled()) { Err(CANCELLED) } else { Ok(()) }
}
pub fn advance(&self, count: usize) -> Result<(), &'static str> {
self.check()?;
if let Some(watch) = &self.watch {
// Serialize reports as well as increments: eight workers must not report a
// smaller completed count after a larger one.
let mut done = self.done.lock().map_err(|_| "export progress unavailable")?;
*done = done.saturating_add(count).min(self.total);
watch.progress(self.name, *done, self.total);
}
self.check()
}
}
+25 -2
View File
@@ -1,10 +1,27 @@
//! Vector hidden-line removal: intersect each projected edge with triangle half planes and
//! its depth plane, then union occluded parameter intervals. A BVH bounds the candidate work.
use solidcraft_geom::{Aabb3, Mesh, Vec2, Vec3};
#[cfg(test)]
use solidcraft_geom::Mesh;
use solidcraft_geom::{Aabb3, Vec2, Vec3};
use solidcraft_io::{DxfEntity, DxfShape};
use solidcraft_render::Camera;
use std::collections::BTreeMap;
#[path = "view_dxf_scene.rs"]
mod scene;
pub(super) struct Drawing {
pub lines: Vec<Line>,
pub work: usize,
pub segments: usize,
pub projection_ms: f64,
pub occlusion_ms: f64,
}
pub(super) fn drawing(meshes: &super::meshes::Scene, cam: &Camera) -> Result<Drawing, &'static str> {
scene::drawing(meshes, cam)
}
/// Keep the hidden curves connected: restarting a dash on every short chord would make a
/// small circular hole look solid. Degree-two joins continue; junctions end the polyline.
pub(super) fn connect(entities: Vec<DxfEntity>) -> Vec<DxfEntity> {
@@ -50,6 +67,7 @@ pub(super) fn connect(entities: Vec<DxfEntity>) -> Vec<DxfEntity> {
result
}
#[derive(Clone, Debug)]
pub(super) struct Line {
pub a: Vec2,
pub b: Vec2,
@@ -73,15 +91,18 @@ fn overlap(a: &Aabb3, b: &Aabb3) -> bool {
a.min.x <= b.max.x && b.min.x <= a.max.x && a.min.y <= b.max.y && b.min.y <= a.max.y
}
#[cfg(test)]
struct Triangle {
p: [Vec3; 3],
bounds: Aabb3,
}
#[cfg(test)]
struct Tree {
bounds: Aabb3,
children: Option<(Box<Tree>, Box<Tree>)>,
triangles: Vec<Triangle>,
}
#[cfg(test)]
impl Tree {
fn new(mut triangles: Vec<Triangle>) -> Self {
let b = triangles.iter().fold(Aabb3::EMPTY, |b, t| b.union(&t.bounds));
@@ -213,6 +234,7 @@ fn merge_lines(lines: Vec<Line>, eps: f64) -> Vec<Line> {
/// B-rep edges plus curved silhouettes. Triangle adjacency is welded by position because
/// different faces can have different vertex indices along their shared boundary.
#[cfg(test)]
fn edges(mesh: &Mesh, cam: &Camera, eps: f64) -> Vec<(Vec3, Vec3)> {
let mut result = Vec::new();
for (_, e) in mesh.edges.iter().enumerate().filter(|(i, _)| mesh.shaded_edge_visible(*i)) {
@@ -241,7 +263,8 @@ fn edges(mesh: &Mesh, cam: &Camera, eps: f64) -> Vec<(Vec3, Vec3)> {
result
}
pub(super) fn drawing(meshes: &[Mesh], cam: &Camera) -> Result<Vec<Line>, &'static str> {
#[cfg(test)]
fn reference_drawing(meshes: &[Mesh], cam: &Camera) -> Result<Vec<Line>, &'static str> {
let (right, up, back) = cam.basis();
let scale = meshes.iter().fold(Aabb3::EMPTY, |b, m| b.union(&m.bounds())).diagonal().max(1.0);
let eps = scale * 1e-8;
+627
View File
@@ -0,0 +1,627 @@
//! Two levels of occlusion acceleration: an instance tree over projected bounds, and one
//! triangle hierarchy per shared mesh. Per-placement node bounds are small; projected
//! triangle coordinates are evaluated only in visited leaves. Covered line intervals prune
//! subsequent nodes and instances, including the entire query once coverage is complete.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Instant;
use solidcraft_doc::apply_point;
use solidcraft_geom::{Aabb3, Mesh, Vec3};
use solidcraft_render::Camera;
use super::super::meshes::{Placement, Scene, WORKERS, claim, transformed_bounds};
use super::super::progress::Stage;
use super::{Drawing, Line, bounds, clip, key, merge_lines, occluded, overlap, xy};
const TOO_COMPLEX: &str = "view exceeds export work budget; hide bodies or increase tolerance";
const MAX_LINES: usize = 1_000_000;
const MAX_SEGMENTS: usize = 4_000_000;
const MAX_SHARED_TRIANGLES: usize = 2_000_000;
const MAX_VIEW_NODES: usize = 256 * 1024 * 1024 / std::mem::size_of::<Aabb3>();
const WORK: usize = 200_000_000;
fn parallel<T: Sync, R: Send>(items: &[T], f: impl Fn(&T) -> Result<R, &'static str> + Sync) -> Result<Vec<R>, &'static str> {
solidcraft_kernel::map_on_threads(items, WORKERS, f).into_iter().map(|r| r.ok_or("export worker failed")?).collect()
}
struct Node {
bounds: Aabb3,
start: usize,
end: usize,
children: Option<(usize, usize)>,
}
struct Bvh {
nodes: Vec<Node>,
ids: Vec<usize>,
}
impl Bvh {
fn new(boxes: &[Aabb3], flat: bool) -> Self {
let mut tree = Self { nodes: Vec::new(), ids: (0..boxes.len()).collect() };
if !boxes.is_empty() {
tree.build(boxes, 0, boxes.len(), flat);
}
tree
}
fn build(&mut self, boxes: &[Aabb3], start: usize, end: usize, flat: bool) -> usize {
let b = self.ids.get(start..end).into_iter().flatten().filter_map(|i| boxes.get(*i)).fold(Aabb3::EMPTY, |b, t| b.union(t));
let index = self.nodes.len();
self.nodes.push(Node { bounds: b, start, end, children: None });
if end - start > 8 {
let size = b.size();
let axis = if !flat && size.z > size.x.max(size.y) {
2
} else if size.x > size.y {
0
} else {
1
};
let value = |i: &usize| {
boxes.get(*i).map(|b| b.center()).map_or(0., |p| match axis {
0 => p.x,
1 => p.y,
_ => p.z,
})
};
let mid = start + (end - start) / 2;
if let Some(ids) = self.ids.get_mut(start..end) {
ids.select_nth_unstable_by(mid - start, |a, b| value(a).total_cmp(&value(b)).then_with(|| a.cmp(b)));
}
let left = self.build(boxes, start, mid, flat);
let right = self.build(boxes, mid, end, flat);
if let Some(node) = self.nodes.get_mut(index) {
node.children = Some((left, right));
}
}
index
}
}
struct Edge {
vertices: [u32; 2],
start: usize,
end: usize,
}
struct Side {
key: [[i64; 3]; 2],
vertices: [u32; 2],
triangle: usize,
}
struct Shared {
mesh: Arc<Mesh>,
tree: Bvh,
edges: Vec<Edge>,
incident: Vec<usize>,
}
impl Shared {
fn new(mesh: &Arc<Mesh>, eps: f64, progress: &Stage) -> Result<Self, &'static str> {
let mut boxes = Vec::with_capacity(mesh.triangles.len());
let mut sides = Vec::new();
for (i, indices) in mesh.triangles.iter().enumerate() {
if i.is_multiple_of(1024) {
progress.check()?;
}
let points = mesh.tri(indices).ok_or("invalid export triangle")?;
if points.iter().any(|p| !p.is_finite()) {
return Err("model contains invalid coordinates");
}
boxes.push(bounds(points));
for (a, b) in [(0, 1), (1, 2), (2, 0)] {
let (Some(pa), Some(pb), Some(ia), Some(ib)) = (points.get(a), points.get(b), indices.get(a), indices.get(b)) else {
return Err("invalid export edge");
};
let (ka, kb) = (key(*pa, eps), key(*pb, eps));
if ka == kb {
continue;
}
let (key, vertices) = if ka < kb { ([ka, kb], [*ia, *ib]) } else { ([kb, ka], [*ib, *ia]) };
sides.push(Side { key, vertices, triangle: i });
}
}
let tree = Bvh::new(&boxes, false);
sides.sort_unstable_by(|a, b| a.key.cmp(&b.key).then_with(|| a.triangle.cmp(&b.triangle)));
let mut edges: Vec<Edge> = Vec::new();
let mut incident = Vec::with_capacity(sides.len());
let mut previous = None;
for side in sides {
if previous != Some(side.key) {
edges.push(Edge { vertices: side.vertices, start: incident.len(), end: incident.len() });
previous = Some(side.key);
}
incident.push(side.triangle);
if let Some(e) = edges.last_mut() {
e.end = incident.len();
}
}
Ok(Self { mesh: mesh.clone(), tree, edges, incident })
}
}
struct View<'a> {
cam: &'a Camera,
right: Vec3,
up: Vec3,
back: Vec3,
}
impl<'a> View<'a> {
fn new(cam: &'a Camera) -> Self {
let (right, up, back) = cam.basis();
Self { cam, right, up, back }
}
fn project(&self, p: Vec3) -> Result<Vec3, &'static str> {
if !p.is_finite() {
return Err("model contains invalid coordinates");
}
let d = p - self.cam.target;
let z = d.dot(self.back);
let projected = if self.cam.fov > 0. {
let w = self.cam.distance - z;
if w <= self.cam.distance * 1e-6 {
return Err("model crosses the camera eye plane; zoom out before exporting");
}
Vec3::new(d.dot(self.right) * self.cam.distance / w, d.dot(self.up) * self.cam.distance / w, 1. / w)
} else {
Vec3::new(d.dot(self.right), d.dot(self.up), z)
};
if !projected.is_finite() || projected.len() > 1e12 {
return Err("projected coordinates exceed the export range");
}
Ok(projected)
}
fn triangle(&self, shared: &Shared, placed: &Placement, id: usize) -> Result<[Vec3; 3], &'static str> {
let indices = shared.mesh.triangles.get(id).ok_or("invalid export triangle")?;
let [a, b, c] = shared.mesh.tri(indices).ok_or("invalid export triangle")?;
Ok([
self.project(apply_point(&placed.transform, a))?,
self.project(apply_point(&placed.transform, b))?,
self.project(apply_point(&placed.transform, c))?,
])
}
}
struct Projected {
nodes: Vec<Aabb3>,
segments: Vec<(Vec3, Vec3)>,
}
impl Projected {
fn new(shared: &Shared, placed: &Placement, view: &View<'_>, eps: f64, segments: &AtomicUsize, progress: &Stage) -> Result<Self, &'static str> {
let mut nodes = vec![Aabb3::EMPTY; shared.tree.nodes.len()];
// Exact projected leaf bounds avoid false near-eye errors from empty corners of a
// rotated model-space box. Interior bounds are just unions of the child bounds.
let mut front = vec![false; shared.mesh.triangles.len()];
for (i, node) in shared.tree.nodes.iter().enumerate().rev() {
if i.is_multiple_of(1024) {
progress.check()?;
}
let mut b = Aabb3::EMPTY;
if let Some((left, right)) = node.children {
b = nodes.get(left).ok_or("invalid export tree")?.union(nodes.get(right).ok_or("invalid export tree")?);
} else {
for id in shared.tree.ids.get(node.start..node.end).ok_or("invalid export tree")? {
let p = view.triangle(shared, placed, *id)?;
b = b.union(&bounds(p));
if let Some(f) = front.get_mut(*id) {
// A positive projective scale preserves the projected winding.
*f = super::cross(xy(p[1] - p[0]), xy(p[2] - p[0])) > 0.;
}
}
}
if let Some(slot) = nodes.get_mut(i) {
*slot = b;
}
}
let mut result = Vec::new();
let mut add = |a: Vec3, b: Vec3| -> Result<(), &'static str> {
let (a, b) = (view.project(apply_point(&placed.transform, a))?, view.project(apply_point(&placed.transform, b))?);
if xy(a).dist(xy(b)) > eps {
if !claim(segments, 1, MAX_SEGMENTS) {
return Err(TOO_COMPLEX);
}
result.push((a, b));
}
Ok(())
};
for (i, edge) in shared.mesh.edges.iter().enumerate() {
if !placed.visibility.get(i).copied().unwrap_or(false) {
continue;
}
for w in edge.windows(2) {
if let (Some(a), Some(b)) = (w.first(), w.get(1)) {
add(*a, *b)?;
}
}
}
for edge in &shared.edges {
let mut positive = false;
let mut negative = false;
for id in shared.incident.get(edge.start..edge.end).ok_or("invalid export adjacency")? {
if front.get(*id).copied().ok_or("invalid export adjacency")? {
positive = true;
} else {
negative = true;
}
}
if (positive && negative) || edge.end - edge.start == 1 {
let a = shared.mesh.positions.get(edge.vertices[0] as usize).ok_or("invalid silhouette")?;
let b = shared.mesh.positions.get(edge.vertices[1] as usize).ok_or("invalid silhouette")?;
add(*a, *b)?;
}
}
Ok(Self { nodes, segments: result })
}
}
/// A sorted union, updated as occluders are found. A node's line interval already covered by
/// nearer triangles cannot change the result and is skipped before visiting its children.
#[derive(Default)]
struct Coverage {
spans: Vec<(f64, f64)>,
}
impl Coverage {
fn contains(&self, lo: f64, hi: f64) -> bool {
self.spans.iter().any(|(a, b)| *a <= lo && *b >= hi)
}
fn add(&mut self, mut lo: f64, mut hi: f64) -> Result<(), &'static str> {
let first = self.spans.partition_point(|(_, b)| *b + 1e-9 < lo);
let mut end = first;
while let Some((a, b)) = self.spans.get(end) {
if *a > hi + 1e-9 {
break;
}
lo = lo.min(*a);
hi = hi.max(*b);
end += 1;
}
if first == end && self.spans.len() >= MAX_LINES / 2 {
return Err(TOO_COMPLEX);
}
self.spans.splice(first..end, [(lo, hi)]);
Ok(())
}
}
fn rectangle(a: Vec3, b: Vec3, box_: &Aabb3) -> Option<(f64, f64)> {
let (mut lo, mut hi) = (0., 1.);
for (v0, v1) in [
(a.x - box_.min.x, b.x - box_.min.x),
(box_.max.x - a.x, box_.max.x - b.x),
(a.y - box_.min.y, b.y - box_.min.y),
(box_.max.y - a.y, box_.max.y - b.y),
] {
if !clip(&mut lo, &mut hi, v0, v1) {
return None;
}
}
Some((lo, hi))
}
struct Budget<'a> {
global: &'a AtomicUsize,
progress: &'a Stage,
local: usize,
spent: usize,
}
impl<'a> Budget<'a> {
fn new(global: &'a AtomicUsize, progress: &'a Stage) -> Self {
Self { global, progress, local: 0, spent: 0 }
}
fn spend(&mut self) -> Result<(), &'static str> {
if self.local == 0 {
self.progress.check()?;
let mut old = self.global.load(Ordering::Relaxed);
loop {
let granted = old.min(4096);
if granted == 0 {
return Err(TOO_COMPLEX);
}
match self.global.compare_exchange_weak(old, old - granted, Ordering::Relaxed, Ordering::Relaxed) {
Ok(_) => {
self.local = granted;
break;
}
Err(current) => old = current,
}
}
}
self.local -= 1;
self.spent += 1;
Ok(())
}
}
struct Query<'a, 'b> {
shared: &'a [Shared],
scene: &'a Scene,
projected: &'a [Projected],
instances: &'a Bvh,
view: &'a View<'a>,
depth_eps: f64,
a: Vec3,
b: Vec3,
bounds: Aabb3,
covered: Coverage,
budget: &'b mut Budget<'a>,
}
impl Query<'_, '_> {
fn relevant(&self, b: &Aabb3) -> bool {
if b.max.z <= self.a.z.min(self.b.z) + self.depth_eps || !overlap(b, &self.bounds) {
return false;
}
rectangle(self.a, self.b, b).is_some_and(|(lo, hi)| !self.covered.contains(lo, hi))
}
fn instance_node(&mut self, index: usize) -> Result<(), &'static str> {
self.budget.spend()?;
let node = self.instances.nodes.get(index).ok_or("invalid instance tree")?;
if !self.relevant(&node.bounds) {
return Ok(());
}
if let Some((left, right)) = node.children {
let depth = |i: usize| self.instances.nodes.get(i).map_or(f64::NEG_INFINITY, |n| n.bounds.max.z);
let (near, far) = if depth(left) >= depth(right) { (left, right) } else { (right, left) };
self.instance_node(near)?;
if !self.covered.contains(0., 1.) {
self.instance_node(far)?;
}
} else {
for id in self.instances.ids.get(node.start..node.end).ok_or("invalid instance tree")? {
self.mesh_node(*id, 0)?;
if self.covered.contains(0., 1.) {
break;
}
}
}
Ok(())
}
fn mesh_node(&mut self, instance: usize, index: usize) -> Result<(), &'static str> {
self.budget.spend()?;
let placed = self.scene.placements.get(instance).ok_or("invalid instance")?;
let shared = self.shared.get(placed.mesh).ok_or("invalid shared geometry")?;
let projected = self.projected.get(instance).ok_or("invalid instance bounds")?;
let b = projected.nodes.get(index).ok_or("invalid mesh tree")?;
if !self.relevant(b) {
return Ok(());
}
let node = shared.tree.nodes.get(index).ok_or("invalid mesh tree")?;
if let Some((left, right)) = node.children {
let depth = |i: usize| projected.nodes.get(i).map_or(f64::NEG_INFINITY, |b| b.max.z);
let (near, far) = if depth(left) >= depth(right) { (left, right) } else { (right, left) };
self.mesh_node(instance, near)?;
if !self.covered.contains(0., 1.) {
self.mesh_node(instance, far)?;
}
} else {
for id in shared.tree.ids.get(node.start..node.end).ok_or("invalid mesh tree")? {
self.budget.spend()?;
let points = self.view.triangle(shared, placed, *id)?;
if let Some((lo, hi)) = occluded(self.a, self.b, points, self.depth_eps) {
self.covered.add(lo, hi)?;
if self.covered.contains(0., 1.) {
break;
}
}
}
}
Ok(())
}
}
pub(super) fn drawing(scene: &Scene, cam: &Camera) -> Result<Drawing, &'static str> {
let start = Instant::now();
let scale = scene
.placements
.iter()
.filter_map(|p| scene.meshes.get(p.mesh).map(|m| transformed_bounds(m, &p.transform)))
.fold(Aabb3::EMPTY, |b, p| b.union(&p))
.diagonal()
.max(1.);
if !scale.is_finite() {
return Err("model contains invalid coordinates");
}
let eps = scale * 1e-8;
// Indexed input can have many triangles but very few positions. Bound adjacency/sort
// scratch space as well as the retained mesh bytes before starting parallel builders.
let unique_triangles = scene.meshes.iter().try_fold(0usize, |n, m| n.checked_add(m.triangles.len()));
if unique_triangles.is_none_or(|n| n > MAX_SHARED_TRIANGLES) {
return Err("view exceeds export memory budget; hide bodies or increase tolerance");
}
let progress = Stage::new("dxf_index", scene.meshes.len());
let shared = parallel(&scene.meshes, |m| {
progress.check()?;
let shared = Shared::new(m, eps, &progress)?;
progress.advance(1)?;
Ok(shared)
})?;
let count = scene.placements.iter().try_fold(0usize, |n, p| n.checked_add(shared.get(p.mesh)?.tree.nodes.len()));
if count.is_none_or(|n| n > MAX_VIEW_NODES) {
return Err("view exceeds export memory budget; hide bodies or increase tolerance");
}
let view = View::new(cam);
let count = AtomicUsize::new(0);
let progress = Stage::new("dxf_projection", scene.placements.len());
let projected = parallel(&scene.placements, |p| {
progress.check()?;
let projected = Projected::new(shared.get(p.mesh).ok_or("missing shared geometry")?, p, &view, eps, &count, &progress)?;
progress.advance(1)?;
Ok(projected)
})?;
let boxes = projected.iter().map(|p| p.nodes.first().copied().unwrap_or(Aabb3::EMPTY)).collect::<Vec<_>>();
let mut instances = Bvh::new(&boxes, true);
// The view is fixed for the entire drawing. Sort the at-most-eight members of each
// instance leaf once, rather than allocating/sorting on every line query.
for node in instances.nodes.iter().filter(|n| n.children.is_none()) {
if let Some(ids) = instances.ids.get_mut(node.start..node.end) {
ids.sort_unstable_by(|a, b| {
boxes
.get(*b)
.map_or(f64::NEG_INFINITY, |b| b.max.z)
.total_cmp(&boxes.get(*a).map_or(f64::NEG_INFINITY, |b| b.max.z))
.then_with(|| a.cmp(b))
});
}
}
let segment_count = count.load(Ordering::Relaxed);
let projection_ms = start.elapsed().as_secs_f64() * 1000.;
let start = Instant::now();
let depth_eps = if cam.fov > 0. { 1e-8 / cam.distance } else { eps };
let global = AtomicUsize::new(WORK);
let output = AtomicUsize::new(0);
let chunks = projected.iter().flat_map(|p| p.segments.chunks(128)).collect::<Vec<_>>();
let progress = Stage::new("dxf_occlusion", segment_count);
let results = parallel(&chunks, |chunk| {
progress.check()?;
let mut budget = Budget::new(&global, &progress);
let mut lines = Vec::new();
for &(a, b) in *chunk {
let mut query = Query {
shared: &shared,
scene,
projected: &projected,
instances: &instances,
view: &view,
depth_eps,
a,
b,
bounds: bounds([a, b]),
covered: Coverage::default(),
budget: &mut budget,
};
query.instance_node(0)?;
let mut cursor = 0.;
let mut emit = |lo: f64, hi: f64, hidden: bool| -> Result<(), &'static str> {
let (p, q) = (xy(a + (b - a) * lo), xy(a + (b - a) * hi));
if p.dist(q) > eps {
if !claim(&output, 1, MAX_LINES) {
return Err(TOO_COMPLEX);
}
lines.push(Line { a: p, b: q, hidden });
}
Ok(())
};
for (lo, hi) in query.covered.spans {
emit(cursor, lo, false)?;
emit(lo, hi, true)?;
cursor = hi;
}
emit(cursor, 1., false)?;
}
progress.advance(chunk.len())?;
Ok((lines, budget.spent))
})?;
let work = results.iter().map(|(_, n)| *n).sum();
let lines = merge_lines(results.into_iter().flat_map(|(lines, _)| lines).collect(), eps);
progress.check()?;
Ok(Drawing { lines, work, segments: segment_count, projection_ms, occlusion_ms: start.elapsed().as_secs_f64() * 1000. })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn coverage_handles_disjoint_spans_and_early_complete_coverage() {
let mut c = Coverage::default();
c.add(0.4, 0.6).unwrap();
c.add(0., 0.2).unwrap();
assert!(c.contains(0.05, 0.15));
assert!(!c.contains(0., 1.));
c.add(0.2, 0.9).unwrap();
c.add(0.8, 1.).unwrap();
assert_eq!(c.spans, vec![(0., 1.)]);
}
fn box_mesh() -> Mesh {
let mut mesh = Mesh::default();
mesh.positions = vec![
Vec3::new(0., 0., 0.),
Vec3::new(8., 0., 0.),
Vec3::new(8., 6., 0.),
Vec3::new(0., 6., 0.),
Vec3::new(0., 0., 3.),
Vec3::new(8., 0., 3.),
Vec3::new(8., 6., 3.),
Vec3::new(0., 6., 3.),
];
mesh.triangles =
vec![[0, 2, 1], [0, 3, 2], [4, 5, 6], [4, 6, 7], [0, 1, 5], [0, 5, 4], [1, 2, 6], [1, 6, 5], [2, 3, 7], [2, 7, 6], [3, 0, 4], [3, 4, 7]];
mesh.edges = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]
.into_iter()
.map(|(a, b)| vec![mesh.positions[a], mesh.positions[b]])
.collect();
mesh
}
fn canonical(lines: Vec<Line>) -> Vec<([i64; 2], [i64; 2], bool)> {
let k = |p: solidcraft_geom::Vec2| [(p.x * 1e6).round() as i64, (p.y * 1e6).round() as i64];
let mut result = lines
.into_iter()
.map(|l| {
let (a, b) = (k(l.a), k(l.b));
if a < b { (a, b, l.hidden) } else { (b, a, l.hidden) }
})
.collect::<Vec<_>>();
result.sort_unstable();
result
}
#[test]
fn shared_rotated_instances_match_flat_occlusion_in_orthographic_and_perspective_views() {
use std::sync::Arc;
let mesh = box_mesh();
let mut placements = Vec::new();
let mut flat = Vec::new();
for (offset, angle) in [(Vec3::ZERO, 0.), (Vec3::new(3., 2., 4.), 0.3), (Vec3::new(2., 1., -4.), -0.6)] {
let transform = solidcraft_doc::rigid(offset, Vec3::ZERO, Vec3::Z, angle);
placements.push(Placement { mesh: 0, transform, visibility: vec![true; mesh.edges.len()] });
let mut placed = mesh.clone();
placed.positions = placed.positions.iter().map(|p| apply_point(&transform, *p)).collect();
placed.edges = placed.edges.iter().map(|e| e.iter().map(|p| apply_point(&transform, *p)).collect()).collect();
flat.push(placed);
}
let scene = Scene { meshes: vec![Arc::new(mesh)], placements, triangles: 36, bytes: 0 };
for (yaw, pitch, roll) in [(0., std::f64::consts::FRAC_PI_2, 0.), (0.7, 0.4, 0.2), (0., 0., 0.)] {
for fov in [0., 0.6] {
let cam = Camera { target: Vec3::new(4., 3., 1.), yaw, pitch, roll, fov, distance: 100., ..Default::default() };
let expected = super::super::reference_drawing(&flat, &cam).unwrap();
let actual = drawing(&scene, &cam).unwrap();
assert_eq!(canonical(actual.lines), canonical(expected), "yaw={yaw}, pitch={pitch}, fov={fov}");
}
}
}
#[test]
fn repeated_surface_over_one_million_triangles_keeps_one_mesh_and_exports_all_placements() {
use solidcraft_render::StandardView;
use std::sync::Arc;
let mut mesh = Mesh::default();
for y in 0..=100 {
for x in 0..=100 {
mesh.positions.push(Vec3::new(x as f64 / 10., y as f64 / 10., 0.));
}
}
for y in 0..100u32 {
for x in 0..100u32 {
let a = y * 101 + x;
mesh.triangles.extend([[a, a + 1, a + 102], [a, a + 102, a + 101]]);
}
}
mesh.edges = vec![
vec![Vec3::ZERO, Vec3::new(10., 0., 0.)],
vec![Vec3::new(10., 0., 0.), Vec3::new(10., 10., 0.)],
vec![Vec3::new(10., 10., 0.), Vec3::new(0., 10., 0.)],
vec![Vec3::new(0., 10., 0.), Vec3::ZERO],
];
let placements = (0..60)
.map(|i| Placement {
mesh: 0,
transform: solidcraft_doc::rigid(Vec3::new((i % 10) as f64 * 20., (i / 10) as f64 * 20., 0.), Vec3::ZERO, Vec3::Z, 0.),
visibility: vec![true; 4],
})
.collect();
let scene = Scene { meshes: vec![Arc::new(mesh)], placements, triangles: 1_200_000, bytes: 0 };
let mut cam = Camera { target: Vec3::new(95., 55., 0.), fov: 0., distance: 300., ..Default::default() };
cam.set_view(StandardView::Top);
let result = drawing(&scene, &cam).unwrap();
assert_eq!(scene.meshes.len(), 1);
assert_eq!(result.lines.len(), 240);
assert!(result.lines.iter().all(|l| !l.hidden));
}
}
+72
View File
@@ -21,6 +21,49 @@ fn export(s: &mut Session, c: Camera, hidden: &str) -> Value {
run(s, CMD, json!({"camera":c,"hidden":hidden}))
}
#[test]
fn view_dxf_cancellation_at_each_stage_preserves_the_destination_and_document() {
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
struct CancelAt {
stage: &'static str,
cancelled: AtomicBool,
reached: AtomicBool,
}
impl solidcraft_kernel::watch::Watch for CancelAt {
fn cancelled(&self) -> bool {
self.cancelled.load(Ordering::Relaxed)
}
fn progress(&self, stage: &'static str, done: usize, _total: usize) {
if stage == self.stage && (done > 0 || stage == "dxf_write") {
self.reached.store(true, Ordering::Relaxed);
self.cancelled.store(true, Ordering::Relaxed);
}
}
}
let dir = std::env::temp_dir().join(format!("solidcraft-dxf-cancel-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("drawing.dxf");
let mut s = sample();
let doc = s.doc.clone();
let undo = s.undo.len();
for stage in ["dxf_meshes", "dxf_index", "dxf_projection", "dxf_occlusion", "dxf_write"] {
std::fs::write(&path, b"previous drawing").unwrap();
let watch = Arc::new(CancelAt { stage, cancelled: AtomicBool::new(false), reached: AtomicBool::new(false) });
let result = solidcraft_kernel::watch::watching(watch.clone(), || {
s.execute(CMD, &json!({"camera":camera(StandardView::Top),"path":path.to_string_lossy()}))
});
assert!(result.unwrap_err().to_string().contains("cancelled"), "{stage}");
assert!(watch.reached.load(Ordering::Relaxed), "{stage}");
assert_eq!(std::fs::read(&path).unwrap(), b"previous drawing", "{stage}");
assert!(Arc::ptr_eq(&doc, &s.doc));
assert_eq!(s.undo.len(), undo);
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn view_dxf_blind_back_hole_is_omitted_or_dashed_and_front_hole_is_visible() {
let mut s = sample();
@@ -100,3 +143,32 @@ fn view_dxf_rejects_invalid_options_and_near_eye_geometry_without_panicking() {
c.fov = 4.;
assert!(s.execute(CMD, &json!({"camera":c})).is_err());
}
#[test]
fn view_dxf_keeps_every_occurrence_while_sharing_geometry() {
let mut s = Session::default();
let component = run(&mut s, "component.create", json!({"name":"Repeated part"}))["component"].as_u64().unwrap();
run(&mut s, "solid.box", json!({"length":8,"width":6,"height":3}));
for x in [15., 30.] {
run(&mut s, "occurrence.copy", json!({"component":component,"translate":[x,0,0]}));
}
run(&mut s, "component.activate", json!({"component":0}));
let doc = s.doc.clone();
let result = export(&mut s, camera(StandardView::Top), "dashed");
assert_eq!(result["stats"]["bodies"], 3);
assert_eq!(result["stats"]["shared_meshes"], 1);
assert_eq!(result["stats"]["worker_limit"], meshes::WORKERS);
assert!(result["hidden"].as_u64().unwrap() > 0);
let lines = solidcraft_io::read_dxf(result["dxf"].as_str().unwrap()).unwrap();
let mut centers = std::collections::BTreeSet::new();
for line in lines {
if let solidcraft_io::Geom2::Line(a, b) = line {
// Each translated copy has a visible top perimeter, including the offscreen one.
if (a.y - b.y).abs() > 5.9 {
centers.insert(((a.x + 20.) / 15.).floor() as i64);
}
}
}
assert!(centers.len() >= 3, "{centers:?}");
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
}