Export the current 3D view as 2D DXF with optional hidden lines (#1449)

Adds File > Export > Current View as DXF: the visible bodies projected from the current camera onto 2D LINE/LWPOLYLINE entities, with hidden edges omitted or dashed.

Co-authored-by: cronos <19868002+crono2250@users.noreply.github.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-15 20:04:25 -04:00
1 parent 845f4913cf
commit c361630954
20 files changed
+798 -3

No files matched your search

+3
View File
@@ -82,6 +82,9 @@ rebuild with no errors, replayed here at 2× speed.
## What works
- **Current-view DXF export** from File → Export Current View as DXF, with hidden edges
omitted or dashed, shown body placements and the current camera. See [view DXF](docs/view-dxf.md).
- **Sketches** on origin planes, offset planes or planar faces: lines, rectangles, circles,
arcs, polygons, slots, ellipses, fit/control splines, conics and text; construction geometry,
projection, trimming, fillets and chamfers, including corners at the origin.
+5
View File
@@ -66,6 +66,11 @@ pointer movement orbits without a mouse button, and vertical scrolling zooms. Ot
retain their saved two-finger scroll preference; camera motion is available through
`view.navigate` and modifier-aware `ui.move` automation.
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,
annotations and exact projected curves remain deferred. See [view DXF](docs/view-dxf.md).
## Interactive modelling
- New Body extrusion and face-offset previews show completed intermediate geometry while a
+12
View File
@@ -1,6 +1,18 @@
{
"settings.measure_axis_distances": "X/Y/Z-Maße anzeigen",
"settings.measure_axis_distances_help": "Bei geöffneter Messung die Abstände entlang der Weltachsen zusätzlich zum Gesamtabstand anzeigen.",
"Export Current View as DXF": "Aktuelle Ansicht als DXF exportieren",
"Export Current View as DXF…": "Aktuelle Ansicht als DXF exportieren…",
"Hidden edges": "Verdeckte Kanten",
"Do not export hidden edges": "Verdeckte Kanten nicht exportieren",
"Export hidden edges as dashed lines": "Verdeckte Kanten gestrichelt exportieren",
"Projection": "Projektion",
"Current perspective view": "Aktuelle perspektivische Ansicht",
"Current orthographic view (1:1 mm)": "Aktuelle orthogonale Ansicht (1:1 mm)",
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Nur sichtbare Körper. Kurven werden durch Liniensegmente angenähert (Toleranz: 0,01 mm).",
"Turn off section analysis before exporting the view.": "Deaktivieren Sie die Schnittanalyse, bevor Sie die Ansicht exportieren.",
"No visible bodies to export.": "Keine sichtbaren Körper zum Exportieren.",
"Export DXF…": "DXF exportieren…",
"Send your report on GitHub": "Bericht auf GitHub senden",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Unten den GitHub-Entwurf öffnen und den kopierten Bericht aus der Zwischenablage einfügen.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Unten den GitHub-Entwurf öffnen. Titel und Bericht werden vorausgefüllt.",
+12
View File
@@ -1,6 +1,18 @@
{
"settings.measure_axis_distances": "Show X/Y/Z distances",
"settings.measure_axis_distances_help": "Show world-axis component dimensions alongside the total distance while Measure is open.",
"Export Current View as DXF": "Export Current View as DXF",
"Export Current View as DXF…": "Export Current View as DXF…",
"Hidden edges": "Hidden edges",
"Do not export hidden edges": "Do not export hidden edges",
"Export hidden edges as dashed lines": "Export hidden edges as dashed lines",
"Projection": "Projection",
"Current perspective view": "Current perspective view",
"Current orthographic view (1:1 mm)": "Current orthographic view (1:1 mm)",
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "Visible bodies only. Curves use line segments (0.01 mm tolerance).",
"Turn off section analysis before exporting the view.": "Turn off section analysis before exporting the view.",
"No visible bodies to export.": "No visible bodies to export.",
"Export DXF…": "Export DXF…",
"Send your report on GitHub": "Send your report on GitHub",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. Open GitHub draft below, then paste the report copied to your clipboard.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. Open GitHub draft below. Your title and report will be filled in.",
+12
View File
@@ -1,6 +1,18 @@
{
"settings.measure_axis_distances": "計測のX・Y・Z距離を表示",
"settings.measure_axis_distances_help": "計測ダイアログを開いている間、2点間距離に加えてワールドX・Y・Z軸方向の寸法線と距離を表示します。",
"Export Current View as DXF": "現在の表示をDXFでエクスポート",
"Export Current View as DXF…": "現在の表示をDXFでエクスポート…",
"Hidden edges": "隠線",
"Do not export hidden edges": "出力しない",
"Export hidden edges as dashed lines": "点線で出力",
"Projection": "投影",
"Current perspective view": "現在の透視投影",
"Current orthographic view (1:1 mm)": "現在の平行投影(実寸・mm)",
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "表示中のボディを出力します。曲線は線分で近似します(許容誤差0.01 mm)。",
"Turn off section analysis before exporting the view.": "断面解析をオフにしてからエクスポートしてください。",
"No visible bodies to export.": "出力する表示中のボディがありません。",
"Export DXF…": "DXFをエクスポート…",
"Send your report on GitHub": "GitHubでレポートを送信",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 下のボタンでGitHubの下書きを開き、クリップボードにコピーされたレポートを貼り付けます。",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 下のボタンでGitHubの下書きを開きます。タイトルとレポートは入力済みです。",
+12
View File
@@ -1,6 +1,18 @@
{
"settings.measure_axis_distances": "X/Y/Z 거리 표시",
"settings.measure_axis_distances_help": "측정 대화상자가 열려 있는 동안 전체 거리와 함께 월드 축 방향 치수와 거리를 표시합니다.",
"Export Current View as DXF": "현재 뷰를 DXF로 내보내기",
"Export Current View as DXF…": "현재 뷰를 DXF로 내보내기…",
"Hidden edges": "숨겨진 모서리",
"Do not export hidden edges": "숨겨진 모서리 내보내지 않기",
"Export hidden edges as dashed lines": "숨겨진 모서리를 파선으로 내보내기",
"Projection": "투영",
"Current perspective view": "현재 원근 투영 뷰",
"Current orthographic view (1:1 mm)": "현재 직교 투영 뷰 (1:1 mm)",
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "표시된 바디만 내보냅니다. 곡선은 선분으로 근사합니다 (허용오차 0.01 mm).",
"Turn off section analysis before exporting the view.": "뷰를 내보내기 전에 단면 분석을 끄세요.",
"No visible bodies to export.": "표시된 바디가 없어 내보낼 수 없습니다.",
"Export DXF…": "DXF 내보내기…",
"Send your report on GitHub": "GitHub에서 보고서 보내기",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 아래에서 GitHub 초안을 열고 클립보드에 복사된 보고서를 붙여넣으세요.",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 아래에서 GitHub 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.",
+12
View File
@@ -1,6 +1,18 @@
{
"settings.measure_axis_distances": "显示 X/Y/Z 距离",
"settings.measure_axis_distances_help": "测量对话框打开时,在总距离之外显示沿世界坐标轴方向的尺寸线和距离。",
"Export Current View as DXF": "将当前视图导出为DXF",
"Export Current View as DXF…": "将当前视图导出为DXF…",
"Hidden edges": "隐藏线",
"Do not export hidden edges": "不导出隐藏线",
"Export hidden edges as dashed lines": "以虚线导出隐藏线",
"Projection": "投影",
"Current perspective view": "当前透视视图",
"Current orthographic view (1:1 mm)": "当前正交视图(1:1毫米)",
"Visible bodies only. Curves use line segments (0.01 mm tolerance).": "仅导出可见实体。曲线以线段近似(公差0.01毫米)。",
"Turn off section analysis before exporting the view.": "请关闭截面分析后再导出视图。",
"No visible bodies to export.": "没有可导出的可见实体。",
"Export DXF…": "导出DXF…",
"Send your report on GitHub": "在 GitHub 上发送报告",
"1. Open GitHub draft below, then paste the report copied to your clipboard.": "1. 点击下方按钮打开 GitHub 草稿,然后粘贴已复制到剪贴板的报告。",
"1. Open GitHub draft below. Your title and report will be filled in.": "1. 点击下方按钮打开 GitHub 草稿。标题和报告将自动填入。",
+2
View File
@@ -74,6 +74,7 @@ mod sketch_modify;
mod sketch_project;
mod surface;
mod surface_analysis;
mod view_dxf;
mod view_navigation;
use serde::Serialize;
@@ -265,6 +266,7 @@ pub fn command_specs() -> Vec<&'static CommandSpec> {
v.extend(libraries::COMMANDS.iter());
v.extend(section::COMMANDS.iter());
v.extend(view_navigation::COMMANDS.iter());
v.extend(view_dxf::COMMANDS.iter());
v.extend(diagnostics::COMMANDS.iter());
v.extend(render::COMMANDS.iter());
v
+101
View File
@@ -0,0 +1,101 @@
//! 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_projection.rs"]
mod projection;
use serde_json::{Value, json};
use solidcraft_io::{DxfEntity, DxfLayer, DxfLinetype, DxfShape};
use solidcraft_render::Camera;
use super::CommandSpec;
use crate::params::{bad, str_};
use crate::{Result, Session};
const CMD: &str = "file.export_view_dxf";
pub static COMMANDS: &[CommandSpec] = &[CommandSpec::new(CMD, "Export Current View as DXF", export)
.icon("export")
.noundo()
.params("camera: Camera (current view); hidden?: omit|dashed (default omit); tolerance?: 0.0001..1 mm (curve chord tolerance, default 0.01); path?: .dxf file (else returns dxf text). Visible placed bodies only; 2D LINE/LWPOLYLINE entities on VISIBLE/HIDDEN layers, mm; perspective projected onto the camera target plane; no viewport crop or overlays.")];
fn export(s: &mut Session, p: &Value) -> Result<Value> {
let cam: Camera =
serde_json::from_value(p.get("camera").cloned().ok_or_else(|| bad(CMD, "camera is required"))?).map_err(|_| bad(CMD, "invalid camera"))?;
if !cam.is_valid()
|| !(0.0..std::f64::consts::PI).contains(&cam.fov)
|| cam.pitch.abs() > std::f64::consts::FRAC_PI_2
|| cam.yaw.abs() > 1e6
|| cam.roll.abs() > 1e6
{
return Err(bad(CMD, "invalid camera"));
}
let dashed = match p.get("hidden") {
None => false,
Some(Value::String(v)) if v == "omit" => false,
Some(Value::String(v)) if v == "dashed" => true,
_ => return Err(bad(CMD, "hidden must be omit or dashed")),
};
let tolerance = match p.get("tolerance") {
None => 0.01,
Some(v) => v.as_f64().filter(|t| (0.0001..=1.0).contains(t)).ok_or_else(|| bad(CMD, "tolerance must be 0.0001..1 mm"))?,
};
let path = match p.get("path") {
None => None,
Some(_) => Some(str_(p, "path").filter(|v| !v.trim().is_empty() && v.len() < 4096).ok_or_else(|| bad(CMD, "invalid path"))?),
};
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 mut visible = 0;
let mut hidden = 0;
let mut entities = Vec::new();
for line in lines {
if line.hidden && !dashed {
continue;
}
let layer = if line.hidden {
hidden += 1;
"HIDDEN"
} else {
visible += 1;
"VISIBLE"
};
entities.push(DxfEntity { layer: layer.into(), shape: DxfShape::Line(line.a, line.b) });
}
let entities = projection::connect(entities);
let mut layers = vec![DxfLayer::new("VISIBLE", 7)];
if dashed {
layers.push(DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) });
}
let text = solidcraft_io::write_dxf_drawing(&layers, &entities);
let mut result = json!({"visible":visible,"hidden":hidden,"units":"mm","tolerance":tolerance,"projection":if cam.fov > 0.0 {"perspective"} else {"orthographic"}});
if let Some(path) = path {
solidcraft_io::vfs::write(path, text.as_bytes()).map_err(|e| crate::EngineError::Other(e.to_string()))?;
result["path"] = json!(path);
} else {
result["dxf"] = json!(text);
}
Ok(result)
}
#[cfg(test)]
#[path = "view_dxf_tests.rs"]
mod tests;
@@ -0,0 +1,342 @@
//! 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};
use solidcraft_io::{DxfEntity, DxfShape};
use solidcraft_render::Camera;
use std::collections::BTreeMap;
/// 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> {
let mut result = Vec::new();
let mut lines = Vec::new();
for entity in entities {
match entity {
DxfEntity { layer, shape: DxfShape::Line(a, b) } if layer == "HIDDEN" => lines.push((a, b)),
e => result.push(e),
}
}
let extent = lines.iter().flat_map(|(a, b)| [a, b]).fold(1.0_f64, |s, p| s.max(p.x.abs()).max(p.y.abs()));
let eps = extent * 1e-8;
let key = |p: Vec2| [(p.x / eps).round() as i64, (p.y / eps).round() as i64];
let mut nodes: BTreeMap<[i64; 2], Vec<usize>> = BTreeMap::new();
for (i, (a, b)) in lines.iter().enumerate() {
nodes.entry(key(*a)).or_default().push(i);
nodes.entry(key(*b)).or_default().push(i);
}
let mut used = vec![false; lines.len()];
for (i, (a, b)) in lines.iter().enumerate() {
if used.get(i).copied().unwrap_or(true) {
continue;
}
if let Some(v) = used.get_mut(i) {
*v = true;
}
let mut points = vec![*a, *b];
for _ in 0..2 {
while let Some(end) = points.last().copied() {
let Some(neighbours) = nodes.get(&key(end)).filter(|n| n.len() == 2) else { break };
let Some(next) = neighbours.iter().copied().find(|i| used.get(*i) == Some(&false)) else { break };
let Some((a, b)) = lines.get(next) else { break };
if let Some(v) = used.get_mut(next) {
*v = true;
}
points.push(if key(*a) == key(end) { *b } else { *a });
}
points.reverse();
}
result.push(DxfEntity { layer: "HIDDEN".into(), shape: DxfShape::Polyline { points } });
}
result
}
pub(super) struct Line {
pub a: Vec2,
pub b: Vec2,
pub hidden: bool,
}
fn xy(p: Vec3) -> Vec2 {
Vec2::new(p.x, p.y)
}
fn cross(a: Vec2, b: Vec2) -> f64 {
a.x * b.y - a.y * b.x
}
fn bounds(points: impl IntoIterator<Item = Vec3>) -> Aabb3 {
let mut b = Aabb3::EMPTY;
for p in points {
b.add(p);
}
b
}
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
}
struct Triangle {
p: [Vec3; 3],
bounds: Aabb3,
}
struct Tree {
bounds: Aabb3,
children: Option<(Box<Tree>, Box<Tree>)>,
triangles: Vec<Triangle>,
}
impl Tree {
fn new(mut triangles: Vec<Triangle>) -> Self {
let b = triangles.iter().fold(Aabb3::EMPTY, |b, t| b.union(&t.bounds));
if triangles.len() <= 12 {
return Self { bounds: b, children: None, triangles };
}
let x = b.size().x > b.size().y;
let middle = triangles.len() / 2;
triangles.select_nth_unstable_by(middle, |a, b| {
let c = |t: &Triangle| if x { t.bounds.center().x } else { t.bounds.center().y };
c(a).total_cmp(&c(b))
});
let right = triangles.split_off(middle);
Self { bounds: b, children: Some((Box::new(Self::new(triangles)), Box::new(Self::new(right)))), triangles: Vec::new() }
}
fn intervals(&self, a: Vec3, b: Vec3, bx: &Aabb3, eps: f64, out: &mut Vec<(f64, f64)>, budget: &mut usize) -> Result<(), &'static str> {
*budget = budget.checked_sub(1).ok_or("view too complex to export; hide some bodies")?;
if !overlap(&self.bounds, bx) || self.bounds.max.z <= a.z.min(b.z) + eps {
return Ok(());
}
if let Some((left, right)) = &self.children {
left.intervals(a, b, bx, eps, out, budget)?;
right.intervals(a, b, bx, eps, out, budget)?;
} else {
for t in &self.triangles {
if overlap(&t.bounds, bx)
&& let Some(span) = occluded(a, b, t.p, eps)
{
out.push(span);
}
}
}
Ok(())
}
}
/// Clip [lo,hi] by the linear inequality f(t) >= 0.
fn clip(lo: &mut f64, hi: &mut f64, f0: f64, f1: f64) -> bool {
if f0 < 0.0 && f1 < 0.0 {
return false;
}
if (f0 < 0.0) != (f1 < 0.0) {
let at = f0 / (f0 - f1);
if f0 < 0.0 {
*lo = lo.max(at);
} else {
*hi = hi.min(at);
}
}
*hi > *lo
}
fn occluded(a: Vec3, b: Vec3, t: [Vec3; 3], eps: f64) -> Option<(f64, f64)> {
let [p, q, r] = t;
let area = cross(xy(q - p), xy(r - p));
if area.abs() < 1e-20 {
return None;
}
let (mut lo, mut hi) = (0.0, 1.0);
for (u, v) in [(p, q), (q, r), (r, p)] {
let side = |w: Vec3| cross(xy(v - u), xy(w - u)) * area.signum();
if !clip(&mut lo, &mut hi, side(a), side(b)) {
return None;
}
}
let depth = |w: Vec3| {
let s = cross(xy(w - p), xy(r - p)) / area;
let u = cross(xy(q - p), xy(w - p)) / area;
p.z + s * (q.z - p.z) + u * (r.z - p.z) - w.z - eps
};
clip(&mut lo, &mut hi, depth(a), depth(b)).then_some((lo, hi))
}
fn key(p: Vec3, eps: f64) -> [i64; 3] {
[(p.x / eps).round() as i64, (p.y / eps).round() as i64, (p.z / eps).round() as i64]
}
/// Merge collinear projected intervals, giving visible strokes precedence. This also removes
/// a rear edge underneath only part of a visible edge, and rejoins subdivided silhouettes.
fn merge_lines(lines: Vec<Line>, eps: f64) -> Vec<Line> {
type Events = (Vec2, Vec2, Vec<(f64, i32, i32)>);
let mut groups: BTreeMap<[i64; 3], Events> = BTreeMap::new();
for line in lines {
let delta = line.b - line.a;
let mut dir = delta / delta.len();
if (dir.x.abs() > 1e-8 && dir.x < 0.0) || (dir.x.abs() <= 1e-8 && dir.y < 0.0) {
dir = -dir;
}
let offset = cross(dir, line.a);
let k = [(dir.x / 1e-8).round() as i64, (dir.y / 1e-8).round() as i64, (offset / eps).round() as i64];
let (origin, dir, events) = groups.entry(k).or_insert((line.a, dir, Vec::new()));
let (a, b) = ((line.a - *origin).dot(*dir), (line.b - *origin).dot(*dir));
let (v, h) = if line.hidden { (0, 1) } else { (1, 0) };
events.push((a.min(b), v, h));
events.push((a.max(b), -v, -h));
}
let mut result = Vec::new();
for (_, (origin, dir, mut events)) in groups {
events.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let (mut visible, mut hidden, mut last) = (0, 0, 0.0);
let mut path: Option<Line> = None;
for (at, v, h) in events {
if at - last > eps {
if visible > 0 || hidden > 0 {
let is_hidden = visible == 0;
let a = origin + dir * last;
let b = origin + dir * at;
if let Some(p) = path.as_mut().filter(|p| p.hidden == is_hidden && p.b.dist(a) <= eps) {
p.b = b;
} else {
if let Some(p) = path.take() {
result.push(p);
}
path = Some(Line { a, b, hidden: is_hidden });
}
} else if let Some(p) = path.take() {
result.push(p);
}
}
visible += v;
hidden += h;
last = at;
}
if let Some(p) = path {
result.push(p);
}
}
result
}
/// B-rep edges plus curved silhouettes. Triangle adjacency is welded by position because
/// different faces can have different vertex indices along their shared boundary.
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)) {
result.extend(e.windows(2).filter_map(|w| Some((*w.first()?, *w.get(1)?))));
}
let mut adjacent: BTreeMap<_, (Vec3, Vec3, bool, bool, usize)> = BTreeMap::new();
for indices in &mesh.triangles {
let Some([a, b, c]) = mesh.tri(indices) else { continue };
let n = (b - a).cross(c - a);
let toward = if cam.fov > 0.0 { cam.eye() - (a + b + c) / 3.0 } else { cam.basis().2 };
let front = n.dot(toward) > 0.0;
for (a, b) in [(a, b), (b, c), (c, a)] {
let (ka, kb) = (key(a, eps), key(b, eps));
let k = if ka < kb { (ka, kb) } else { (kb, ka) };
let e = adjacent.entry(k).or_insert((a, b, false, false, 0));
e.2 |= front;
e.3 |= !front;
e.4 += 1;
}
}
for (_, (a, b, front, back, n)) in adjacent {
if (front && back) || n == 1 {
result.push((a, b));
}
}
result
}
pub(super) fn 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;
let project = |p: Vec3| -> Result<Vec3, &'static str> {
if !p.is_finite() {
return Err("model contains invalid coordinates");
}
let d = p - cam.target;
let z = d.dot(back);
let projected = if cam.fov > 0.0 {
let w = cam.distance - z;
if w <= cam.distance * 1e-6 {
return Err("model crosses the camera eye plane; zoom out before exporting");
}
Vec3::new(d.dot(right) * cam.distance / w, d.dot(up) * cam.distance / w, 1.0 / w)
} else {
Vec3::new(d.dot(right), d.dot(up), z)
};
if !projected.is_finite() || projected.len() > 1e12 {
return Err("projected coordinates exceed the export range");
}
Ok(projected)
};
let mut triangles = Vec::new();
let mut segments = Vec::new();
for m in meshes {
for t in &m.triangles {
if let Some([a, b, c]) = m.tri(t) {
let p = [project(a)?, project(b)?, project(c)?];
triangles.push(Triangle { p, bounds: bounds(p) });
}
}
for (a, b) in edges(m, cam, eps) {
let (a, b) = (project(a)?, project(b)?);
if xy(a).dist(xy(b)) > eps {
segments.push((a, b));
}
}
}
let tree = Tree::new(triangles);
let depth_eps = if cam.fov > 0.0 { 1e-8 / cam.distance } else { eps };
let mut result = Vec::new();
let mut budget = 20_000_000usize;
for (a, b) in segments {
let mut spans = Vec::new();
tree.intervals(a, b, &bounds([a, b]), depth_eps, &mut spans, &mut budget)?;
spans.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
let mut merged: Vec<(f64, f64)> = Vec::new();
for (lo, hi) in spans {
if let Some(last) = merged.last_mut().filter(|last| lo <= last.1 + 1e-9) {
last.1 = last.1.max(hi);
} else {
merged.push((lo, hi));
}
}
let mut emit = |lo: f64, hi: f64, hidden: bool| {
let (a, b) = (xy(a + (b - a) * lo), xy(a + (b - a) * hi));
if a.dist(b) <= eps {
return;
}
result.push(Line { a, b, hidden });
};
let mut cursor = 0.0;
for (lo, hi) in merged {
emit(cursor, lo, false);
emit(lo, hi, true);
cursor = hi;
}
emit(cursor, 1.0, false);
if result.len() > 1_000_000 {
return Err("view too complex to export; hide some bodies");
}
}
Ok(merge_lines(result, eps))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_visible_edge_overrides_only_the_overlapping_part_of_a_hidden_edge() {
let lines =
vec![Line { a: Vec2::ZERO, b: Vec2::new(10., 0.), hidden: true }, Line { a: Vec2::new(3., 0.), b: Vec2::new(7., 0.), hidden: false }];
let merged = merge_lines(lines, 1e-8);
assert_eq!(merged.len(), 3);
let length = |hidden| merged.iter().filter(|l| l.hidden == hidden).map(|l| l.a.dist(l.b)).sum::<f64>();
assert!((length(false) - 4.).abs() < 1e-8 && (length(true) - 6.).abs() < 1e-8);
}
#[test]
fn a_line_is_split_at_triangle_boundaries_and_depth_crossings() {
let t = [Vec3::new(0., 0., 1.), Vec3::new(2., 0., 1.), Vec3::new(0., 2., 1.)];
let (lo, hi) = occluded(Vec3::new(-1., 0.5, 0.), Vec3::new(3., 0.5, 0.), t, 0.).unwrap();
assert!((lo - 0.25).abs() < 1e-9 && (hi - 0.625).abs() < 1e-9);
assert!(occluded(Vec3::new(0., 0.5, 2.), Vec3::new(1., 0.5, 2.), t, 0.).is_none());
let (lo, hi) = occluded(Vec3::new(0., 0.5, 0.), Vec3::new(1., 0.5, 2.), t, 0.).unwrap();
assert!(lo.abs() < 1e-9 && (hi - 0.5).abs() < 1e-9);
}
}
+102
View File
@@ -0,0 +1,102 @@
use super::*;
use solidcraft_geom::{Vec2, Vec3};
use solidcraft_render::StandardView;
fn run(s: &mut Session, id: &str, p: Value) -> Value {
s.execute(id, &p).unwrap()
}
fn sample() -> Session {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"body_name":"Plate"}));
// A blind hole on the bottom, completely hidden from above.
run(&mut s, "solid.cylinder", json!({"radius":3,"height":4,"base":[10,10,0],"operation":"cut","targets":["Plate"]}));
s
}
fn camera(view: StandardView) -> Camera {
let mut c = Camera { target: Vec3::new(20., 15., 5.), fov: 0., distance: 150., ..Default::default() };
c.set_view(view);
c
}
fn export(s: &mut Session, c: Camera, hidden: &str) -> Value {
run(s, CMD, json!({"camera":c,"hidden":hidden}))
}
#[test]
fn view_dxf_blind_back_hole_is_omitted_or_dashed_and_front_hole_is_visible() {
let mut s = sample();
let undo = s.undo.len();
let doc = s.doc.clone();
let top = export(&mut s, camera(StandardView::Top), "omit");
let dashed = export(&mut s, camera(StandardView::Top), "dashed");
assert!(top["visible"].as_u64().unwrap() > 0);
assert_eq!(top["hidden"], 0);
assert!(dashed["hidden"].as_u64().unwrap() > 12, "{dashed}");
assert_eq!(top["visible"], dashed["visible"]);
assert!(!top["dxf"].as_str().unwrap().contains("HIDDEN"));
assert!(dashed["dxf"].as_str().unwrap().contains("LWPOLYLINE"));
assert!(dashed["dxf"].as_str().unwrap().contains("128\n"));
let bottom = export(&mut s, camera(StandardView::Bottom), "omit");
assert!(bottom["visible"].as_u64().unwrap() > top["visible"].as_u64().unwrap());
assert_eq!(s.undo.len(), undo);
assert!(std::sync::Arc::ptr_eq(&doc, &s.doc));
}
#[test]
fn view_dxf_respects_hidden_bodies_mm_and_camera_roll() {
let mut s = Session::default();
run(&mut s, "solid.box", json!({"length":40,"width":30,"height":10,"body_name":"Plate"}));
run(&mut s, "solid.box", json!({"length":5,"width":5,"height":5,"corner":[100,0,0],"body_name":"Other"}));
run(&mut s, "browser.visibility", json!({"items":["Other"],"visible":false}));
let mut c = camera(StandardView::Top);
let drawing = export(&mut s, c, "omit");
let size = |v: &Value| {
let mut points = Vec::new();
for g in solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap() {
if let solidcraft_io::Geom2::Line(a, b) = g {
points.extend([a, b]);
}
}
let min = points.iter().fold(Vec2::new(f64::INFINITY, f64::INFINITY), |a, p| Vec2::new(a.x.min(p.x), a.y.min(p.y)));
let max = points.iter().fold(Vec2::new(f64::NEG_INFINITY, f64::NEG_INFINITY), |a, p| Vec2::new(a.x.max(p.x), a.y.max(p.y)));
max - min
};
let d = size(&drawing);
assert!((d.x - 40.).abs() < 0.001 && (d.y - 30.).abs() < 0.001, "{d:?}");
c.roll = std::f64::consts::FRAC_PI_2;
let d = size(&export(&mut s, c, "omit"));
assert!((d.x - 30.).abs() < 0.001 && (d.y - 40.).abs() < 0.001, "{d:?}");
run(&mut s, "browser.visibility", json!({"items":["Plate"],"visible":false}));
assert!(s.execute(CMD, &json!({"camera":c})).is_err());
}
#[test]
fn view_dxf_cylinders_have_side_silhouettes_and_perspective_is_supported() {
let mut s = Session::default();
run(&mut s, "solid.cylinder", json!({"radius":10,"height":20}));
let mut c = camera(StandardView::Front);
c.target = Vec3::new(0., 0., 10.);
let v = export(&mut s, c, "omit");
let lines = solidcraft_io::read_dxf(v["dxf"].as_str().unwrap()).unwrap();
assert!(lines.iter().filter(|g| matches!(g,solidcraft_io::Geom2::Line(a,b) if (a.y-b.y).abs()>19.9)).count() >= 2);
c.fov = 0.6;
let v = export(&mut s, c, "dashed");
assert_eq!(v["projection"], "perspective");
assert!(v["visible"].as_u64().unwrap() > 0);
}
#[test]
fn view_dxf_rejects_invalid_options_and_near_eye_geometry_without_panicking() {
let mut s = sample();
for extra in [json!({"hidden":"all"}), json!({"hidden":true}), json!({"tolerance":0}), json!({"tolerance":"x"}), json!({"path":false})] {
let mut p = extra;
p["camera"] = json!(camera(StandardView::Top));
assert!(s.execute(CMD, &p).is_err(), "{p}");
}
assert!(s.execute(CMD, &json!({})).is_err());
let mut c = camera(StandardView::Top);
c.fov = 0.5;
c.distance = 1.;
assert!(s.execute(CMD, &json!({"camera":c})).is_err());
c.fov = 4.;
assert!(s.execute(CMD, &json!({"camera":c})).is_err());
}
+1
View File
@@ -29,6 +29,7 @@ fn file_commands_stay_inside_the_agents_folders() {
("file.save_as", json!({"path": at(&out, "x.solidcraft")})),
("file.save", json!({"path": at(&root, "../out/x.solidcraft")})),
("file.export", json!({"path": at(&out, "x.stl")})),
("file.export_view_dxf", json!({"path": at(&out, "x.dxf"),"camera":render::Camera::default()})),
("file.export", json!({"path": at(&out, "x.json"), "format": "json"})),
("file.save_as", json!({"path": at(&out, "x.json"), "format": "json", "preview": true})),
("parameters.export", json!({"path": at(&out, "x.csv")})),
+38 -1
View File
@@ -22,6 +22,8 @@ pub enum DxfLinetype {
Continuous,
/// AutoCAD's DASHED: a dash of 12.7 mm and a gap of 6.35 mm.
Dashed,
/// Short 2 mm dashes and 1 mm gaps for hidden edges in millimetre drawings.
Hidden,
}
impl DxfLinetype {
@@ -29,6 +31,7 @@ impl DxfLinetype {
match self {
DxfLinetype::Continuous => "Continuous",
DxfLinetype::Dashed => "DASHED",
DxfLinetype::Hidden => "HIDDEN",
}
}
}
@@ -57,6 +60,10 @@ impl DxfLayer {
#[derive(Clone, Debug, PartialEq)]
pub enum DxfShape {
Line(Vec2, Vec2),
/// Connected straight segments, with continuous linetype generation through the vertices.
Polyline {
points: Vec<Vec2>,
},
Circle {
c: Vec2,
r: f64,
@@ -207,6 +214,7 @@ impl DxfShape {
fn sample(&self, out: &mut Vec<Vec2>) {
match self {
DxfShape::Line(a, b) => out.extend([*a, *b]),
DxfShape::Polyline { points } => out.extend(points.iter().copied()),
DxfShape::Circle { c, r } => out.extend([*c - Vec2::new(*r, *r), *c + Vec2::new(*r, *r)]),
DxfShape::Arc { c, r, a0, a1 } => {
let sweep = (a1 - a0).rem_euclid(360.0);
@@ -348,12 +356,15 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
o.code(2, "TABLES");
// The three every drawing has, and DASHED, which the dashed layers name: dash and gap lengths
// (a gap is negative) after the pattern's total.
let linetypes: [(&str, &str, &[f64]); 4] = [
let mut linetypes: Vec<(&str, &str, &[f64])> = vec![
("ByBlock", "", &[]),
("ByLayer", "", &[]),
("Continuous", "Solid line", &[]),
("DASHED", "Dashed __ __ __ __ __ __ __ __ __ __", &[12.7, -6.35]),
];
if layer_list.iter().any(|l| l.linetype == DxfLinetype::Hidden) {
linetypes.push(("HIDDEN", "Hidden edges", &[2.0, -1.0]));
}
let t = o.table("LTYPE", linetypes.len());
for (name, description, pattern) in linetypes {
o.record("LTYPE", "AcDbLinetypeTableRecord", &t);
@@ -414,6 +425,7 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
for e in entities {
let kind = match &e.shape {
DxfShape::Line(..) => "LINE",
DxfShape::Polyline { .. } => "LWPOLYLINE",
DxfShape::Circle { .. } => "CIRCLE",
DxfShape::Arc { .. } => "ARC",
DxfShape::Ellipse { .. } => "ELLIPSE",
@@ -427,6 +439,16 @@ pub fn write_dxf_drawing(layers: &[DxfLayer], entities: &[DxfEntity]) -> String
o.code(100, "AcDbEntity");
o.code(8, if e.layer.trim().is_empty() { "0" } else { e.layer.as_str() });
match &e.shape {
DxfShape::Polyline { points } => {
o.code(100, "AcDbPolyline");
o.code(90, points.len());
// Autodesk DXF reference: bit 128 (PLINEGEN) continues the dash pattern.
o.code(70, 128);
for p in points {
o.num(10, p.x);
o.num(20, p.y);
}
}
DxfShape::Line(a, b) => {
o.code(100, "AcDbLine");
o.point(10, *a);
@@ -811,6 +833,21 @@ mod tests {
);
}
#[test]
fn hidden_polylines_continue_short_dashes_across_chords_and_round_trip() {
let points = vec![Vec2::ZERO, Vec2::new(1., 0.), Vec2::new(2., 1.), Vec2::new(3., 1.)];
let layer = DxfLayer { linetype: DxfLinetype::Hidden, ..DxfLayer::new("HIDDEN", 8) };
let text = write_dxf_drawing(&[layer], &[DxfEntity { layer: "HIDDEN".into(), shape: DxfShape::Polyline { points } }]);
let records = entities(&text);
assert_eq!(records[0].0, "LWPOLYLINE");
assert_eq!(field(&records[0].1, 70), "128");
assert_eq!(field(&records[0].1, 90), "4");
let types = table_records(&pairs(&text), "LTYPE");
let hidden = types.iter().find(|r| field(r, 2) == "HIDDEN").unwrap();
assert_eq!(hidden.iter().filter(|(c, _)| *c == 49).map(|(_, v)| v.parse::<f64>().unwrap()).collect::<Vec<_>>(), [2., -1.]);
assert_eq!(read_dxf(&text).unwrap().len(), 3);
}
#[test]
fn text_is_centred_on_its_point_and_the_reader_gets_it_back() {
let at = Vec2::new(30.0, 12.0);
+11
View File
@@ -258,6 +258,9 @@ pub enum Kind {
/// Surface tools and Split Face (`dialogs_surface`).
Surface(crate::dialogs_surface::Surf),
SaveMesh(crate::dialogs_save_mesh::SaveMesh),
ViewDxf {
dashed: bool,
},
/// Plastic features (`dialogs_plastic`).
Plastic(crate::dialogs_plastic::Pl),
/// Appearance (`dialogs_appearance`).
@@ -609,6 +612,7 @@ impl Dialog {
.or_else(|| crate::dialogs_sheet::start(app, id))
.or_else(|| crate::dialogs_surface::start(app, id))
.or_else(|| crate::dialogs_save_mesh::start(app, id))
.or_else(|| crate::dialogs_view_dxf::start(app, id))
.or_else(|| crate::dialogs_plastic::start(app, id))
.or_else(|| crate::dialogs_appearance::start(app, id))
.or_else(|| crate::dialogs_motion::start(app, id))
@@ -1379,6 +1383,7 @@ fn title(k: &Kind) -> &'static str {
Kind::Sheet(k) => k.title(),
Kind::Surface(k) => k.title(),
Kind::SaveMesh(_) => "SAVE AS MESH",
Kind::ViewDxf { .. } => "Export Current View as DXF",
Kind::Plastic(k) => k.title(),
Kind::Appearance(_) => "APPEARANCE",
Kind::Motion(k) => k.title(),
@@ -2773,6 +2778,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::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),
@@ -2835,6 +2841,7 @@ pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
let raw_label = match &d.kind {
Kind::ConfirmDelete { .. } => "Delete",
Kind::SaveMesh(k) => crate::dialogs_save_mesh::button(k),
Kind::ViewDxf { .. } => "Export DXF…",
_ => "OK",
};
let label = tr(raw_label);
@@ -3278,6 +3285,9 @@ 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);
}
// Saving asks for a file name first; `Err("")` is the user backing out of that.
if let Kind::SaveMesh(k) = &d.kind {
return crate::dialogs_save_mesh::run(app, k, &d.inputs);
@@ -3975,6 +3985,7 @@ fn dialog_commands(app: &SolveApp, d: &Dialog) -> Result<Vec<(String, Value)>, S
Kind::Surface(k) => return crate::dialogs_surface::commands(app, k, &d.inputs, &d.extra),
Kind::SketchModify(k) => return crate::sketch_modify_ui::commands(app, k, &d.inputs, &d.extra),
Kind::SaveMesh(k) => return crate::dialogs_save_mesh::commands(k, &d.inputs),
Kind::ViewDxf { .. } => return Ok(Vec::new()),
Kind::Plastic(k) => return crate::dialogs_plastic::commands(app, k, &d.inputs, &d.extra),
Kind::Appearance(k) => return Ok(crate::dialogs_appearance::commands(k, &d.inputs)),
Kind::Motion(k) => return crate::dialogs_motion::commands(app, k, &d.inputs),
+88
View File
@@ -0,0 +1,88 @@
//! Export the camera at the instant Export is pressed, with a remembered hidden-edge choice.
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()))
}
pub fn rows(app: &SolveApp, ui: &mut egui::Ui, dashed: &mut bool) {
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"));
crate::scenario::publish_handle("dialog:dxf:omit", r.rect.center());
let r = ui.radio_value(dashed, true, app.tr_label("Export hidden edges as dashed lines"));
crate::scenario::publish_handle("dialog:dxf:dashed", 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.end_row();
}
pub fn run(app: &mut SolveApp, dashed: bool) -> 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)) {
return Err(app.tr_label("No visible bodies to export.").into());
}
app.preferences.view_dxf_dashed = dashed;
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);
}
}
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use solidcraft_engine::render::StandardView;
#[test]
fn view_dxf_file_menu_exports_with_the_current_camera_and_remembers_hidden_choice() {
let mut h = crate::scenario::Harness::new();
h.app.run("solid.box", json!({"length":40,"width":30,"height":10})).unwrap();
h.app.run("solid.cylinder", json!({"radius":3,"height":4,"base":[10,10,0],"operation":"cut"})).unwrap();
h.app.cam.fov = 0.0;
h.app.cam.set_view(StandardView::Top);
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 })));
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| {
assert!(name.ends_with("-view.dxf"));
assert_eq!(extensions, &["dxf"]);
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);
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);
let saved = h.app.prefs();
let mut restored = SolveApp::new(Default::default(), Default::default());
restored.load_prefs(&saved);
assert!(restored.preferences.view_dxf_dashed);
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 })));
h.step(&json!({"click":{"handle":"dialog:dxf:omit"}})).unwrap();
h.step(&json!({"click":{"handle":"dialog:Export DXF…"}})).unwrap();
h.frames(3);
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);
}
}
+1
View File
@@ -35,6 +35,7 @@ pub mod dialogs_save_mesh;
pub mod dialogs_sheet;
pub mod dialogs_surface;
pub mod dialogs_sweep;
pub mod dialogs_view_dxf;
pub mod dim_view;
#[cfg(test)]
mod dimension_create_ui_tests;
+3
View File
@@ -328,6 +328,8 @@ pub struct Prefs {
/// Save as mesh: the last format (`stl` or `3mf`), whether it was sent to a slicer, which one
/// (an installed slicer's id or a program path), and the programs the user added (newest first).
pub save_mesh_format: String,
/// Hidden edges in the current-view DXF export (false: omit, true: dashed).
pub view_dxf_dashed: bool,
pub save_mesh_send: bool,
pub save_mesh_slicer: String,
pub custom_slicers: Vec<String>,
@@ -420,6 +422,7 @@ impl Default for Prefs {
check_updates: true,
update: crate::update::Memo::default(),
save_mesh_format: "stl".into(),
view_dxf_dashed: false,
save_mesh_send: false,
save_mesh_slicer: String::new(),
custom_slicers: Vec::new(),
+4
View File
@@ -448,6 +448,10 @@ fn file_menu(app: &mut SolveApp, ctx: &egui::Context) {
export(app, "step");
close = true;
}
if item_if(ui, app.tr_label("Export Current View as DXF…"), "", shown) {
app.start("file.export_view_dxf");
close = true;
}
if item_if(ui, "Export OBJ…", "", shown) {
export(app, "obj");
close = true;
+3 -2
View File
@@ -1,6 +1,6 @@
# Features
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **376 commands.** Planned (registered, not built yet): 3. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
SolidCraft's commands by area, generated by `cargo xtask features` from the command registry (`solidcraft-cli commands`). **377 commands.** Planned (registered, not built yet): 3. Every command runs the same way from the toolbar, a script, `solidcraft-cli exec <id> '<json>'`, the control channel and MCP; parameters are in the [command reference](book/src/reference/commands.md) (`cargo xtask book`). Status and plans: [ROADMAP](../ROADMAP.md).
## Sketch (102)
@@ -323,7 +323,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Compare Bodies | `inspect.compare` | – |
| Section Analysis | `inspect.section` | SOLID › INSPECT |
## Files (8)
## Files (9)
| Command | Id | Toolbar |
|---|---|---|
@@ -335,6 +335,7 @@ SolidCraft's commands by area, generated by `cargo xtask features` from the comm
| Export | `file.export` | – |
| Save As Mesh | `file.save_mesh` | MESH › MODIFY |
| Installed Slicers | `file.slicers` | – |
| Export Current View as DXF | `file.export_view_dxf` | – |
## Documents (4)
+34
View File
@@ -0,0 +1,34 @@
# 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でエクスポート…**.
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
placements. Hidden bodies neither draw nor hide other bodies. The complete projected
geometry is exported, including geometry beyond the viewport's edges. Grid, sketches,
dimensions, selection highlights and other overlays are excluded. Turn off section analysis
before exporting.
Coordinates are 2D, Z=0, in millimetres. Orthographic views retain 1:1 dimensions in the
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.
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
continuous linetype generation so the pattern does not restart on each chord. The pattern
is embedded in the file. The polyline flags follow the
[Autodesk DXF reference](https://help.autodesk.com/cloudhelp/2017/ENU/AutoCAD-DXF/files/GUID-748FC305-F3F2-4F74-825A-61F04D757A50.htm).
Edges are split where another surface occludes them, including partial occlusion by another
body. Hidden edges are never included in omit mode. Triangle diagonals and smooth seams
are excluded; curved-body silhouettes are included. Very complex drawings fail with an
error rather than returning an incomplete export. If geometry crosses the perspective
camera's eye plane, zoom out before exporting.
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.