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 <[email protected]> Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
20 files changed
+798
-3
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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の下書きを開きます。タイトルとレポートは入力済みです。",
|
||||
|
||||
@@ -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 초안을 여세요. 제목과 보고서가 자동으로 입력됩니다.",
|
||||
|
||||
@@ -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 草稿。标题和报告将自动填入。",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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")})),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user