Accelerate current-view DXF export and keep desktop exports responsive (#1738)
Thanks @crono2250.
This commit is contained in:
1 parent
4bacd0cae0
commit
bba03803b7
20 files changed
+1319
-53
No files matched your search
+8
-1
@@ -68,9 +68,16 @@ retain their saved two-finger scroll preference; camera motion is available thro
|
||||
|
||||
M11 precursor: File → Export Current View as DXF exports shown placed bodies from the current
|
||||
camera as a 2D millimetre drawing, with hidden edges omitted or dashed. Vector occlusion splits
|
||||
partly hidden edges and includes curved silhouettes. Curves are approximated; drawing sheets,
|
||||
partly hidden edges and includes curved silhouettes. Repeated part meshes and triangle
|
||||
hierarchies are shared; bounded desktop preparation and occlusion use up to eight workers.
|
||||
Curves are approximated; drawing sheets,
|
||||
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
|
||||
|
||||
Desktop view DXF export runs in the file-operation worker with five-stage progress and
|
||||
cooperative cancellation. Curve tolerance (0.0001–1 mm, default 0.01 mm) is remembered alongside
|
||||
the hidden-edge setting. Cancelled exports preserve the model and the previous destination;
|
||||
completed drawings publish atomically. The browser still exports synchronously.
|
||||
|
||||
## Interactive modelling
|
||||
|
||||
- New Body extrusion and face-offset previews show completed intermediate geometry while a
|
||||
|
||||
@@ -431,6 +431,18 @@
|
||||
"drive.stopped_by_contact": "Angehalten bei {value}: {a} würde {b} durchdringen",
|
||||
"drive.stopped_by_loop": "Angehalten bei {value}: die Schleife über {joint} lässt sich nicht weiter schließen",
|
||||
"drive.unconnected": "Bewegt nichts: {part} ist mit nichts Festgesetztem verbunden",
|
||||
"dxf.cancel_wait": "Warten, bis der Export angehalten wurde…",
|
||||
"dxf.cancelled": "DXF-Export abgebrochen",
|
||||
"dxf.cancelling": "DXF-Export wird abgebrochen",
|
||||
"dxf.exporting": "DXF wird exportiert",
|
||||
"dxf.invalid_tolerance": "Die Kurventoleranz muss zwischen 0,0001 und 1 mm liegen.",
|
||||
"dxf.progress.index": "Flächen vorbereiten (2/5)",
|
||||
"dxf.progress.meshes": "Netze vorbereiten (1/5)",
|
||||
"dxf.progress.occlusion": "Verdeckte Kanten ermitteln (4/5)",
|
||||
"dxf.progress.projection": "Geometrie projizieren (3/5)",
|
||||
"dxf.progress.write": "DXF schreiben (5/5)",
|
||||
"dxf.tolerance": "Kurventoleranz",
|
||||
"dxf.tolerance_help": "Nur sichtbare Körper. Kurven werden durch gerade Segmente angenähert. Eine kleinere Toleranz liefert feinere Kurven und benötigt mehr Zeit und Speicher.",
|
||||
"home.documentation": "Dokumentation",
|
||||
"home.in_this_browser": "in diesem Browser",
|
||||
"home.missing": "fehlt",
|
||||
|
||||
@@ -634,6 +634,18 @@
|
||||
"drive.stopped_by_contact": "Stopped at {value}: {a} would pass through {b}",
|
||||
"drive.stopped_by_loop": "Stopped at {value}: the loop through {joint} can't close any further",
|
||||
"drive.unconnected": "Moves nothing: {part} is not connected to anything grounded",
|
||||
"dxf.cancel_wait": "Waiting for the export to stop…",
|
||||
"dxf.cancelled": "DXF export cancelled",
|
||||
"dxf.cancelling": "Cancelling DXF export",
|
||||
"dxf.exporting": "Exporting DXF",
|
||||
"dxf.invalid_tolerance": "Curve tolerance must be between 0.0001 and 1 mm.",
|
||||
"dxf.progress.index": "Preparing surfaces (2/5)",
|
||||
"dxf.progress.meshes": "Preparing meshes (1/5)",
|
||||
"dxf.progress.occlusion": "Resolving hidden lines (4/5)",
|
||||
"dxf.progress.projection": "Projecting geometry (3/5)",
|
||||
"dxf.progress.write": "Writing DXF (5/5)",
|
||||
"dxf.tolerance": "Curve tolerance",
|
||||
"dxf.tolerance_help": "Visible bodies only. Curves use straight segments. Smaller tolerance gives finer curves and uses more time and memory.",
|
||||
"expression, e.g. 25 mm": "expression, e.g. 25 mm",
|
||||
"fits the face, e.g. M8": "fits the face, e.g. M8",
|
||||
"from the rule": "from the rule",
|
||||
|
||||
@@ -634,6 +634,18 @@
|
||||
"drive.stopped_by_contact": "{value} で停止: {a} が {b} を通り抜けてしまいます",
|
||||
"drive.stopped_by_loop": "{value} で停止: {joint} を通るループはこれ以上閉じられません",
|
||||
"drive.unconnected": "何も動きません: {part} は空間固定されたものに接続されていません",
|
||||
"dxf.cancel_wait": "出力処理の停止を待っています…",
|
||||
"dxf.cancelled": "DXF出力をキャンセルしました",
|
||||
"dxf.cancelling": "DXF出力をキャンセル中",
|
||||
"dxf.exporting": "DXFを出力中",
|
||||
"dxf.invalid_tolerance": "曲線の許容誤差は0.0001~1 mmで指定してください。",
|
||||
"dxf.progress.index": "面データを準備中 (2/5)",
|
||||
"dxf.progress.meshes": "メッシュを準備中 (1/5)",
|
||||
"dxf.progress.occlusion": "隠線を判定中 (4/5)",
|
||||
"dxf.progress.projection": "形状を投影中 (3/5)",
|
||||
"dxf.progress.write": "DXFを書き込み中 (5/5)",
|
||||
"dxf.tolerance": "曲線の許容誤差",
|
||||
"dxf.tolerance_help": "表示中のボディを出力し、曲線は線分で近似します。許容誤差を小さくすると曲線が滑らかになりますが、処理時間とメモリ使用量が増えます。",
|
||||
"expression, e.g. 25 mm": "計算式 (例: 25 mm)",
|
||||
"fits the face, e.g. M8": "面に適合するサイズ (例: M8)",
|
||||
"from the rule": "ルールから",
|
||||
|
||||
@@ -431,6 +431,18 @@
|
||||
"drive.stopped_by_contact": "{value}에서 멈춤: {a}이(가) {b}을(를) 통과하게 됩니다",
|
||||
"drive.stopped_by_loop": "{value}에서 멈춤: {joint}을(를) 지나는 루프를 더 이상 닫을 수 없습니다",
|
||||
"drive.unconnected": "아무것도 움직이지 않습니다: {part}이(가) 고정된 것에 연결되어 있지 않습니다",
|
||||
"dxf.cancel_wait": "내보내기가 중지될 때까지 기다리는 중…",
|
||||
"dxf.cancelled": "DXF 내보내기를 취소했습니다",
|
||||
"dxf.cancelling": "DXF 내보내기 취소 중",
|
||||
"dxf.exporting": "DXF 내보내는 중",
|
||||
"dxf.invalid_tolerance": "곡선 허용 오차는 0.0001~1 mm 범위여야 합니다.",
|
||||
"dxf.progress.index": "면 데이터 준비 중 (2/5)",
|
||||
"dxf.progress.meshes": "메시 준비 중 (1/5)",
|
||||
"dxf.progress.occlusion": "숨은선 판정 중 (4/5)",
|
||||
"dxf.progress.projection": "형상 투영 중 (3/5)",
|
||||
"dxf.progress.write": "DXF 쓰는 중 (5/5)",
|
||||
"dxf.tolerance": "곡선 허용 오차",
|
||||
"dxf.tolerance_help": "표시된 바디만 내보내며 곡선은 직선 구간으로 근사합니다. 허용 오차가 작을수록 곡선이 매끄러워지지만 처리 시간과 메모리 사용량이 늘어납니다.",
|
||||
"home.documentation": "문서",
|
||||
"home.in_this_browser": "이 브라우저에 저장됨",
|
||||
"home.missing": "찾을 수 없음",
|
||||
|
||||
@@ -634,6 +634,18 @@
|
||||
"drive.stopped_by_contact": "停在 {value}:{a} 会穿过 {b}",
|
||||
"drive.stopped_by_loop": "停在 {value}:经过 {joint} 的环路无法再闭合",
|
||||
"drive.unconnected": "没有任何移动:{part} 未连接到任何固定的零部件",
|
||||
"dxf.cancel_wait": "正在等待导出停止…",
|
||||
"dxf.cancelled": "DXF 导出已取消",
|
||||
"dxf.cancelling": "正在取消 DXF 导出",
|
||||
"dxf.exporting": "正在导出 DXF",
|
||||
"dxf.invalid_tolerance": "曲线公差必须介于 0.0001 和 1 mm 之间。",
|
||||
"dxf.progress.index": "正在准备曲面 (2/5)",
|
||||
"dxf.progress.meshes": "正在准备网格 (1/5)",
|
||||
"dxf.progress.occlusion": "正在判定隐藏线 (4/5)",
|
||||
"dxf.progress.projection": "正在投影几何体 (3/5)",
|
||||
"dxf.progress.write": "正在写入 DXF (5/5)",
|
||||
"dxf.tolerance": "曲线公差",
|
||||
"dxf.tolerance_help": "仅导出可见实体。曲线使用直线段近似。公差越小,曲线越精细,但需要更多时间和内存。",
|
||||
"expression, e.g. 25 mm": "表达式,例如 25 mm",
|
||||
"fits the face, e.g. M8": "适配该面,例如 M8",
|
||||
"from the rule": "来自规则",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -85,7 +85,7 @@ mod outline;
|
||||
#[cfg(test)]
|
||||
mod outline_tests;
|
||||
mod par;
|
||||
pub use par::{for_each_on_threads, map_on_threads};
|
||||
pub use par::{for_each_on_threads, map_on_threads, map_with_local_pools};
|
||||
#[cfg(test)]
|
||||
mod pinch_tests;
|
||||
mod planar_faces;
|
||||
|
||||
@@ -33,6 +33,37 @@ pub fn for_each_on_threads<T: Sync>(items: &[T], max_threads: usize, f: impl Fn(
|
||||
let _ = map_on_threads(items, max_threads, f);
|
||||
}
|
||||
|
||||
/// Independent kernel jobs, with one computation lane per native dispatcher. Each dispatcher
|
||||
/// owns a single-thread Rayon pool: nested triangulation neither queues behind a common
|
||||
/// one-thread pool nor multiplies the requested concurrency. No second body can be stolen by
|
||||
/// a worker while it fills a shared lazy mesh cell. The browser runs serially.
|
||||
pub fn map_with_local_pools<T: Sync, R: Send>(items: &[T], max_threads: usize, f: impl Fn(&T) -> Result<R> + Sync) -> Vec<Result<R>> {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let one = |item: &T| {
|
||||
use std::cell::OnceCell;
|
||||
thread_local! {
|
||||
static POOL: OnceCell<rayon::ThreadPool> = const { OnceCell::new() };
|
||||
}
|
||||
POOL.with(|cell| {
|
||||
if cell.get().is_none() {
|
||||
let pool = rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(1)
|
||||
.build()
|
||||
.map_err(|e| crate::KernelError::Failed(format!("cannot start mesh worker: {e}")))?;
|
||||
let _ = cell.set(pool);
|
||||
}
|
||||
let pool = cell.get().ok_or_else(|| crate::KernelError::Failed("missing mesh worker".into()))?;
|
||||
pool.install(|| guard("mesh worker", || f(item)))
|
||||
})
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let one = |item: &T| guard("mesh worker", || f(item));
|
||||
map_on_threads(items, max_threads, one)
|
||||
.into_iter()
|
||||
.map(|r| r.unwrap_or_else(|| Err(crate::KernelError::Internal("mesh worker failed".into()))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `f` over `items` as [`for_each_on_threads`] runs it, results in item order (`None` where an
|
||||
/// item panicked).
|
||||
pub fn map_on_threads<T: Sync, R: Send>(items: &[T], max_threads: usize, f: impl Fn(&T) -> R + Sync) -> Vec<Option<R>> {
|
||||
@@ -82,6 +113,19 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::KernelError;
|
||||
|
||||
#[test]
|
||||
fn local_pools_keep_order_and_errors_and_bound_nested_parallelism() {
|
||||
let items: Vec<usize> = (0..32).collect();
|
||||
let result = map_with_local_pools(&items, 8, |i| {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
assert_eq!(rayon::current_num_threads(), 1);
|
||||
if *i == 7 { Err(KernelError::Invalid("seven".into())) } else { Ok(*i * 2) }
|
||||
});
|
||||
for (i, r) in result.iter().enumerate() {
|
||||
assert_eq!(r.as_ref().ok().copied(), (i != 7).then_some(i * 2));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_item_once_on_threads() {
|
||||
let items: Vec<u32> = (0..1000).collect();
|
||||
|
||||
@@ -34,7 +34,7 @@ pub fn watching<R>(watch: Arc<dyn Watch>, f: impl FnOnce() -> R) -> R {
|
||||
}
|
||||
|
||||
/// The watch over this thread's work, to hand to the worker threads it starts.
|
||||
pub(crate) fn current() -> Option<Arc<dyn Watch>> {
|
||||
pub fn current() -> Option<Arc<dyn Watch>> {
|
||||
CURRENT.with(|c| c.borrow().clone())
|
||||
}
|
||||
|
||||
|
||||
@@ -260,6 +260,7 @@ pub enum Kind {
|
||||
SaveMesh(crate::dialogs_save_mesh::SaveMesh),
|
||||
ViewDxf {
|
||||
dashed: bool,
|
||||
tolerance: f64,
|
||||
},
|
||||
/// Plastic features (`dialogs_plastic`).
|
||||
Plastic(crate::dialogs_plastic::Pl),
|
||||
@@ -2948,7 +2949,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
Kind::Surface(k) => enter |= crate::dialogs_surface::rows(ui, k),
|
||||
Kind::SketchModify(k) => enter |= crate::sketch_modify_ui::rows(ui, k),
|
||||
Kind::SaveMesh(k) => enter |= crate::dialogs_save_mesh::rows(app, ui, k),
|
||||
Kind::ViewDxf { dashed } => crate::dialogs_view_dxf::rows(app, ui, dashed),
|
||||
Kind::ViewDxf { dashed, tolerance } => crate::dialogs_view_dxf::rows(app, ui, dashed, tolerance),
|
||||
Kind::Plastic(k) => enter |= crate::dialogs_plastic::rows(app, ui, k, &d.inputs),
|
||||
Kind::Appearance(k) => enter |= crate::dialogs_appearance::rows(app, ui, k, &mut d.inputs),
|
||||
Kind::Motion(k) => enter |= crate::dialogs_motion::rows(app, ui, k, &mut d.inputs),
|
||||
@@ -3477,8 +3478,8 @@ fn sels(d: &Dialog, i: usize) -> &[Sel] {
|
||||
}
|
||||
|
||||
pub(crate) fn run_dialog(app: &mut SolveApp, d: &Dialog) -> Result<(), String> {
|
||||
if let Kind::ViewDxf { dashed } = &d.kind {
|
||||
return crate::dialogs_view_dxf::run(app, *dashed);
|
||||
if let Kind::ViewDxf { dashed, tolerance } = &d.kind {
|
||||
return crate::dialogs_view_dxf::run(app, *dashed, *tolerance);
|
||||
}
|
||||
// Saving asks for a file name first; `Err("")` is the user backing out of that.
|
||||
if let Kind::SaveMesh(k) = &d.kind {
|
||||
|
||||
@@ -3,10 +3,14 @@ use crate::{SolveApp, dialogs::Kind, selection::SelInput};
|
||||
use serde_json::json;
|
||||
|
||||
pub fn start(app: &SolveApp, id: &str) -> Option<(Kind, Vec<SelInput>)> {
|
||||
(id == "file.export_view_dxf").then(|| (Kind::ViewDxf { dashed: app.preferences.view_dxf_dashed }, Vec::new()))
|
||||
(id == "file.export_view_dxf").then(|| {
|
||||
let tolerance = app.preferences.view_dxf_tolerance;
|
||||
let tolerance = if tolerance.is_finite() && (0.0001..=1.0).contains(&tolerance) { tolerance } else { 0.01 };
|
||||
(Kind::ViewDxf { dashed: app.preferences.view_dxf_dashed, tolerance }, Vec::new())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool) {
|
||||
pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool, tolerance: &mut f64) {
|
||||
crate::dialogs::row_label(ui, "Hidden edges");
|
||||
ui.vertical(|ui| {
|
||||
let r = ui.radio_value(dashed, false, app.tr_label("Do not export hidden edges"));
|
||||
@@ -15,34 +19,54 @@ pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool) {
|
||||
crate::scenario::publish_handle("dialog:dxf:dashed", r.rect.center());
|
||||
});
|
||||
ui.end_row();
|
||||
crate::dialogs::row_label(ui, "dxf.tolerance");
|
||||
let r = ui.add(egui::DragValue::new(tolerance).range(0.0001..=1.0).speed(0.001).max_decimals(4).suffix(" mm"));
|
||||
crate::scenario::publish_handle("dialog:dxf:tolerance", r.rect.center());
|
||||
ui.end_row();
|
||||
crate::dialogs::row_label(ui, "Projection");
|
||||
ui.label(app.tr_label(if app.cam.fov > 0.0 { "Current perspective view" } else { "Current orthographic view (1:1 mm)" }));
|
||||
ui.end_row();
|
||||
crate::dialogs::row_label(ui, "");
|
||||
ui.label(app.tr_label("Visible bodies only. Curves use line segments (0.01 mm tolerance)."));
|
||||
ui.add(egui::Label::new(app.tr_label("dxf.tolerance_help")).wrap());
|
||||
ui.end_row();
|
||||
}
|
||||
|
||||
pub fn run(app: &mut SolveApp, dashed: bool) -> Result<(), String> {
|
||||
pub fn run(app: &mut SolveApp, dashed: bool, tolerance: f64) -> Result<(), String> {
|
||||
if crate::viewport::section_plane(app).is_some() {
|
||||
return Err(app.tr_label("Turn off section analysis before exporting the view.").into());
|
||||
}
|
||||
if !app.session.world_state().bodies.iter().any(|b| app.session.body_visible(&b.name)) {
|
||||
if app.session.model.state().bodies.is_empty() {
|
||||
return Err(app.tr_label("No visible bodies to export.").into());
|
||||
}
|
||||
if !tolerance.is_finite() || !(0.0001..=1.0).contains(&tolerance) {
|
||||
return Err(app.tr_label("dxf.invalid_tolerance").into());
|
||||
}
|
||||
app.preferences.view_dxf_dashed = dashed;
|
||||
app.preferences.view_dxf_tolerance = tolerance;
|
||||
let camera = app.cam;
|
||||
app.pick_save(&format!("{}-view.dxf", app.session.doc.name), &["dxf"], move |app, path| {
|
||||
if let Some(path) = path {
|
||||
let params = json!({"camera":camera,"hidden":if dashed {"dashed"} else {"omit"},"path":path});
|
||||
if app.run("file.export_view_dxf", params).is_ok() {
|
||||
app.set_status(crate::i18n::format("ui.saved_to", &[("path", &path)]), false);
|
||||
}
|
||||
let params = json!({"camera":camera,"hidden":if dashed {"dashed"} else {"omit"},"tolerance":tolerance,"path":path});
|
||||
let mut request = crate::file_load::Request::new(&path, crate::file_load::Kind::ExportDxf);
|
||||
request.options = params.as_object().cloned().unwrap_or_default();
|
||||
let _ = crate::file_load::begin_request(app, request);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn progress_text(work: Option<(&str, usize, usize)>) -> String {
|
||||
let (stage, done, total) = work.unwrap_or(("dxf_meshes", 0, 1));
|
||||
let label = match stage {
|
||||
"dxf_index" => "dxf.progress.index",
|
||||
"dxf_projection" => "dxf.progress.projection",
|
||||
"dxf_occlusion" => "dxf.progress.occlusion",
|
||||
"dxf_write" => "dxf.progress.write",
|
||||
_ => "dxf.progress.meshes",
|
||||
};
|
||||
format!("{}: {done}/{total}", crate::i18n::tr_label(label))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -58,7 +82,11 @@ mod tests {
|
||||
h.frames(3);
|
||||
h.step(&json!({"click":{"handle":"appbar:folder"}})).unwrap();
|
||||
h.step(&json!({"widget":"Export Current View as DXF…"})).unwrap();
|
||||
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: false })));
|
||||
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: false, .. })));
|
||||
h.step(&json!({"click":{"handle":"dialog:dxf:tolerance"},"double":true})).unwrap();
|
||||
h.step(&json!({"key":"A","cmd":true})).unwrap();
|
||||
h.step(&json!({"text":"0.1"})).unwrap();
|
||||
h.step(&json!({"key":"Tab"})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:dxf:dashed"}})).unwrap();
|
||||
let dir = h.dir.clone();
|
||||
h.app.services.pick_save = Some(Box::new(move |name, extensions| {
|
||||
@@ -67,7 +95,7 @@ mod tests {
|
||||
Box::pin(std::future::ready(Some(dir.join("view.dxf").to_string_lossy().into_owned())))
|
||||
}));
|
||||
h.step(&json!({"click":{"handle":"dialog:Export DXF…"}})).unwrap();
|
||||
h.frames(3);
|
||||
wait_for_export(&mut h);
|
||||
let text = std::fs::read_to_string(h.dir.join("view.dxf")).unwrap();
|
||||
assert!(text.contains("LWPOLYLINE") && text.contains("HIDDEN"));
|
||||
assert!(h.app.dialog.is_none() && h.app.preferences.view_dxf_dashed);
|
||||
@@ -75,14 +103,27 @@ mod tests {
|
||||
let mut restored = SolveApp::new(Default::default(), Default::default());
|
||||
restored.load_prefs(&saved);
|
||||
assert!(restored.preferences.view_dxf_dashed);
|
||||
assert_eq!(restored.preferences.view_dxf_tolerance, 0.1);
|
||||
h.app.start("file.export_view_dxf");
|
||||
h.frames(3);
|
||||
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: true })));
|
||||
assert!(matches!(h.app.dialog.as_ref().map(|d| &d.kind), Some(Kind::ViewDxf { dashed: true, tolerance: 0.1 })));
|
||||
h.step(&json!({"click":{"handle":"dialog:dxf:omit"}})).unwrap();
|
||||
h.step(&json!({"click":{"handle":"dialog:Export DXF…"}})).unwrap();
|
||||
h.frames(3);
|
||||
wait_for_export(&mut h);
|
||||
let text = std::fs::read_to_string(h.dir.join("view.dxf")).unwrap();
|
||||
assert!(!text.contains("LWPOLYLINE") && !text.contains("HIDDEN"));
|
||||
assert!(!h.app.preferences.view_dxf_dashed);
|
||||
}
|
||||
|
||||
fn wait_for_export(h: &mut crate::scenario::Harness) {
|
||||
let began = std::time::Instant::now();
|
||||
loop {
|
||||
h.frames(1);
|
||||
if h.app.file_load.idle() && !h.app.file_picker_busy() {
|
||||
break;
|
||||
}
|
||||
assert!(began.elapsed().as_secs() < 10, "export did not finish");
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,8 @@ pub enum Kind {
|
||||
Insert,
|
||||
/// Open a design an earlier run autosaved before it closed.
|
||||
Recover { id: String, dir: Option<String> },
|
||||
/// Export a snapshot of the current design without adopting the worker's session.
|
||||
ExportDxf,
|
||||
}
|
||||
|
||||
pub struct Request {
|
||||
@@ -63,6 +65,7 @@ impl Request {
|
||||
Kind::Insert if solidcraft_engine::io::is_mesh_path(&self.path) => ("file.insert_mesh", json!({"path": self.path})),
|
||||
Kind::Insert => ("file.insert_step", self.params()),
|
||||
Kind::Recover { id, dir } => ("doc.recover", json!({"id": id, "dir": dir})),
|
||||
Kind::ExportDxf => ("file.export_view_dxf", Value::Object(self.options.clone())),
|
||||
}
|
||||
}
|
||||
fn answer(&self, r: Result<Value, String>) {
|
||||
@@ -99,6 +102,14 @@ struct Running {
|
||||
staged: Option<Staged>,
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
if self.request.kind == Kind::ExportDxf {
|
||||
self.progress.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A STEP import done a piece per frame, for the web app: it has one thread, which must keep
|
||||
/// drawing (and offer Cancel) while a large file comes in. The file is parsed in one frame,
|
||||
/// then its solids are converted a few at a time, each frame for about [`STAGED_FRAME_MS`];
|
||||
@@ -208,6 +219,14 @@ pub fn begin_request(app: &mut SolveApp, request: Request) -> Result<(), String>
|
||||
}
|
||||
r
|
||||
}
|
||||
Kind::ExportDxf => {
|
||||
let (command, params) = request.command();
|
||||
let r = app.run(command, params);
|
||||
if r.is_ok() && !request.path.is_empty() {
|
||||
app.set_status(crate::i18n::format("ui.saved_to", &[("path", &request.path)]), false);
|
||||
}
|
||||
r
|
||||
}
|
||||
};
|
||||
request.answer(r);
|
||||
Ok(())
|
||||
@@ -236,10 +255,15 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
|
||||
let kind = match command {
|
||||
Some("doc.open") => Kind::Open,
|
||||
Some("file.insert_step" | "file.insert_mesh") => Kind::Insert,
|
||||
Some("file.export_view_dxf") => Kind::ExportDxf,
|
||||
_ => return Some(req),
|
||||
};
|
||||
let params = req.params.get("params");
|
||||
let Some(path) = params.and_then(|p| p.get("path")).and_then(Value::as_str) else { return Some(req) };
|
||||
let path = match params.and_then(|p| p.get("path")).and_then(Value::as_str) {
|
||||
Some(path) => path,
|
||||
None if kind == Kind::ExportDxf => "",
|
||||
None => return Some(req),
|
||||
};
|
||||
let scope = match crate::control::request_scope(app, &req.params) {
|
||||
Ok(scope) => scope,
|
||||
Err(e) => {
|
||||
@@ -247,7 +271,11 @@ pub fn control(app: &mut SolveApp, req: crate::control::ControlRequest) -> Optio
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let options = ["up", "names"].iter().filter_map(|k| Some((k.to_string(), params?.get(*k).filter(|v| !v.is_null())?.clone()))).collect();
|
||||
let options = if kind == Kind::ExportDxf {
|
||||
params.and_then(Value::as_object).cloned().unwrap_or_default()
|
||||
} else {
|
||||
["up", "names"].iter().filter_map(|k| Some((k.to_string(), params?.get(*k).filter(|v| !v.is_null())?.clone()))).collect()
|
||||
};
|
||||
let _ = begin_request(app, Request { path: path.into(), kind, reply: Some(req.reply.clone()), scope, options });
|
||||
None
|
||||
}
|
||||
@@ -281,7 +309,10 @@ fn start(app: &mut SolveApp) {
|
||||
return;
|
||||
}
|
||||
let Some(request) = app.file_load.queued.pop_front() else { return };
|
||||
if !request.insert() && app.docs.count() >= crate::documents::MAX_OPEN && !crate::documents::active_is_blank(app) {
|
||||
if matches!(request.kind, Kind::Open | Kind::Recover { .. })
|
||||
&& app.docs.count() >= crate::documents::MAX_OPEN
|
||||
&& !crate::documents::active_is_blank(app)
|
||||
{
|
||||
let e = "too many open designs; close a tab before opening another";
|
||||
app.set_status(e, true);
|
||||
request.answer(Err(e.into()));
|
||||
@@ -289,7 +320,7 @@ fn start(app: &mut SolveApp) {
|
||||
}
|
||||
let insert = request.insert();
|
||||
let before = if insert { app.session.world_state().bodies.iter().map(|b| b.name.clone()).collect() } else { Vec::new() };
|
||||
let mut scratch = if insert { app.session.scratch() } else { Session::default() };
|
||||
let mut scratch = if insert || request.kind == Kind::ExportDxf { app.session.scratch() } else { Session::default() };
|
||||
if insert {
|
||||
scratch.selection = app.session.selection.clone();
|
||||
}
|
||||
@@ -679,6 +710,11 @@ fn compute(
|
||||
let result =
|
||||
solidcraft_engine::io::vfs::scoped(scope, || solidcraft_engine::doc::progress::watch(progress.clone(), || session.execute(command, params)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if command == "file.export_view_dxf" {
|
||||
// A successful export has already published its complete file. A Cancel arriving
|
||||
// after that commit must not turn a completed save into a misleading cancellation.
|
||||
return Ok(Made { session, result, image: None });
|
||||
}
|
||||
if progress.is_cancelled() {
|
||||
return cancelled();
|
||||
}
|
||||
@@ -739,6 +775,12 @@ pub fn tick(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
{
|
||||
let request = &running.request;
|
||||
match job {
|
||||
Ok(made) if request.kind == Kind::ExportDxf => {
|
||||
if !request.path.is_empty() {
|
||||
app.set_status(crate::i18n::format("ui.saved_to", &[("path", &request.path)]), false);
|
||||
}
|
||||
request.answer(Ok(made.result));
|
||||
}
|
||||
_ if running.cancelled() => request.answer(Err("file operation cancelled".into())),
|
||||
Ok(made) if running.matches(&app.session) => {
|
||||
if request.insert() {
|
||||
@@ -813,7 +855,8 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
let phase = r.phase.load(Ordering::Relaxed);
|
||||
let (done, total) = r.progress.features();
|
||||
// Long work inside one feature (a large STEP file) has progress of its own.
|
||||
let work = r.progress.work().filter(|(_, d, t)| phase == 0 && d < t);
|
||||
let export = r.request.kind == Kind::ExportDxf;
|
||||
let work = r.progress.work().filter(|(_, d, t)| export || (phase == 0 && d < t));
|
||||
let screen = ctx.content_rect();
|
||||
// Leave the title bar to the window. The resize edges are a foreground layer above this.
|
||||
let cover = egui::Rect::from_min_max(egui::pos2(screen.left(), screen.top() + crate::toolbar::APP_BAR_HEIGHT), screen.max);
|
||||
@@ -848,20 +891,23 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.set_width(width - 16.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(
|
||||
egui::RichText::new(if cancelled {
|
||||
egui::RichText::new(if cancelled && export {
|
||||
crate::i18n::tr_label("dxf.cancelling")
|
||||
} else if cancelled {
|
||||
"Waiting for file operation"
|
||||
} else {
|
||||
match r.request.kind {
|
||||
Kind::Insert => "Inserting geometry",
|
||||
Kind::Open => "Opening design",
|
||||
Kind::Recover { .. } => "Recovering design",
|
||||
Kind::ExportDxf => crate::i18n::tr_label("dxf.exporting"),
|
||||
}
|
||||
})
|
||||
.strong()
|
||||
.size(14.0),
|
||||
);
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
let b = ui.button("Cancel");
|
||||
let b = ui.button(crate::i18n::tr("ui.cancel"));
|
||||
crate::scenario::publish_handle("file_loading:cancel", b.rect.center());
|
||||
cancel |= b.clicked();
|
||||
});
|
||||
@@ -879,10 +925,13 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(if cancelled {
|
||||
ui.label(if cancelled && export {
|
||||
crate::i18n::tr_label("dxf.cancel_wait").to_string()
|
||||
} else if cancelled {
|
||||
"Waiting for the previous operation to stop...".to_string()
|
||||
} else {
|
||||
match (phase, work) {
|
||||
(_, work) if r.request.kind == Kind::ExportDxf => crate::dialogs_view_dxf::progress_text(work),
|
||||
(_, Some(("reading", d, t))) => format!("Reading the file... {}%", d * 100 / t.max(1)),
|
||||
(_, Some((_, d, t))) => format!("Converting solid {} of {t}", d + 1),
|
||||
(1, _) => "Preparing the view...".into(),
|
||||
@@ -912,7 +961,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
// import to overlap it in memory. The rebuild stops at its next feature and the
|
||||
// current design stays as it was.
|
||||
app.file_load.cancel();
|
||||
app.set_status("file operation cancelled", false);
|
||||
app.set_status(if export { crate::i18n::tr_label("dxf.cancelled") } else { "file operation cancelled" }, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -920,6 +969,77 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn dxf_export_progress_keeps_frames_alive_and_never_adopts_a_worker_document() {
|
||||
let mut h = crate::scenario::Harness::new();
|
||||
h.app.run("solid.box", json!({"length":10,"width":10,"height":10})).unwrap();
|
||||
h.frames(3);
|
||||
let base = h.app.session.doc.clone();
|
||||
let undo = h.app.session.undo.len();
|
||||
let cam = h.app.cam;
|
||||
let progress = Arc::new(Progress::default());
|
||||
solidcraft_engine::doc::progress::watch(progress.clone(), || {
|
||||
solidcraft_engine::kernel::watch::current().unwrap().progress("dxf_occlusion", 64, 128);
|
||||
});
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
h.app.file_load.running = Some(Running {
|
||||
request: Request::new("drawing.dxf", Kind::ExportDxf),
|
||||
base: base.clone(),
|
||||
base_revision: h.app.session.revision,
|
||||
before: Vec::new(),
|
||||
rx,
|
||||
phase: Arc::new(AtomicU8::new(0)),
|
||||
progress: progress.clone(),
|
||||
preview_rx: None,
|
||||
preview: None,
|
||||
partial_rx: None,
|
||||
built: None,
|
||||
staged: None,
|
||||
});
|
||||
// No worker result is available: drawing a frame must not wait for one.
|
||||
h.frames(4);
|
||||
assert!(h.app.file_load.busy());
|
||||
let at = crate::scenario::handle_at("file_loading:cancel").unwrap();
|
||||
let (request, _reply) = crate::control::ControlRequest::new("ui.click", json!({"x":at.x,"y":at.y}));
|
||||
let _ = crate::control::handle(&mut h.app, &h.ctx, &request);
|
||||
h.frames(4);
|
||||
assert!(progress.is_cancelled());
|
||||
tx.send(Err("DXF export cancelled".into())).unwrap();
|
||||
h.frames(2);
|
||||
assert!(h.app.file_load.idle());
|
||||
assert!(Arc::ptr_eq(&base, &h.app.session.doc));
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.cam, cam);
|
||||
|
||||
// The success path is equally read-only, even if the worker has another session.
|
||||
let mut other = Session::default();
|
||||
other.execute("file.new", &json!({"name":"Worker must not replace the current design"})).unwrap();
|
||||
let mut request = Request::new("drawing.dxf", Kind::ExportDxf);
|
||||
let (reply_tx, reply_rx) = std::sync::mpsc::channel();
|
||||
request.reply = Some(reply_tx);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
h.app.file_load.running = Some(Running {
|
||||
request,
|
||||
base: base.clone(),
|
||||
base_revision: h.app.session.revision,
|
||||
before: Vec::new(),
|
||||
rx,
|
||||
phase: Arc::new(AtomicU8::new(0)),
|
||||
progress: Arc::new(Progress::default()),
|
||||
preview_rx: None,
|
||||
preview: None,
|
||||
partial_rx: None,
|
||||
built: None,
|
||||
staged: None,
|
||||
});
|
||||
tx.send(Ok(Made { session: other, result: json!({"visible":12}), image: None })).unwrap();
|
||||
h.frames(2);
|
||||
assert_eq!(reply_rx.try_recv().unwrap()["result"]["visible"], 12);
|
||||
assert!(Arc::ptr_eq(&base, &h.app.session.doc));
|
||||
assert_eq!(h.app.session.undo.len(), undo);
|
||||
assert_eq!(h.app.cam, cam);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opening_result_and_preview_cannot_overwrite_a_newer_design() {
|
||||
for (insert, change_document) in [(false, true), (true, true), (false, false)] {
|
||||
|
||||
@@ -332,6 +332,8 @@ pub struct Prefs {
|
||||
pub save_mesh_format: String,
|
||||
/// Hidden edges in the current-view DXF export (false: omit, true: dashed).
|
||||
pub view_dxf_dashed: bool,
|
||||
/// Model-space chord tolerance for current-view DXF curves, in millimetres.
|
||||
pub view_dxf_tolerance: f64,
|
||||
pub save_mesh_send: bool,
|
||||
pub save_mesh_slicer: String,
|
||||
pub custom_slicers: Vec<String>,
|
||||
@@ -428,6 +430,7 @@ impl Default for Prefs {
|
||||
update: crate::update::Memo::default(),
|
||||
save_mesh_format: "stl".into(),
|
||||
view_dxf_dashed: false,
|
||||
view_dxf_tolerance: 0.01,
|
||||
save_mesh_send: false,
|
||||
save_mesh_slicer: String::new(),
|
||||
custom_slicers: Vec::new(),
|
||||
|
||||
+31
-3
@@ -1,8 +1,22 @@
|
||||
# Export the current view as DXF
|
||||
|
||||
Choose **File → Export Current View as DXF…**, choose whether to omit hidden edges or
|
||||
export them as dashed lines, then choose **Export DXF…** and a destination. The hidden-edge
|
||||
choice is remembered between runs. The Japanese menu is **ファイル → 現在の表示をDXFでエクスポート…**.
|
||||
export them as dashed lines, set **Curve tolerance**, then choose **Export DXF…** and a
|
||||
destination. Both settings are remembered. The Japanese menu is
|
||||
**ファイル → 現在の表示をDXFでエクスポート…**.
|
||||
|
||||
On desktop, the export runs on a background worker while the window continues repainting
|
||||
and responding to its title-bar controls. A progress card shows mesh preparation, surface
|
||||
preparation, projection, hidden-line removal and writing, with a count and progress bar for
|
||||
the current stage. **Cancel** stops at the next mesh or traversal checkpoint; a single
|
||||
meshing call or sort may need to finish first. The model waits during the export and remains
|
||||
unchanged on success, failure and cancellation. Individual control-channel
|
||||
`file.export_view_dxf` commands use the same worker.
|
||||
The browser's export still runs synchronously on its single thread.
|
||||
|
||||
An export replaces its destination atomically only after the complete drawing is ready.
|
||||
Cancellation that takes effect before the final write preserves any previous file. Once
|
||||
that atomic write starts, it finishes; a late Cancel reports the completed save.
|
||||
|
||||
The drawing uses the camera when Export is pressed, including roll, the world's up axis
|
||||
and perspective. Only shown bodies are exported, at their current component/occurrence
|
||||
@@ -15,7 +29,9 @@ Coordinates are 2D, Z=0, in millimetres. Orthographic views retain 1:1 dimension
|
||||
view plane. Perspective views retain foreshortening, projected onto the plane through the
|
||||
camera target; they are intended for illustrations rather than dimensioned fabrication.
|
||||
Curves and curved silhouettes are approximated with straight segments at a default
|
||||
model-space chord tolerance of 0.01 mm. This is not an exact analytic curve export.
|
||||
model-space chord tolerance of 0.01 mm. The setting accepts 0.0001–1 mm; smaller values
|
||||
produce finer curves and generally need more time and memory. This is not an exact analytic
|
||||
curve export or a tolerance guarantee for the original imported surfaces.
|
||||
|
||||
The AutoCAD 2000 ASCII DXF contains `VISIBLE` (continuous) and, when requested, `HIDDEN`
|
||||
(2 mm dashes / 1 mm gaps) layers. Hidden curves use connected `LWPOLYLINE` entities with
|
||||
@@ -29,6 +45,18 @@ are excluded; curved-body silhouettes are included. Very complex drawings fail w
|
||||
error rather than returning an incomplete export. If geometry crosses the perspective
|
||||
camera's eye plane, zoom out before exporting.
|
||||
|
||||
Repeated component placements share their export mesh and triangle hierarchy. Each placement
|
||||
keeps its own edge choices, position and orientation; no bodies are fused or omitted to reduce
|
||||
the workload. A hierarchy over placed parts rejects unrelated occluders before searching a
|
||||
part's triangles. Already hidden intervals are skipped, and a fully hidden line stops searching.
|
||||
Desktop mesh preparation and independent line queries use at most eight computation workers;
|
||||
the browser runs serially. Export memory, candidate lines, output lines and total occlusion
|
||||
work remain bounded, and exceeding a budget returns an error without writing a partial drawing.
|
||||
|
||||
Programmatic callers use `file.export_view_dxf` with `camera: Camera`, `hidden: "omit"` or
|
||||
`"dashed"`, optional `tolerance` (0.0001–1 mm) and optional `path`. Without `path`, the
|
||||
command returns the DXF text. The command does not modify the design or its undo history.
|
||||
The result also includes `stats`: placed body and shared mesh counts, source triangle count,
|
||||
retained mesh bytes, worker limit, candidate segment count, occlusion work, and preparation,
|
||||
projection and occlusion times in milliseconds. These timings exclude STEP import and DXF
|
||||
serialization; the worker limit is a maximum, not a claim that every worker was busy.
|
||||
Reference in new issue
Block a user