From 768eaf5b2d79958f6e13a0e94f2899ac7afc8ba8 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:05:53 +0300 Subject: [PATCH 01/16] feat(annotate): expose annotation panel tools --- src/modules/annotate/mod.rs | 40 ++++++- src/ui/ribbon/annotation_panel.rs | 170 ++++++++++++++++++++++++++++++ src/ui/ribbon/draw_panel.rs | 6 ++ src/ui/ribbon/mod.rs | 1 + 4 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 src/ui/ribbon/annotation_panel.rs diff --git a/src/modules/annotate/mod.rs b/src/modules/annotate/mod.rs index 6ff2016ae..c9e783fc2 100644 --- a/src/modules/annotate/mod.rs +++ b/src/modules/annotate/mod.rs @@ -42,7 +42,7 @@ impl CadModule for AnnotateModule { } fn ribbon_groups(&self) -> &[RibbonGroup] { - use crate::modules::draw::draw::{revcloud, wipeout}; + use crate::modules::draw::draw::{centerline, dimcenter, revcloud, wipeout}; static GROUPS: std::sync::OnceLock> = std::sync::OnceLock::new(); GROUPS.get_or_init(|| { @@ -148,6 +148,44 @@ impl CadModule for AnnotateModule { }, ], }, + // ── Centerlines ─────────────────────────────────────────────── + RibbonGroup { + title: "Centerlines", + tools: vec![ + RibbonItem::LargeTool(dimcenter::tool()), + RibbonItem::LargeTool(centerline::tool()), + RibbonItem::Dropdown { + id: "ANNOTATE_CENTER_ASSOCIATIVITY", + icon: crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + items: vec![ + ( + "CENTERREASSOCIATE", + "Reassociate Center Object", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + ), + ( + "CENTERDISASSOCIATE", + "Disassociate Center Object", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + ), + ( + "CENTERRESET", + "Reset Center Object", + crate::modules::IconKind::Svg(include_bytes!( + "../../../assets/icons/line.svg" + )), + ), + ], + default: "CENTERREASSOCIATE", + }, + ], + }, // ── Leaders ────────────────────────────────────────────────── RibbonGroup { title: "Leaders", diff --git a/src/ui/ribbon/annotation_panel.rs b/src/ui/ribbon/annotation_panel.rs new file mode 100644 index 000000000..7b0a8839c --- /dev/null +++ b/src/ui/ribbon/annotation_panel.rs @@ -0,0 +1,170 @@ +//! Additional annotation tools reached from the Draw tab's Annotation panel title. + +use super::draw_panel::Tool; + +pub(super) const TOOLS: &[Tool] = &[ + Tool { + command: "DDEDIT", + label: "Edit Text", + icon: include_bytes!("../../../assets/icons/ddedit.svg"), + options: &[], + }, + Tool { + command: "TOLERANCE", + label: "Geometric Tolerance", + icon: include_bytes!("../../../assets/icons/tolerance.svg"), + options: &[], + }, + Tool { + command: "DIMALIGNED", + label: "Aligned Dimension", + icon: include_bytes!("../../../assets/icons/dim_aligned.svg"), + options: &[], + }, + Tool { + command: "DIMARC", + label: "Arc Length Dimension", + icon: include_bytes!("../../../assets/icons/dim_angular.svg"), + options: &[], + }, + Tool { + command: "DIMJOGGED", + label: "Jogged Radius Dimension", + icon: include_bytes!("../../../assets/icons/dim_jog.svg"), + options: &[], + }, + Tool { + command: "DIMDIAMETER", + label: "Diameter Dimension", + icon: include_bytes!("../../../assets/icons/dim_diameter.svg"), + options: &[], + }, + Tool { + command: "DIMORDINATE", + label: "Ordinate Dimension", + icon: include_bytes!("../../../assets/icons/dim_ordinate.svg"), + options: &[], + }, + Tool { + command: "QDIM", + label: "Quick Dimension", + icon: include_bytes!("../../../assets/icons/qdim.svg"), + options: &[], + }, + Tool { + command: "DIMCONTINUE", + label: "Continue Dimension", + icon: include_bytes!("../../../assets/icons/dim_continue.svg"), + options: &[], + }, + Tool { + command: "DIMBASELINE", + label: "Baseline Dimension", + icon: include_bytes!("../../../assets/icons/dim_baseline.svg"), + options: &[], + }, + Tool { + command: "DIMBREAK", + label: "Dimension Break", + icon: include_bytes!("../../../assets/icons/dim_break.svg"), + options: &[], + }, + Tool { + command: "DIMEDIT", + label: "Edit Dimension", + icon: include_bytes!("../../../assets/icons/dim_edit.svg"), + options: &[], + }, + Tool { + command: "DIMTEDIT", + label: "Edit Dimension Text", + icon: include_bytes!("../../../assets/icons/dim_tedit.svg"), + options: &[], + }, + Tool { + command: "DIMSPACE", + label: "Adjust Dimension Space", + icon: include_bytes!("../../../assets/icons/dim_space.svg"), + options: &[], + }, + Tool { + command: "DIMJOGLINE", + label: "Add or Remove Jog Line", + icon: include_bytes!("../../../assets/icons/dim_jog.svg"), + options: &[], + }, + Tool { + command: "MLEADERADD", + label: "Add Leader", + icon: include_bytes!("../../../assets/icons/mleader_add.svg"), + options: &[], + }, + Tool { + command: "MLEADERREMOVE", + label: "Remove Leader", + icon: include_bytes!("../../../assets/icons/mleader_remove.svg"), + options: &[], + }, + Tool { + command: "MLEADERALIGN", + label: "Align Leaders", + icon: include_bytes!("../../../assets/icons/mleader_align.svg"), + options: &[], + }, + Tool { + command: "MLEADERCOLLECT", + label: "Collect Leaders", + icon: include_bytes!("../../../assets/icons/mleader_collect.svg"), + options: &[], + }, + Tool { + command: "WIPEOUT", + label: "Wipeout", + icon: include_bytes!("../../../assets/icons/wipeout.svg"), + options: &[], + }, + Tool { + command: "TABLE", + label: "Table and Data", + icon: include_bytes!("../../../assets/icons/table.svg"), + options: &[ + ("TABLE", "Table"), + ("DATALINK", "Link Data"), + ("DATAEXTRACTION", "Extract Data"), + ], + }, + Tool { + command: "CENTERMARK", + label: "Center Mark and Line", + icon: include_bytes!("../../../assets/icons/line.svg"), + options: &[ + ("CENTERMARK", "Center Mark"), + ("CENTERLINE", "Center Line"), + ("CENTERREASSOCIATE", "Reassociate Center Object"), + ("CENTERDISASSOCIATE", "Disassociate Center Object"), + ("CENTERRESET", "Reset Center Object"), + ], + }, + Tool { + command: "REVCLOUD", + label: "Revision Cloud", + icon: include_bytes!("../../../assets/icons/revcloud.svg"), + options: &[ + ("REVCLOUD_RECTANGULAR", "Rectangular"), + ("REVCLOUD_POLYGONAL", "Polygonal"), + ("REVCLOUD_FREEHAND", "Freehand"), + ], + }, + Tool { + command: "OBJECTSCALE", + label: "Annotation Scales", + icon: include_bytes!("../../../assets/icons/add_scale.svg"), + options: &[ + ("OBJECTSCALE ADD", "Add Current Scale"), + ("OBJECTSCALE DELETE", "Delete Current Scale"), + ("OBJECTSCALE", "Add or Delete Scales"), + ("SCALELISTEDIT", "Edit Scale List"), + ("ANNORESET", "Sync Scale Positions"), + ], + }, +]; diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index f2e4677c4..897dd528a 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -140,6 +140,12 @@ const PANELS: &[Panel] = &[ title: "Modify", tools: super::modify_panel::TOOLS, }, + Panel { + id: "annotation_extension", + title_id: "annotation_extension_title", + title: "Annotation", + tools: super::annotation_panel::TOOLS, + }, ]; fn panel_for_dropdown(id: &str) -> Option { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index a5eb2ffe3..d57cf6c22 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -21,6 +21,7 @@ use crate::plugin::all_ribbon_modules; use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; +mod annotation_panel; mod draw_panel; mod modify_panel; use widgets::{StyleContext, *}; From ec14c98f07cc6bd7a42befe7e04a6e7fea8fc790 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 21:59:20 +0300 Subject: [PATCH 02/16] fix(annotate): keep tools in their ribbon tab --- src/ui/ribbon/annotation_panel.rs | 170 ------------------------------ src/ui/ribbon/draw_panel.rs | 6 -- src/ui/ribbon/mod.rs | 1 - 3 files changed, 177 deletions(-) delete mode 100644 src/ui/ribbon/annotation_panel.rs diff --git a/src/ui/ribbon/annotation_panel.rs b/src/ui/ribbon/annotation_panel.rs deleted file mode 100644 index 7b0a8839c..000000000 --- a/src/ui/ribbon/annotation_panel.rs +++ /dev/null @@ -1,170 +0,0 @@ -//! Additional annotation tools reached from the Draw tab's Annotation panel title. - -use super::draw_panel::Tool; - -pub(super) const TOOLS: &[Tool] = &[ - Tool { - command: "DDEDIT", - label: "Edit Text", - icon: include_bytes!("../../../assets/icons/ddedit.svg"), - options: &[], - }, - Tool { - command: "TOLERANCE", - label: "Geometric Tolerance", - icon: include_bytes!("../../../assets/icons/tolerance.svg"), - options: &[], - }, - Tool { - command: "DIMALIGNED", - label: "Aligned Dimension", - icon: include_bytes!("../../../assets/icons/dim_aligned.svg"), - options: &[], - }, - Tool { - command: "DIMARC", - label: "Arc Length Dimension", - icon: include_bytes!("../../../assets/icons/dim_angular.svg"), - options: &[], - }, - Tool { - command: "DIMJOGGED", - label: "Jogged Radius Dimension", - icon: include_bytes!("../../../assets/icons/dim_jog.svg"), - options: &[], - }, - Tool { - command: "DIMDIAMETER", - label: "Diameter Dimension", - icon: include_bytes!("../../../assets/icons/dim_diameter.svg"), - options: &[], - }, - Tool { - command: "DIMORDINATE", - label: "Ordinate Dimension", - icon: include_bytes!("../../../assets/icons/dim_ordinate.svg"), - options: &[], - }, - Tool { - command: "QDIM", - label: "Quick Dimension", - icon: include_bytes!("../../../assets/icons/qdim.svg"), - options: &[], - }, - Tool { - command: "DIMCONTINUE", - label: "Continue Dimension", - icon: include_bytes!("../../../assets/icons/dim_continue.svg"), - options: &[], - }, - Tool { - command: "DIMBASELINE", - label: "Baseline Dimension", - icon: include_bytes!("../../../assets/icons/dim_baseline.svg"), - options: &[], - }, - Tool { - command: "DIMBREAK", - label: "Dimension Break", - icon: include_bytes!("../../../assets/icons/dim_break.svg"), - options: &[], - }, - Tool { - command: "DIMEDIT", - label: "Edit Dimension", - icon: include_bytes!("../../../assets/icons/dim_edit.svg"), - options: &[], - }, - Tool { - command: "DIMTEDIT", - label: "Edit Dimension Text", - icon: include_bytes!("../../../assets/icons/dim_tedit.svg"), - options: &[], - }, - Tool { - command: "DIMSPACE", - label: "Adjust Dimension Space", - icon: include_bytes!("../../../assets/icons/dim_space.svg"), - options: &[], - }, - Tool { - command: "DIMJOGLINE", - label: "Add or Remove Jog Line", - icon: include_bytes!("../../../assets/icons/dim_jog.svg"), - options: &[], - }, - Tool { - command: "MLEADERADD", - label: "Add Leader", - icon: include_bytes!("../../../assets/icons/mleader_add.svg"), - options: &[], - }, - Tool { - command: "MLEADERREMOVE", - label: "Remove Leader", - icon: include_bytes!("../../../assets/icons/mleader_remove.svg"), - options: &[], - }, - Tool { - command: "MLEADERALIGN", - label: "Align Leaders", - icon: include_bytes!("../../../assets/icons/mleader_align.svg"), - options: &[], - }, - Tool { - command: "MLEADERCOLLECT", - label: "Collect Leaders", - icon: include_bytes!("../../../assets/icons/mleader_collect.svg"), - options: &[], - }, - Tool { - command: "WIPEOUT", - label: "Wipeout", - icon: include_bytes!("../../../assets/icons/wipeout.svg"), - options: &[], - }, - Tool { - command: "TABLE", - label: "Table and Data", - icon: include_bytes!("../../../assets/icons/table.svg"), - options: &[ - ("TABLE", "Table"), - ("DATALINK", "Link Data"), - ("DATAEXTRACTION", "Extract Data"), - ], - }, - Tool { - command: "CENTERMARK", - label: "Center Mark and Line", - icon: include_bytes!("../../../assets/icons/line.svg"), - options: &[ - ("CENTERMARK", "Center Mark"), - ("CENTERLINE", "Center Line"), - ("CENTERREASSOCIATE", "Reassociate Center Object"), - ("CENTERDISASSOCIATE", "Disassociate Center Object"), - ("CENTERRESET", "Reset Center Object"), - ], - }, - Tool { - command: "REVCLOUD", - label: "Revision Cloud", - icon: include_bytes!("../../../assets/icons/revcloud.svg"), - options: &[ - ("REVCLOUD_RECTANGULAR", "Rectangular"), - ("REVCLOUD_POLYGONAL", "Polygonal"), - ("REVCLOUD_FREEHAND", "Freehand"), - ], - }, - Tool { - command: "OBJECTSCALE", - label: "Annotation Scales", - icon: include_bytes!("../../../assets/icons/add_scale.svg"), - options: &[ - ("OBJECTSCALE ADD", "Add Current Scale"), - ("OBJECTSCALE DELETE", "Delete Current Scale"), - ("OBJECTSCALE", "Add or Delete Scales"), - ("SCALELISTEDIT", "Edit Scale List"), - ("ANNORESET", "Sync Scale Positions"), - ], - }, -]; diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index 897dd528a..f2e4677c4 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -140,12 +140,6 @@ const PANELS: &[Panel] = &[ title: "Modify", tools: super::modify_panel::TOOLS, }, - Panel { - id: "annotation_extension", - title_id: "annotation_extension_title", - title: "Annotation", - tools: super::annotation_panel::TOOLS, - }, ]; fn panel_for_dropdown(id: &str) -> Option { diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index d57cf6c22..a5eb2ffe3 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -21,7 +21,6 @@ use crate::plugin::all_ribbon_modules; use crate::ui::properties::{linetype_display_name, lw_options, LinetypeItem}; mod widgets; -mod annotation_panel; mod draw_panel; mod modify_panel; use widgets::{StyleContext, *}; From 4ce42f9cdbd3260f1818a04beb5a3b5bd33b8219 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:00:59 +0300 Subject: [PATCH 03/16] fix(draw): remove cross-tab extension tools --- src/ui/ribbon/draw_panel.rs | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/ui/ribbon/draw_panel.rs b/src/ui/ribbon/draw_panel.rs index f2e4677c4..54c6ed688 100644 --- a/src/ui/ribbon/draw_panel.rs +++ b/src/ui/ribbon/draw_panel.rs @@ -73,12 +73,6 @@ const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/region.svg"), options: &[], }, - Tool { - command: "WIPEOUT", - label: "Wipeout", - icon: include_bytes!("../../../assets/icons/wipeout.svg"), - options: &[], - }, Tool { command: "BOUNDARY", label: "Boundary", @@ -107,16 +101,6 @@ const TOOLS: &[Tool] = &[ ("DDPTYPE", "Point Style"), ], }, - Tool { - command: "REVCLOUD", - label: "Revision Cloud", - icon: include_bytes!("../../../assets/icons/revcloud.svg"), - options: &[ - ("REVCLOUD_RECTANGULAR", "Rectangular"), - ("REVCLOUD_POLYGONAL", "Polygonal"), - ("REVCLOUD_FREEHAND", "Freehand"), - ], - }, ]; #[derive(Clone, Copy)] @@ -305,7 +289,7 @@ mod tests { #[test] fn extension_submenus_resolve_to_their_parent_panel() { - assert_eq!(parent_panel("REVCLOUD"), Some(PANEL_ID)); + assert_eq!(parent_panel("MULTIPOINT"), Some(PANEL_ID)); assert_eq!(parent_panel("DRAWORDER_FRONT"), Some("modify_extension")); assert_eq!(parent_panel("unknown"), None); } From dfcae61ca70e6eb7bffbbdfdd0182823e37defae Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Mon, 14 Sep 2026 22:01:29 +0300 Subject: [PATCH 04/16] fix(modify): remove cleanup tab duplicate --- src/ui/ribbon/modify_panel.rs | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs index 835e03c27..04eee0f55 100644 --- a/src/ui/ribbon/modify_panel.rs +++ b/src/ui/ribbon/modify_panel.rs @@ -67,12 +67,6 @@ pub(super) const TOOLS: &[Tool] = &[ icon: include_bytes!("../../../assets/icons/modify_ncopy.svg"), options: &[], }, - Tool { - command: "OVERKILL", - label: "Delete Duplicate Objects", - icon: include_bytes!("../../../assets/icons/modify_overkill.svg"), - options: &[], - }, Tool { command: "DRAWORDER_FRONT", label: "Draw Order", From c13e86b87d8427b88927763f956f125efac5565a Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:00:47 +0900 Subject: [PATCH 05/16] fix(dimension): draw linear and aligned dimensions in their own plane off world Z A linear or aligned dimension whose normal is not world Z was drawn and exploded as if it lay in world XY. The builders read the offset and the extension directions in XY only, so a dimension turned into a working plane such as XZ lost its offset (now along Z): the dimension line fell onto the measured points and the extension lines stuck out along world Y. Move such a dimension into its own OCS before building it and map the result back onto its plane, as arc length and large radial dimensions already do, for both tessellation and EXPLODE. Co-Authored-By: Claude Opus 5 --- src/entities/dimension.rs | 139 +++++++++++++++++++++++++++++ src/modules/draw/modify/explode.rs | 65 ++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index bc3fa9a2b..470022408 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3431,6 +3431,26 @@ fn tessellate_dimension_inner( return wires; } } + if let Some((local, normal)) = linear_dimension_in_ocs(dim) { + let mut wires = tessellate_dimension_inner( + document, + handle, + &local, + selected, + entity_color, + line_weight_px, + anno_scale, + selected_set, + active_viewport, + bg_color, + None, + world_per_pixel, + ); + for wire in &mut wires { + map_wire_ocs_to_wcs(wire, normal); + } + return wires; + } let name = handle.value().to_string(); // (Baked-block fast path moved up into scene::tessellate_entity so the // recursive call goes through the LOD ladder, not the kernel path.) @@ -5226,6 +5246,50 @@ pub(crate) fn large_radial_dimension_in_ocs( Some((local, normal)) } +/// A linear or aligned dimension in a plane off world Z, moved into its own +/// OCS. Their builders read the offset and extension directions in XY, so +/// they draw it there and the result is mapped back onto the plane. `None` +/// for a dimension in world XY or of another kind. +pub(crate) fn linear_dimension_in_ocs(dimension: &Dimension) -> Option<(Dimension, Vector3)> { + let length = dimension.base().normal.length(); + if !length.is_finite() || length <= 1.0e-12 { + return None; + } + let normal = dimension.base().normal / length; + if (normal - Vector3::UNIT_Z).length() <= 1.0e-12 { + return None; + } + let normal_tuple = (normal.x, normal.y, normal.z); + let to_ocs = |point: Vector3| { + let point = crate::scene::view::transform::wcs_point_to_ocs( + (point.x, point.y, point.z), + normal_tuple, + ); + Vector3::new(point.0, point.1, point.2) + }; + let mut local = dimension.clone(); + match &mut local { + Dimension::Linear(d) => { + d.first_point = to_ocs(d.first_point); + d.second_point = to_ocs(d.second_point); + d.definition_point = to_ocs(d.definition_point); + d.base.definition_point = d.definition_point; + d.base.text_middle_point = to_ocs(d.base.text_middle_point); + d.base.normal = Vector3::UNIT_Z; + } + Dimension::Aligned(d) => { + d.first_point = to_ocs(d.first_point); + d.second_point = to_ocs(d.second_point); + d.definition_point = to_ocs(d.definition_point); + d.base.definition_point = d.definition_point; + d.base.text_middle_point = to_ocs(d.base.text_middle_point); + d.base.normal = Vector3::UNIT_Z; + } + _ => return None, + } + Some((local, normal)) +} + fn map_wire_ocs_to_wcs(wire: &mut WireModel, normal: Vector3) { let normal_tuple = (normal.x, normal.y, normal.z); let map = |x: f64, y: f64, z: f64| { @@ -7687,4 +7751,79 @@ mod linear_transform_tests { ); assert_measures_ten(&dim); } + + /// Every stroke point the dimension draws, with its low residual added. + fn stroke_points(dim: &Dimension) -> Vec { + let document = CadDocument::new(); + let wires = dim.tessellate( + &document, + Handle::new(0x40), + false, + [1.0; 4], + 1.0, + 1.0, + &rustc_hash::FxHashSet::default(), + None, + [0.0, 0.0, 0.0, 1.0], + None, + None, + ); + let mut points = Vec::new(); + for wire in &wires { + for (index, high) in wire.points.iter().enumerate() { + if !high[0].is_finite() { + continue; + } + let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]); + points.push(DVec3::new( + high[0] as f64 + low[0] as f64, + high[1] as f64 + low[1] as f64, + high[2] as f64 + low[2] as f64, + )); + } + } + points + } + + /// Turned 90° about X into the XZ plane, the dimension has to draw the + /// same strokes turned with it. + fn assert_draws_turned_with_its_plane(flat: Dimension) { + let turn = glam::DQuat::from_axis_angle(DVec3::X, FRAC_PI_2); + let expected: Vec = stroke_points(&flat).into_iter().map(|p| turn * p).collect(); + assert!(!expected.is_empty(), "the dimension draws no strokes"); + let mut turned = flat; + apply_transform( + &mut turned, + &EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::X, + angle_rad: FRAC_PI_2, + }, + ); + let drawn = stroke_points(&turned); + assert_eq!(drawn.len(), expected.len(), "stroke point count"); + for (index, (got, want)) in drawn.iter().zip(&expected).enumerate() { + assert!( + got.abs_diff_eq(*want, 1e-3), + "point {index}: expected {want:?}, got {got:?}" + ); + } + } + + /// A linear dimension in a working plane off world Z draws in that plane. + /// Its offset used to be read in world XY only, so once it ran along Z the + /// dimension line fell onto the measured points and the extension lines + /// stuck out along world Y. + #[test] + fn a_linear_dimension_off_world_z_draws_in_its_own_plane() { + assert_draws_turned_with_its_plane(horizontal()); + } + + /// An aligned dimension draws through the same builder and plane. + #[test] + fn an_aligned_dimension_off_world_z_draws_in_its_own_plane() { + let mut d = DimensionAligned::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(6.0, 8.0, 0.0)); + d.definition_point = Vector3::new(2.0, 11.0, 0.0); + assert_draws_turned_with_its_plane(Dimension::Aligned(d)); + } } diff --git a/src/modules/draw/modify/explode.rs b/src/modules/draw/modify/explode.rs index 52606f06e..5243e8af2 100644 --- a/src/modules/draw/modify/explode.rs +++ b/src/modules/draw/modify/explode.rs @@ -927,6 +927,19 @@ fn explode_dimension(dim: &Dimension, doc: &CadDocument) -> Vec { .collect(); } } + if let Some((local, normal)) = crate::entities::dimension::linear_dimension_in_ocs(dim) { + let (x_axis, y_axis) = + crate::scene::view::transform::ocs_axes((normal.x, normal.y, normal.z)); + let plane = WorkingPlane::new( + DVec3::ZERO, + DVec3::new(x_axis.0, x_axis.1, x_axis.2), + DVec3::new(y_axis.0, y_axis.1, y_axis.2), + ); + return explode_dimension(&local, doc) + .into_iter() + .map(|entity| plane.place_entity(entity)) + .collect(); + } let base = dim.base(); let met = dim_metrics(dim, doc); @@ -1659,6 +1672,58 @@ mod tests { assert_eq!(solids, 2, "expected 2 filled (SOLID) arrowheads, got {solids}"); } + /// The line segments EXPLODE turns the dimension into. + fn exploded_segments(dim: &Dimension, doc: &CadDocument) -> Vec<(DVec3, DVec3)> { + let point = |v: Vector3| DVec3::new(v.x, v.y, v.z); + explode_dimension(dim, doc) + .into_iter() + .filter_map(|entity| match entity { + EntityType::Line(line) => Some((point(line.start), point(line.end))), + _ => None, + }) + .collect() + } + + /// EXPLODE of a linear dimension in a working plane off world Z leaves + /// its lines in that plane. Turned 90° about X, the dimension has to + /// explode into the same lines turned with it; its offset, now along Z, + /// used to be read in world XY only. + #[test] + fn exploding_a_linear_dimension_off_world_z_keeps_its_plane() { + use crate::command::EntityTransform; + let doc = CadDocument::new(); + let mut d = DimensionLinear::new(Vector3::new(0.0, 0.0, 0.0), Vector3::new(10.0, 0.0, 0.0)); + d.definition_point = Vector3::new(0.0, 5.0, 0.0); + let flat = Dimension::Linear(d); + let turn = glam::DQuat::from_axis_angle(DVec3::X, std::f64::consts::FRAC_PI_2); + let expected: Vec<(DVec3, DVec3)> = exploded_segments(&flat, &doc) + .into_iter() + .map(|(a, b)| (turn * a, turn * b)) + .collect(); + assert!(!expected.is_empty(), "the dimension explodes into no lines"); + + let mut turned = EntityType::Dimension(flat); + crate::scene::view::dispatch::apply_transform( + &mut turned, + &EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::X, + angle_rad: std::f64::consts::FRAC_PI_2, + }, + ); + let EntityType::Dimension(turned) = turned else { + panic!("dimension expected"); + }; + let lines = exploded_segments(&turned, &doc); + assert_eq!(lines.len(), expected.len(), "line count"); + for (index, (got, want)) in lines.iter().zip(&expected).enumerate() { + assert!( + got.0.abs_diff_eq(want.0, 1e-9) && got.1.abs_diff_eq(want.1, 1e-9), + "line {index}: expected {want:?}, got {got:?}" + ); + } + } + // An angular dimension must bake its swept ARC (not just two rays), else a // saved+reloaded angular dim collapses to a V. The block should carry the // two extension lines plus many arc chords. From f85b23bf74ab63a92aef4ba6cea1a70021f6a942 Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Mon, 14 Sep 2026 17:07:56 -0500 Subject: [PATCH 06/16] Enumerate installed Windows printers in the plot dialog Populate the plot-dialog printer list from the Windows spooler cache. Validate record counts and UTF-16 name bounds before reading the buffer. Refs #1261 --- Cargo.toml | 2 +- src/io/print_to_printer.rs | 138 ++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1467496a5..78b77d9bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ fluent-syntax = "0.12" naga = { version = "27", features = ["wgsl-in"] } [target.'cfg(target_os = "windows")'.dependencies] -windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation"] } +windows-sys = { version = "0.61", features = ["Win32_UI_Shell", "Win32_UI_WindowsAndMessaging", "Win32_System_Com", "Win32_System_Registry", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_Graphics_Printing"] } [target.'cfg(target_os = "linux")'.dependencies] ashpd = { version = "0.13.13", default-features = false, features = ["async-io", "wayland"] } diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 6f4d3a680..d0ca2ad7e 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -89,8 +89,7 @@ pub fn print_existing_pdf(_path: &std::path::Path, _opts: &PrintOptions) -> Resu } /// Enumerate installed printers. Linux/macOS query CUPS via `lpstat -e`; -/// Windows returns an empty list (the "printto" dispatch targets a named -/// printer directly and the system default is always available). +/// Windows queries the spooler's cached local and connected printer list. #[cfg(not(target_arch = "wasm32"))] pub fn list_printers() -> Vec { #[cfg(not(target_os = "windows"))] @@ -111,7 +110,140 @@ pub fn list_printers() -> Vec { } #[cfg(target_os = "windows")] { - Vec::new() + windows_printers().unwrap_or_else(|error| { + eprintln!("Could not enumerate printers: {error}"); + Vec::new() + }) + } +} + +#[cfg(target_os = "windows")] +fn windows_printers() -> std::io::Result> { + use windows_sys::Win32::Foundation::{GetLastError, ERROR_INSUFFICIENT_BUFFER}; + use windows_sys::Win32::Graphics::Printing::{ + EnumPrintersW, PRINTER_ENUM_CONNECTIONS, PRINTER_ENUM_LOCAL, + }; + + let mut bytes_needed = 0; + let mut printer_count = 0; + let mut buffer = Vec::::new(); + // The printer list can grow between the size query and the data query. + for _ in 0..4 { + let buffer_bytes = u32::try_from(std::mem::size_of_val(buffer.as_slice())) + .map_err(|_| std::io::Error::from(std::io::ErrorKind::OutOfMemory))?; + let data = if buffer.is_empty() { + std::ptr::null_mut() + } else { + buffer.as_mut_ptr().cast::() + }; + // SAFETY: data is null for the size query, otherwise it points to an + // aligned, writable allocation of buffer_bytes bytes. + let success = unsafe { + EnumPrintersW( + PRINTER_ENUM_LOCAL | PRINTER_ENUM_CONNECTIONS, + std::ptr::null(), + 4, + data, + buffer_bytes, + &mut bytes_needed, + &mut printer_count, + ) + }; + if success != 0 { + return windows_printer_names(&buffer, printer_count as usize); + } + let error = unsafe { GetLastError() }; + if error != ERROR_INSUFFICIENT_BUFFER || bytes_needed <= buffer_bytes { + return Err(std::io::Error::from_raw_os_error(error as i32)); + } + buffer.resize( + (bytes_needed as usize).div_ceil(std::mem::size_of::()), + 0, + ); + } + Err(std::io::Error::from_raw_os_error( + ERROR_INSUFFICIENT_BUFFER as i32, + )) +} + +#[cfg(target_os = "windows")] +fn windows_printer_names(buffer: &[usize], printer_count: usize) -> std::io::Result> { + use std::io::{Error, ErrorKind}; + use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W; + + const { + assert!(std::mem::align_of::() >= std::mem::align_of::()); + } + let buffer_bytes = std::mem::size_of_val(buffer); + if printer_count > buffer_bytes / std::mem::size_of::() { + return Err(Error::from(ErrorKind::InvalidData)); + } + // SAFETY: the initialized buffer is aligned and large enough for these + // records. Raw pointer fields are validated before reading any names. + let records = unsafe { + std::slice::from_raw_parts(buffer.as_ptr().cast::(), printer_count) + }; + let mut names = Vec::with_capacity(printer_count); + for record in records { + if record.pPrinterName.is_null() { + continue; + } + let offset = (record.pPrinterName as usize) + .checked_sub(buffer.as_ptr() as usize) + .filter(|offset| *offset < buffer_bytes && offset % 2 == 0) + .ok_or(Error::from(ErrorKind::InvalidData))?; + // SAFETY: offset is UTF-16 aligned and the slice ends within buffer. + let utf16 = unsafe { + std::slice::from_raw_parts( + buffer.as_ptr().cast::().add(offset).cast::(), + (buffer_bytes - offset) / 2, + ) + }; + let length = utf16 + .iter() + .position(|&unit| unit == 0) + .ok_or(Error::from(ErrorKind::InvalidData))?; + if length != 0 { + names.push(String::from_utf16_lossy(&utf16[..length])); + } + } + names.sort(); + names.dedup(); + Ok(names) +} + +#[cfg(all(test, target_os = "windows"))] +mod printer_buffer_tests { + use super::windows_printer_names; + use windows_sys::Win32::Graphics::Printing::PRINTER_INFO_4W; + + #[test] + fn printer_names_stay_within_the_returned_buffer() { + assert!(windows_printer_names(&[], 0).unwrap().is_empty()); + assert!(windows_printer_names(&[], 1).is_err()); + let mut buffer = vec![0usize; 16]; + let name_offset = std::mem::size_of::(); + let name: Vec = "Printer \u{03b1}\0".encode_utf16().collect(); + // SAFETY: the aligned allocation holds the record and UTF-16 name. + unsafe { + let record = buffer.as_mut_ptr().cast::(); + let name_ptr = buffer + .as_mut_ptr() + .cast::() + .add(name_offset) + .cast::(); + std::ptr::copy_nonoverlapping(name.as_ptr(), name_ptr, name.len()); + (*record).pPrinterName = name_ptr; + assert_eq!( + windows_printer_names(&buffer, 1).unwrap(), + ["Printer \u{03b1}"] + ); + (*record).pPrinterName = buffer.as_mut_ptr().cast::().wrapping_sub(1); + assert!(windows_printer_names(&buffer, 1).is_err()); + (*record).pPrinterName = name_ptr; + } + buffer[name_offset / std::mem::size_of::()..].fill(usize::MAX); + assert!(windows_printer_names(&buffer, 1).is_err()); } } From 37cfa06b0211849cdc5c35409e291dffb05ee5ed Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Mon, 14 Sep 2026 17:07:57 -0500 Subject: [PATCH 07/16] Preserve fractional viewport placement when compositing paper space Keep fractional raster placement and matching integer texture coverage through compositing. Apply parent clipping without changing the scale. Fixes #1267 --- src/scene/pipeline/gpu_tests.rs | 15 +- src/scene/pipeline/mod.rs | 34 ++- src/scene/view/render.rs | 375 +++++++++++++++++++++++++++----- 3 files changed, 341 insertions(+), 83 deletions(-) diff --git a/src/scene/pipeline/gpu_tests.rs b/src/scene/pipeline/gpu_tests.rs index c5a00cc5f..2e8cc64e2 100644 --- a/src/scene/pipeline/gpu_tests.rs +++ b/src/scene/pipeline/gpu_tests.rs @@ -547,7 +547,8 @@ fn test_selected_circle_arc_ellipse_highlight_overlay() { pipeline.render( &mut encoder, &target, - iced::Size::new(512, 512), + iced::Rectangle::with_size(iced::Size::new(512.0, 512.0)), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, [0.0, 0.0, 0.0, 1.0], false, @@ -635,7 +636,8 @@ fn test_thick_and_tapered_arc_gpu_rendering() { pipeline.render( &mut encoder, &target, - iced::Size::new(512, 512), + iced::Rectangle::with_size(iced::Size::new(512.0, 512.0)), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, [0.0, 0.0, 0.0, 1.0], false, @@ -740,7 +742,8 @@ fn test_tilted_3d_donut_and_thick_arc_gpu_rendering() { pipeline.render( &mut encoder, &target, - iced::Size::new(512, 512), + iced::Rectangle::with_size(iced::Size::new(512.0, 512.0)), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, [0.0, 0.0, 0.0, 1.0], false, @@ -855,7 +858,8 @@ fn test_selected_ellipse_overlay() { pipeline.render( &mut encoder, &target, - iced::Size::new(512, 512), + iced::Rectangle::with_size(iced::Size::new(512.0, 512.0)), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, [0.0, 0.0, 0.0, 1.0], false, @@ -926,7 +930,8 @@ fn test_pline_arc_switch_preview_and_render() { pipeline.render( &mut encoder, &target, - iced::Size::new(512, 512), + iced::Rectangle::with_size(iced::Size::new(512.0, 512.0)), + iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, iced::Rectangle { x: 0, y: 0, width: 512, height: 512 }, [0.0, 0.0, 0.0, 1.0], false, diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index c3f700f71..49172946a 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -201,6 +201,7 @@ pub struct Pipeline { /// geometry passes render at this size; the blit UV is scaled by /// `depth_texture_size / alloc_size` so it samples only the filled region. depth_texture_size: Size, + pub(in crate::scene) viewport: Option, /// Actual allocated size of the depth / MSAA / resolve textures. Rounded /// up from the requested size to a coarse grid so a divider drag (which /// changes the pane size a few pixels every frame) doesn't recreate these @@ -2321,6 +2322,7 @@ impl Pipeline { wipeout_bgl1, image_bgl1, depth_texture_size: Size::new(1, 1), + viewport: None, // (0, 0) forces the first `ensure_depth_texture` to allocate at the // real rounded size — the constructor textures above are placeholders. alloc_size: Size::new(0, 0), @@ -3967,8 +3969,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", ); } - /// Render the geometry passes at `vp_size` (the full viewport size — the - /// MSAA / resolve textures are this size) and blit the resulting resolve + /// Render geometry at its fractional pixel rectangle and blit the resolve /// to `surface_dest` on the swap-chain. The UV crop is read from the /// blit uniform buffer (written by `upload_blit_uv` during `prepare`) /// so a viewport that hangs off the canvas still composites the correct @@ -3977,19 +3978,14 @@ analytic={:.1} regular={:.1} blocks={:.1}", &self, encoder: &mut wgpu::CommandEncoder, target: &wgpu::TextureView, - vp_size: Size, + raster: Rectangle, surface_dest: Rectangle, + surface_clip: Rectangle, bg_color: [f32; 4], mesh_wireframe: bool, hidden_line: bool, show_3d_edges: bool, ) { - let vp = Rectangle:: { - x: 0, - y: 0, - width: vp_size.width, - height: vp_size.height, - }; let msaa = &self.msaa_view; let [r, g, b, a] = bg_color; let clear_color = if self.clip_boundary.is_some() || self.skip_background { @@ -4101,8 +4097,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", occlusion_query_set: None, multiview_mask: None, }); - // MSAA texture is clip-bounds-sized, so viewport starts at (0, 0). - pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); // Stamp the viewport clip boundary into the just-cleared stencil // (interior → 1) before any content draws, so every pass below can // clip to the shape with reference 1. @@ -4155,7 +4150,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", occlusion_query_set: None, multiview_mask: None, }); - pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); pass.set_pipeline(&self.image_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); @@ -4192,7 +4187,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", occlusion_query_set: None, multiview_mask: None, }); - pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); // Four draw paths share this pass: @@ -4491,7 +4486,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", occlusion_query_set: None, multiview_mask: None, }); - pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); if !fill.chunks_3d.is_empty() { @@ -4562,7 +4557,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", occlusion_query_set: None, multiview_mask: None, }); - pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); pass.set_pipeline(&self.wire_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); @@ -4605,7 +4600,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", occlusion_query_set: None, multiview_mask: None, }); - pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); pass.set_pipeline(&self.wire_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); // In a filled-with-edges mode the mesh outline edges frame the shaded @@ -4756,7 +4751,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", occlusion_query_set: None, multiview_mask: None, }); - pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); pass.set_pipeline(&self.text_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); @@ -4809,7 +4804,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", occlusion_query_set: None, multiview_mask: None, }); - pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); pass.set_pipeline(&self.wipeout_pipeline); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); @@ -4859,7 +4854,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", occlusion_query_set: None, multiview_mask: None, }); - pass.set_viewport(0.0, 0.0, vp.width as f32, vp.height as f32, 0.0, 1.0); + pass.set_viewport(raster.x, raster.y, raster.width, raster.height, 0.0, 1.0); pass.set_bind_group(0, &self.uniform_bind_group, &[]); pass.set_stencil_reference(stencil_ref); if !self.gpu_selected_wires.is_empty() { @@ -5002,6 +4997,7 @@ analytic={:.1} regular={:.1} blocks={:.1}", 1.0, ); pass.set_pipeline(&self.blit_pipeline); + pass.set_scissor_rect(surface_clip.x, surface_clip.y, surface_clip.width, surface_clip.height); pass.set_bind_group(0, &self.blit_bind_group, &[]); pass.draw(0..6, 0..1); } diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index efde7319e..6f8966578 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -395,32 +395,23 @@ impl shader::Primitive for Primitive { inner.slot_id = vp.instance_id; inner.forget_cached_keys(); } - // The MSAA / depth / resolve textures are always sized to the - // FULL viewport rectangle (not the on-canvas-visible portion) - // so the camera matrices render at consistent aspect / scale. - // The blit step picks the visible sub-rectangle out via the - // shader's UV crop uniform, which lets partially off-canvas - // viewports composite to their visible surface area without - // drift. - let clip_size = Size::new( - (vp.screen_rect.width * bounds.width * scale).ceil().max(1.0) as u32, - (vp.screen_rect.height * bounds.height * scale).ceil().max(1.0) as u32, + // Round texture coverage, retaining fractional raster placement. + let placement = physical_viewport( + Rectangle { + x: (bounds.x + vp.screen_rect.x * bounds.width) * scale, + y: (bounds.y + vp.screen_rect.y * bounds.height) * scale, + width: vp.screen_rect.width * bounds.width * scale, + height: vp.screen_rect.height * bounds.height * scale, + }, + viewport.physical_size(), ); + let clip_size = placement.size; + inner.viewport = Some(placement); inner.ensure_depth_texture(device, clip_size); let viewcube_side = (crate::scene::VIEWCUBE_RENDER_PX.ceil() * scale).ceil().max(1.0) as u32; - inner - .viewcube - .ensure_depth_texture(device, Size::new(viewcube_side, viewcube_side)); - // Compute the UV crop for this viewport. `screen_rect` is in - // normalized canvas units (0..1) but may extend negative or - // beyond 1 when the viewport hangs off the canvas. The on- - // canvas portion in viewport-local UV is straightforward to - // derive from how much sticks out on each side. - let sr = vp.screen_rect; - let (uo_x, us_x) = uv_crop_axis(sr.x, sr.width); - let (uo_y, us_y) = uv_crop_axis(sr.y, sr.height); - inner.upload_blit_uv(queue, [uo_x, uo_y], [us_x, us_y]); + inner.viewcube.ensure_depth_texture(device, Size::new(viewcube_side, viewcube_side)); + inner.upload_blit_uv(queue, placement.uv_offset, placement.uv_scale); inner.upload_background_images( device, queue, @@ -440,7 +431,7 @@ impl shader::Primitive for Primitive { // drawing size. The ViewCube is excluded from the signature and // keeps updating in its own always-on pass, so cube hover still // tracks while the scene is cached. - let sig = render_signature(vp, clip_size.width, clip_size.height); + let sig = render_signature(vp, &placement); // REDRAW bypass: pin render_sig to force a full pass this frame // even if the signature is otherwise unchanged. The request is // one-shot per viewport (consumed by the builder that produced @@ -1311,8 +1302,6 @@ retained_contributors={}", ) { let nav_render_started = iced::time::Instant::now(); pipeline.frame_rendered.store(true, std::sync::atomic::Ordering::Relaxed); - let cw = clip.width as f32; - let ch = clip.height as f32; let clip_right = clip.x + clip.width; let clip_bottom = clip.y + clip.height; for vp in &self.viewports { @@ -1322,27 +1311,25 @@ retained_contributors={}", let Some(inner) = pipeline.inners.get(*slot) else { continue; }; - // Where the viewport would land on the surface in absolute - // pixels (i32 because either edge may stick off the canvas). - let vp_full_x = clip.x as i32 + (vp.screen_rect.x * cw) as i32; - let vp_full_y = clip.y as i32 + (vp.screen_rect.y * ch) as i32; - let vp_full_w = (vp.screen_rect.width * cw).max(1.0) as i32; - let vp_full_h = (vp.screen_rect.height * ch).max(1.0) as i32; - // Intersect with the surface clip — that's the slice we blit. - let dest_x = vp_full_x.max(clip.x as i32); - let dest_y = vp_full_y.max(clip.y as i32); - let dest_right = (vp_full_x + vp_full_w).min(clip_right as i32); - let dest_bottom = (vp_full_y + vp_full_h).min(clip_bottom as i32); - if dest_right <= dest_x || dest_bottom <= dest_y { + let Some(placement) = inner.viewport else { + continue; + }; + let surface_dest = placement.surface; + let left = surface_dest.x.max(clip.x); + let top = surface_dest.y.max(clip.y); + let surface_clip = Rectangle { + x: left, + y: top, + width: (surface_dest.x + surface_dest.width).min(clip_right).saturating_sub(left), + height: (surface_dest.y + surface_dest.height).min(clip_bottom).saturating_sub(top), + }; + if surface_clip.width == 0 || surface_clip.height == 0 { continue; } - let surface_dest = Rectangle { - x: dest_x as u32, - y: dest_y as u32, - width: (dest_right - dest_x) as u32, - height: (dest_bottom - dest_y) as u32, - }; - let vp_size = Size::new(vp_full_w.max(1) as u32, vp_full_h.max(1) as u32); + let vp_full_x = surface_dest.x as i32; + let vp_full_y = surface_dest.y as i32; + let vp_full_w = surface_dest.width as i32; + let vp_full_h = surface_dest.height as i32; // `mesh_fill` is false for Wireframe 2D / Wireframe 3D — flip // the draw path so meshes use the wireframe pipeline + the // pre-built triangle-edge index buffer. @@ -1350,8 +1337,9 @@ retained_contributors={}", inner.render( encoder, target, - vp_size, + placement.raster, surface_dest, + surface_clip, self.bg_color, mesh_wireframe, vp.hidden_line, @@ -1363,6 +1351,8 @@ retained_contributors={}", // rejects negative origins, and a clamped cube would scale // distortedly. The active viewport is normally fully visible. if vp.show_viewcube + && surface_dest.width == placement.size.width + && surface_dest.height == placement.size.height && vp_full_x >= clip.x as i32 && vp_full_y >= clip.y as i32 && vp_full_x + vp_full_w <= clip_right as i32 @@ -1408,7 +1398,7 @@ retained_contributors={}", /// live preview IS included (its coordinates), so a rubber-band tracking the /// cursor still renders, and the frame where the preview clears erases it /// instead of freezing the last overlay on screen. -fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 { +fn render_signature(vp: &ViewportData, placement: &PhysicalViewport) -> u64 { use std::hash::{Hash, Hasher}; let mut h = rustc_hash::FxHasher::default(); // Camera + per-view shading flags all live in the uniforms (view_rot, eye @@ -1453,8 +1443,11 @@ fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 { // frame (skip_hatch flips false) re-renders with hatches and re-caches. vp.skip_hatch.hash(&mut h); vp.skip_background.hash(&mut h); - clip_w.hash(&mut h); - clip_h.hash(&mut h); + placement.size.width.hash(&mut h); + placement.size.height.hash(&mut h); + for value in [placement.raster.x, placement.raster.y, placement.raster.width, placement.raster.height] { + value.to_bits().hash(&mut h); + } // Live overlay (command preview / interim / grip drag). Small — a handful // of wires — so hashing its coordinates is cheap and catches the endpoint // moving with the cursor as well as the preview appearing / clearing. @@ -1535,18 +1528,282 @@ fn render_signature(vp: &ViewportData, clip_w: u32, clip_h: u32) -> u64 { h.finish() } -/// On-canvas-visible UV crop on one axis. `pos` and `size` are in the -/// shader widget's normalized 0..1 coords. Returns `(uv_offset, uv_scale)` -/// applied as `actual_uv = quad_uv * uv_scale + uv_offset` in the blit -/// shader — identity `(0.0, 1.0)` for fully on-canvas viewports. -fn uv_crop_axis(pos: f32, size: f32) -> (f32, f32) { - if size <= 0.0 { - return (0.0, 1.0); +#[derive(Clone, Copy)] +pub(in crate::scene) struct PhysicalViewport { + size: Size, + raster: Rectangle, + surface: Rectangle, + uv_offset: [f32; 2], + uv_scale: [f32; 2], +} + +fn physical_viewport(rect: Rectangle, window: Size) -> PhysicalViewport { + let x = rect.x.floor(); + let y = rect.y.floor(); + let right = (rect.x + rect.width).ceil(); + let bottom = (rect.y + rect.height).ceil(); + let width = (right - x).max(1.0); + let height = (bottom - y).max(1.0); + let left = x.clamp(0.0, window.width as f32); + let top = y.clamp(0.0, window.height as f32); + let visible_w = (right.min(window.width as f32) - left).max(0.0); + let visible_h = (bottom.min(window.height as f32) - top).max(0.0); + PhysicalViewport { + size: Size::new(width as u32, height as u32), + raster: Rectangle { + x: rect.x - x, + y: rect.y - y, + width: rect.width.max(0.0), + height: rect.height.max(0.0), + }, + surface: Rectangle { + x: left as u32, + y: top as u32, + width: visible_w as u32, + height: visible_h as u32, + }, + uv_offset: [(left - x) / width, (top - y) / height], + uv_scale: [visible_w / width, visible_h / height], + } +} + +#[cfg(test)] +mod pixel_placement_tests { + use super::*; + + #[test] + fn raster_and_blit_preserve_fractional_position_at_different_scales() { + for scale in [1.0, 1.25, 1.5, 2.0] { + for x in [-17.37, 0.25, 13.61, 199.7] { + for width in [0.4, 18.73, 181.83] { + let rect = Rectangle { + x: x * scale, + y: 12.13 * scale, + width: width * scale, + height: 91.17 * scale, + }; + let placement = physical_viewport(rect, Size::new(256, 256)); + assert!(placement.raster.x >= 0.0 && placement.raster.x < 1.0); + assert!( + placement.raster.x + placement.raster.width + <= placement.size.width as f32 + 1e-5 + ); + if placement.surface.width == 0 { + continue; + } + // Follow a model point through rasterization, texture UV + // crop, then the blit; paper geometry lands directly here. + for u in [0.0, 0.1, 0.5, 0.9, 1.0] { + let texture_x = placement.raster.x + u * placement.raster.width; + let output_x = placement.surface.x as f32 + + (texture_x / placement.size.width as f32 - placement.uv_offset[0]) + / placement.uv_scale[0] + * placement.surface.width as f32; + assert!((output_x - (rect.x + u * rect.width)).abs() < 0.0001); + } + assert!( + (placement.uv_scale[0] * placement.size.width as f32 + - placement.surface.width as f32) + .abs() + < 1e-5 + ); + } + } + } + } + + #[test] + fn integer_translation_reuses_the_same_raster_placement() { + let rect = Rectangle { + x: 13.25, + y: 27.5, + width: 100.75, + height: 51.25, + }; + let first = physical_viewport(rect, Size::new(512, 512)); + let moved = physical_viewport( + Rectangle { + x: rect.x + 20.0, + ..rect + }, + Size::new(512, 512), + ); + assert_eq!(first.raster, moved.raster); + assert_eq!(first.size, moved.size); + assert_eq!(first.uv_scale, moved.uv_scale); + assert_eq!(moved.surface.x, first.surface.x + 20); + } + + #[test] + #[ignore = "requires a GPU adapter"] + fn fractional_viewports_keep_wire_pixels_aligned_after_blitting() { + use iced::futures::executor::block_on; + use iced::wgpu; + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle()); + let adapter = block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) + .expect("GPU adapter"); + let (device, queue) = block_on(adapter.request_device(&wgpu::DeviceDescriptor { + required_limits: adapter.limits(), + ..Default::default() + })) + .expect("GPU device"); + let validation = device.push_error_scope(wgpu::ErrorFilter::Validation); + let mut pipeline = + crate::scene::pipeline::Pipeline::new(&device, &queue, wgpu::TextureFormat::Bgra8Unorm); + let target = device.create_texture(&wgpu::TextureDescriptor { + label: Some("pixel placement regression"), + size: wgpu::Extent3d { + width: 256, + height: 256, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Bgra8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = target.create_view(&Default::default()); + let readback = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pixel readback"), + size: 256 * 256 * 4, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + for (x, width, parent_clip) in [ + (13.37, 181.83, false), + (-12.61, 187.29, false), + (19.75, 173.75, false), + (13.37, 181.83, true), + (-12.61, 187.29, true), + (19.75, 173.75, true), + ] { + let rect = Rectangle { + x, + y: 18.37, + width, + height: 181.83, + }; + let placement = physical_viewport(rect, Size::new(256, 256)); + pipeline.ensure_depth_texture(&device, placement.size); + pipeline.upload_blit_uv(&queue, placement.uv_offset, placement.uv_scale); + let mut uniforms = Uniforms::new( + &Camera::default(), + Rectangle::with_size(Size::new(width, rect.height)), + false, + ); + uniforms.eye_high = [0.0; 3]; + uniforms.eye_low = [0.0; 3]; + uniforms.view_rot = Mat4::IDENTITY; + pipeline.upload_uniforms(&device, &queue, &uniforms); + let u = 0.73; + let ndc_x = 2.0 * u - 1.0; + let wire = WireModel::solid( + "alignment".into(), + vec![[ndc_x, -0.8, 0.5], [ndc_x, 0.8, 0.5]], + [1.0; 4], + false, + ); + pipeline.upload_preview_wires(&device, &queue, &[wire], &Default::default()); + let mut encoder = device.create_command_encoder(&Default::default()); + { + let _clear = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + ..Default::default() + }); + } + let clip = if parent_clip { + Rectangle { + x: placement.surface.x + 11, + y: 64, + width: placement.surface.width - 22, + height: 96, + } + } else { + placement.surface + }; + pipeline.render( + &mut encoder, + &view, + placement.raster, + placement.surface, + clip, + [0.0, 0.0, 0.0, 1.0], + false, + false, + false, + ); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(256 * 4), + rows_per_image: Some(256), + }, + }, + wgpu::Extent3d { + width: 256, + height: 256, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + let (tx, rx) = std::sync::mpsc::channel(); + readback + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + tx.send(result).unwrap(); + }); + device.poll(wgpu::PollType::wait_indefinitely()).unwrap(); + rx.recv().unwrap().unwrap(); + { + let bytes = readback.slice(..).get_mapped_range(); + let row = (rect.y + rect.height * 0.5) as usize; + let expected = rect.x + u * rect.width; + let row_has_wire = bytes[48 * 256 * 4..49 * 256 * 4] + .chunks_exact(4) + .any(|pixel| pixel[..3] != [0, 0, 0]); + assert_eq!( + row_has_wire, !parent_clip, + "parent clip must exclude the wire at row 48" + ); + let mut weight = 0.0; + let mut center = 0.0; + for column in (expected as usize - 4)..=(expected as usize + 4) { + let value = bytes[(row * 256 + column) * 4] as f32; + weight += value; + center += (column as f32 + 0.5) * value; + } + assert!(weight > 0.0, "wire must produce pixels"); + assert!( + (center / weight - expected).abs() < 0.25, + "x={x}, width={width}, expected={expected}, actual={}", + center / weight + ); + } + readback.unmap(); + } + assert!( + block_on(validation.pop()).is_none(), + "GPU validation failed" + ); } - let left_off = (-pos).max(0.0); - let right_off = (pos + size - 1.0).max(0.0); - let visible = (size - left_off - right_off).max(0.0); - (left_off / size, visible / size) } /// Apply a clip-space crop to `view_proj` so the sub-rect of the original From c93cc175928017159b8e58b04b0911f69ed9880a Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Mon, 14 Sep 2026 17:07:57 -0500 Subject: [PATCH 08/16] Include bitmap images in PDF plots, preview, and printing Include decoded IMAGE/OLE content in the shared PDF plotting path, with clipping, transforms, transparency, draw order, and resource reuse. Use named page content for export and printing. Fixes #1266 --- Cargo.lock | 1 + Cargo.toml | 2 +- src/app/update/file.rs | 451 +- src/io/pdf_export.rs | 455 +- src/io/print_to_printer.rs | 54 +- src/scene/mod.rs | 51 +- src/scene/project.rs | 59 +- tests/fixtures/embedded-image-print.dxf | 7522 +++++++++++++++++++++++ tests/fixtures/embedded-image-print.md | 8 + tests/pdf_export_images_check.rs | 512 ++ tests/pdf_export_text_check.rs | 61 +- 11 files changed, 8598 insertions(+), 578 deletions(-) create mode 100644 tests/fixtures/embedded-image-print.dxf create mode 100644 tests/fixtures/embedded-image-print.md create mode 100644 tests/pdf_export_images_check.rs diff --git a/Cargo.lock b/Cargo.lock index 49635a72c..c5c129bcc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4601,6 +4601,7 @@ dependencies = [ "base64", "flate2", "getrandom 0.3.4", + "image", "lopdf", "serde", "serde_derive", diff --git a/Cargo.toml b/Cargo.toml index 1467496a5..f7d03a426 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -85,7 +85,7 @@ meshopt = "0.6.2" i18n-embed = { version = "0.16", features = ["desktop-requester"] } rayon = "1" open = "5" -printpdf = { version = "0.9.1", default-features = false } +printpdf = { version = "0.9.1", default-features = false, features = ["images"] } ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "win-system-proxy"] } [target.'cfg(target_arch = "wasm32")'.dependencies] diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1bad3d3ce..c6e699748 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -92,33 +92,7 @@ fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool { } } -type LayoutPlotParams = ( - std::sync::Arc>, - Vec, - Vec, - crate::io::pdf_export::PlotGroupSplits, - f64, - f64, - f64, - f64, - i32, - f32, - Option<(f32, f32, f32, f32)>, -); - -type ClippedPlotParams = ( - Vec, - Vec, - Vec, - crate::io::pdf_export::PlotGroupSplits, - f64, - f64, - f64, - f64, - i32, - f32, - Option<(f32, f32, f32, f32)>, -); +use crate::io::pdf_export::{PdfPageInput, PlotContent}; fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f64) { use crate::io::paper_sizes::{sheet_mm, Orientation, PaperSize}; @@ -149,11 +123,8 @@ fn plot_dialog_sheet_mm(d: &crate::ui::window::plot::PlotDialogState) -> (f64, f } } -fn plot_content_extents( - wires: &[crate::io::pdf_export::PlotWire], - hatches: &[crate::scene::model::hatch_model::HatchModel], - wipeouts: &[crate::scene::model::hatch_model::HatchModel], -) -> Option<(f64, f64, f64, f64)> { +fn plot_content_extents(content: &PlotContent) -> Option<(f64, f64, f64, f64)> { + let PlotContent { wires, hatches, wipeouts, images, .. } = content; let mut bounds = ( f64::INFINITY, f64::INFINITY, @@ -168,7 +139,7 @@ fn plot_content_extents( bounds.3 = bounds.3.max(y); } }; - for wire in wires { + for wire in wires.iter().filter(|wire| wire.name != "paper_printable_area") { let [x0, y0, x1, y1] = wire.aabb; include(x0 as f64, y0 as f64); include(x1 as f64, y1 as f64); @@ -178,6 +149,29 @@ fn plot_content_extents( include(hatch.world_origin[0] + x as f64, hatch.world_origin[1] + y as f64); } } + for plot in images { + let mut image_bounds = [f64::INFINITY, f64::INFINITY, + f64::NEG_INFINITY, f64::NEG_INFINITY]; + for vertex in &plot.image.verts { + for axis in 0..2 { + let value = vertex.pos[axis] as f64 + vertex.pos_low[axis] as f64; + image_bounds[axis] = image_bounds[axis].min(value); + image_bounds[axis + 2] = image_bounds[axis + 2].max(value); + } + } + for clip in &plot.clips { + for axis in 0..2 { + let min = clip.iter().map(|p| p[axis]).fold(f64::INFINITY, f64::min); + let max = clip.iter().map(|p| p[axis]).fold(f64::NEG_INFINITY, f64::max); + image_bounds[axis] = image_bounds[axis].max(min); + image_bounds[axis + 2] = image_bounds[axis + 2].min(max); + } + } + if image_bounds[0] < image_bounds[2] && image_bounds[1] < image_bounds[3] { + include(image_bounds[0], image_bounds[1]); + include(image_bounds[2], image_bounds[3]); + } + } (bounds.0.is_finite() && bounds.1.is_finite() && bounds.2.is_finite() @@ -191,12 +185,7 @@ fn plot_scene_content( scene: &crate::scene::Scene, paper_space_last: bool, render_mode_override: Option, -) -> ( - std::sync::Arc>, - Vec, - Vec, - crate::io::pdf_export::PlotGroupSplits, -) { +) -> PlotContent { let (mut paper_wires, mut model_wires) = scene.plot_wire_groups(render_mode_override); let plot_viewport_borders = scene .effective_plot_settings() @@ -230,20 +219,23 @@ fn plot_scene_content( let model_wires = with_depth(model_wires); let paper_hatches = scene.paper_plot_hatches().as_ref().clone(); let paper_wipeouts = scene.paper_plot_wipeouts().as_ref().clone(); + let paper_images = scene.paper_plot_images(); if scene.current_layout == "Model" { let splits = crate::io::pdf_export::PlotGroupSplits { wires: paper_wires.len(), hatches: paper_hatches.len(), wipeouts: paper_wipeouts.len(), + images: paper_images.len(), + }; + return PlotContent { + wires: std::sync::Arc::new(paper_wires), + hatches: paper_hatches, + wipeouts: paper_wipeouts, + images: paper_images, + group_splits: splits, }; - return ( - std::sync::Arc::new(paper_wires), - paper_hatches, - paper_wipeouts, - splits, - ); } - let (mut model_pattern_wires, model_hatches, model_wipeouts) = + let (mut model_pattern_wires, model_hatches, model_wipeouts, model_images) = scene.viewport_plot_fills(); model_pattern_wires.retain(|(wire, _)| wire.plot_visible); let model_pattern_wires = model_pattern_wires @@ -251,36 +243,42 @@ fn plot_scene_content( .map(|(wire, draw_depth)| crate::io::pdf_export::PlotWire { wire, draw_depth }) .collect::>(); - let (wires, hatches, wipeouts, splits) = if paper_space_last { + let (wires, hatches, wipeouts, images, splits) = if paper_space_last { let splits = crate::io::pdf_export::PlotGroupSplits { wires: model_wires.len() + model_pattern_wires.len(), hatches: model_hatches.len(), wipeouts: model_wipeouts.len(), + images: model_images.len(), }; let mut wires = model_wires; let mut hatches = model_hatches; let mut wipeouts = model_wipeouts; + let mut images = model_images; wires.extend(model_pattern_wires); wires.extend(paper_wires); hatches.extend(paper_hatches); wipeouts.extend(paper_wipeouts); - (wires, hatches, wipeouts, splits) + images.extend(paper_images); + (wires, hatches, wipeouts, images, splits) } else { let splits = crate::io::pdf_export::PlotGroupSplits { wires: paper_wires.len(), hatches: paper_hatches.len(), wipeouts: paper_wipeouts.len(), + images: paper_images.len(), }; let mut wires = paper_wires; let mut hatches = paper_hatches; let mut wipeouts = paper_wipeouts; + let mut images = paper_images; wires.extend(model_wires); wires.extend(model_pattern_wires); hatches.extend(model_hatches); wipeouts.extend(model_wipeouts); - (wires, hatches, wipeouts, splits) + images.extend(model_images); + (wires, hatches, wipeouts, images, splits) }; - (std::sync::Arc::new(wires), hatches, wipeouts, splits) + PlotContent { wires: std::sync::Arc::new(wires), hatches, wipeouts, images, group_splits: splits } } impl OpenCADStudio { @@ -2986,32 +2984,16 @@ pub(super) fn on_open_file(&mut self) -> Task { self.load_plotsettings_into_dialog(&settings); } } - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - self.direct_plot_params() + let Some(page) = + self.direct_plot_page() else { self.command_line .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); return Task::none(); }; - let plot_style = self.dialog_plot_style(&self.plot_dialog); - let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); let work = move || { - crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &worker_path, - plot_style.as_ref(), - render_options, - ) + crate::io::pdf_export::export_pdf(&page, &worker_path) .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) }; @@ -3029,33 +3011,17 @@ pub(super) fn on_open_file(&mut self) -> Task { "Extents" => self.extents_plot_job(), _ => self.window_plot_job(), }; - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = + let Some(page) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); return Task::none(); }; - let plot_style = self.dialog_plot_style(&self.plot_dialog); - let render_options = Self::pdf_plot_options(&self.plot_dialog, group_splits); let worker_path = path.clone(); self.close_active_modal(); let work = move || { - crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &worker_path, - plot_style.as_ref(), - render_options, - ) + crate::io::pdf_export::export_pdf(&page, &worker_path) .map(|_| {crate::tf!( "Plotted window to {}", worker_path @@ -3192,8 +3158,7 @@ pub(super) fn on_open_file(&mut self) -> Task { dialog.style_name ).into_owned()); } - let plot_style = self.dialog_plot_style(&dialog); - let params = match dialog.area.as_str() { + let area_page = match dialog.area.as_str() { "Display" => self.display_plot_job(), "Extents" => self.extents_plot_job(), "Limits" => self.limits_plot_job(), @@ -3203,62 +3168,12 @@ pub(super) fn on_open_file(&mut self) -> Task { } _ => None, }; - let ( - wires, - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) = if dialog.area == "Layout" { - self.layout_plot_params_for("Layout") + let page = if dialog.area == "Layout" { + self.layout_plot_page_for("Layout") } else { - let ( - wires, - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) = params.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())?; - ( - std::sync::Arc::new(wires), - hatches, - wipeouts, - group_splits, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - ) + area_page.ok_or_else(|| crate::tf!("Layout '{name}' plot area is empty.").into_owned())? }; - pages.push(crate::io::pdf_export::PdfPageInput { - wires, - hatches, - wipeouts, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - options: Self::pdf_plot_options(&dialog, group_splits), - plot_style, - }); + pages.push(page); } Ok(pages) })(); @@ -3348,7 +3263,7 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::none(); } }; - let options = self.plot_print_options(&dialog, Default::default()); + let options = self.plot_print_options(&dialog); let temp_path = crate::io::print_to_printer::temp_pdf_path("print_all"); self.save_config(); self.close_active_modal(); @@ -3402,42 +3317,31 @@ pub(super) fn on_open_file(&mut self) -> Task { } } - /// Build the render inputs and page geometry for a full-layout plot: wires - /// plus hatch / wipeout fills, the effective (rotation-swapped) sheet size, - /// draw-origin offset, rotation, unit scale, and optional clip. Shared by - /// PDF export, preview, and printer output so all three render identically. - pub(super) fn layout_plot_params(&self) -> LayoutPlotParams { - self.layout_plot_params_for(&self.plot_dialog.area) + /// Assemble a layout page for PDF export, preview, or printing. + pub(super) fn layout_plot_page(&self) -> PdfPageInput { + self.layout_plot_page_for(&self.plot_dialog.area) } - fn layout_plot_params_for(&self, plot_area: &str) -> LayoutPlotParams { + fn layout_plot_page_for(&self, plot_area: &str) -> PdfPageInput { let i = self.active_tab; let scene = &self.tabs[i].scene; let paper_space = scene.current_layout != "Model"; let selected_sheet = plot_dialog_sheet_mm(&self.plot_dialog); - let (source_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content( + let mut content = plot_scene_content( scene, self.plot_dialog.paperspace_last, plot_render_mode_override(&self.plot_dialog), ); // The printable-area rectangle is an on-screen guide, not drawing // content. It used to leak into every paper-space PDF/preview/print. - let wires = if paper_space { - group_splits.wires = source_wires[..group_splits.wires.min(source_wires.len())] + if paper_space { + content.group_splits.wires = content.wires[..content.group_splits.wires.min(content.wires.len())] .iter() .filter(|wire| wire.name != "paper_printable_area") .count(); - std::sync::Arc::new( - source_wires - .iter() - .filter(|wire| wire.name != "paper_printable_area") - .cloned() - .collect(), - ) - } else { - source_wires - }; + std::sync::Arc::make_mut(&mut content.wires) + .retain(|wire| wire.name != "paper_printable_area"); + } if let Some(((x0, y0), (x1, y1))) = scene.paper_limits() { // Paper geometry is stored in layout units; PDF pages use mm. // Scale both geometry and offsets by the same unit conversion. @@ -3470,7 +3374,7 @@ pub(super) fn on_open_file(&mut self) -> Task { let (scale, offset_x, offset_y) = if plot_extents { let (min_x, min_y, max_x, max_y) = - plot_content_extents(&wires, &hatches, &wipeouts) + plot_content_extents(&content) .unwrap_or((x0, y0, x1, y1)); let content_w = (max_x - min_x).max(1e-9); let content_h = (max_y - min_y).max(1e-9); @@ -3556,19 +3460,18 @@ pub(super) fn on_open_file(&mut self) -> Task { } else { None }; - return ( - wires, - hatches, - wipeouts, - group_splits, - page_w, - page_h, + return PdfPageInput { + content, + paper_w: page_w, + paper_h: page_h, offset_x, offset_y, - rotation, - scale as f32, + rotation_deg: rotation, + scale: scale as f32, clip, - ); + options: Self::pdf_plot_options(&self.plot_dialog), + plot_style: self.dialog_plot_style(&self.plot_dialog), + }; } // Model space keeps its established extents + 5% margin behaviour. @@ -3588,65 +3491,50 @@ pub(super) fn on_open_file(&mut self) -> Task { } else { (297.0, 210.0, 0.0, 0.0) }; - ( - wires, - hatches, - wipeouts, - group_splits, - page_w, - page_h, + PdfPageInput { + content, + paper_w: page_w, + paper_h: page_h, offset_x, offset_y, - 0, - 1.0, - None, - ) + rotation_deg: 0, + scale: 1.0, + clip: None, + options: Self::pdf_plot_options(&self.plot_dialog), + plot_style: self.dialog_plot_style(&self.plot_dialog), + } } /// Build direct PDF/printer output using the physical layout sheet in /// paper space and the selected ISO sheet/scale around Model extents. - fn direct_plot_params(&self) -> Option { + fn direct_plot_page(&self) -> Option { if self.tabs[self.active_tab].scene.current_layout != "Model" { - return Some(self.layout_plot_params_for("Layout")); + return Some(self.layout_plot_page_for("Layout")); } - let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = - self.extents_plot_job()?; - if wires.is_empty() && hatches.is_empty() && wipeouts.is_empty() { + let page = self.extents_plot_job()?; + let content = &page.content; + if content.wires.is_empty() && content.hatches.is_empty() + && content.wipeouts.is_empty() && content.images.is_empty() + { return None; } - Some(( - std::sync::Arc::new(wires), - hatches, - wipeouts, - group_splits, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - )) + Some(page) } pub(super) fn on_print_to_printer(&mut self) -> Task { - let Some((wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip)) = - self.direct_plot_params() + let Some(page) = + self.direct_plot_page() else { self.command_line .push_error(crate::t!("Nothing to plot: model space contains no printable geometry.").as_ref()); return Task::none(); }; - let plot_style = self.dialog_plot_style(&self.plot_dialog); - let options = self.plot_print_options(&self.plot_dialog, group_splits); + let options = self.plot_print_options(&self.plot_dialog); self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); background_task( move || { iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires_with( - wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, - plot_style, options, - )) + crate::io::print_to_printer::print_wires_with(page, options)) }, Message::PrintResult, ) @@ -4537,7 +4425,6 @@ pub(super) fn on_open_file(&mut self) -> Task { self.active_modal = None; self.reset_modal_geometry(); - let plot_style = self.dialog_plot_style(&d); // Extents, Window and Display use one plot path in both spaces. Only // Paper-space Layout is special: it uses the physical sheet bounds. if d.area != "Layout" { @@ -4551,19 +4438,7 @@ pub(super) fn on_open_file(&mut self) -> Task { } _ => None, }; - let Some(( - w_wires, - w_hatches, - w_wipeouts, - wgroup_splits, - sw, - sh, - wox, - woy, - wrotation, - wscale, - wclip, - )) = job + let Some(page) = job else { self.command_line .push_error(crate::t!("Plot area is empty. Pick a larger window.").as_ref()); @@ -4574,13 +4449,8 @@ pub(super) fn on_open_file(&mut self) -> Task { // whatever the output target is. if preview { let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); - let render_options = Self::pdf_plot_options(&d, wgroup_splits); let work = move || { - crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) + crate::io::pdf_export::export_pdf(&page, &tmp).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); @@ -4590,42 +4460,21 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::done(Message::PlotWindowExport); } let tmp = crate::io::print_to_printer::temp_pdf_path("print"); - let render_options = Self::pdf_plot_options(&d, wgroup_splits); - let opts = self.plot_print_options(&d, wgroup_splits); + let opts = self.plot_print_options(&d); let work = move || { - crate::io::pdf_export::export_pdf( - &w_wires, &w_hatches, &w_wipeouts, sw, sh, wox, woy, wrotation, wscale, wclip, &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) + crate::io::pdf_export::export_pdf(&page, &tmp).and_then(|_| crate::io::print_to_printer::print_existing_pdf(&tmp, &opts)) .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) .map_err(|e| crate::tf!("Print failed: {e}").into_owned()) }; return self.run_plot_work(true, false, work); } - let (wires, hatches, wipeouts, group_splits, page_w, page_h, ox, oy, rotation, scale, clip) = - self.layout_plot_params(); + let page = self.layout_plot_page(); if preview { let tmp = crate::io::print_to_printer::temp_pdf_path("preview"); - let render_options = Self::pdf_plot_options(&d, group_splits); let work = move || { - crate::io::pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - page_w, - page_h, - ox, - oy, - rotation, - scale, - clip, - &tmp, - plot_style.as_ref(), - render_options, - ).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) + crate::io::pdf_export::export_pdf(&page, &tmp).and_then(|_| crate::io::print_to_printer::open_in_viewer(&tmp)) .map(|_| crate::t!("Opened plot preview.").into_owned()).map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) }; return self.run_plot_work(d.background, true, work); @@ -4636,14 +4485,11 @@ pub(super) fn on_open_file(&mut self) -> Task { return Task::done(Message::PlotExport); } - let opts = self.plot_print_options(&d, group_splits); + let opts = self.plot_print_options(&d); self.command_line.push_info(crate::t!("Sending to system printer…").as_ref()); let work = move || { iced::futures::executor::block_on( - crate::io::print_to_printer::print_wires_with( - wires, hatches, wipeouts, page_w, page_h, ox, oy, rotation, scale, clip, - plot_style, opts, - )) + crate::io::print_to_printer::print_wires_with(page, opts)) .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) }; @@ -4655,19 +4501,16 @@ pub(super) fn on_open_file(&mut self) -> Task { fn plot_print_options( &self, d: &crate::ui::window::plot::PlotDialogState, - group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> crate::io::print_to_printer::PrintOptions { crate::io::print_to_printer::PrintOptions { printer: d.printer.clone(), copies: d.copies.trim().parse::().unwrap_or(1).max(1), quality: Some(d.quality.clone()), - render: Self::pdf_plot_options(d, group_splits), } } fn pdf_plot_options( d: &crate::ui::window::plot::PlotDialogState, - group_splits: crate::io::pdf_export::PlotGroupSplits, ) -> crate::io::pdf_export::PdfPlotOptions { crate::io::pdf_export::PdfPlotOptions { object_lineweights: d.lineweights, @@ -4675,7 +4518,6 @@ pub(super) fn on_open_file(&mut self) -> Task { transparency: d.transparency, stamp: d.stamp, merge_lines: d.merge_lines, - group_splits, } } @@ -4692,22 +4534,22 @@ pub(super) fn on_open_file(&mut self) -> Task { .cloned() } - fn window_plot_job(&self) -> Option { + fn window_plot_job(&self) -> Option { self.area_plot_job(self.plot_window?) } - fn display_plot_job(&self) -> Option { + fn display_plot_job(&self) -> Option { self.area_plot_job(self.display_plot_window()?) } - fn limits_plot_job(&self) -> Option { + fn limits_plot_job(&self) -> Option { let (min, max) = self.tabs[self.active_tab] .scene .current_drawing_limits()?; self.area_plot_job((min.x, min.y, max.x, max.y)) } - fn named_view_plot_job(&self, name: &str) -> Option { + fn named_view_plot_job(&self, name: &str) -> Option { let view = self.tabs[self.active_tab] .scene .document @@ -4728,7 +4570,7 @@ pub(super) fn on_open_file(&mut self) -> Task { )) } - fn extents_plot_job(&self) -> Option { + fn extents_plot_job(&self) -> Option { let scene = &self.tabs[self.active_tab].scene; if scene.current_layout == "Model" { let (min, max) = scene.model_space_extents()?; @@ -4739,21 +4581,12 @@ pub(super) fn on_open_file(&mut self) -> Task { max.y as f64, )); } - let (wires, hatches, wipeouts, _) = - plot_scene_content( - scene, - self.plot_dialog.paperspace_last, - plot_render_mode_override(&self.plot_dialog), - ); - let extents = plot_content_extents( - &wires - .iter() - .filter(|wire| wire.name != "paper_printable_area") - .cloned() - .collect::>(), - &hatches, - &wipeouts, - )?; + let content = plot_scene_content( + scene, + self.plot_dialog.paperspace_last, + plot_render_mode_override(&self.plot_dialog), + ); + let extents = plot_content_extents(&content)?; self.area_plot_job(extents) } @@ -4815,7 +4648,7 @@ pub(super) fn on_open_file(&mut self) -> Task { /// Render a selected rectangle through one shared Model/Paper path. The /// window may lie partly or wholly outside a paper sheet. - fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { + fn area_plot_job(&self, window: (f64, f64, f64, f64)) -> Option { use crate::io::paper_sizes::{window_to_sheet, PlotScale}; let i = self.active_tab; let (x0, y0, x1, y1) = window; @@ -4854,24 +4687,19 @@ pub(super) fn on_open_file(&mut self) -> Task { }; let scene = &self.tabs[i].scene; let (wx0, wy0, wx1, wy1) = (x0 as f32, y0 as f32, x1 as f32, y1 as f32); - let (all_wires, hatches, wipeouts, mut group_splits) = - plot_scene_content( + let mut content = plot_scene_content( scene, self.plot_dialog.paperspace_last, plot_render_mode_override(&self.plot_dialog), ); - let first_wire_count = all_wires[..group_splits.wires.min(all_wires.len())] - .iter() - .filter(|w| w.name != "paper_printable_area") - .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) - .count(); - let wires: Vec<_> = all_wires - .iter() - .filter(|w| w.name != "paper_printable_area") - .filter(|w| w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0) - .cloned() - .collect(); - group_splits.wires = first_wire_count; + let in_window = |wire: &crate::io::pdf_export::PlotWire| { + wire.name != "paper_printable_area" + && wire.aabb[0] <= wx1 && wire.aabb[2] >= wx0 + && wire.aabb[1] <= wy1 && wire.aabb[3] >= wy0 + }; + content.group_splits.wires = content.wires[..content.group_splits.wires.min(content.wires.len())] + .iter().filter(|wire| in_window(wire)).count(); + std::sync::Arc::make_mut(&mut content.wires).retain(in_window); let offset_x = (target_x / scale) - x0; let offset_y = (target_y / scale) - y0; let clip = Some(( @@ -4885,19 +4713,18 @@ pub(super) fn on_open_file(&mut self) -> Task { 90 | 270 => (sheet_h, sheet_w), _ => (sheet_w, sheet_h), }; - Some(( - wires, - hatches, - wipeouts, - group_splits, - page_w, - page_h, + Some(PdfPageInput { + content, + paper_w: page_w, + paper_h: page_h, offset_x, offset_y, - rotation, - scale as f32, + rotation_deg: rotation, + scale: scale as f32, clip, - )) + options: Self::pdf_plot_options(&self.plot_dialog), + plot_style: self.dialog_plot_style(&self.plot_dialog), + }) } pub(super) fn on_plot_style_panel_apply(&mut self) -> Task { diff --git a/src/io/pdf_export.rs b/src/io/pdf_export.rs index 5dc8161e1..840555a8e 100644 --- a/src/io/pdf_export.rs +++ b/src/io/pdf_export.rs @@ -13,6 +13,7 @@ use crate::scene::model::hatch_model::HatchModel; #[cfg(not(target_arch = "wasm32"))] use crate::scene::model::hatch_model::HatchPattern; use crate::scene::WireModel; +use crate::scene::model::image_model::ImageModel; #[cfg(not(target_arch = "wasm32"))] use printpdf::{ BlendMode, BuiltinFont, Color, ExtendedGraphicsState, ExtendedGraphicsStateId, Line, @@ -37,25 +38,18 @@ impl std::ops::Deref for PlotWire { } } +/// Decoded image geometry plus inherited block/viewport clip boundaries. +#[derive(Clone, Debug)] +pub struct PlotImage { + pub image: ImageModel, + pub clips: Vec>, +} + // The web build has no `printpdf` (it pulls a wasm-incompatible `memchr` via // lopdf → nom_locate) and no filesystem, so PDF export is native-only; the web // build gets these stubs so the call sites still compile. #[cfg(target_arch = "wasm32")] -pub fn export_pdf( - _wires: &[PlotWire], - _hatches: &[HatchModel], - _wipeouts: &[HatchModel], - _paper_w: f64, - _paper_h: f64, - _offset_x: f64, - _offset_y: f64, - _rotation_deg: i32, - _scale: f32, - _clip: Option<(f32, f32, f32, f32)>, - _path: &Path, - _plot_style: Option<&PlotStyleTable>, - _options: PdfPlotOptions, -) -> Result<(), String> { +pub fn export_pdf(_page: &PdfPageInput, _path: &Path) -> Result<(), String> { Err("PDF export is not available in the web version.".into()) } @@ -94,7 +88,6 @@ pub struct PdfPlotOptions { pub transparency: bool, pub stamp: bool, pub merge_lines: bool, - pub group_splits: PlotGroupSplits, } /// End indexes of the first paper/model render group in each flat input list. @@ -103,16 +96,26 @@ pub struct PlotGroupSplits { pub wires: usize, pub hatches: usize, pub wipeouts: usize, + pub images: usize, } -/// Owned render data for one page in a multi-page PDF. -#[cfg_attr(target_arch = "wasm32", allow(dead_code))] -pub struct PdfPageInput { +#[derive(Default)] +pub struct PlotContent { pub wires: std::sync::Arc>, pub hatches: Vec, pub wipeouts: Vec, + pub images: Vec, + pub group_splits: PlotGroupSplits, +} + +/// Owned geometry and settings for one PDF page. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub struct PdfPageInput { + pub content: PlotContent, + /// Page dimensions in mm, after any 90/270-degree rotation. pub paper_w: f64, pub paper_h: f64, + /// Absolute-world offsets stay f64 to preserve local detail at UTM coordinates. pub offset_x: f64, pub offset_y: f64, pub rotation_deg: i32, @@ -130,50 +133,16 @@ impl Default for PdfPlotOptions { transparency: false, stamp: false, merge_lines: false, - group_splits: PlotGroupSplits::default(), } } } // ── Public entry point ──────────────────────────────────────────────────── -/// Export `wires` to a PDF file. -/// -/// - `paper_w` / `paper_h`: page dimensions in mm (already swapped for 90°/270° by caller). -/// - `offset_x` / `offset_y`: added to every wire coordinate so the drawing -/// origin maps to the bottom-left corner of the page. -/// - `rotation_deg`: 0 | 90 | 180 | 270 — rotates the entire drawing on the page. +/// Export one page to a PDF file. #[cfg(not(target_arch = "wasm32"))] -pub fn export_pdf( - wires: &[PlotWire], - hatches: &[HatchModel], - wipeouts: &[HatchModel], - paper_w: f64, - paper_h: f64, - offset_x: f64, - offset_y: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - path: &Path, - plot_style: Option<&PlotStyleTable>, - options: PdfPlotOptions, -) -> Result<(), String> { - let bytes = build_pdf( - wires, - hatches, - wipeouts, - paper_w as f32, - paper_h as f32, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - plot_style, - options, - ); - write_pdf_atomically(path, &bytes) +pub fn export_pdf(page: &PdfPageInput, path: &Path) -> Result<(), String> { + export_pdf_pages(std::slice::from_ref(page), path, None) } /// Export several independently sized pages into one PDF file. @@ -186,7 +155,7 @@ pub fn export_pdf_pages( if pages.is_empty() { return Err("No pages were selected.".into()); } - let bytes = build_pdf_pages(pages, plot_style); + let bytes = build_pdf_pages(pages, plot_style)?; write_pdf_atomically(path, &bytes) } @@ -205,6 +174,202 @@ fn write_pdf_atomically(path: &Path, bytes: &[u8]) -> Result<(), String> { Ok(()) } +#[cfg(not(target_arch = "wasm32"))] +fn image_clip(ops: &mut Vec, rings: Vec>) { + ops.push(Op::DrawPolygon { + polygon: Polygon { + rings: rings + .into_iter() + .map(|ring| PolygonRing { + points: ring + .into_iter() + .map(|[x, y]| LinePoint { + p: Point { x: Pt(x), y: Pt(y) }, + bezier: false, + }) + .collect(), + }) + .collect(), + mode: PaintMode::Clip, + winding_order: WindingOrder::EvenOdd, + }, + }); +} + +#[cfg(not(target_arch = "wasm32"))] +fn emit_image( + doc: &mut PdfDocument, + resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, + ops: &mut Vec, + plot: &PlotImage, + ox: f64, + oy: f64, + options: PdfPlotOptions, +) -> Result<(), String> { + let image = &plot.image; + let expected_byte_count = (image.width as usize) + .checked_mul(image.height as usize) + .and_then(|n| n.checked_mul(4)); + if image.width == 0 || image.height == 0 || expected_byte_count != Some(image.pixels.len()) { + return Err("Cannot plot bitmap: dimensions do not match its RGBA pixels.".into()); + } + if image.verts.len() < 3 || image.verts.len() % 3 != 0 { + return Err("Cannot plot bitmap: incomplete triangle geometry.".into()); + } + if options.transparency && image.opacity <= 0.0 { + return Ok(()); + } + let to_page = |high: [f32; 3], low: [f32; 3]| { + [ + ((high[0] as f64 + low[0] as f64 + ox) * MM_TO_PT as f64) as f32, + ((high[1] as f64 + low[1] as f64 + oy) * MM_TO_PT as f64) as f32, + ] + }; + let corners: [[f32; 2]; 4] = + std::array::from_fn(|i| to_page(image.corners[i], image.corners_low[i])); + if !corners.iter().flatten().all(|v| v.is_finite()) + || !image.verts.iter().all(|v| { + to_page(v.pos, v.pos_low) + .iter() + .chain(&v.uv) + .all(|c| c.is_finite()) + }) + || !plot + .clips + .iter() + .all(|ring| ring.len() >= 3 && ring.iter().flatten().all(|v| v.is_finite())) + || !image.opacity.is_finite() + { + return Err("Cannot plot bitmap: invalid coordinates, clip boundary, or opacity.".into()); + } + let key = ( + std::sync::Arc::as_ptr(&image.pixels) as usize, + image.width, + image.height, + ); + let id = resources + .entry(key) + .or_insert_with(|| { + let id = printpdf::XObjectId::new(); + // add_image clones its argument; the public map accepts owned pixels. + doc.resources.xobjects.map.insert( + id.clone(), + printpdf::XObject::Image(printpdf::RawImage { + pixels: printpdf::RawImageData::U8(image.pixels.as_ref().clone()), + width: image.width as usize, + height: image.height as usize, + data_format: printpdf::RawImageFormat::RGBA8, + tag: Vec::new(), + }), + ); + id + }) + .clone(); + ops.push(Op::SaveGraphicsState); + if options.transparency && image.opacity < 1.0 { + // printpdf 0.9.1 swaps the serialized CA/ca keys. Set both inside + // this image's saved state so the nonstroking alpha is correct. + let alpha = image.opacity.clamp(0.0, 1.0); + let gs = doc.add_graphics_state( + ExtendedGraphicsState::default() + .with_current_fill_alpha(alpha) + .with_current_stroke_alpha(alpha), + ); + ops.push(Op::LoadGraphicsState { gs }); + } + for clip in &plot.clips { + image_clip( + ops, + vec![clip + .iter() + .map(|p| { + [ + ((p[0] + ox) * MM_TO_PT as f64) as f32, + ((p[1] + oy) * MM_TO_PT as f64) as f32, + ] + }) + .collect()], + ); + } + let draw = |ops: &mut Vec, matrix: [f32; 6]| { + ops.push(Op::SetTransformationMatrix { + matrix: printpdf::CurTransMat::Raw(matrix), + }); + ops.push(Op::UseXobject { + id: id.clone(), + // Cancel printpdf's pixel-size transform: matrix maps the PDF + // image's unit square directly onto the CAD quad in page points. + transform: printpdf::XObjectTransform { + dpi: Some(72.0), + scale_x: Some(1.0 / image.width as f32), + scale_y: Some(1.0 / image.height as f32), + ..Default::default() + }, + }); + }; + let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]]; + let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]]; + let affine = (0..2).all(|axis| { + (corners[2][axis] - corners[0][axis] - u[axis] - v[axis]).abs() + <= 1e-5 * (u[axis].abs() + v[axis].abs()).max(1.0) + }); + if affine { + // A single draw preserves intrinsic IMAGE clipping without overlapping image draws. + image_clip( + ops, + image + .verts + .chunks_exact(3) + .map(|tri| { + tri.iter() + .map(|vertex| to_page(vertex.pos, vertex.pos_low)) + .collect() + }) + .collect(), + ); + draw(ops, [u[0], u[1], v[0], v[1], corners[0][0], corners[0][1]]); + } else { + // PDF transforms are affine; perspective sampling is approximate. + for triangle in image.verts.chunks_exact(3) { + let page = std::array::from_fn(|i| to_page(triangle[i].pos, triangle[i].pos_low)); + let uv = std::array::from_fn(|i| [triangle[i].uv[0], 1.0 - triangle[i].uv[1]]); + let matrix = image_triangle_matrix(page, uv) + .filter(|matrix| matrix.iter().all(|v| v.is_finite())) + .ok_or("Cannot plot bitmap: degenerate texture mapping.")?; + ops.push(Op::SaveGraphicsState); + image_clip(ops, vec![page.to_vec()]); + draw(ops, matrix); + ops.push(Op::RestoreGraphicsState); + } + } + ops.push(Op::RestoreGraphicsState); + Ok(()) +} + +#[cfg(not(target_arch = "wasm32"))] +fn image_triangle_matrix(p: [[f32; 2]; 3], uv: [[f32; 2]; 3]) -> Option<[f32; 6]> { + let u = [uv[1][0] - uv[0][0], uv[1][1] - uv[0][1]]; + let v = [uv[2][0] - uv[0][0], uv[2][1] - uv[0][1]]; + let determinant = u[0] * v[1] - v[0] * u[1]; + if determinant.abs() < 1e-12 { + return None; + } + let axes: [[f32; 2]; 2] = std::array::from_fn(|i| { + [ + ((p[1][i] - p[0][i]) * v[1] - (p[2][i] - p[0][i]) * u[1]) / determinant, + (u[0] * (p[2][i] - p[0][i]) - v[0] * (p[1][i] - p[0][i])) / determinant, + ] + }); + Some([ + axes[0][0], + axes[1][0], + axes[0][1], + axes[1][1], + p[0][0] - axes[0][0] * uv[0][0] - axes[0][1] * uv[0][1], + p[0][1] - axes[1][0] * uv[0][0] - axes[1][1] * uv[0][1], + ]) +} + /// Show a parented PDF save-file dialog and return the chosen path. /// /// The parent comes from `iced::window::run`, keeping the portal request tied @@ -230,83 +395,32 @@ pub fn pick_pdf_path_owned( // ── PDF builder ─────────────────────────────────────────────────────────── #[cfg(not(target_arch = "wasm32"))] -fn build_pdf( - wires: &[PlotWire], - hatches: &[HatchModel], - wipeouts: &[HatchModel], - paper_w: f32, - paper_h: f32, - // Absolute-world offsets, kept in f64: at UTM the drawing sits at ~5e5/4.5e6 - // where an f32 has ~0.03 m / ~0.5 m of resolution, so an f32 offset is itself - // already quantised before it can cancel the coordinate it is meant to cancel. - ox: f64, - oy: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option<&PlotStyleTable>, - options: PdfPlotOptions, -) -> Vec { +fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Result, String> { let mut doc = PdfDocument::new("Open CAD Studio Export"); - append_pdf_page( - &mut doc, - wires, - hatches, - wipeouts, - paper_w, - paper_h, - ox, - oy, - rotation_deg, - scale, - clip, - plot_style, - options, - ); - let mut warnings = Vec::new(); - doc.save(&PdfSaveOptions::default(), &mut warnings) -} - -#[cfg(not(target_arch = "wasm32"))] -fn build_pdf_pages(pages: &[PdfPageInput], plot_style: Option<&PlotStyleTable>) -> Vec { - let mut doc = PdfDocument::new("Open CAD Studio Export"); - for page in pages { - append_pdf_page( - &mut doc, - &page.wires, - &page.hatches, - &page.wipeouts, - page.paper_w as f32, - page.paper_h as f32, - page.offset_x, - page.offset_y, - page.rotation_deg, - page.scale, - page.clip, - page.plot_style.as_ref().or(plot_style), - page.options, - ); + // Borrowing all pages keeps their pixel Arcs alive until this cache is dropped. + // Allocation addresses cannot be reused by another source during this export. + let mut image_resources = std::collections::HashMap::new(); + for (index, page) in pages.iter().enumerate() { + append_pdf_page(&mut doc, &mut image_resources, page, plot_style) + .map_err(|error| format!("Page {}: {error}", index + 1))?; } let mut warnings = Vec::new(); - doc.save(&PdfSaveOptions::default(), &mut warnings) + Ok(doc.save(&PdfSaveOptions::default(), &mut warnings)) } #[cfg(not(target_arch = "wasm32"))] fn append_pdf_page( doc: &mut PdfDocument, - wires: &[PlotWire], - hatches: &[HatchModel], - wipeouts: &[HatchModel], - paper_w: f32, - paper_h: f32, - ox: f64, - oy: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option<&PlotStyleTable>, - options: PdfPlotOptions, -) { + image_resources: &mut std::collections::HashMap<(usize, u32, u32), printpdf::XObjectId>, + page: &PdfPageInput, + fallback_plot_style: Option<&PlotStyleTable>, +) -> Result<(), String> { + let PlotContent { wires, hatches, wipeouts, images, group_splits } = &page.content; + let (paper_w, paper_h) = (page.paper_w as f32, page.paper_h as f32); + let (ox, oy) = (page.offset_x, page.offset_y); + let (rotation_deg, scale, clip) = (page.rotation_deg, page.scale, page.clip); + let plot_style = page.plot_style.as_ref().or(fallback_plot_style); + let options = page.options; let mut ops: Vec = Vec::new(); // White page background. @@ -411,23 +525,26 @@ fn append_pdf_page( let (first_wires, second_wires) = - wires.split_at(options.group_splits.wires.min(wires.len())); + wires.split_at(group_splits.wires.min(wires.len())); let (first_hatches, second_hatches) = - hatches.split_at(options.group_splits.hatches.min(hatches.len())); + hatches.split_at(group_splits.hatches.min(hatches.len())); let (first_wipeouts, second_wipeouts) = - wipeouts.split_at(options.group_splits.wipeouts.min(wipeouts.len())); - for (wires, hatches, wipeouts) in [ - (first_wires, first_hatches, first_wipeouts), - (second_wires, second_hatches, second_wipeouts), + wipeouts.split_at(group_splits.wipeouts.min(wipeouts.len())); + let (first_images, second_images) = + images.split_at(group_splits.images.min(images.len())); + for (wires, hatches, wipeouts, images) in [ + (first_wires, first_hatches, first_wipeouts, first_images), + (second_wires, second_hatches, second_wipeouts, second_images), ] { enum DrawItem<'a> { WireFill(&'a PlotWire), Hatch(&'a HatchModel), + Image(&'a PlotImage), Wire(&'a PlotWire), Text(&'a PlotWire), } - let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len()); + let mut draw_items = Vec::with_capacity(wires.len() * 2 + hatches.len() + wipeouts.len() + images.len()); let mut sequence = 0usize; for wire in wires { if !wire.fill_tris.is_empty() { @@ -445,6 +562,10 @@ fn append_pdf_page( draw_items.push((hatch.draw_depth, 1u8, sequence, DrawItem::Hatch(hatch))); sequence += 1; } + for image in images { + draw_items.push((image.image.draw_depth, 1u8, sequence, DrawItem::Image(image))); + sequence += 1; + } draw_items.sort_by(|a, b| { a.0.total_cmp(&b.0) .then_with(|| a.1.cmp(&b.1)) @@ -508,6 +629,10 @@ fn append_pdf_page( last_dash = None; continue; } + DrawItem::Image(image) => { + emit_image(doc, image_resources, &mut ops, image, ox, oy, options)?; + continue; + } DrawItem::Wire(wire) => wire, }; let [mut r, mut g, mut b, a] = wire.color; @@ -722,6 +847,7 @@ fn append_pdf_page( let page = PdfPage::new(Mm(paper_w), Mm(paper_h), ops); doc.pages.push(page); + Ok(()) } /// Build a PDF dash array (in points) from a WireModel linetype pattern. @@ -1517,6 +1643,21 @@ fn emit_text( mod tests { use super::*; + fn test_page(wires: Vec) -> PdfPageInput { + PdfPageInput { + content: PlotContent { wires: std::sync::Arc::new(wires), ..Default::default() }, + paper_w: 210.0, + paper_h: 297.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + } + } + #[test] fn atomic_write_replaces_an_existing_pdf() { let path = std::env::temp_dir().join(format!( @@ -1546,20 +1687,10 @@ mod tests { ), draw_depth: 0.0, }; - let bytes = build_pdf( - &[w], - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 2.0, - Some((10.0, 10.0, 100.0, 100.0)), - None, - PdfPlotOptions::default(), - ); + let mut page = test_page(vec![w]); + page.scale = 2.0; + page.clip = Some((10.0, 10.0, 100.0, 100.0)); + let bytes = build_pdf_pages(&[page], None).unwrap(); // A valid PDF is produced (starts with the PDF header) and is non-trivial. assert!(bytes.starts_with(b"%PDF"), "not a PDF"); assert!(bytes.len() > 200, "suspiciously small: {}", bytes.len()); @@ -1594,34 +1725,8 @@ mod tests { let mut blank = wire.clone(); blank.wire.text_verts.clear(); - let with_text = build_pdf( - &[wire], - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - None, - PdfPlotOptions::default(), - ); - let no_text = build_pdf( - &[blank], - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - None, - PdfPlotOptions::default(), - ); + let with_text = build_pdf_pages(&[test_page(vec![wire])], None).unwrap(); + let no_text = build_pdf_pages(&[test_page(vec![blank])], None).unwrap(); assert!(with_text.starts_with(b"%PDF")); assert!( with_text.len() > no_text.len(), diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 6f4d3a680..425b06e9f 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -9,9 +9,6 @@ #[cfg(not(target_arch = "wasm32"))] use crate::io::pdf_export; -use crate::io::plot_style::PlotStyleTable; -use crate::scene::model::hatch_model::HatchModel; -use crate::io::pdf_export::PlotWire; #[cfg(not(target_arch = "wasm32"))] pub(crate) fn temp_pdf_path(kind: &str) -> std::path::PathBuf { @@ -50,8 +47,6 @@ pub struct PrintOptions { /// setting wins, so the field is legitimately unread there. #[cfg_attr(target_os = "windows", allow(dead_code))] pub quality: Option, - /// Controls applied while building the intermediate PDF. - pub render: crate::io::pdf_export::PdfPlotOptions, } #[cfg(target_arch = "wasm32")] @@ -60,19 +55,8 @@ pub fn list_printers() -> Vec { } #[cfg(target_arch = "wasm32")] -#[allow(clippy::too_many_arguments)] pub async fn print_wires_with( - _wires: std::sync::Arc>, - _hatches: Vec, - _wipeouts: Vec, - _paper_w: f64, - _paper_h: f64, - _offset_x: f64, - _offset_y: f64, - _rotation_deg: i32, - _scale: f32, - _clip: Option<(f32, f32, f32, f32)>, - _plot_style: Option, + _page: crate::io::pdf_export::PdfPageInput, _opts: PrintOptions, ) -> Result { Err("Printing is not available in the web version.".into()) @@ -172,46 +156,18 @@ pub fn open_printer_properties(_printer: Option<&str>) -> Result<(), String> { Err("Printer properties are not available in the web version.".into()) } -/// Like [`print_wires`] but honours a [`PrintOptions`] bundle (printer, copies, -/// grayscale, quality, DPI). +/// Render a page and send it to the selected printer. #[cfg(not(target_arch = "wasm32"))] -#[allow(clippy::too_many_arguments)] pub async fn print_wires_with( - wires: std::sync::Arc>, - hatches: Vec, - wipeouts: Vec, - paper_w: f64, - paper_h: f64, - offset_x: f64, - offset_y: f64, - rotation_deg: i32, - scale: f32, - clip: Option<(f32, f32, f32, f32)>, - plot_style: Option, + page: crate::io::pdf_export::PdfPageInput, opts: PrintOptions, ) -> Result { let tmp_path = temp_pdf_path("print"); - pdf_export::export_pdf( - &wires, - &hatches, - &wipeouts, - paper_w, - paper_h, - offset_x, - offset_y, - rotation_deg, - scale, - clip, - &tmp_path, - plot_style.as_ref(), - opts.render, - )?; + pdf_export::export_pdf(&page, &tmp_path)?; dispatch_to_printer_opts(&tmp_path, &opts) } -/// Send an already-rendered PDF to a printer with [`PrintOptions`]. Used for -/// clipped window plots, whose PDF is built with a scale + clip the plain -/// `print_wires_with` path doesn't expose. +/// Send an already-rendered PDF to the selected printer. #[cfg(not(target_arch = "wasm32"))] pub fn print_existing_pdf(path: &std::path::Path, opts: &PrintOptions) -> Result { dispatch_to_printer_opts(path, opts) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d36fef59f..e5844afb0 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -6648,6 +6648,49 @@ impl Scene { all_visible: bool, viewport: Option, ) -> Vec { + self.collect_images(target_block, frozen, annotation_scale_handle, all_visible, + viewport, false, |image, _| image) + } + + pub fn paper_plot_images(&self) -> Vec { + let scale = if self.current_layout == "Model" { + self.displayed_annotation_scale_handle() + } else { + self.paper_annotation_scale_handle() + }; + self.placed_images(self.current_layout_block_handle(), None, + scale, self.annotation_all_visible(), None, true) + } + + fn placed_images( + &self, + target_block: Handle, + frozen: Option<&HashSet>, + annotation_scale_handle: Option, + all_visible: bool, + viewport: Option, + plotting: bool, + ) -> Vec { + self.collect_images(target_block, frozen, annotation_scale_handle, all_visible, + viewport, plotting, |image, context| crate::io::pdf_export::PlotImage { + image, + clips: context.clips.clone(), + }) + } + + fn collect_images( + &self, + target_block: Handle, + frozen: Option<&HashSet>, + annotation_scale_handle: Option, + all_visible: bool, + viewport: Option, + plotting: bool, + mut collect: impl FnMut(ImageModel, &render_graph::InstanceContext) -> T, + ) -> Vec { + if self.images.is_empty() { + return Vec::new(); + } let depth_map = self.draw_depth_map(); let graph = render_graph::RenderSceneGraph::new( &self.document, @@ -6673,8 +6716,10 @@ impl Scene { graph.walk_root( root, |entity, context| { - context.is_instanced() - || !self.entity_temporarily_hidden(entity.common().handle) + (!plotting || self.document.layers.get(&entity.common().layer) + .is_none_or(|layer| layer.is_plottable)) + && (context.is_instanced() + || !self.entity_temporarily_hidden(entity.common().handle)) }, |entity, context| { let handle = entity.common().handle; @@ -6734,7 +6779,7 @@ impl Scene { ); } placed.draw_depth = context.draw_depth(handle, depth_map.as_ref()); - models.push(placed); + models.push(collect(placed, context)); }, ); models diff --git a/src/scene/project.rs b/src/scene/project.rs index 29ef0735d..f20c10e38 100644 --- a/src/scene/project.rs +++ b/src/scene/project.rs @@ -386,12 +386,12 @@ impl Scene { /// needs the equivalent paper-space geometry explicitly. pub fn viewport_plot_fills( &self, - ) -> (Vec<(WireModel, f32)>, Vec, Vec) { + ) -> (Vec<(WireModel, f32)>, Vec, Vec, Vec) { use acadrust::entities::Viewport; use model::hatch_model::HatchPattern; if self.current_layout == "Model" { - return (Vec::new(), Vec::new(), Vec::new()); + return (Vec::new(), Vec::new(), Vec::new(), Vec::new()); } let paper_block = self.current_layout_block_handle(); let model_block = self.model_space_block_handle(); @@ -411,6 +411,7 @@ impl Scene { let mut pattern_wires = Vec::new(); let mut projected_hatches = Vec::new(); let mut projected_wipeouts = Vec::new(); + let mut projected_images = Vec::new(); for viewport in viewports { let Some(camera) = self.camera_for_viewport(viewport.common.handle) else { @@ -463,6 +464,58 @@ impl Scene { let frozen: rustc_hash::FxHashSet = viewport.frozen_layers.iter().copied().collect(); + let mut viewport_clips = vec![vec![ + [xmin as f64, ymin as f64], [xmax as f64, ymin as f64], + [xmax as f64, ymax as f64], [xmin as f64, ymax as f64], + ]]; + if !self.images.is_empty() && !viewport.clip_boundary_handle.is_null() { + let boundary = self.clip_boundary_polygon( + viewport.clip_boundary_handle, viewport.center.z as f32); + if boundary.len() >= 3 { + viewport_clips.push(boundary.iter().map(|p| [p[0] as f64, p[1] as f64]).collect()); + } + } + for mut plot in self.placed_images(model_block, Some(&frozen), + self.viewport_scale_handle(viewport.common.handle), + self.annotation_all_visible(), Some(viewport.common.handle), true) + { + let mut valid = true; + let mut project_point = |high: &mut [f32; 3], low: &mut [f32; 3]| { + let point = std::array::from_fn(|i| high[i] as f64 + low[i] as f64); + if let Some([x, y]) = project_3d(point) { + *high = [x, y, 0.0]; + *low = [0.0; 3]; + } else { + valid = false; + } + }; + for (high, low) in plot.image.corners.iter_mut().zip(&mut plot.image.corners_low) { + project_point(high, low); + } + for vertex in &mut plot.image.verts { + project_point(&mut vertex.pos, &mut vertex.pos_low); + } + for clip in &mut plot.clips { + for point in clip { + if let Some([x, y]) = project(point[0], point[1]) { + *point = [x as f64, y as f64]; + } else { + valid = false; + } + } + } + if !valid { + continue; + } + if [(0, xmin, xmax), (1, ymin, ymax)].iter().any(|&(axis, min, max)| { + plot.image.verts.iter().all(|v| v.pos[axis] < min) + || plot.image.verts.iter().all(|v| v.pos[axis] > max) + }) { + continue; + } + plot.clips.extend(viewport_clips.iter().cloned()); + projected_images.push(plot); + } let hatches = self.plot_hatches_for_block( model_block, Some(&frozen), @@ -536,7 +589,7 @@ impl Scene { } } - (pattern_wires, projected_hatches, projected_wipeouts) + (pattern_wires, projected_hatches, projected_wipeouts, projected_images) } /// A content viewport's clip boundary, projected into that viewport's diff --git a/tests/fixtures/embedded-image-print.dxf b/tests/fixtures/embedded-image-print.dxf new file mode 100644 index 000000000..6fa5972f6 --- /dev/null +++ b/tests/fixtures/embedded-image-print.dxf @@ -0,0 +1,7522 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1024 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +Synthetic reproduction + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +OpenSans + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +0.175 + 9 +$DIMEXO + 40 +0.125 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +0.375 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +0.25 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +12 + 9 +$DIMBLK + 1 +ARCHTICK + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +100.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +EZDXF + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +0.5 + 9 +$DIMGAP + 40 +0.1 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +1 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +OpenSansCondensed-Light + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +2 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +3 + 9 +$DIMDSEP + 70 +46 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +2 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2461298.5564930555 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461298.5564930555 + 9 +$TDUUPDATE + 40 +2458532.1544311 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +99 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +0 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +-0.0 + 20 +-0.0 + 9 +$PLIMMAX + 10 +11.0 + 20 +8.5 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +0 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +1 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{AE9B3164-57C5-40A6-B897-3AA314A90313} + 9 +$VERSIONGUID + 2 +{DBBE3EFA-6A7C-4482-B175-433C33A11394} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +8B +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +5.5 + 22 +4.25 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +12.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +1 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +21 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +2F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER + 70 +0 + 3 +Center ____ _ ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +5.08 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +30 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTERX2 + 70 +0 + 3 +Center (2x) ________ __ ________ __ ________ + 72 +65 + 73 +4 + 40 +8.89 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +31 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +CENTER2 + 70 +0 + 3 +Center (.5x) ____ _ ____ _ ____ _ ____ _ ____ + 72 +65 + 73 +4 + 40 +2.54 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +32 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED + 70 +0 + 3 +Dashed __ __ __ __ __ __ __ __ __ __ __ __ __ _ + 72 +65 + 73 +2 + 40 +1.524 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +33 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHEDX2 + 70 +0 + 3 +Dashed (2x) ____ ____ ____ ____ ____ ____ + 72 +65 + 73 +2 + 40 +3.048 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +34 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHED2 + 70 +0 + 3 +Dashed (.5x) _ _ _ _ _ _ _ _ _ _ _ _ _ _ + 72 +65 + 73 +2 + 40 +0.762 + 49 +0.635 + 74 +0 + 49 +-0.127 + 74 +0 + 0 +LTYPE + 5 +35 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM + 70 +0 + 3 +Phantom ______ __ __ ______ __ __ ______ + 72 +65 + 73 +6 + 40 +6.35 + 49 +3.175 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +0.635 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +36 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOMX2 + 70 +0 + 3 +Phantom (2x)____________ ____ ____ ____________ + 72 +65 + 73 +6 + 40 +10.795 + 49 +6.35 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 49 +1.27 + 74 +0 + 49 +-0.635 + 74 +0 + 0 +LTYPE + 5 +37 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +PHANTOM2 + 70 +0 + 3 +Phantom (.5x) ___ _ _ ___ _ _ ___ _ _ ___ _ _ ___ + 72 +65 + 73 +6 + 40 +3.175 + 49 +1.587 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 49 +0.318 + 74 +0 + 49 +-0.318 + 74 +0 + 0 +LTYPE + 5 +38 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT + 70 +0 + 3 +Dash dot __ . __ . __ . __ . __ . __ . __ . __ + 72 +65 + 73 +4 + 40 +3.556 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +39 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOTX2 + 70 +0 + 3 +Dash dot (2x) ____ . ____ . ____ . ____ + 72 +65 + 73 +4 + 40 +6.096 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3A +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DASHDOT2 + 70 +0 + 3 +Dash dot (.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +4 + 40 +1.778 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3B +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT + 70 +0 + 3 +Dot . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.508 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3C +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOTX2 + 70 +0 + 3 +Dot (2x) . . . . . . . . + 72 +65 + 73 +2 + 40 +1.016 + 49 +0.0 + 74 +0 + 49 +-1.016 + 74 +0 + 0 +LTYPE + 5 +3D +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DOT2 + 70 +0 + 3 +Dot (.5) . . . . . . . . . . . . . . . . . . . + 72 +65 + 73 +2 + 40 +0.254 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +LTYPE + 5 +3E +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE + 70 +0 + 3 +Divide __ . . __ . . __ . . __ . . __ . . __ + 72 +65 + 73 +6 + 40 +4.064 + 49 +2.54 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +3F +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDEX2 + 70 +0 + 3 +Divide (2x) ____ . . ____ . . ____ . . ____ + 72 +65 + 73 +6 + 40 +6.604 + 49 +5.08 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.508 + 74 +0 + 0 +LTYPE + 5 +40 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +DIVIDE2 + 70 +0 + 3 +Divide(.5x) _ . _ . _ . _ . _ . _ . _ . _ + 72 +65 + 73 +6 + 40 +2.032 + 49 +1.27 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 49 +0.0 + 74 +0 + 49 +-0.254 + 74 +0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +26 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +STYLE + 5 +41 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Light + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Light.ttf + 4 + + 0 +STYLE + 5 +42 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Light-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-LightItalic.ttf + 4 + + 0 +STYLE + 5 +43 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Regular.ttf + 4 + + 0 +STYLE + 5 +44 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Italic.ttf + 4 + + 0 +STYLE + 5 +45 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-SemiBold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-SemiBold.ttf + 4 + + 0 +STYLE + 5 +46 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-SemiBoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-SemiBoldItalic.ttf + 4 + + 0 +STYLE + 5 +47 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-Bold.ttf + 4 + + 0 +STYLE + 5 +48 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +49 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-ExtraBold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-ExtraBold.ttf + 4 + + 0 +STYLE + 5 +4A +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSans-ExtraBoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSans-ExtraBoldItalic.ttf + 4 + + 0 +STYLE + 5 +4B +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSansCondensed-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSansCondensed-Bold.ttf + 4 + + 0 +STYLE + 5 +4C +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSansCondensed-Light + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSansCondensed-Light.ttf + 4 + + 0 +STYLE + 5 +4D +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +OpenSansCondensed-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +OpenSansCondensed-LightItalic.ttf + 4 + + 0 +STYLE + 5 +4E +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-Regular.ttf + 4 + + 0 +STYLE + 5 +4F +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-Bold.ttf + 4 + + 0 +STYLE + 5 +50 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +51 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSans-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSans-Italic.ttf + 4 + + 0 +STYLE + 5 +52 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-Regular.ttf + 4 + + 0 +STYLE + 5 +53 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-Bold.ttf + 4 + + 0 +STYLE + 5 +54 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +55 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationSerif-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationSerif-Italic.ttf + 4 + + 0 +STYLE + 5 +56 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-Regular.ttf + 4 + + 0 +STYLE + 5 +57 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono-Bold + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-Bold.ttf + 4 + + 0 +STYLE + 5 +58 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono-BoldItalic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-BoldItalic.ttf + 4 + + 0 +STYLE + 5 +59 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +LiberationMono-Italic + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +LiberationMono-Italic.ttf + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +96 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +97 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +12 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +73 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZDXF + 70 +0 + 40 +1.0 + 41 +0.175 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +342 +74 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7C +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_100_H25_CM + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +2.5 +142 +0.125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7D +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_50_H25_CM + 70 +0 + 40 +1.0 + 41 +0.125 + 42 +0.0625 + 43 +3.75 + 44 +0.1875 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.125 +141 +2.5 +142 +0.0625 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.05 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7E +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_25_H25_CM + 70 +0 + 40 +1.0 + 41 +0.0625 + 42 +0.03125 + 43 +3.75 + 44 +0.09375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.0625 +141 +2.5 +142 +0.03125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.025 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +7F +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_20_H25_CM + 70 +0 + 40 +1.0 + 41 +0.05 + 42 +0.025 + 43 +3.75 + 44 +0.07500000000000001 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.05 +141 +2.5 +142 +0.025 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.020000000000000004 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +80 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_10_H25_CM + 70 +0 + 40 +1.0 + 41 +0.025 + 42 +0.0125 + 43 +3.75 + 44 +0.037500000000000006 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.025 +141 +2.5 +142 +0.0125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.010000000000000002 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +81 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_5_H25_CM + 70 +0 + 40 +1.0 + 41 +0.0125 + 42 +0.00625 + 43 +3.75 + 44 +0.018750000000000003 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.0125 +141 +2.5 +142 +0.00625 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.005000000000000001 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +82 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_M_1_H25_CM + 70 +0 + 40 +1.0 + 41 +0.0025 + 42 +0.00125 + 43 +3.75 + 44 +0.00375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.0025 +141 +2.5 +142 +0.00125 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.001 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +83 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_RADIUS + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +0.0 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +0 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +1 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +342 +84 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +88 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_RADIUS_INSIDE + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +0.0 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +0 + 78 +12 + 79 +3 +170 +0 +171 +3 +172 +0 +173 +0 +174 +1 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +0 +290 +0 +340 +4C +342 +84 +371 +-2 +372 +-2 + 0 +DIMSTYLE +105 +89 +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +EZ_CURVED + 70 +0 + 40 +1.0 + 41 +0.25 + 42 +0.125 + 43 +3.75 + 44 +0.375 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +0.25 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +100.0 +145 +0.0 +146 +0.5 +147 +0.1 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +12 + 79 +2 + 90 +2 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +46 +279 +2 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +1 +289 +3 +290 +0 +340 +4C +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +6 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +74 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_ARCHTICK +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +78 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDFILLED +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +84 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +_CLOSEDBLANK +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +8C +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +SyntheticImage +340 +0 + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +75 +330 +74 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_ARCHTICK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_ARCHTICK + 1 + + 0 +LWPOLYLINE + 5 +77 +330 +74 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbPolyline + 90 +2 + 70 +0 + 43 +0.15 + 10 +-0.5 + 20 +-0.5 + 10 +0.5 + 20 +0.5 + 0 +ENDBLK + 5 +76 +330 +74 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +79 +330 +78 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDFILLED + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDFILLED + 1 + + 0 +SOLID + 5 +7B +330 +78 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbTrace + 10 +-1.0 + 20 +0.16439898372402537 + 30 +0.0 + 11 +0.0 + 21 +0.0 + 31 +0.0 + 12 +-1.0 + 22 +-0.16439898372402537 + 32 +0.0 + 13 +-1.0 + 23 +-0.16439898372402537 + 33 +0.0 + 0 +ENDBLK + 5 +7A +330 +78 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +85 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +_CLOSEDBLANK + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +_CLOSEDBLANK + 1 + + 0 +LWPOLYLINE + 5 +87 +330 +84 +100 +AcDbEntity + 8 +0 + 6 +BYBLOCK + 62 +0 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +-1.0 + 20 +0.16439898372402537 + 10 +0.0 + 20 +0.0 + 10 +-1.0 + 20 +-0.16439898372402537 + 0 +ENDBLK + 5 +86 +330 +84 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +8D +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +SyntheticImage + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +SyntheticImage + 1 + + 0 +OLE2FRAME + 5 +8F +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbOle2Frame + 70 +2 + 3 +Picture + 10 +0.0 + 20 +1.0 + 30 +0.0 + 11 +2.0 + 21 +0.0 + 31 +0.0 + 71 +3 + 72 +0 + 73 +0 + 90 +8192 +310 +D0CF11E0A1B11AE1000000000000000000000000000000003E000300FEFF090006000000000000000000000001000000000000000000000000100000FEFFFFFF00000000FEFFFFFF0000000001000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFF52006F006F007400200045006E00740072007900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000016000501FFFFFFFFFFFFFFFF01000000000000000000000000000000000000000000000000000000000000000000000000000000FEFFFFFF000000 +310 +000000000043004F004E00540045004E005400530000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000012000201FFFFFFFFFFFFFFFFFFFFFFFF000000000000000000000000000000000000000000000000000000000000000000000000020000003618 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +0000000000000000FEFFFFFFFDFFFFFF030000000400000005000000060000000700000008000000090000000A0000000B0000000C0000000D0000000E000000FEFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF +310 +FFFFFFFFFFFFFFFFFFFFFFFF424D361800000000000036000000280000004000000020000000010018000000000000180000C40E0000C40E000000000000000000000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF +310 +0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF00FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF00 +310 +00FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000 +310 +FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF000000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF0000FF0000FF00FF0000 +310 +FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF0000FF0000FF00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF0000FF00FF0000FF0000FF0000FF0000 +310 +FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000 +310 +FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00 +310 +00FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF00000000FF00FF0000FF0000FF000000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF +310 +0000FF0000FF0000FF0000FF0000FF0000FFFF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF0000FF000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 +310 +00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 + 1 +OLE + 0 +ENDBLK + 5 +8E +330 +8C +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +VIEWPORT + 5 +8A +330 +1B +100 +AcDbEntity + 67 +1 + 8 +VIEWPORTS +100 +AcDbViewport + 10 +5.5 + 20 +4.25 + 30 +0.0 + 40 +12.100000000000001 + 41 +9.350000000000001 + 68 +1 + 69 +1 + 12 +5.5 + 22 +4.25 + 13 +0.0 + 23 +0.0 + 14 +10.0 + 24 +10.0 + 15 +10.0 + 25 +10.0 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 45 +9.350000000000001 + 50 +0.0 + 51 +0.0 + 72 +100 + 90 +557088 + 1 + +281 +0 + 71 +0 + 74 +0 +110 +0.0 +120 +0.0 +130 +0.0 +111 +1.0 +121 +0.0 +131 +0.0 +112 +0.0 +122 +1.0 +132 +0.0 + 79 +0 +146 +0.0 +282 +0 + 0 +INSERT + 5 +90 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbBlockReference + 2 +SyntheticImage + 10 +2.0 + 20 +4.0 + 30 +0.0 + 0 +LWPOLYLINE + 5 +92 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +2.0 + 20 +4.0 + 10 +4.0 + 20 +4.0 + 10 +4.0 + 20 +5.0 + 10 +2.0 + 20 +5.0 + 0 +TEXT + 5 +93 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbText + 10 +2.0 + 20 +5.4 + 30 +0.0 + 40 +0.2 + 1 +EMBEDDED BITMAP +100 +AcDbText + 0 +LWPOLYLINE + 5 +94 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbPolyline + 90 +3 + 70 +1 + 10 +6.0 + 20 +4.0 + 10 +8.0 + 20 +4.0 + 10 +7.0 + 20 +5.0 + 0 +TEXT + 5 +95 +330 +1B +100 +AcDbEntity + 67 +1 + 8 +0 +100 +AcDbText + 10 +6.0 + 20 +5.4 + 30 +0.0 + 40 +0.2 + 1 +VECTOR CONTROL +100 +AcDbText + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +281 +1 + 3 +2dWireframe +350 +5A + 3 +Basic +350 +5B + 3 +Brighten +350 +5C + 3 +ColorChange +350 +5D + 3 +Conceptual +350 +5E + 3 +Dim +350 +5F + 3 +EdgeColorOff +350 +60 + 3 +Facepattern +350 +61 + 3 +Flat +350 +62 + 3 +FlatWithEdges +350 +63 + 3 +Gouraud +350 +64 + 3 +GouraudWithEdges +350 +65 + 3 +Hidden +350 +66 + 3 +JitterOff +350 +67 + 3 +Linepattern +350 +68 + 3 +Modeling +350 +69 + 3 +OverhangOff +350 +6A + 3 +Realistic +350 +6B + 3 +Shaded +350 +6C + 3 +Shaded with edges +350 +6D + 3 +Shades of Gray +350 +6E + 3 +Sketchy +350 +6F + 3 +Thicken +350 +70 + 3 +Wireframe +350 +71 + 3 +X-Ray +350 +72 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 2 +Example saved printer + 4 +ezdxf_(11.00_x_8.50_Inches) + 6 + + 40 +0.0 + 41 +0.0 + 42 +0.0 + 43 +0.0 + 44 +279.4 + 45 +215.89999999999998 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +672 + 72 +0 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +0.03937007874015748 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +-0.0 + 20 +-0.0 + 11 +11.0 + 21 +8.5 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B +331 +8A + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +280 +1 +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +98 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.3 @ 2026-09-14T18:21:21.360639+00:00 + 0 +VISUALSTYLE + 5 +5A +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +2dWireframe + 70 +4 + 90 +0 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +5B +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Basic + 70 +7 + 90 +1 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +5C +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Brighten + 70 +12 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +5D +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +ColorChange + 70 +16 + 90 +2 + 40 +0.6 + 62 +8 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +5E +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Conceptual + 70 +9 + 90 +3 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +5F +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Dim + 70 +11 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +60 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +EdgeColorOff + 70 +22 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +61 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Facepattern + 70 +15 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +62 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Flat + 70 +0 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +63 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +FlatWithEdges + 70 +1 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +64 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Gouraud + 70 +2 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +65 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +GouraudWithEdges + 70 +3 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +66 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Hidden + 70 +6 + 90 +1 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +67 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +JitterOff + 70 +20 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +68 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Linepattern + 70 +14 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +69 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Modeling + 70 +10 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6A +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +OverhangOff + 70 +21 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +6B +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Realistic + 70 +8 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6C +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Shaded + 70 +27 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6D +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Shaded with edges + 70 +26 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6E +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Shades of Gray + 70 +23 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +6F +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Sketchy + 70 +24 + 90 +1 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +70 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Thicken + 70 +13 + 90 +2 + 40 +0.6 + 62 +7 +290 +0 +291 +1 + 0 +VISUALSTYLE + 5 +71 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +Wireframe + 70 +5 + 90 +0 + 40 +0.6 + 62 +7 +290 +0 +291 +0 + 0 +VISUALSTYLE + 5 +72 +102 +{ACAD_REACTORS +330 +16 +102 +} +330 +16 +100 +AcDbVisualStyle + 2 +X-Ray + 70 +25 + 90 +2 + 40 +0.5 + 62 +7 +290 +0 +291 +0 + 0 +DICTIONARYVAR + 5 +98 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.3 @ 2026-09-14T18:21:21.373779+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/tests/fixtures/embedded-image-print.md b/tests/fixtures/embedded-image-print.md new file mode 100644 index 000000000..8114d574d --- /dev/null +++ b/tests/fixtures/embedded-image-print.md @@ -0,0 +1,8 @@ +Synthetic regression fixture for #1266. Generated with ezdxf/Pillow from basic +geometry; it contains no external references or customer drawing content. + +Layout1 has an INSERT of SyntheticImage at (2, 4). That block contains one +OLE2FRAME whose compound-file CONTENTS stream embeds a 64 x 32 BMP: red on +the left, blue on the right, with a green diagonal from top-left to bottom-right. +The frame occupies (0, 0) to (2, 1) in the block. Text and a triangle provide +vector controls for manually opening the fixture and checking Plot Preview. diff --git a/tests/pdf_export_images_check.rs b/tests/pdf_export_images_check.rs new file mode 100644 index 000000000..c625faa36 --- /dev/null +++ b/tests/pdf_export_images_check.rs @@ -0,0 +1,512 @@ +//! #1266: decoded IMAGE/OLE content must survive the shared PDF/preview/print path. +#![cfg(not(target_arch = "wasm32"))] + +use acadrust::entities::{Insert, Viewport, Wipeout}; +use acadrust::types::{Vector2, Vector3}; +use acadrust::EntityType; +use std::sync::Arc; +use OpenCADStudio::io::pdf_export::{ + export_pdf, export_pdf_pages, PdfPageInput, PdfPlotOptions, PlotContent, PlotGroupSplits, + PlotImage, PlotWire, +}; +use OpenCADStudio::scene::model::image_model::{ImageModel, ImageQuadVertex}; +use OpenCADStudio::scene::{Scene, WireModel}; + +fn embedded_scene() -> Scene { + let mut scene = Scene::new(); + scene.document = OpenCADStudio::io::load_bytes( + "embedded-image-print.dxf", + include_bytes!("fixtures/embedded-image-print.dxf").to_vec(), + ) + .expect("synthetic embedded bitmap DXF"); + scene.images = + OpenCADStudio::scene::build_derived_caches_with_progress(&scene.document, &|_| {}, None) + .images; + scene.set_current_layout("Layout1".into()); + scene +} + +fn page(images: Vec) -> PdfPageInput { + PdfPageInput { + content: PlotContent { + images, + ..Default::default() + }, + paper_w: 100.0, + paper_h: 100.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + } +} + +fn export(pages: &[PdfPageInput]) -> Vec { + static NEXT: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!( + "ocs1266-{}-{}.pdf", + std::process::id(), + NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + if let [page] = pages { + export_pdf(page, &path).expect("export bitmap PDF"); + } else { + export_pdf_pages(pages, &path, None).expect("export bitmap PDF pages"); + } + let bytes = std::fs::read(&path).unwrap(); + std::fs::remove_file(path).unwrap(); + bytes +} + +struct Raster { + pixels: Vec, + width: usize, + height: usize, + scale: f32, +} +impl Raster { + fn from_pdf(bytes: &[u8], page: usize) -> Self { + Self::at_scale(bytes, page, 2.0) + } + + fn at_scale(bytes: &[u8], page: usize, scale: f32) -> Self { + let pdf = hayro::hayro_syntax::Pdf::new(Arc::new(bytes.to_vec())).unwrap(); + let pixmap = hayro::render( + &pdf.pages()[page], + &hayro::RenderCache::new(), + &hayro::hayro_interpret::InterpreterSettings::default(), + &hayro::RenderSettings { + x_scale: scale, + y_scale: scale, + bg_color: hayro::vello_cpu::color::palette::css::WHITE, + ..Default::default() + }, + ); + Self { + pixels: pixmap.data_as_u8_slice().to_vec(), + width: u32::from(pixmap.width()) as usize, + height: u32::from(pixmap.height()) as usize, + scale, + } + } + + fn assert_color(&self, x_mm: f32, y_mm: f32, expected: [u8; 3]) { + let x = (x_mm * 72.0 / 25.4 * self.scale) as usize; + let y = self.height - 1 - (y_mm * 72.0 / 25.4 * self.scale) as usize; + let actual = &self.pixels[(y * self.width + x) * 4..][..3]; + assert!( + actual + .iter() + .zip(expected) + .all(|(&a, b)| a.abs_diff(b) < 25), + "at ({x_mm}, {y_mm}): expected {expected:?}, got {actual:?}" + ); + } +} + +fn image() -> PlotImage { + let corners = [ + [10.0, 10.0, 0.0], + [50.0, 10.0, 0.0], + [50.0, 50.0, 0.0], + [10.0, 50.0, 0.0], + ]; + let uv = [[0.0, 1.0], [1.0, 1.0], [1.0, 0.0], [0.0, 0.0]]; + // Four distinct quadrants detect flips, reflections, and diagonal seams. + let pixels = (0..32) + .flat_map(|y| { + (0..32).flat_map(move |x| match (x < 16, y < 16) { + (true, true) => [255, 0, 0, 255], + (false, true) => [0, 0, 255, 255], + (true, false) => [0, 255, 0, 255], + (false, false) => [255, 255, 0, 255], + }) + }) + .collect(); + PlotImage { + image: ImageModel { + render_instance: None, + file_path: "synthetic".into(), + pixels: Arc::new(pixels), + width: 32, + height: 32, + opacity: 1.0, + corners, + corners_low: [[0.0; 3]; 4], + draw_depth: 0.5, + verts: [0, 1, 2, 0, 2, 3] + .into_iter() + .map(|i| ImageQuadVertex { + pos: corners[i], + pos_low: [0.0; 3], + uv: uv[i], + }) + .collect(), + }, + clips: Vec::new(), + } +} + +#[test] +fn embedded_ole_inside_insert_reaches_rendered_pdf() { + let scene = embedded_scene(); + let images = scene.paper_plot_images(); + assert_eq!(images.len(), 1); + assert_eq!((images[0].image.width, images[0].image.height), (64, 32)); + assert!(scene + .images + .values() + .any(|source| Arc::ptr_eq(&source.pixels, &images[0].image.pixels))); + let mut input = page(images); + input.scale = 10.0; + let raster = Raster::from_pdf(&export(&[input]), 0); + raster.assert_color(23.0, 42.0, [255, 0, 0]); + raster.assert_color(37.0, 48.0, [0, 0, 255]); + raster.assert_color(25.0, 47.5, [0, 255, 0]); + raster.assert_color(45.0, 45.0, [255, 255, 255]); +} + +#[test] +fn rotated_nonuniform_insert_preserves_pixel_orientation() { + let mut scene = embedded_scene(); + let handle = scene + .document + .entities() + .find_map(|e| match e { + EntityType::Insert(i) if i.block_name == "SyntheticImage" => Some(i.common.handle), + _ => None, + }) + .unwrap(); + let Some(EntityType::Insert(insert)) = scene.document.get_entity_mut(handle) else { + panic!() + }; + insert.insert_point = Vector3::new(60.0, 20.0, 0.0); + insert.rotation = std::f64::consts::FRAC_PI_2; + insert.set_x_scale(20.0); + insert.set_y_scale(10.0); + let raster = Raster::from_pdf(&export(&[page(scene.paper_plot_images())]), 0); + raster.assert_color(58.0, 26.0, [255, 0, 0]); + raster.assert_color(52.0, 54.0, [0, 0, 255]); + raster.assert_color(52.5, 30.0, [0, 255, 0]); + raster.assert_color(65.0, 40.0, [255, 255, 255]); +} + +#[test] +fn clipping_page_transform_and_shared_resources_survive_serialization() { + let mut first = image(); + // A visible-region triangle (IMAGE clip), plus inherited and viewport clips. + first.image.verts.truncate(3); + first + .clips + .push(vec![[15.0, 5.0], [55.0, 5.0], [55.0, 45.0], [15.0, 45.0]]); + first + .clips + .push(vec![[5.0, 15.0], [45.0, 15.0], [45.0, 55.0], [5.0, 55.0]]); + let mut second = page(vec![first.clone()]); + second.rotation_deg = 90; + second.scale = 0.5; + second.offset_x = 10.0; + second.offset_y = 10.0; + let bytes = export(&[page(vec![first]), second]); + let parsed = printpdf::PdfDocument::parse( + &bytes, + &printpdf::PdfParseOptions::default(), + &mut Vec::new(), + ) + .unwrap(); + let uses: Vec<_> = parsed + .pages + .iter() + .flat_map(|p| p.ops.iter()) + .filter_map(|op| { + if let printpdf::Op::UseXobject { id, .. } = op { + Some(id) + } else { + None + } + }) + .collect(); + assert_eq!( + uses.len(), + 2, + "one image draw per affine quad, without a diagonal seam" + ); + assert_eq!(uses[0], uses[1], "pages should share one image resource"); + let raster = Raster::from_pdf(&bytes, 0); + raster.assert_color(40.0, 20.0, [255, 255, 0]); + for (x, y) in [(12.0, 20.0), (48.0, 20.0), (20.0, 40.0), (40.0, 12.0)] { + raster.assert_color(x, y, [255, 255, 255]); + } + Raster::from_pdf(&bytes, 1).assert_color(85.0, 25.0, [255, 255, 0]); +} + +#[test] +fn image_alpha_and_plot_transparency_are_preserved() { + let mut plot = image(); + plot.image.opacity = 0.5; + // Intrinsic transparent texels remain transparent with either plot setting. + let pixels = Arc::make_mut(&mut plot.image.pixels); + for y in 16..32 { + for x in 0..16 { + pixels[(y * 32 + x) * 4 + 3] = 0; + } + } + let opaque = page(vec![plot.clone()]); + let mut transparent = page(vec![plot]); + transparent.options.transparency = true; + let bytes = export(&[opaque, transparent]); + let off = Raster::from_pdf(&bytes, 0); + off.assert_color(20.0, 40.0, [255, 0, 0]); + off.assert_color(20.0, 20.0, [255, 255, 255]); + let on = Raster::from_pdf(&bytes, 1); + on.assert_color(20.0, 40.0, [255, 128, 128]); + on.assert_color(20.0, 20.0, [255, 255, 255]); +} + +#[test] +fn depth_and_paper_model_groups_order_images_with_wipeouts_and_wires() { + let mut scene = Scene::new(); + let wipeout = Wipeout::polygonal( + &[ + Vector2::new(10.0, 30.0), + Vector2::new(30.0, 30.0), + Vector2::new(30.0, 50.0), + Vector2::new(10.0, 50.0), + ], + 0.0, + ); + scene.add_entity(EntityType::Wipeout(wipeout)); + let mut input = page(vec![image()]); + input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone(); + assert_eq!(input.content.wipeouts.len(), 1); + input.content.wipeouts[0].draw_depth = 0.8; + let mut wire = WireModel::solid( + "over image".into(), + vec![[35.0, 15.0, 0.0], [45.0, 15.0, 0.0]], + [0.0, 0.0, 0.0, 1.0], + false, + ); + wire.line_weight_px = 8.0; + input.content.wires = Arc::new(vec![PlotWire { + wire, + draw_depth: 0.9, + }]); + let raster = Raster::from_pdf(&export(&[input]), 0); + raster.assert_color(20.0, 40.0, [255, 255, 255]); + raster.assert_color(40.0, 40.0, [0, 0, 255]); + raster.assert_color(40.0, 15.0, [0, 0, 0]); + + // First group wins by depth internally, second group still paints last. + let mut input = page(vec![image()]); + input.content.wipeouts = scene.paper_plot_wipeouts().as_ref().clone(); + input.content.wipeouts[0].draw_depth = 0.9; + input.content.group_splits = PlotGroupSplits { + wipeouts: 1, + ..Default::default() + }; + Raster::from_pdf(&export(&[input]), 0).assert_color(20.0, 40.0, [255, 0, 0]); +} + +#[test] +fn viewport_projection_clips_images_and_respects_frozen_and_nonplot_layers() { + let mut scene = embedded_scene(); + scene.set_current_layout("Model".into()); + scene.ensure_layer("Bitmap"); + let mut insert = Insert::new("SyntheticImage", Vector3::new(0.0, 0.0, 0.0)); + insert.common.layer = "Bitmap".into(); + insert.set_x_scale(20.0); + insert.set_y_scale(20.0); + scene.add_entity(EntityType::Insert(insert)); + scene.set_current_layout("Layout1".into()); + let mut viewport = Viewport::new(); + viewport.center = Vector3::new(50.0, 50.0, 0.0); + viewport.width = 20.0; + viewport.height = 20.0; + viewport.view_height = 20.0; + viewport.view_center = Vector3::new(20.0, 10.0, 0.0); + viewport.id = 2; + viewport.status.is_on = true; + let handle = scene.add_entity(EntityType::Viewport(viewport)); + let (_, _, _, images) = scene.viewport_plot_fills(); + assert_eq!(images.len(), 1); + let raster = Raster::from_pdf(&export(&[page(images)]), 0); + raster.assert_color(42.0, 43.0, [255, 0, 0]); + raster.assert_color(58.0, 57.0, [0, 0, 255]); + raster.assert_color(35.0, 45.0, [255, 255, 255]); + let layer = scene.document.layers.get("Bitmap").unwrap().handle; + let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else { + panic!() + }; + vp.frozen_layers.push(layer); + assert!(scene.viewport_plot_fills().3.is_empty()); + let Some(EntityType::Viewport(vp)) = scene.document.get_entity_mut(handle) else { + panic!() + }; + vp.frozen_layers.clear(); + scene + .document + .layers + .get_mut("Bitmap") + .unwrap() + .is_plottable = false; + assert!(scene.viewport_plot_fills().3.is_empty()); +} + +#[test] +fn linked_image_uses_decoded_pixels_and_its_image_clip_without_reopening() { + use acadrust::entities::{ClipBoundary, RasterImage}; + let path = std::env::temp_dir().join(format!("ocs1266-linked-{}.png", std::process::id())); + let source = image(); + image::save_buffer(&path, &source.image.pixels, 32, 32, image::ColorType::Rgba8).unwrap(); + let mut raster = RasterImage::new( + path.to_str().unwrap(), + Vector3::new(10.0, 10.0, 0.0), + 32.0, + 32.0, + ); + raster.u_vector = Vector3::new(1.25, 0.0, 0.0); + raster.v_vector = Vector3::new(0.0, 1.25, 0.0); + raster.clipping_enabled = true; + raster.clip_boundary = + ClipBoundary::rectangular(Vector2::new(0.0, 0.0), Vector2::new(16.0, 32.0)); + let mut scene = Scene::new(); + let handle = scene.add_entity(EntityType::RasterImage(raster)); + scene.populate_images_from_document(); + std::fs::remove_file(path).unwrap(); + let images = scene.paper_plot_images(); + assert_eq!(images.len(), 1); + assert!(Arc::ptr_eq( + &images[0].image.pixels, + &scene.images[&handle].pixels + )); + let rendered = Raster::from_pdf(&export(&[page(images)]), 0); + rendered.assert_color(20.0, 40.0, [255, 0, 0]); + rendered.assert_color(20.0, 20.0, [0, 255, 0]); + rendered.assert_color(40.0, 40.0, [255, 255, 255]); +} + +#[test] +fn equal_sized_sources_remain_distinct_across_pages() { + let pages = { + let red = image(); + let mut cyan = image(); + Arc::make_mut(&mut cyan.image.pixels) + .chunks_exact_mut(4) + .for_each(|pixel| pixel.copy_from_slice(&[0, 255, 255, 255])); + assert!(!Arc::ptr_eq(&red.image.pixels, &cyan.image.pixels)); + vec![ + page(vec![red.clone()]), + page(vec![cyan.clone()]), + page(vec![red]), + page(vec![cyan]), + ] + }; + let bytes = export(&pages); + let parsed = printpdf::PdfDocument::parse( + &bytes, + &printpdf::PdfParseOptions::default(), + &mut Vec::new(), + ) + .unwrap(); + let uses: Vec<_> = parsed + .pages + .iter() + .flat_map(|page| &page.ops) + .filter_map(|op| { + if let printpdf::Op::UseXobject { id, .. } = op { + Some(id) + } else { + None + } + }) + .collect(); + assert_eq!(uses.len(), 4); + assert_ne!(uses[0], uses[1]); + assert_eq!(uses[0], uses[2]); + assert_eq!(uses[1], uses[3]); + for (index, expected) in [[255, 0, 0], [0, 255, 255], [255, 0, 0], [0, 255, 255]] + .into_iter() + .enumerate() + { + Raster::from_pdf(&bytes, index).assert_color(20.0, 40.0, expected); + } +} + +#[test] +fn affine_triangle_clip_has_no_shared_edge_seam() { + let mut plot = image(); + Arc::make_mut(&mut plot.image.pixels) + .chunks_exact_mut(4) + .for_each(|pixel| pixel.copy_from_slice(&[0, 0, 0, 128])); + let mut sheared = plot.clone(); + let transform = |[x, y, z]: [f32; 3]| [x + 0.27 * y + 5.0, 0.19 * x + y + 3.0, z]; + sheared + .image + .corners + .iter_mut() + .for_each(|p| *p = transform(*p)); + sheared + .image + .verts + .iter_mut() + .for_each(|v| v.pos = transform(v.pos)); + let pages = [page(vec![plot]), page(vec![sheared])]; + let bytes = export(&pages); + for (index, page) in pages.iter().enumerate() { + let raster = Raster::at_scale(&bytes, index, 8.0); + let corners = page.content.images[0].image.corners; + let u = [corners[1][0] - corners[0][0], corners[1][1] - corners[0][1]]; + let v = [corners[3][0] - corners[0][0], corners[3][1] - corners[0][1]]; + let determinant = u[0] * v[1] - u[1] * v[0]; + let mut checked = 0; + for y in 0..raster.height { + for x in 0..raster.width { + let dx = (x as f32 + 0.5) / raster.scale * 25.4 / 72.0 - corners[0][0]; + let dy = (raster.height as f32 - y as f32 - 0.5) / raster.scale * 25.4 / 72.0 + - corners[0][1]; + let a = (dx * v[1] - dy * v[0]) / determinant; + let b = (u[0] * dy - u[1] * dx) / determinant; + if (0.15..0.85).contains(&a) && (0.15..0.85).contains(&b) { + let rgb = &raster.pixels[(y * raster.width + x) * 4..][..3]; + assert!(rgb.iter().all(|&c| c.abs_diff(127) <= 2), + "page {index}, pixel ({x}, {y}): shared-edge gap or double coverage: {rgb:?}"); + checked += 1; + } + } + } + assert!(checked > 100_000); + } +} + +#[test] +fn malformed_bitmap_reports_an_error_without_replacing_the_pdf() { + let path = std::env::temp_dir().join(format!("ocs1266-invalid-{}.pdf", std::process::id())); + std::fs::write(&path, b"existing PDF").unwrap(); + let mut pixels = image(); + pixels.image.width += 1; + let mut coordinates = image(); + coordinates.image.corners[0][0] = f32::NAN; + let mut mapping = image(); + mapping.image.corners[2][0] += 10.0; + mapping + .image + .verts + .iter_mut() + .for_each(|vertex| vertex.uv = [0.0, 0.0]); + for (plot, reason) in [ + (pixels, "dimensions"), + (coordinates, "coordinates"), + (mapping, "texture mapping"), + ] { + let error = export_pdf(&page(vec![plot]), &path).unwrap_err(); + assert!( + error.contains("Page 1") && error.contains(reason), + "{error}" + ); + assert_eq!(std::fs::read(&path).unwrap(), b"existing PDF"); + } + std::fs::remove_file(path).unwrap(); +} diff --git a/tests/pdf_export_text_check.rs b/tests/pdf_export_text_check.rs index c15db1277..1efd0825a 100644 --- a/tests/pdf_export_text_check.rs +++ b/tests/pdf_export_text_check.rs @@ -11,7 +11,9 @@ use acadrust::entities::{Dimension, DimensionLinear, Text}; use acadrust::types::Vector3; use acadrust::EntityType; -use OpenCADStudio::io::pdf_export::{export_pdf, PdfPlotOptions, PlotWire}; +use OpenCADStudio::io::pdf_export::{ + export_pdf, PdfPageInput, PdfPlotOptions, PlotContent, PlotWire, +}; use OpenCADStudio::scene::Scene; #[test] @@ -60,49 +62,38 @@ fn text_and_dim_reach_pdf_export() { .collect(); let p_text = dir.join("with_text.pdf"); - export_pdf( - &plot_wires, - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - &p_text, - None, - PdfPlotOptions::default(), - ) - .expect("export with text"); + let mut page = PdfPageInput { + content: PlotContent { + wires: std::sync::Arc::new(plot_wires), + ..Default::default() + }, + paper_w: 210.0, + paper_h: 297.0, + offset_x: 0.0, + offset_y: 0.0, + rotation_deg: 0, + scale: 1.0, + clip: None, + options: PdfPlotOptions::default(), + plot_style: None, + }; + export_pdf(&page, &p_text).expect("export with text"); let with_text = std::fs::read(&p_text).expect("read pdf"); assert!(with_text.starts_with(b"%PDF"), "not a PDF"); - let stripped: Vec<_> = plot_wires - .into_iter() + let stripped: Vec<_> = page + .content + .wires + .iter() + .cloned() .map(|mut w| { w.wire.text_verts.clear(); w }) .collect(); let p_bare = dir.join("no_text.pdf"); - export_pdf( - &stripped, - &[], - &[], - 210.0, - 297.0, - 0.0, - 0.0, - 0, - 1.0, - None, - &p_bare, - None, - PdfPlotOptions::default(), - ) - .expect("export without text"); + page.content.wires = std::sync::Arc::new(stripped); + export_pdf(&page, &p_bare).expect("export without text"); let no_text = std::fs::read(&p_bare).expect("read pdf"); assert!( From 743a25b76a9202adf7623c8524d81df0e8d27122 Mon Sep 17 00:00:00 2001 From: kevin9327 <5299031+kevin9327@users.noreply.github.com> Date: Tue, 15 Sep 2026 07:09:27 +0900 Subject: [PATCH 09/16] fix(plot-style): keep custom lineweights at full precision when saving Saving a plot style table wrote every value of its custom lineweight table with two decimals, so a pen such as 0.035 mm came back as 0.04 mm after a save and reload, and every color using it plotted with the wrong weight. Write the lineweights in full, through the same formatting the scale factor already uses, which keeps the decimal point on a whole value. Co-Authored-By: Claude Opus 5 --- src/io/plot_style.rs | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/io/plot_style.rs b/src/io/plot_style.rs index becceca44..89b2033c6 100644 --- a/src/io/plot_style.rs +++ b/src/io/plot_style.rs @@ -357,13 +357,8 @@ impl PlotStyleTable { let description = self.description.replace(['\r', '\n'], " "); s.push_str(&format!("description=\"{description}\n")); s.push_str("aci_table_available=TRUE\n"); - // The factor scales every lineweight when applied, so write it in full, - // keeping the decimal point on a whole value. - let mut scale_factor = self.scale_factor.to_string(); - if scale_factor.chars().all(|c| c.is_ascii_digit() || c == '-') { - scale_factor.push_str(".0"); - } - s.push_str(&format!("scale_factor={scale_factor}\n")); + // The factor scales every lineweight when applied, so write it in full. + s.push_str(&format!("scale_factor={}\n", full_real(self.scale_factor))); s.push_str(&format!( "apply_factor={}\n", if self.apply_factor { "TRUE" } else { "FALSE" } @@ -420,7 +415,7 @@ impl PlotStyleTable { } s.push_str("}\ncustom_lineweight_table{\n"); for (index, weight) in self.lineweights.iter().enumerate() { - s.push_str(&format!(" {index}={weight:.2}\n")); + s.push_str(&format!(" {index}={}\n", full_real(*weight))); } s.push_str("}\n"); s @@ -512,6 +507,15 @@ fn adler32(bytes: &[u8]) -> u32 { (b << 16) | a } +/// A real number written in full, keeping the decimal point on a whole value. +fn full_real(value: f32) -> String { + let mut text = value.to_string(); + if text.chars().all(|c| c.is_ascii_digit() || c == '-') { + text.push_str(".0"); + } + text +} + fn packed_rgb([r, g, b]: [u8; 3]) -> i32 { u32::from_be_bytes([0xC2, r, g, b]) as i32 } @@ -780,4 +784,16 @@ mod round_trip_tests { assert!(table.to_text().contains("\nscale_factor=1.0\n")); assert_eq!(saved_and_reloaded(&table).scale_factor, 1.0); } + + /// Saving keeps each custom lineweight as the table holds it. They used to + /// be cut to two decimals, so a 0.035 mm pen came back as a 0.04 mm one. + #[test] + fn custom_lineweights_survive_a_save() { + let mut table = PlotStyleTable::identity("pens.ctb"); + table.lineweights[1] = 0.035; + table.aci_entries[1].lineweight = 1; + let reloaded = saved_and_reloaded(&table); + assert_eq!(reloaded.lineweights, table.lineweights); + assert_eq!(reloaded.resolve_lineweight(1), Some(0.035)); + } } From e16ab7c86f439a56cef7a1f574937c8b75483dbf Mon Sep 17 00:00:00 2001 From: Christopher Overbeck Date: Mon, 14 Sep 2026 17:09:33 -0500 Subject: [PATCH 10/16] Preserve narrow spline clearances when tessellating solid fills Recover failed small spline boundaries with the kernel chord-error sampler, using the same coordinate frame as the fill mesh. Preserve source curves and enforce recovery and total hatch vertex limits. Fixes #1268 --- src/scene/entity.rs | 35 ++++- src/scene/hatch_boundary.rs | 167 ++++++++++++++++++++ src/scene/hatch_boundary/tests.rs | 246 ++++++++++++++++++++++++++++++ src/scene/mod.rs | 1 + 4 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 src/scene/hatch_boundary.rs create mode 100644 src/scene/hatch_boundary/tests.rs diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 26d122d7f..a74c5f0ac 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1870,7 +1870,7 @@ impl Scene { .map(|point| to_xy(point[0], point[1])) .collect(); rings.push(ring); - local_rings.push(local_ring); + local_rings.push((local_ring, path)); ring_sources.push(path.boundary_handles.clone()); } @@ -1883,7 +1883,8 @@ impl Scene { let mut local_boundary = Vec::new(); let mut boundary_exterior = Vec::new(); let mut boundary_sources = Vec::new(); - for (((ring, local_ring), sources), depth) in rings + let mut spline_paths = Vec::new(); + for (((ring, (local_ring, path)), sources), depth) in rings .into_iter() .zip(local_rings) .zip(ring_sources) @@ -1901,12 +1902,14 @@ impl Scene { boundary.push([f64::NAN, f64::NAN]); local_boundary.push([f32::NAN, f32::NAN]); } + let range = boundary.len()..boundary.len() + ring.len(); boundary.extend(ring); - local_boundary.extend( - local_ring - .into_iter() - .map(|[x, y]| [x as f32, y as f32]), - ); + local_boundary.extend(local_ring.iter().map(|&[x, y]| [x as f32, y as f32])); + if path.edges.iter().any(|edge| { + matches!(edge, acadrust::entities::BoundaryEdge::Spline(_)) + }) { + spline_paths.push((path, local_ring, range)); + } boundary_exterior.push(depth == 0); boundary_sources.push(sources); } @@ -2095,6 +2098,24 @@ impl Scene { } } } + let project = |point: [f64; 2]| { + let [x, y] = to_xy(point[0], point[1]); + [(x - world_origin[0]) as f32 as f64, (y - world_origin[1]) as f32 as f64] + }; + for (path, ring, range) in spline_paths.into_iter().rev() { + if range.end > boundary.len() { + continue; + } + let curves = || path.edges.iter().filter_map(crate::entities::hatch::edge_curve).collect(); + if let Some(refined) = super::hatch_boundary::refine(&ring, curves, &project) { + if boundary.len() - range.len() + refined.len() > MAX_HATCH_MODEL_VERTS { + continue; + } + boundary.splice(range.clone(), refined.iter().map(|p| to_xy(p[0], p[1]))); + local_boundary.splice(range, refined.into_iter().map(|[x, y]| [x as f32, y as f32])); + } + } + let boundary_f32: Vec<[f32; 2]> = boundary .iter() .map(|&[x, y]| { diff --git a/src/scene/hatch_boundary.rs b/src/scene/hatch_boundary.rs new file mode 100644 index 000000000..daec2f0a4 --- /dev/null +++ b/src/scene/hatch_boundary.rs @@ -0,0 +1,167 @@ +use cadkernel::geom2d::{triangulate_rings, Curve}; +use cadkernel::tessellation::DEFAULT_ANGLE; + +#[cfg(test)] +mod tests; + +const MAX_RECOVERY_POINTS: usize = 512; +const MAX_EXTRA_POINTS: usize = 128; +const MAX_PASSES: usize = 16; +const MAX_INTERSECTION_CHECKS: usize = 1_000_000; + +/// Resample a crossed spline boundary without changing ordinary boundaries. +/// `project` must use the same coordinate frame as the eventual fill mesh. +pub(super) fn refine( + ring: &[[f64; 2]], + curves: impl FnOnce() -> Vec, + project: impl Fn([f64; 2]) -> [f64; 2], +) -> Option> { + if ring.len() > MAX_RECOVERY_POINTS { + return None; + } + let mut work_left = MAX_INTERSECTION_CHECKS; + if !crossings(&[ring], &project, &mut work_left)?[0] { + return None; + } + let projected = ring.iter().copied().map(&project).collect(); + if !triangulate_rings(&[projected]).1.is_empty() { + return None; + } + let limit = (ring.len() + MAX_EXTRA_POINTS).min(MAX_RECOVERY_POINTS); + let mut spans = Vec::new(); + for curve in curves() { + if let Curve::Nurbs(spline) = curve { + let (start, end) = spline.domain(); + if end <= start || spline.control_points().len() > limit { + return None; + } + for pair in spline.knots().windows(2) { + if pair[0] >= start && pair[1] <= end && pair[0] < pair[1] { + spans.push(Curve::Nurbs(spline.trimmed( + (pair[0] - start) / (end - start), + (pair[1] - start) / (end - start), + )?)); + } + } + } else { + spans.push(curve); + } + } + let mut remaining = limit; + let mut points: Vec<_> = spans + .iter() + .map(|curve| sample(curve, &mut remaining)) + .collect::>()?; + for _ in 0..MAX_PASSES { + let marked = crossings(&points, &project, &mut work_left)?; + if !marked.iter().any(|marked| *marked) { + let ring = super::entity::chain_path_edges(points); + let projected = ring.iter().copied().map(&project).collect(); + return (!triangulate_rings(&[projected]).1.is_empty()).then_some(ring); + } + let mut next = Vec::new(); + let mut sampled = Vec::new(); + let mut changed = false; + let mut remaining = limit; + for ((curve, points), marked) in spans.into_iter().zip(points).zip(marked) { + if marked { + if let Curve::Nurbs(spline) = &curve { + let (a, b) = spline.split_at(0.5)?; + for part in [Curve::Nurbs(a), Curve::Nurbs(b)] { + sampled.push(sample(&part, &mut remaining)?); + next.push(part); + } + changed = true; + continue; + } + } + remaining = remaining.checked_sub(points.len())?; + next.push(curve); + sampled.push(points); + } + if !changed { + return None; + } + spans = next; + points = sampled; + } + None +} + +fn sample(curve: &Curve, remaining: &mut usize) -> Option> { + let Curve::Nurbs(spline) = curve else { + let points = curve.tessellate_angle(DEFAULT_ANGLE); + *remaining = remaining.checked_sub(points.len())?; + return Some(points); + }; + let mut min = [f64::INFINITY; 2]; + let mut max = [f64::NEG_INFINITY; 2]; + for point in spline.control_points() { + for axis in 0..2 { + min[axis] = min[axis].min(point[axis]); + max[axis] = max[axis].max(point[axis]); + } + } + // Scale chord error to this span; splitting tightens only crossed spans. + let tolerance = (max[0] - min[0]).hypot(max[1] - min[1]) * (1.0 - (DEFAULT_ANGLE * 0.5).cos()); + if !tolerance.is_finite() || tolerance <= 0.0 { + return None; + } + let points = curve.tessellate_within(tolerance); + *remaining = remaining.checked_sub(points.len())?; + Some(points) +} + +fn crossings( + edges: &[impl AsRef<[[f64; 2]]>], + project: &impl Fn([f64; 2]) -> [f64; 2], + work_left: &mut usize, +) -> Option> { + let mut marked = vec![false; edges.len()]; + for projected in [false, true] { + let mut segments = Vec::new(); + for (edge, points) in edges.iter().enumerate() { + for pair in points.as_ref().windows(2) { + let [a, b] = [pair[0], pair[1]].map(|p| if projected { project(p) } else { p }); + if !a.into_iter().chain(b).all(f64::is_finite) { + return None; + } + segments.push((a[0].min(b[0]), a[0].max(b[0]), edge, a, b)); + } + } + segments.sort_unstable_by(|a, b| a.0.total_cmp(&b.0)); + for (index, &(_, right, edge, a, b)) in segments.iter().enumerate() { + for &(left, _, other, c, d) in &segments[index + 1..] { + if left > right { + break; + } + *work_left = work_left.checked_sub(1)?; + if a[1].max(b[1]) < c[1].min(d[1]) || c[1].max(d[1]) < a[1].min(b[1]) { + continue; + } + let cross = |a: [f64; 2], b: [f64; 2], c: [f64; 2]| { + (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]) + }; + let opposite = |a: f64, b: f64| (a < 0.0 && b > 0.0) || (a > 0.0 && b < 0.0); + let inside = |p: [f64; 2], a: [f64; 2], b: [f64; 2]| { + cross(a, b, p) == 0.0 + && (0..2).any(|axis| { + p[axis] > a[axis].min(b[axis]) && p[axis] < a[axis].max(b[axis]) + }) + }; + if opposite(cross(a, b, c), cross(a, b, d)) + && opposite(cross(c, d, a), cross(c, d, b)) + || (inside(a, c, d) + || inside(b, c, d) + || inside(c, a, b) + || inside(d, a, b) + || a != b && (a == c && b == d || a == d && b == c)) + { + marked[edge] = true; + marked[other] = true; + } + } + } + } + Some(marked) +} diff --git a/src/scene/hatch_boundary/tests.rs b/src/scene/hatch_boundary/tests.rs new file mode 100644 index 000000000..175ce7a92 --- /dev/null +++ b/src/scene/hatch_boundary/tests.rs @@ -0,0 +1,246 @@ +use super::*; +use crate::scene::Scene; +use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, Hatch, PolylineEdge, SplineEdge}; +use acadrust::types::{Vector2, Vector3}; +use cadkernel::geom2d::NurbsCurve; + +fn narrow(clearance: f64, offset: [f64; 2]) -> NurbsCurve { + let line = |a: [f64; 2], b: [f64; 2]| { + [ + a, + std::array::from_fn(|i| a[i] + (b[i] - a[i]) / 3.0), + std::array::from_fn(|i| a[i] + 2.0 * (b[i] - a[i]) / 3.0), + b, + ] + }; + let [a, b, c, d, e, f] = [ + [-1.0, -1.0], + [0.0, -1.0], + [0.0, 1.0], + [clearance, 0.999], + [-0.5, 1.5], + [-1.0, 1.5], + ]; + // The returning cubic passes close to the vertical side b--c. + let spans = [ + line(a, b), + line(b, c), + line(c, d), + [d, [clearance, 1.1], [-0.3, 1.5], e], + line(e, f), + line(f, a), + ]; + let mut controls = vec![a]; + for span in spans { + controls.extend_from_slice(&span[1..]); + } + for p in &mut controls { + for i in 0..2 { + p[i] += offset[i]; + } + } + let mut knots = vec![0.0; 4]; + for i in 1..6 { + knots.extend([i as f64; 3]); + } + knots.extend([6.0; 4]); + NurbsCurve::new(3, controls, knots, None).unwrap() +} + +fn edge(spline: NurbsCurve) -> BoundaryEdge { + BoundaryEdge::Spline(SplineEdge { + degree: spline.degree() as i32, + rational: false, + periodic: false, + knots: spline.knots().to_vec(), + control_points: spline + .control_points() + .iter() + .map(|p| Vector3::new(p[0], p[1], 0.0)) + .collect(), + fit_points: Vec::new(), + start_tangent: Vector2::new(0.0, 0.0), + end_tangent: Vector2::new(0.0, 0.0), + }) +} + +fn rectangle(a: [f64; 2], b: [f64; 2], offset: [f64; 2]) -> BoundaryPath { + let points = [a, [b[0], a[1]], b, [a[0], b[1]]] + .map(|p| Vector2::new(p[0] + offset[0], p[1] + offset[1])); + let mut path = BoundaryPath::new(); + path.add_edge(BoundaryEdge::Polyline(PolylineEdge::new( + points.to_vec(), + true, + ))); + path +} + +#[test] +fn narrow_fill_preserves_corners_holes_and_shared_origin() { + let curve = narrow(0.00001, [0.0; 2]); + let area: f64 = (0..6) + .map(|i| { + Curve::Nurbs(curve.trimmed(i as f64 / 6.0, (i + 1) as f64 / 6.0).unwrap()) + .enclosed_area() + }) + .sum(); + for (offset, shuffled, multiple) in [ + ([0.0; 2], false, false), + ([1e6, -1e6], false, false), + ([0.0; 2], true, true), + ([1e6, -1e6], true, true), + ] { + let spline = narrow(0.00001, offset); + let mut path = BoundaryPath::new(); + path.edges = if shuffled { + let (a, b) = spline.split_at(0.5).unwrap(); + vec![edge(b), edge(a.reversed())] + } else { + vec![edge(spline.clone())] + }; + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + if multiple { + hatch.paths.extend([ + rectangle([-0.8, -0.8], [-0.2, -0.2], offset), + rectangle([-0.6, -0.6], [-0.4, -0.4], offset), + rectangle([1.0, -1.0], [2.0, 2.5], offset), + ]); + } + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + if multiple { + assert_eq!(model.world_origin, [offset[0] + 0.5, offset[1] + 0.75]); + } + let project = |p: [f64; 2]| { + [ + (p[0] - model.world_origin[0]) as f32, + (p[1] - model.world_origin[1]) as f32, + ] + }; + let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE); + let original: Vec<_> = original + .into_iter() + .map(|p| project(p).map(f64::from)) + .collect(); + assert!(triangulate_rings(&[original]).1.is_empty()); + for i in 0..=6 { + assert!(model + .boundary + .contains(&project(spline.point_at(i as f64 / 6.0)))); + } + assert_eq!(model.boundary_paths.as_deref().unwrap(), &hatch.paths); + let (vertices, indices) = model.fill_mesh(); + assert!(!indices.is_empty()); + let mut filled_area = 0.0; + for triangle in indices.chunks_exact(3) { + let [a, b, c] = [triangle[0], triangle[1], triangle[2]] + .map(|i| vertices[i as usize].map(f64::from)); + let twice_area = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]); + assert!(twice_area > 0.0); + filled_area += twice_area * 0.5; + if multiple { + let p = + [0, 1].map(|i| (a[i] + b[i] + c[i]) / 3.0 + model.world_origin[i] - offset[i]); + let inside = |lo, hi| p.iter().all(|&v| v > lo && v < hi); + assert!(!inside(-0.8, -0.2) || inside(-0.6, -0.4)); + } + } + let expected = area + if multiple { 3.5 - 0.36 + 0.04 } else { 0.0 }; + assert!( + (filled_area - expected).abs() < 0.001, + "{filled_area} vs {expected}" + ); + } +} + +#[test] +fn valid_samples_are_unchanged_and_invalid_samples_stay_rejected() { + for clearance in [0.01, 0.000001, 0.000002, 0.000005] { + let spline = narrow(clearance, [0.0; 2]); + let original = Curve::Nurbs(spline.clone()).tessellate_angle(DEFAULT_ANGLE); + let mut path = BoundaryPath::new(); + path.edges.push(edge(spline)); + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + let expected: Vec<_> = original + .iter() + .map(|p| { + [ + (p[0] - model.world_origin[0]) as f32, + (p[1] - model.world_origin[1]) as f32, + ] + }) + .collect(); + assert_eq!(model.boundary.as_ref(), &expected); + assert_eq!(model.fill_mesh().1.is_empty(), clearance < 0.01); + } + let curve = Curve::Nurbs(narrow(0.01, [0.0; 2])); + assert!(refine( + &curve.tessellate_angle(DEFAULT_ANGLE), + || panic!("valid path resampled"), + |p| p + ) + .is_none()); + assert!(refine( + &vec![[0.0; 2]; MAX_RECOVERY_POINTS + 1], + || panic!("size cap ignored"), + |p| p + ) + .is_none()); +} + +#[test] +fn contacts_and_intersection_work_are_bounded() { + for (a, b, expected) in [ + ([[0.0, 0.0], [1.0, 1.0]], [[1.0, 1.0], [2.0, 0.0]], false), + ([[0.0, 0.0], [2.0, 0.0]], [[1.0, 0.0], [1.0, 1.0]], true), + ([[0.0, 0.0], [2.0, 0.0]], [[1.0, 0.0], [3.0, 0.0]], true), + ] { + assert_eq!(crossings(&[a, b], &|p| p, &mut 10).unwrap(), [expected; 2]); + } + let edges = vec![[[0.0, 0.0], [1.0, 1.0]]; 100]; + let mut budget = 7; + assert!(crossings(&edges, &|p| p, &mut budget).is_none()); + assert_eq!(budget, 0); +} + +#[test] +fn recovery_respects_the_total_hatch_vertex_cap() { + let curve = Curve::Nurbs(narrow(0.00001, [0.0; 2])); + let original = curve.tessellate_angle(DEFAULT_ANGLE); + let Curve::Nurbs(spline) = curve.clone() else { + unreachable!() + }; + let mut path = BoundaryPath::new(); + path.edges.push(edge(spline)); + let mut hatch = Hatch::new(); + hatch.is_solid = true; + hatch.paths.push(path); + let count = 16_384 - original.len() - 2; + let points = (0..count) + .map(|i| { + let angle = std::f64::consts::TAU * i as f64 / count as f64; + Vector2::new(1.5 + 0.5 * angle.cos(), 0.75 + 1.75 * angle.sin()) + }) + .collect(); + let mut other = BoundaryPath::new(); + other.add_edge(BoundaryEdge::Polyline(PolylineEdge::new(points, true))); + hatch.paths.push(other); + let model = Scene::hatch_model_from_dxf(&hatch, [1.0; 4]).unwrap(); + let project = |p: [f64; 2]| { + [ + (p[0] - model.world_origin[0]) as f32 as f64, + (p[1] - model.world_origin[1]) as f32 as f64, + ] + }; + assert!(refine(&original, || vec![curve], project).unwrap().len() > original.len()); + assert_eq!(model.boundary.len(), 16_384); + let expected: Vec<_> = original + .into_iter() + .map(|p| project(p).map(|v| v as f32)) + .collect(); + assert_eq!(&model.boundary[..expected.len()], expected.as_slice()); +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs index d36fef59f..d2b43361e 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -33,6 +33,7 @@ pub(crate) mod dimension_assoc; pub(crate) mod centermark; mod dwg_native_constraints; mod entity; +mod hatch_boundary; mod group_layer; mod layout; mod limits; From 66be530655fa86e504d367e2bec20c63369d0028 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:47:28 +0300 Subject: [PATCH 11/16] Complete automatic constraint behavior --- Cargo.lock | 4 +- Cargo.toml | 4 +- src/app/command_driver.rs | 191 ++++++++++++++++-- src/app/commands/draw.rs | 101 ++++++---- src/app/commands/styleprops.rs | 49 ++++- src/app/mod.rs | 31 +++ src/app/properties.rs | 28 ++- src/app/settings.rs | 122 +++++++++++ src/app/update/command.rs | 40 +++- src/app/update/file.rs | 13 ++ src/app/update/mod.rs | 111 ++++++++++ src/app/update/viewport.rs | 135 ++++++++++++- src/app/view/mod.rs | 12 +- src/app/view/modal.rs | 15 ++ src/command.rs | 2 + src/modules/draw/select.rs | 37 +++- src/modules/parametric/mod.rs | 2 +- src/scene/mod.rs | 58 +++++- src/scene/parametric_constraints.rs | 246 +++++++++++++++++++---- src/scene/parametric_solve.rs | 218 +++++++++++++++++++- src/scene/selection.rs | 1 + src/scene/view/render.rs | 5 +- src/ui/overlay.rs | 27 ++- src/ui/window/auto_constrain_settings.rs | 143 +++++++++++++ src/ui/window/mod.rs | 1 + 25 files changed, 1494 insertions(+), 102 deletions(-) create mode 100644 src/ui/window/auto_constrain_settings.rs diff --git a/Cargo.lock b/Cargo.lock index 7efbae01f..b706764fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -919,7 +919,7 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" +source = "git+https://github.com/ramox81/cadkernel.git?rev=ffac0b9ba9fc3aec7723a772b08ea84065359c3a#ffac0b9ba9fc3aec7723a772b08ea84065359c3a" dependencies = [ "acadrust", "cavalier_contours", @@ -930,7 +930,7 @@ dependencies = [ [[package]] name = "cadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=b8b664a#b8b664ae993e392fb84689966a44fa9a3f4e38cd" +source = "git+https://github.com/ramox81/cadkernel.git?rev=ffac0b9ba9fc3aec7723a772b08ea84065359c3a#ffac0b9ba9fc3aec7723a772b08ea84065359c3a" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 688a7c795..a3f180cef 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,8 +33,8 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "568a12c", features = ["serde"] } -cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "b8b664a" } +cadkernel = { git = "https://github.com/ramox81/cadkernel.git", rev = "ffac0b9ba9fc3aec7723a772b08ea84065359c3a", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/ramox81/cadkernel.git", rev = "ffac0b9ba9fc3aec7723a772b08ea84065359c3a" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 0600de34d..a689e8852 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1592,6 +1592,92 @@ impl OpenCADStudio { Task::none() } + fn apply_continuous_constraints(&mut self, i: usize, new_handles: &[acadrust::Handle]) { + let supported_creation = self.tabs[i] + .active_cmd + .as_ref() + .is_some_and(|command| { + matches!( + command.name(), + "LINE" + | "PLINE" + | "POLYLINE" + | "RECTANG" + | "RECTANGLE" + | "POLYGON" + | "CIRCLE" + | "ARC" + ) + }); + if supported_creation { + self.apply_inferred_constraints(i, new_handles); + } + } + + pub(in crate::app) fn apply_inferred_constraints( + &mut self, + i: usize, + changed_handles: &[acadrust::Handle], + ) { + if !self.constraint_infer || changed_handles.is_empty() { + return; + } + let scope = self.tabs[i].current_parametric_scope(); + let owner = scope.owner_handle(&self.tabs[i].scene.document); + let candidates = self.tabs[i] + .scene + .document + .block_records + .iter() + .find(|record| record.handle == owner) + .map(|record| record.entity_handles.clone()) + .unwrap_or_default(); + let inferred = self.tabs[i].scene.inferred_parametric_constraints( + scope, + &candidates, + &self.auto_constrain_settings, + ); + let inferred: Vec<_> = inferred + .into_iter() + .filter(|(_, refs)| { + refs.iter() + .any(|reference| changed_handles.contains(&reference.entity)) + }) + .collect(); + if inferred.is_empty() { + return; + } + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new(scope) + }); + self.tabs[i] + .scene + .record_undo_parametric_constraints_before(scope, before); + let mut touched = Vec::new(); + for (kind, refs) in inferred { + touched.extend(refs.iter().map(|reference| reference.entity)); + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + } + touched.sort_unstable_by_key(|handle| handle.value()); + touched.dedup(); + let changes: Vec<_> = touched + .into_iter() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + } + fn apply_cmd_result_inner(&mut self, result: CmdResult) -> Task { let i = self.active_tab; if let Some(bind) = self.tabs[i] @@ -1613,6 +1699,15 @@ impl OpenCADStudio { .as_ref() .is_some_and(|command| command.preserve_commit_layer()); match result { + CmdResult::OpenAutoConstrainSettings => { + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.active_modal = Some(super::ModalKind::AutoConstrainSettings); + } CmdResult::NeedPoint => { // ATTEDIT finished its entity pick: hand the chosen block off to // the attribute editor dialog and end the command (open_attribute @@ -1720,6 +1815,9 @@ impl OpenCADStudio { .attach_dimension_association(handle, sources); } } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } self.tabs[i].dirty = true; let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); if let Some(p) = prompt { @@ -1760,15 +1858,18 @@ impl OpenCADStudio { } self.refresh_layer_panel(); } + let mut committed = Vec::new(); for entity in entities { - if preserve_commit_style { - let _ = self.commit_entity_handle_preserve_style(entity); + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) } else if preserve_commit_layer { - let _ = self.commit_entity_handle_preserve_layer(entity); + self.commit_entity_handle_preserve_layer(entity) } else { - self.commit_entity(entity); - } + self.commit_entity_handle(entity) + }; + committed.extend(handle); } + self.apply_continuous_constraints(i, &committed); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); let prompt = self.tabs[i].active_cmd.as_ref().map(|c| c.prompt()); @@ -1799,15 +1900,18 @@ impl OpenCADStudio { } self.refresh_layer_panel(); } + let mut committed = Vec::new(); for entity in entities { - if preserve_commit_style { - let _ = self.commit_entity_handle_preserve_style(entity); + let handle = if preserve_commit_style { + self.commit_entity_handle_preserve_style(entity) } else if preserve_commit_layer { - let _ = self.commit_entity_handle_preserve_layer(entity); + self.commit_entity_handle_preserve_layer(entity) } else { - self.commit_entity(entity); - } + self.commit_entity_handle(entity) + }; + committed.extend(handle); } + self.apply_continuous_constraints(i, &committed); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2063,6 +2167,7 @@ impl OpenCADStudio { // transform_entities — always delta-safe. let pending = self.begin_undo(i, label, handles.len(), true); self.tabs[i].scene.transform_entities(&handles, &transform); + self.apply_inferred_constraints(i, &handles); self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2181,6 +2286,9 @@ impl OpenCADStudio { .attach_dimension_association(handle, sources); } } + if let Some(handle) = committed { + self.apply_continuous_constraints(i, &[handle]); + } self.tabs[i].dirty = true; self.tabs[i].scene.clear_preview_wire(); self.tabs[i].active_cmd = None; @@ -2910,6 +3018,7 @@ impl OpenCADStudio { self.tabs[i] .scene .record_undo_parametric_constraints_before(scope, constraints_before); + let retain_size = self.constraint_solve_mode && driving_param.is_none(); self.tabs[i].scene.parametric_constraint_set_mut(scope).add( kind, refs, @@ -2919,7 +3028,11 @@ impl OpenCADStudio { .into_iter() .map(|h| (h, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + retain_size, + ); self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; self.tabs[i].snap_result = None; @@ -4467,6 +4580,7 @@ impl OpenCADStudio { let pending = self.begin_undo(i, "STRETCH", handles.len(), !structural); let mut count = 0usize; let mut changed_handles = Vec::new(); + let mut driven_refs = Vec::new(); // Helper: is DXF point (x, y) inside the world-space window? // Drawing plane is world XY (= DXF XY). @@ -4485,6 +4599,54 @@ impl OpenCADStudio { let mut stretched_dims: Vec = Vec::new(); for handle in &handles { let before = self.tabs[i].scene.document.get_entity_arc(*handle); + if let Some(entity) = before.as_deref() { + use crate::scene::parametric_constraints::ParametricRef; + match entity { + acadrust::EntityType::Line(line) => { + if in_win(line.start.x, line.start.y) { + driven_refs.push(ParametricRef::point(*handle, 0)); + } + if in_win(line.end.x, line.end.y) { + driven_refs.push(ParametricRef::point(*handle, 1)); + } + } + acadrust::EntityType::LwPolyline(polyline) => { + if let Some(world) = + crate::entities::curve::lwpolyline_world_xy(polyline) + { + for (index, vertex) in world.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + } + acadrust::EntityType::Polyline2D(polyline) => { + for (index, vertex) in polyline.vertices.iter().enumerate() { + if in_win(vertex.location.x, vertex.location.y) { + driven_refs.push(ParametricRef::point( + *handle, + index as i32, + )); + } + } + } + acadrust::EntityType::Circle(circle) + if in_win(circle.center.x, circle.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + acadrust::EntityType::Arc(arc) + if in_win(arc.center.x, arc.center.y) => + { + driven_refs.push(ParametricRef::center(*handle)); + } + _ => {} + } + } let Some(entity) = self.tabs[i].scene.document.get_entity_mut(*handle) else { continue; }; @@ -4707,7 +4869,12 @@ impl OpenCADStudio { .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + ); + self.apply_inferred_constraints(i, &changed_handles); } self.tabs[i].dirty = true; self.tabs[i].active_cmd = None; diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index e45c8c1a4..7ef3f7b36 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -1113,55 +1113,86 @@ impl OpenCADStudio { } } + "CONSTRAINTSETTINGS" => { + self.auto_constrain_saved = Some(self.auto_constrain_settings.clone()); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.active_modal = Some(crate::app::ModalKind::AutoConstrainSettings); + } + "AUTOCONSTRAIN" => { let handles = self.tabs[i].scene.selected_handles_in_order(); if handles.is_empty() { use crate::modules::draw::select::SelectObjectsCommand; - let sel = SelectObjectsCommand::new(cmd); + let sel = SelectObjectsCommand::auto_constrain(cmd); self.command_line.push_info(&sel.prompt()); self.tabs[i].active_cmd = Some(Box::new(sel)); } else { let scope = self.tabs[i].current_parametric_scope(); let inferred = self.tabs[i] .scene - .inferred_parametric_constraints(scope, &handles); + .inferred_parametric_constraints( + scope, + &handles, + &self.auto_constrain_settings, + ); if inferred.is_empty() { - self.command_line - .push_output("No supported geometric relations were found."); - return None; - } - let before = self.tabs[i] - .scene - .parametric_constraint_set(scope) - .cloned() - .unwrap_or_else(|| { - crate::scene::parametric_constraints::ParametricConstraintSet::new( - scope, + self.command_line.push_output( + format!( + "0 constraint(s) applied to {} object(s).", + handles.len() ) - }); - let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); - self.tabs[i] - .scene - .record_undo_parametric_constraints_before(scope, before); - let count = inferred.len(); - for (kind, refs) in inferred { + .as_str(), + ); + self.tabs[i].scene.deselect_all(); + self.refresh_selected_grips(); + } else { + let before = self.tabs[i] + .scene + .parametric_constraint_set(scope) + .cloned() + .unwrap_or_else(|| { + crate::scene::parametric_constraints::ParametricConstraintSet::new( + scope, + ) + }); + let pending = self.begin_undo(i, "Auto constrain", handles.len(), true); self.tabs[i] .scene - .parametric_constraint_set_mut(scope) - .add(kind, refs, None); - } - let changes: Vec<_> = handles - .iter() - .copied() - .map(|handle| (handle, crate::scene::ChangeKind::Modified)) - .collect(); - self.tabs[i].scene.bump_entities(&changes); - self.tabs[i].dirty = true; - self.refresh_properties(); - self.command_line - .push_output(format!("{} constraint(s) applied.", count).as_str()); - if let Some(pd) = pending { - self.commit_undo_delta(i, pd); + .record_undo_parametric_constraints_before(scope, before); + let count = inferred.len(); + for (kind, refs) in inferred { + self.tabs[i] + .scene + .parametric_constraint_set_mut(scope) + .add(kind, refs, None); + } + let changes: Vec<_> = handles + .iter() + .copied() + .map(|handle| (handle, crate::scene::ChangeKind::Modified)) + .collect(); + self.tabs[i].scene.bump_entities_with_parametric_policy( + &changes, + &[], + self.constraint_solve_mode, + ); + self.tabs[i].dirty = true; + self.refresh_properties(); + self.command_line.push_output( + format!( + "{} constraint(s) applied to {} object(s).", + count, + handles.len() + ) + .as_str(), + ); + if let Some(pd) = pending { + self.commit_undo_delta(i, pd); + } } } } diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 9fed425e9..751d2e5d2 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1046,6 +1046,9 @@ impl OpenCADStudio { | "GRIPHOT" | "GRIPHOVER" | "GRIPOBJLIMIT" + | "CONSTRAINTSOLVEMODE" + | "CONSTRAINTINFER" + | "CONSTRAINTBARDISPLAY" ) => { return self.dispatch_styleprops(&format!("SETVAR {cmd}"), i); @@ -1068,7 +1071,7 @@ impl OpenCADStudio { let value = it.next().map(|s| s.trim().to_string()); if name.is_empty() || name == "?" { self.command_line.push_info( - crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), + crate::t!("SETVAR: CETRANSPARENCY LTSCALE CELTSCALE PDMODE PDSIZE TEXTSIZE ORTHOMODE FILLMODE MIRRTEXT FRAME IMAGEFRAME PDFFRAME WIPEOUTFRAME XCLIPFRAME POINTCLOUDCLIPFRAME ZOOMWHEEL ZOOMFACTOR CURSORSIZE PICKBOX CURSORTYPE SNAPANG TEXTFILL CLIPROMPTLINES COMMANDLINEFADETIME ATTREQ ATTDIA DIMASSOC DIMCONTINUEMODE CONSTRAINTSOLVEMODE CONSTRAINTINFER CONSTRAINTBARDISPLAY ANGBASE ANGDIR SKETCHINC SKPOLY SKTOLERANCE DONUTID DONUTOD CENTEREXE CENTERLAYER CENTERLTYPE CENTERLTSCALE CENTERLTYPEFILE CENTERCROSSSIZE CENTERCROSSGAP CENTERMARKEXE COLORTHEME SELECTIONAREA SELECTIONAREAOPACITY SELECTIONEFFECT SELECTIONEFFECTCOLOR WINDOWSAREACOLOR CROSSINGAREACOLOR SELECTIONPREVIEW GRIPSIZE GRIPCOLOR GRIPHOT GRIPHOVER GRIPOBJLIMIT | CLAYER CELTYPE TEXTSTYLE (read-only)").as_ref(), ); } else { if name == "CETRANSPARENCY" { @@ -1262,6 +1265,50 @@ impl OpenCADStudio { } return Some(self.finish_dispatch(cmd)); } + if matches!( + name.as_str(), + "CONSTRAINTSOLVEMODE" | "CONSTRAINTINFER" | "CONSTRAINTBARDISPLAY" + ) { + let current = match name.as_str() { + "CONSTRAINTSOLVEMODE" => i16::from(self.constraint_solve_mode), + "CONSTRAINTINFER" => i16::from(self.constraint_infer), + "CONSTRAINTBARDISPLAY" => self.constraint_bar_display, + _ => unreachable!(), + }; + let maximum = if name == "CONSTRAINTBARDISPLAY" { 3 } else { 1 }; + if let Some(value) = &value { + match value + .parse::() + .ok() + .filter(|value| (0..=maximum).contains(value)) + { + Some(mode) => { + match name.as_str() { + "CONSTRAINTSOLVEMODE" => { + self.constraint_solve_mode = mode != 0 + } + "CONSTRAINTINFER" => self.constraint_infer = mode != 0, + "CONSTRAINTBARDISPLAY" => { + self.constraint_bar_display = mode + } + _ => unreachable!(), + } + self.persist_settings_if_changed(); + self.command_line + .push_output(&crate::tf!("{name} = {mode}")); + } + None => self.command_line.push_error(&crate::tf!( + "{name}: expected an integer from 0 to {maximum}." + )), + } + } else { + self.command_line.push_output(crate::tf!( + "Enter new value for {name} <{current}>:" + ).as_ref()); + self.pending_setvar = Some(name.clone()); + } + return Some(self.finish_dispatch(cmd)); + } if matches!(name.as_str(), "DONUTID" | "DONUTOD") { let current = if name == "DONUTID" { crate::modules::draw::defaults::get_donut_inner_diameter() diff --git a/src/app/mod.rs b/src/app/mod.rs index 50a864cf0..af96e2ac4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -556,6 +556,14 @@ pub(super) struct OpenCADStudio { /// glyph plus a driven value or named-parameter name. When false, every /// pill shows only the glyph. pub show_constraint_values: bool, + pub auto_constrain_settings: settings::AutoConstrainSettings, + auto_constrain_saved: Option, + auto_constrain_selected_row: usize, + auto_constrain_distance_input: String, + auto_constrain_angle_input: String, + pub constraint_solve_mode: bool, + pub constraint_infer: bool, + pub constraint_bar_display: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); /// 0 disables autosave. pub savetime_min: i32, @@ -1722,6 +1730,7 @@ pub enum ModalKind { DrawingUnits, GeometricTolerance, DraftingSettings, + AutoConstrainSettings, LayerStateEditor, Plot, PrintAll, @@ -2640,6 +2649,20 @@ pub enum Message { DraftingSettingsClose, DraftingSettingsCloseDiscard, DraftingSettingsCloseKeep, + AutoConstrainSelectRow(usize), + AutoConstrainToggleKind(settings::AutoConstraintKind), + AutoConstrainMoveUp, + AutoConstrainMoveDown, + AutoConstrainSelectAll, + AutoConstrainClearAll, + AutoConstrainReset, + AutoConstrainToggleTangentPoint, + AutoConstrainTogglePerpendicularIntersection, + AutoConstrainDistanceChanged(String), + AutoConstrainAngleChanged(String), + AutoConstrainApply, + AutoConstrainOk, + AutoConstrainCancel, /// Toggle a ribbon dropdown open/closed. ToggleRibbonDropdown(String), /// Toggle a collapsed ribbon panel's flyout open/closed (by panel title). @@ -3631,6 +3654,14 @@ impl OpenCADStudio { backup_on_save: true, file_assoc_enabled: true, show_constraint_values: true, + auto_constrain_settings: settings::AutoConstrainSettings::default(), + auto_constrain_saved: None, + auto_constrain_selected_row: 0, + auto_constrain_distance_input: "0.05".to_string(), + auto_constrain_angle_input: "1".to_string(), + constraint_solve_mode: true, + constraint_infer: false, + constraint_bar_display: 3, savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, diff --git a/src/app/properties.rs b/src/app/properties.rs index 393467b2d..cb3b846be 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2772,6 +2772,25 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} } pub(super) fn invalidate_property_targets(&mut self, i: usize, handles: &[Handle]) { + self.invalidate_property_targets_with_driven(i, handles, &[]); + } + + pub(super) fn invalidate_property_targets_with_driven( + &mut self, + i: usize, + handles: &[Handle], + driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + ) { + self.invalidate_property_targets_with_originals(i, handles, driven_refs, &[]); + } + + pub(super) fn invalidate_property_targets_with_originals( + &mut self, + i: usize, + handles: &[Handle], + driven_refs: &[crate::scene::parametric_constraints::ParametricRef], + retained_originals: &[(Handle, acadrust::EntityType)], + ) { let mut context_object_changed = false; for &handle in handles { // A dimension is drawn from the block holding its picture, and that @@ -2801,7 +2820,14 @@ handles={handles_ms:.1} panel={:.1} ribbon={ribbon_ms:.1} tail={:.1} selected={} .iter() .map(|&handle| (handle, crate::scene::ChangeKind::Modified)) .collect(); - self.tabs[i].scene.bump_entities(&changes); + self.tabs[i] + .scene + .bump_entities_with_parametric_originals( + &changes, + driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + retained_originals, + ); } /// Apply a single-property edit to every handle in `handles`, recording the diff --git a/src/app/settings.rs b/src/app/settings.rs index 4607b72d6..12cf09c9d 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -11,6 +11,103 @@ use crate::snap::SnapType; use serde::{Deserialize, Serialize}; +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AutoConstraintKind { + Coincident, + Collinear, + Parallel, + Perpendicular, + Tangent, + Concentric, + Horizontal, + Vertical, + Equal, +} + +impl AutoConstraintKind { + pub const ALL: [Self; 9] = [ + Self::Coincident, + Self::Collinear, + Self::Parallel, + Self::Perpendicular, + Self::Tangent, + Self::Concentric, + Self::Horizontal, + Self::Vertical, + Self::Equal, + ]; + + pub const fn label(self) -> &'static str { + match self { + Self::Coincident => "Coincident", + Self::Collinear => "Collinear", + Self::Parallel => "Parallel", + Self::Perpendicular => "Perpendicular", + Self::Tangent => "Tangent", + Self::Concentric => "Concentric", + Self::Horizontal => "Horizontal", + Self::Vertical => "Vertical", + Self::Equal => "Equal", + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AutoConstrainSettings { + pub priority: Vec, + pub enabled: Vec, + pub tangent_must_share_point: bool, + pub perpendicular_must_intersect: bool, + pub distance_tolerance: f64, + pub angle_tolerance_deg: f64, +} + +impl Default for AutoConstrainSettings { + fn default() -> Self { + Self { + priority: AutoConstraintKind::ALL.to_vec(), + enabled: AutoConstraintKind::ALL.to_vec(), + tangent_must_share_point: true, + perpendicular_must_intersect: true, + distance_tolerance: 0.05, + angle_tolerance_deg: 1.0, + } + } +} + +impl AutoConstrainSettings { + pub fn sanitize(&mut self) { + let mut priority = Vec::with_capacity(AutoConstraintKind::ALL.len()); + for kind in self + .priority + .iter() + .copied() + .chain(AutoConstraintKind::ALL) + { + if !priority.contains(&kind) { + priority.push(kind); + } + } + self.priority = priority; + self.enabled + .retain(|kind| AutoConstraintKind::ALL.contains(kind)); + self.enabled.sort_by_key(|kind| { + self.priority + .iter() + .position(|candidate| candidate == kind) + .unwrap_or(usize::MAX) + }); + self.enabled.dedup(); + if !self.distance_tolerance.is_finite() || self.distance_tolerance < 0.0 { + self.distance_tolerance = 0.05; + } + if !self.angle_tolerance_deg.is_finite() || self.angle_tolerance_deg < 0.0 { + self.angle_tolerance_deg = 1.0; + } + } +} + /// Cursor shown over the drawing viewport. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum CursorType { @@ -289,6 +386,19 @@ pub struct UserSettings { /// canvas detail on a dense sketch. #[serde(default = "default_show_constraint_values")] pub show_constraint_values: bool, + /// Inference types, priority, intersection rules, and tolerances used by + /// the Auto Constrain command. + #[serde(default)] + pub auto_constrain: AutoConstrainSettings, + /// Keep existing geometry size while solving after a constraint edit. + #[serde(default = "default_constraint_solve_mode")] + pub constraint_solve_mode: bool, + /// Apply eligible geometric constraints while creating geometry. + #[serde(default)] + pub constraint_infer: bool, + /// Constraint bar display bit mask: 1 after applying, 2 on selection. + #[serde(default = "default_constraint_bar_display")] + pub constraint_bar_display: i16, /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). /// 0 disables autosave. pub savetime_min: i32, @@ -359,6 +469,14 @@ fn default_show_constraint_values() -> bool { true } +fn default_constraint_solve_mode() -> bool { + true +} + +fn default_constraint_bar_display() -> i16 { + 3 +} + fn deserialize_clipromptlines<'de, D>(de: D) -> Result where D: serde::Deserializer<'de>, @@ -415,6 +533,10 @@ impl Default for UserSettings { backup_on_save: true, file_assoc_enabled: true, show_constraint_values: true, + auto_constrain: AutoConstrainSettings::default(), + constraint_solve_mode: true, + constraint_infer: false, + constraint_bar_display: 3, savetime_min: 10, default_save_format: crate::io::DEFAULT_SAVE_FORMAT.to_string(), pick_add: true, diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 4dd845b72..fd58e70d5 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -3247,6 +3247,29 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.tabs[i].properties.active_field = None; let handles = self.property_target_handles(i); if !handles.is_empty() { + let driven_marker = match field { + "start_x" | "start_y" | "start_z" => Some(0), + "end_x" | "end_y" | "end_z" => Some(1), + "center_x" | "center_y" | "center_z" => Some(-3), + _ => None, + }; + let retained_originals: Vec<_> = if self.constraint_solve_mode + && driven_marker.is_some() + { + handles + .iter() + .filter_map(|handle| { + self.tabs[i] + .scene + .document + .get_entity(*handle) + .cloned() + .map(|entity| (*handle, entity)) + }) + .collect() + } else { + Vec::new() + }; let evaluates_expression = self.tabs[i] .properties .sections @@ -3563,7 +3586,22 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { } } } - self.invalidate_property_targets(i, &handles); + let driven_refs: Vec<_> = driven_marker + .into_iter() + .flat_map(|marker| { + handles.iter().copied().map(move |handle| { + crate::scene::parametric_constraints::ParametricRef::point( + handle, marker, + ) + }) + }) + .collect(); + self.invalidate_property_targets_with_originals( + i, + &handles, + &driven_refs, + &retained_originals, + ); self.tabs[i].dirty = true; self.refresh_properties(); } diff --git a/src/app/update/file.rs b/src/app/update/file.rs index ac9b254cf..1d7301d63 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -429,6 +429,10 @@ impl OpenCADStudio { backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, show_constraint_values: self.show_constraint_values, + auto_constrain: self.auto_constrain_settings.clone(), + constraint_solve_mode: self.constraint_solve_mode, + constraint_infer: self.constraint_infer, + constraint_bar_display: self.constraint_bar_display, savetime_min: self.savetime_min, default_save_format: self.default_save_format.clone(), pick_add: self.pick_add, @@ -510,6 +514,15 @@ impl OpenCADStudio { self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; self.show_constraint_values = s.show_constraint_values; + self.auto_constrain_settings = s.auto_constrain.clone(); + self.auto_constrain_settings.sanitize(); + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + self.constraint_solve_mode = s.constraint_solve_mode; + self.constraint_infer = s.constraint_infer; + self.constraint_bar_display = s.constraint_bar_display.clamp(0, 3); self.savetime_min = s.savetime_min; self.default_save_format = crate::io::canonical_save_format(&s.default_save_format).to_string(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 4d3919596..6354847fb 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -4289,6 +4289,112 @@ impl OpenCADStudio { self.drafting_settings_close_confirm = false; Task::none() } + Message::AutoConstrainSelectRow(index) => { + if index < self.auto_constrain_settings.priority.len() { + self.auto_constrain_selected_row = index; + } + Task::none() + } + Message::AutoConstrainToggleKind(kind) => { + if let Some(index) = self + .auto_constrain_settings + .enabled + .iter() + .position(|candidate| *candidate == kind) + { + self.auto_constrain_settings.enabled.remove(index); + } else { + self.auto_constrain_settings.enabled.push(kind); + } + Task::none() + } + Message::AutoConstrainMoveUp => { + let index = self.auto_constrain_selected_row; + if index > 0 && index < self.auto_constrain_settings.priority.len() { + self.auto_constrain_settings.priority.swap(index, index - 1); + self.auto_constrain_selected_row -= 1; + } + Task::none() + } + Message::AutoConstrainMoveDown => { + let index = self.auto_constrain_selected_row; + if index + 1 < self.auto_constrain_settings.priority.len() { + self.auto_constrain_settings.priority.swap(index, index + 1); + self.auto_constrain_selected_row += 1; + } + Task::none() + } + Message::AutoConstrainSelectAll => { + self.auto_constrain_settings.enabled = + super::settings::AutoConstraintKind::ALL.to_vec(); + Task::none() + } + Message::AutoConstrainClearAll => { + self.auto_constrain_settings.enabled.clear(); + Task::none() + } + Message::AutoConstrainReset => { + self.auto_constrain_settings = + super::settings::AutoConstrainSettings::default(); + self.auto_constrain_selected_row = 0; + self.auto_constrain_distance_input = + format!("{}", self.auto_constrain_settings.distance_tolerance); + self.auto_constrain_angle_input = + format!("{}", self.auto_constrain_settings.angle_tolerance_deg); + Task::none() + } + Message::AutoConstrainToggleTangentPoint => { + self.auto_constrain_settings.tangent_must_share_point = + !self.auto_constrain_settings.tangent_must_share_point; + Task::none() + } + Message::AutoConstrainTogglePerpendicularIntersection => { + self.auto_constrain_settings.perpendicular_must_intersect = + !self.auto_constrain_settings.perpendicular_must_intersect; + Task::none() + } + Message::AutoConstrainDistanceChanged(value) => { + self.auto_constrain_distance_input = value; + Task::none() + } + Message::AutoConstrainAngleChanged(value) => { + self.auto_constrain_angle_input = value; + Task::none() + } + action @ (Message::AutoConstrainApply | Message::AutoConstrainOk) => { + let distance = self.auto_constrain_distance_input.trim().parse::(); + let angle = self.auto_constrain_angle_input.trim().parse::(); + match (distance, angle) { + (Ok(distance), Ok(angle)) + if distance.is_finite() + && distance >= 0.0 + && angle.is_finite() + && angle >= 0.0 => + { + self.auto_constrain_settings.distance_tolerance = distance; + self.auto_constrain_settings.angle_tolerance_deg = angle; + self.auto_constrain_settings.sanitize(); + self.auto_constrain_saved = + Some(self.auto_constrain_settings.clone()); + self.persist_settings_if_changed(); + if matches!(action, Message::AutoConstrainOk) { + self.close_active_modal(); + } + } + _ => self.command_line.push_error( + crate::t!("Distance and angle tolerances must be non-negative numbers.") + .as_ref(), + ), + } + Task::none() + } + Message::AutoConstrainCancel => { + if let Some(saved) = self.auto_constrain_saved.take() { + self.auto_constrain_settings = saved; + } + self.close_active_modal(); + Task::none() + } // ── Ribbon dropdowns ────────────────────────────────────────── Message::ToggleRibbonDropdown(id) => { @@ -6847,6 +6953,11 @@ impl OpenCADStudio { return Task::none(); } self.drafting_settings_close_confirm = false; + if self.active_modal == Some(super::ModalKind::AutoConstrainSettings) { + if let Some(saved) = self.auto_constrain_saved.take() { + self.auto_constrain_settings = saved; + } + } let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery); self.close_active_modal(); if resume_open_queue { diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index ae3372607..11d5bf180 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -933,6 +933,13 @@ impl OpenCADStudio { return Task::none(); } let i = self.active_tab; + let constraint_hover = self + .constraint_glyph_under(i, p) + .map(|(_, handles)| handles) + .unwrap_or_default(); + self.tabs[i] + .scene + .set_constraint_hover_highlights(&constraint_hover); // Modifier-driven selection must be known before cursor_plane/axis and // drafting constraints are read, not merely before the final callback. if let Some(command) = self.tabs[i].active_cmd.as_mut() { @@ -1737,11 +1744,31 @@ impl OpenCADStudio { ); } } + let driven_refs: Vec<_> = grip + .targets + .iter() + .filter_map(|target| { + let entity = self.tabs[i] + .scene + .document + .get_entity(target.handle)?; + crate::scene::parametric_constraints::driven_ref_for_grip( + entity, + target.handle, + target.grip_id, + ) + }) + .collect(); // Re-solve constrained neighbors on each drag frame and include // their original state in the gesture's undo record. let solved_by_constraints = self.tabs[i] .scene - .solve_parametric_constraints_preview(&edited_handles); + .solve_parametric_constraints_preview( + &edited_handles, + &driven_refs, + self.constraint_solve_mode && !driven_refs.is_empty(), + &self.grip_originals, + ); for (handle, _) in &solved_by_constraints { let handle = *handle; if !self.grip_preview_handles.contains(&handle) { @@ -2637,6 +2664,7 @@ impl OpenCADStudio { // viewport so it doesn't stick while the mouse is over the // ribbon / panels. self.tabs[i].scene.set_hover_highlight(None); + self.tabs[i].scene.set_constraint_hover_highlights(&[]); // Don't touch `context_menu` here. ViewportExit also fires // when an upper overlay (the right-click menu panel) takes // the cursor, so clearing the menu state on every exit @@ -2949,6 +2977,94 @@ impl OpenCADStudio { Task::none() } + fn constraint_glyph_under( + &self, + i: usize, + cursor: iced::Point, + ) -> Option<( + crate::scene::parametric_constraints::ConstraintKind, + Vec, + )> { + if self.tabs[i].scene.current_layout != "Model" { + return None; + } + let (vw, vh) = self.tabs[i].scene.selection.borrow().vp_size; + let scope = self.tabs[i].current_parametric_scope(); + let set = self.tabs[i].scene.parametric_constraint_set(scope)?; + let edit_frame = self.tabs[i].scene.viewport_edit_frame((vw, vh)); + let bounds = match &edit_frame { + Some((_, full)) => *full, + None => self.tabs[i].scene.active_model_tile_bounds(vw, vh), + }; + let (view_rot, eye) = if let Some((camera, _)) = &edit_frame { + (camera.view_proj_rte(bounds), camera.eye()) + } else { + let camera = self.tabs[i].scene.camera.borrow(); + (camera.view_proj_rte(bounds), camera.eye()) + }; + let mut ids = Vec::new(); + let mut glyphs = Vec::new(); + for constraint in set.constraints.iter().filter(|constraint| { + let selected = constraint.refs.iter().any(|reference| { + self.tabs[i].scene.selected.contains(&reference.entity) + }); + constraint.enabled + && self.tabs[i].scene.should_display_parametric_constraint( + scope, + constraint.id, + selected, + self.constraint_bar_display, + ) + }) { + let Some((anchor, outward)) = + crate::scene::parametric_constraints::glyph_placement( + &self.tabs[i].scene.document, + constraint, + ) else { + continue; + }; + let Some(screen) = crate::scene::pick::grip::project_rte( + glam::DVec3::new(anchor.x, anchor.y, anchor.z), + view_rot, + eye, + bounds, + ) else { + continue; + }; + let Some(outward_screen) = crate::scene::pick::grip::project_rte( + glam::DVec3::new( + anchor.x + outward.x, + anchor.y + outward.y, + anchor.z + outward.z, + ), + view_rot, + eye, + bounds, + ) else { + continue; + }; + let direction = (outward_screen - screen).normalize_or(glam::Vec2::NEG_Y); + let point = iced::Point::new(bounds.x + screen.x, bounds.y + screen.y); + let label = if self.show_constraint_values { + crate::scene::parametric_constraints::glyph_label(constraint) + } else { + constraint.kind.glyph_symbol().to_string() + }; + let conflict = set + .conflicts + .iter() + .any(|(id, _)| *id == constraint.id); + ids.push(constraint.id); + glyphs.push((point, direction.to_array(), label, conflict)); + } + let index = crate::ui::overlay::constraint_glyph_hit(&glyphs, cursor)?; + let constraint = set.get(ids[index])?; + let mut handles: Vec<_> = constraint.refs.iter().map(|r| r.entity).collect(); + handles.sort_unstable_by_key(|handle| handle.value()); + handles.dedup(); + Some((constraint.kind, handles)) + } + pub(super) fn on_viewport_left_press(&mut self) -> Task { let i = self.active_tab; if let Some(command) = self.tabs[i].active_cmd.as_mut() { @@ -3004,6 +3120,23 @@ impl OpenCADStudio { }; let (vw, vh) = vp_size; + if self.tabs[i].active_cmd.is_none() { + if let Some((kind, handles)) = self.constraint_glyph_under(i, p) { + self.tabs[i].scene.deselect_all(); + self.tabs[i].scene.select_entities(&handles); + self.refresh_selected_grips(); + self.refresh_properties(); + self.command_line + .push_info(&format!("{kind:?} constraint selected.")); + self.tabs[i] + .scene + .selection + .borrow_mut() + .clear_left_selection_gesture(); + return Task::none(); + } + } + // An engaged grip owns the next left press (click-move-click placement // or the release of a press-drag). Do not let the same press arm the // normal box/lasso state or trigger another viewport control; the diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index bd48e6260..d4c133128 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -835,7 +835,17 @@ bg={bg_ms:.1}ms n={view_count}" set.constraints .iter() .filter(|c| c.enabled) - .filter(|c| tab.scene.is_parametric_constraint_visible(scope, c.id)) + .filter(|c| { + let selected = c.refs.iter().any(|reference| { + tab.scene.selected.contains(&reference.entity) + }); + tab.scene.should_display_parametric_constraint( + scope, + c.id, + selected, + self.constraint_bar_display, + ) + }) .filter_map(|c| { let (anchor, outward) = crate::scene::parametric_constraints::glyph_placement( diff --git a/src/app/view/modal.rs b/src/app/view/modal.rs index 218fe3e4d..2291d8654 100644 --- a/src/app/view/modal.rs +++ b/src/app/view/modal.rs @@ -25,6 +25,7 @@ impl OpenCADStudio { Some(K::DrawingUnits) => crate::t!("Drawing Units").into_owned(), Some(K::GeometricTolerance) => crate::t!("Geometric Tolerance").into_owned(), Some(K::DraftingSettings) => crate::t!("Drafting Settings").into_owned(), + Some(K::AutoConstrainSettings) => crate::t!("Constraint Settings").into_owned(), Some(K::LayerStateEditor) => crate::tr!("modal", "edit-layer-state"), Some(K::Plot) => crate::tr!("modal", "plot"), Some(K::PrintAll) => t!("Print All").into_owned(), @@ -307,6 +308,20 @@ impl OpenCADStudio { }, ) } + super::super::ModalKind::AutoConstrainSettings => sized_flow( + ex, + 620, + 610, + |flow| { + crate::ui::window::auto_constrain_settings::view_window( + &self.auto_constrain_settings, + self.auto_constrain_selected_row, + &self.auto_constrain_distance_input, + &self.auto_constrain_angle_input, + flow, + ) + }, + ), super::super::ModalKind::FindReplace => automatic_flow(ex, |flow| { crate::ui::window::find_replace::view_window( &self.find_replace.search, diff --git a/src/command.rs b/src/command.rs index b4e9da1df..bdc482e3f 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1466,6 +1466,8 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, + /// Opens the Auto Constrain settings dialog from the selection prompt. + OpenAutoConstrainSettings, /// Adds a Coincident constraint between the sub-entity points nearest /// the two picked positions. The host resolves the raw points because /// `CadCommand` has no document access. diff --git a/src/modules/draw/select.rs b/src/modules/draw/select.rs index 8116fbc07..d695ce6d7 100644 --- a/src/modules/draw/select.rs +++ b/src/modules/draw/select.rs @@ -34,6 +34,8 @@ pub struct SelectObjectsCommand { /// Whether the polygon being picked takes what it merely touches. Ignored /// by a fence, which has no inside to speak of. pick_crossing: bool, + /// Add the command-specific Settings keyword used by Auto Constrain. + auto_constrain_settings: bool, } impl SelectObjectsCommand { @@ -47,6 +49,7 @@ impl SelectObjectsCommand { show_options: true, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } @@ -61,6 +64,7 @@ impl SelectObjectsCommand { show_options: false, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } @@ -75,8 +79,15 @@ impl SelectObjectsCommand { show_options: false, pick: None, pick_crossing: true, + auto_constrain_settings: false, } } + + pub fn auto_constrain(pending_cmd: &str) -> Self { + let mut command = Self::new(pending_cmd); + command.auto_constrain_settings = true; + command + } } impl CadCommand for SelectObjectsCommand { @@ -101,6 +112,14 @@ impl CadCommand for SelectObjectsCommand { .into_owned(); } if self.commit_on_enter && !self.handles.is_empty() { + if self.auto_constrain_settings { + return t!( + "%{cmd} Select objects or [Settings] (%{count} selected, Enter to apply):", + cmd = self.prompt_cmd, + count = self.handles.len() + ) + .into_owned(); + } t!( "%{cmd} Select objects (%{count} selected, Enter to apply):", cmd = self.prompt_cmd, @@ -108,6 +127,13 @@ impl CadCommand for SelectObjectsCommand { ) .into_owned() } else { + if self.auto_constrain_settings { + return t!( + "%{cmd} Select objects or [Settings]:", + cmd = self.prompt_cmd + ) + .into_owned(); + } t!("%{cmd} Select objects:", cmd = self.prompt_cmd).into_owned() } } @@ -130,7 +156,10 @@ impl CadCommand for SelectObjectsCommand { // the on-screen keyboard-less case needs. fn options(&self) -> Vec { if self.commit_on_enter && self.show_options { - vec![ + if self.auto_constrain_settings { + return vec![CmdOption::new(t!("Settings").as_ref(), "S")]; + } + let options = vec![ CmdOption::new(t!("Window").as_ref(), "W"), CmdOption::new(t!("Crossing").as_ref(), "C"), CmdOption::new(t!("Fence").as_ref(), "F"), @@ -141,7 +170,8 @@ impl CadCommand for SelectObjectsCommand { CmdOption::new(t!("Remove").as_ref(), "R"), CmdOption::new(t!("Previous").as_ref(), "P"), CmdOption::new(t!("Last").as_ref(), "L"), - ] + ]; + options } else { Vec::new() } @@ -181,6 +211,9 @@ impl CadCommand for SelectObjectsCommand { return None; } match text.trim().to_uppercase().as_str() { + "S" | "SETTINGS" if self.auto_constrain_settings => { + Some(CmdResult::OpenAutoConstrainSettings) + } "F" | "FENCE" => { self.pick = Some(FencePick::fence()); Some(CmdResult::NeedPoint) diff --git a/src/modules/parametric/mod.rs b/src/modules/parametric/mod.rs index 41efdbf9e..9539651ee 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -128,7 +128,7 @@ impl CadModule for ParametricModule { inventory::submit!(crate::command::CommandRegistration { names: &[ - "AUTOCONSTRAIN", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", + "AUTOCONSTRAIN", "CONSTRAINTSETTINGS", "SMOOTHCONSTRAINT", "GCSHOW", "GCHIDE", "GCRESET", "GCSHOWALL", "GCHIDEALL", "DCSHOW", "DCHIDE", "DCSHOWALL", "DCHIDEALL", "DCCONVERT", "DELCONSTRAINT", ] diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 928cc2844..b8296048c 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1676,6 +1676,8 @@ pub struct Scene { /// Entity drawn with the selection-highlight colour without being part /// of the real selection — used to preview a row in the cycling list box. pub hover_highlight: Option, + /// All entities related to the constraint indicator currently under the cursor. + constraint_hover_highlights: HashSet, /// Color used to draw the selection highlight overlay wires (SELECTIONEFFECTCOLOR). pub selection_color: [f32; 4], /// Whether selection visual effect (glow/highlight) is enabled (SELECTIONEFFECT). @@ -1956,6 +1958,11 @@ pub struct Scene { parametric_constraints::ParametricScope, parametric_constraints::ConstraintId, )>, + /// Constraint glyphs explicitly shown through the constraint-bar commands. + shown_parametric_constraints: HashSet<( + parametric_constraints::ParametricScope, + parametric_constraints::ConstraintId, + )>, /// Document-wide named-parameter and expression table decoded from /// standard associative variables. pub(crate) named_parameters: named_parameters::ParameterTable, @@ -2199,6 +2206,7 @@ impl Scene { command_preview_hidden: HashSet::default(), refedit_keep: None, hover_highlight: None, + constraint_hover_highlights: HashSet::default(), selection_color: crate::scene::model::wire_model::WireModel::SELECTED, selection_effect: true, transparency_display: true, @@ -2273,6 +2281,7 @@ impl Scene { associative_hatch_source_cache: RefCell::new(None), parametric_constraints: Vec::new(), hidden_parametric_constraints: HashSet::default(), + shown_parametric_constraints: HashSet::default(), named_parameters: named_parameters::ParameterTable::new(), has_associative_centers: std::cell::Cell::new(None), block_defn_cache: RefCell::new(HashMap::default()), @@ -2832,6 +2841,33 @@ impl Scene { } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_parametric_policy(changes, &[], false); + } + + pub fn bump_entities_with_parametric_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + ) { + self.bump_entities_with_parametric_policy(changes, driven_refs, false); + } + + pub fn bump_entities_with_parametric_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + ) { + self.bump_entities_with_parametric_originals(changes, driven_refs, retain_size, &[]); + } + + pub fn bump_entities_with_parametric_originals( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[parametric_constraints::ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], + ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) || self @@ -2865,7 +2901,12 @@ impl Scene { } } if !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints(&changes) { + for change in self.refresh_parametric_constraints_with_originals( + &changes, + driven_refs, + retain_size, + retained_originals, + ) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } @@ -5069,9 +5110,20 @@ impl Scene { /// remains the picked member for hit-test/UI bookkeeping; rendering expands /// it to the same selectable group that a click would select. pub fn hover_highlight_handles(&self) -> HashSet { - self.hover_highlight + let mut handles = self + .hover_highlight .map(|handle| self.handles_expanded_for_selectable_groups(&[handle])) - .unwrap_or_default() + .unwrap_or_default(); + handles.extend(self.constraint_hover_highlights.iter().copied()); + handles + } + + pub fn set_constraint_hover_highlights(&mut self, handles: &[Handle]) { + let desired: HashSet<_> = handles.iter().copied().collect(); + if desired != self.constraint_hover_highlights { + self.constraint_hover_highlights = desired; + self.bump_selection(); + } } /// Keep the current selection visible and temporarily filter every other diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index fe55c5dc4..c8866ead4 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -83,6 +83,45 @@ impl ParametricRef { } } +/// Point reference driven by a native entity grip. Segment midpoint and +/// scalar radius grips deliberately return None because they do not identify +/// one stored point parameter. +pub(crate) fn driven_ref_for_grip( + entity: &acadrust::EntityType, + handle: Handle, + grip_id: usize, +) -> Option { + match entity { + acadrust::EntityType::Line(_) if grip_id <= 1 => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::LwPolyline(polyline) if grip_id < polyline.vertices.len() => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::Polyline2D(polyline) if grip_id < polyline.vertices.len() => { + Some(ParametricRef::point(handle, grip_id as i32)) + } + acadrust::EntityType::Arc(_) => match grip_id { + 0 => Some(ParametricRef::center(handle)), + 1 => Some(ParametricRef::point(handle, 0)), + 3 => Some(ParametricRef::point(handle, 1)), + _ => None, + }, + acadrust::EntityType::Circle(_) if grip_id == 0 => { + Some(ParametricRef::center(handle)) + } + acadrust::EntityType::Point(_) + | acadrust::EntityType::Insert(_) + | acadrust::EntityType::Text(_) + | acadrust::EntityType::MText(_) + if grip_id == 0 => + { + Some(ParametricRef::point(handle, 0)) + } + _ => None, + } +} + /// The friendly, user-facing constraint types — the "what button did they /// click" vocabulary, one layer above the `cadkernel_constraints` primitives each maps /// onto (that mapping is `constraint_map`, a later stage; see the design @@ -576,6 +615,18 @@ impl super::Scene { !self.hidden_parametric_constraints.contains(&(scope, id)) } + pub fn should_display_parametric_constraint( + &self, + scope: ParametricScope, + id: ConstraintId, + related_entity_selected: bool, + display_mode: i16, + ) -> bool { + self.shown_parametric_constraints.contains(&(scope, id)) + || (display_mode & 1 != 0 && self.is_parametric_constraint_visible(scope, id)) + || (display_mode & 2 != 0 && related_entity_selected) + } + pub fn set_parametric_constraint_visibility( &mut self, scope: ParametricScope, @@ -601,8 +652,10 @@ impl super::Scene { for id in &ids { if visible { self.hidden_parametric_constraints.remove(&(scope, *id)); + self.shown_parametric_constraints.insert((scope, *id)); } else { self.hidden_parametric_constraints.insert((scope, *id)); + self.shown_parametric_constraints.remove(&(scope, *id)); } } ids.len() @@ -613,40 +666,155 @@ impl super::Scene { &self, scope: ParametricScope, handles: &[Handle], + settings: &crate::app::settings::AutoConstrainSettings, ) -> Vec<(ConstraintKind, Vec)> { use cadkernel::geom2d::{ - infer_constraints, Arc, Circle, ConstraintEndpoint, InferredConstraint, Line, - ParametricPrimitive, Tolerance, + infer_constraints_with_settings, Arc, Circle, ConstraintEndpoint, InferenceKind, + InferenceSettings, InferredConstraint, Line, ParametricPrimitive, }; + struct Source { + handle: Handle, + primitive: ParametricPrimitive, + whole: ParametricRef, + endpoints: [ParametricRef; 2], + } let mut sources = Vec::new(); for handle in handles { - let primitive = match self.document.get_entity(*handle) { - Some(acadrust::EntityType::Line(line)) => ParametricPrimitive::Line(Line { - start: [line.start.x, line.start.y], - end: [line.end.x, line.end.y], + match self.document.get_entity(*handle) { + Some(acadrust::EntityType::Line(line)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [line.start.x, line.start.y], + end: [line.end.x, line.end.y], + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::point(*handle, 0), + ParametricRef::point(*handle, 1), + ], }), - Some(acadrust::EntityType::Circle(circle)) => ParametricPrimitive::Circle(Circle { - centre: [circle.center.x, circle.center.y], - radius: circle.radius, + Some(acadrust::EntityType::Circle(circle)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Circle(Circle { + centre: [circle.center.x, circle.center.y], + radius: circle.radius, + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::center(*handle), + ParametricRef::center(*handle), + ], }), - Some(acadrust::EntityType::Arc(arc)) => ParametricPrimitive::Arc(Arc { - centre: [arc.center.x, arc.center.y], - radius: arc.radius, - start_angle: arc.start_angle, - end_angle: arc.end_angle, + Some(acadrust::EntityType::Arc(arc)) => sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Arc(Arc { + centre: [arc.center.x, arc.center.y], + radius: arc.radius, + start_angle: arc.start_angle, + end_angle: arc.end_angle, + }), + whole: ParametricRef::whole(*handle), + endpoints: [ + ParametricRef::point(*handle, 0), + ParametricRef::point(*handle, 1), + ], }), + Some(acadrust::EntityType::LwPolyline(polyline)) => { + let Some(world) = crate::entities::curve::lwpolyline_world_xy(polyline) else { + continue; + }; + let count = world.vertices.len(); + for index in 0..count { + let next = index + 1; + if next >= count && !world.is_closed { + break; + } + if world.vertices[index].bulge.abs() > 1e-9 { + continue; + } + let a = world.vertices[index].location; + let b = world.vertices[next % count].location; + sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [a.x, a.y], + end: [b.x, b.y], + }), + whole: ParametricRef::segment(*handle, index), + endpoints: [ + ParametricRef::point(*handle, index as i32), + ParametricRef::point(*handle, (next % count) as i32), + ], + }); + } + } + Some(acadrust::EntityType::Polyline2D(polyline)) => { + let count = polyline.vertices.len(); + for index in 0..count { + let next = index + 1; + if next >= count && !polyline.is_closed() { + break; + } + if polyline.vertices[index].bulge.abs() > 1e-9 { + continue; + } + let a = polyline.vertices[index].location; + let b = polyline.vertices[next % count].location; + sources.push(Source { + handle: *handle, + primitive: ParametricPrimitive::Line(Line { + start: [a.x, a.y], + end: [b.x, b.y], + }), + whole: ParametricRef::segment(*handle, index), + endpoints: [ + ParametricRef::point(*handle, index as i32), + ParametricRef::point(*handle, (next % count) as i32), + ], + }); + } + } _ => continue, - }; - sources.push((*handle, primitive)); + } } - let primitives: Vec<_> = sources.iter().map(|(_, primitive)| *primitive).collect(); + let primitives: Vec<_> = sources.iter().map(|source| source.primitive).collect(); let marker = |endpoint| match endpoint { - ConstraintEndpoint::Start => 0, - ConstraintEndpoint::End => 1, + ConstraintEndpoint::Start => 0usize, + ConstraintEndpoint::End => 1usize, + }; + let kind = |kind| match kind { + crate::app::settings::AutoConstraintKind::Coincident => InferenceKind::Coincident, + crate::app::settings::AutoConstraintKind::Collinear => InferenceKind::Collinear, + crate::app::settings::AutoConstraintKind::Parallel => InferenceKind::Parallel, + crate::app::settings::AutoConstraintKind::Perpendicular => { + InferenceKind::Perpendicular + } + crate::app::settings::AutoConstraintKind::Tangent => InferenceKind::Tangent, + crate::app::settings::AutoConstraintKind::Concentric => InferenceKind::Concentric, + crate::app::settings::AutoConstraintKind::Horizontal => InferenceKind::Horizontal, + crate::app::settings::AutoConstraintKind::Vertical => InferenceKind::Vertical, + crate::app::settings::AutoConstraintKind::Equal => InferenceKind::Equal, + }; + let inference_settings = InferenceSettings { + priority: settings + .priority + .iter() + .copied() + .filter(|candidate| settings.enabled.contains(candidate)) + .map(kind) + .collect(), + distance_tolerance: settings.distance_tolerance, + angle_tolerance_radians: settings.angle_tolerance_deg.to_radians(), + tangent_must_share_point: settings.tangent_must_share_point, + perpendicular_must_intersect: settings.perpendicular_must_intersect, }; let mut mapped: Vec<_> = - infer_constraints(&primitives, Tolerance::new(1e-6), 0.5_f64.to_radians()) + infer_constraints_with_settings(&primitives, &inference_settings) .into_iter() + .filter(|relation| { + !matches!(relation, InferredConstraint::Coincident { first, second, .. } + if sources[*first].handle == sources[*second].handle) + }) .map(|relation| match relation { InferredConstraint::Coincident { first, @@ -656,53 +824,57 @@ impl super::Scene { } => ( ConstraintKind::Coincident, vec![ - ParametricRef::point(sources[first].0, marker(first_endpoint)), - ParametricRef::point(sources[second].0, marker(second_endpoint)), + sources[first].endpoints[marker(first_endpoint)], + sources[second].endpoints[marker(second_endpoint)], ], ), InferredConstraint::Collinear { first, second } => ( ConstraintKind::Colinear, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), InferredConstraint::Concentric { first, second } => ( ConstraintKind::Concentric, vec![ - ParametricRef::center(sources[first].0), - ParametricRef::center(sources[second].0), + ParametricRef::center(sources[first].handle), + ParametricRef::center(sources[second].handle), ], ), InferredConstraint::Parallel { first, second } => ( ConstraintKind::Parallel, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), InferredConstraint::Perpendicular { first, second } => ( ConstraintKind::Perpendicular, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), InferredConstraint::Horizontal { entity } => ( ConstraintKind::Horizontal, - vec![ParametricRef::whole(sources[entity].0)], + vec![sources[entity].whole], ), InferredConstraint::Vertical { entity } => ( ConstraintKind::Vertical, - vec![ParametricRef::whole(sources[entity].0)], + vec![sources[entity].whole], ), InferredConstraint::Tangent { first, second } => ( ConstraintKind::Tangent, vec![ - ParametricRef::whole(sources[first].0), - ParametricRef::whole(sources[second].0), + sources[first].whole, + sources[second].whole, ], ), + InferredConstraint::Equal { first, second } => ( + ConstraintKind::Equal, + vec![sources[first].whole, sources[second].whole], + ), }) .collect(); if let Some(existing) = self.parametric_constraint_set(scope) { @@ -1174,7 +1346,11 @@ mod tests { ); let inferred = - scene.inferred_parametric_constraints(ParametricScope::ModelSpace, &[first, second]); + scene.inferred_parametric_constraints( + ParametricScope::ModelSpace, + &[first, second], + &crate::app::settings::AutoConstrainSettings::default(), + ); assert!(!inferred.iter().any(|(kind, refs)| { *kind == ConstraintKind::Horizontal && *refs == [ParametricRef::whole(first)] diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index 20e9581bb..9057a3410 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1549,6 +1549,44 @@ type SolveResult = ( )>, ); +fn retained_line_length(entity: &EntityType, segment: Option) -> Option { + match (entity, segment) { + (EntityType::Line(line), None) => Some((line.end - line.start).length()), + (EntityType::LwPolyline(polyline), Some(index)) => { + let world = crate::entities::curve::lwpolyline_world_xy(polyline)?; + let a = world.vertices.get(index)?.location; + let b = if let Some(vertex) = world.vertices.get(index + 1) { + vertex.location + } else if world.is_closed { + world.vertices.first()?.location + } else { + return None; + }; + Some((b - a).length()) + } + (EntityType::Polyline2D(polyline), Some(index)) => { + let a = polyline.vertices.get(index)?.location; + let b = if let Some(vertex) = polyline.vertices.get(index + 1) { + vertex.location + } else if polyline.is_closed() { + polyline.vertices.first()?.location + } else { + return None; + }; + Some((b - a).length()) + } + _ => None, + } +} + +fn retained_radius(entity: &EntityType) -> Option { + match entity { + EntityType::Circle(circle) => Some(circle.radius), + EntityType::Arc(arc) => Some(arc.radius), + _ => None, + } +} + /// Rebuilds `set`'s entire `cadkernel_constraints::System` from current document /// geometry, solves it, and returns the resulting entity states for every /// handle whose registered coordinates actually moved beyond floating-point @@ -1564,6 +1602,9 @@ fn solve_scope( document: &acadrust::CadDocument, drawing_params: &ParameterTable, set: &ParametricConstraintSet, + driven_refs: &[ParametricRef], + retain_size: bool, + retained_before: &HashMap>, ) -> Option { let params = if set.local_parameters.is_empty() { drawing_params @@ -1597,6 +1638,81 @@ fn solve_scope( return None; } + // Preserve current line lengths and radii for this solve when + // CONSTRAINTSOLVEMODE requests it. These temporary equations guide the + // solution without becoming persistent dimensional constraints. + if retain_size { + for (handle, geom) in &cache { + match geom { + EntityGeom::Line(line) => { + let current_length = { + let store = sys.store(); + let dx = store.get(line.p2.x) - store.get(line.p1.x); + let dy = store.get(line.p2.y) - store.get(line.p1.y); + (dx * dx + dy * dy).sqrt() + }; + let length = retained_before + .get(handle) + .and_then(|entity| retained_line_length(entity, None)) + .unwrap_or(current_length); + let target = sys.add_param(length, true); + sys.add_constraint(Rc::new(P2PDistance::new(line.p1, line.p2, target))); + } + EntityGeom::Polyline { + points, + straight, + closed, + } => { + for index in 0..straight.len() { + if !straight[index] { + continue; + } + let Some(a) = points.get(index).copied() else { + continue; + }; + let b = if let Some(point) = points.get(index + 1).copied() { + Some(point) + } else if *closed { + points.first().copied() + } else { + None + }; + let Some(b) = b else { continue }; + let current_length = { + let store = sys.store(); + let dx = store.get(b.x) - store.get(a.x); + let dy = store.get(b.y) - store.get(a.y); + (dx * dx + dy * dy).sqrt() + }; + let length = retained_before + .get(handle) + .and_then(|entity| retained_line_length(entity, Some(index))) + .unwrap_or(current_length); + let target = sys.add_param(length, true); + sys.add_constraint(Rc::new(P2PDistance::new(a, b, target))); + } + } + EntityGeom::Circle(circle) => { + let radius = retained_before + .get(handle) + .and_then(|entity| retained_radius(entity)) + .unwrap_or_else(|| sys.store().get(circle.rad)); + let target = sys.add_param(radius, true); + sys.add_constraint(Rc::new(Equal::new(circle.rad, target, 1.0))); + } + EntityGeom::Arc(arc) => { + let radius = retained_before + .get(handle) + .and_then(|entity| retained_radius(entity)) + .unwrap_or_else(|| sys.store().get(arc.circle.rad)); + let target = sys.add_param(radius, true); + sys.add_constraint(Rc::new(Equal::new(arc.circle.rad, target, 1.0))); + } + _ => {} + } + } + } + // Arc rules (this module's doc comment): keep every registered arc's // `start`/`end` points consistent with its center/radius/angle, // unconditionally — the kernel adds these the moment an arc @@ -1633,6 +1749,33 @@ fn solve_scope( ))); } + // A point explicitly edited by a grip, STRETCH, or Properties is the + // driver's target for this solve. Pin its current coordinates for this + // solve only so constrained neighbours respond while the chosen point + // stays at the exact cursor or typed coordinate. + for reference in driven_refs { + if !set + .constraints + .iter() + .any(|constraint| constraint.refs.iter().any(|r| r.entity == reference.entity)) + { + continue; + } + let Some(point) = + resolve_constraint_point(document, &mut sys, &mut cache, *reference) + else { + continue; + }; + let (x, y) = { + let store = sys.store(); + (store.get(point.x), store.get(point.y)) + }; + let target_x = sys.add_param(x, true); + let target_y = sys.add_param(y, true); + sys.add_constraint(Rc::new(Equal::new(point.x, target_x, 1.0))); + sys.add_constraint(Rc::new(Equal::new(point.y, target_y, 1.0))); + } + // Ellipse rules (same motivation as arc rules, different mechanism): // `cadkernel_constraints::geo::Ellipse` stores `focus1` as an absolute point, not an // offset from `center`. If some constraint (e.g. Concentric) pulls only @@ -2151,9 +2294,43 @@ impl Scene { /// the resulting `(Handle, ChangeKind::Modified)` entries the same way /// `refresh_associative_dimensions`/`_hatches` do, for `bump_entities` /// to fold into its own `changes` vec. + #[allow(dead_code)] pub(crate) fn refresh_parametric_constraints( &mut self, changes: &[(Handle, ChangeKind)], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_policy(changes, &[], false) + } + + #[allow(dead_code)] + pub(crate) fn refresh_parametric_constraints_with_driven( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_policy(changes, driven_refs, false) + } + + pub(crate) fn refresh_parametric_constraints_with_policy( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + ) -> Vec<(Handle, ChangeKind)> { + self.refresh_parametric_constraints_with_originals( + changes, + driven_refs, + retain_size, + &[], + ) + } + + pub(crate) fn refresh_parametric_constraints_with_originals( + &mut self, + changes: &[(Handle, ChangeKind)], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], ) -> Vec<(Handle, ChangeKind)> { // An erased entity takes its constraints with it instead of leaving // dangling references. Done first, @@ -2185,6 +2362,25 @@ impl Scene { } } + let retained_before: HashMap> = if retain_size { + let mut retained: HashMap> = self + .undo_recording + .as_ref() + .into_iter() + .flat_map(|recording| recording.before.iter()) + .filter_map(|(handle, entity)| { + entity + .as_ref() + .map(|entity| (*handle, std::sync::Arc::clone(entity))) + }) + .collect(); + for (handle, entity) in retained_originals { + retained.insert(*handle, std::sync::Arc::new(entity.clone())); + } + retained + } else { + HashMap::new() + }; let mut result = Vec::new(); for i in 0..self.parametric_constraints.len() { let touched = changes.iter().any(|(handle, _)| { @@ -2200,6 +2396,9 @@ impl Scene { &self.document, &self.named_parameters, &self.parametric_constraints[i], + driven_refs, + retain_size, + &retained_before, ) else { continue; }; @@ -2224,10 +2423,17 @@ impl Scene { pub(crate) fn solve_parametric_constraints_preview( &self, touched: &[Handle], + driven_refs: &[ParametricRef], + retain_size: bool, + retained_originals: &[(Handle, EntityType)], ) -> Vec<(Handle, EntityType)> { if self.parametric_constraints.is_empty() || touched.is_empty() { return Vec::new(); } + let retained_before: HashMap> = retained_originals + .iter() + .map(|(handle, entity)| (*handle, std::sync::Arc::new(entity.clone()))) + .collect(); let mut result = Vec::new(); for set in &self.parametric_constraints { let is_touched = touched @@ -2236,8 +2442,14 @@ impl Scene { if !is_touched { continue; } - let Some((solved, _dof, _conflicts)) = - solve_scope(&self.document, &self.named_parameters, set) + let Some((solved, _dof, _conflicts)) = solve_scope( + &self.document, + &self.named_parameters, + set, + driven_refs, + retain_size, + &retained_before, + ) else { continue; }; @@ -2515,7 +2727,7 @@ mod tests { } let b_before = scene.document.get_entity(b).cloned(); - let solved = scene.solve_parametric_constraints_preview(&[a]); + let solved = scene.solve_parametric_constraints_preview(&[a], &[], false, &[]); assert_eq!( scene.document.get_entity(b), diff --git a/src/scene/selection.rs b/src/scene/selection.rs index 46fdc260f..a52a57543 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1001,6 +1001,7 @@ impl Scene { self.hover_highlight = None; hover_changed = true; } + hover_changed |= self.constraint_hover_highlights.remove(&h); self.hatches.remove(&h); self.images.remove(&h); self.meshes.remove(&h); diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index efde7319e..601a59482 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3435,7 +3435,10 @@ impl Scene { &self, inst: &ViewportInstance, ) -> Arc> { - if self.selected.is_empty() && self.hover_highlight.is_none() { + if self.selected.is_empty() + && self.hover_highlight.is_none() + && self.constraint_hover_highlights.is_empty() + { return Arc::new(Vec::new()); } diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 4cd3418d2..3b6b9468c 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -94,6 +94,27 @@ fn constraint_glyph_offsets(glyphs: &[(Point, [f32; 2], String, bool)]) -> Vec Option { + let offsets = constraint_glyph_offsets(glyphs); + glyphs + .iter() + .zip(offsets) + .enumerate() + .rev() + .find_map(|(index, ((anchor, outward, label, _), tangent_offset))| { + let (top_left, size) = + constraint_glyph_box(*anchor, *outward, label, tangent_offset); + (cursor.x >= top_left.x + && cursor.x <= top_left.x + size.width + && cursor.y >= top_left.y + && cursor.y <= top_left.y + size.height) + .then_some(index) + }) +} + /// Convert CURSORSIZE to a screen-space arm length while keeping the original /// 60 px cursor at the default value and the full-viewport result at 100. pub(crate) fn crosshair_arm_px(bounds: iced::Rectangle, value: i32) -> f32 { @@ -989,6 +1010,11 @@ impl canvas::Program for SelectionCanvas { } } } + if let Some(pos) = cursor.position_in(bounds) { + if constraint_glyph_hit(&self.constraint_glyphs, pos).is_some() { + return mouse::Interaction::Pointer; + } + } // The resize cursor over a divider is supplied by the input pane_grid // layered above this overlay; `draw` only suppresses the CAD crosshair // there (see `divider_under`). @@ -1703,7 +1729,6 @@ impl canvas::Program for SelectionCanvas { frame.stroke(&b1, stroke.clone()); frame.stroke(&b2, stroke); } - // Constraint glyphs are visual-only and have no hit testing. if !self.constraint_glyphs.is_empty() { let offsets = constraint_glyph_offsets(&self.constraint_glyphs); let normal_bg = theme.palette().primary.base.color; diff --git a/src/ui/window/auto_constrain_settings.rs b/src/ui/window/auto_constrain_settings.rs new file mode 100644 index 000000000..2a5afa621 --- /dev/null +++ b/src/ui/window/auto_constrain_settings.rs @@ -0,0 +1,143 @@ +use crate::app::settings::AutoConstrainSettings; +use crate::app::Message; +use iced::widget::{button, checkbox, column, container, row, scrollable, text, text_input, Space}; +use iced::{Element, Fill}; + +pub fn view_window<'a>( + settings: &'a AutoConstrainSettings, + selected_row: usize, + distance_input: &'a str, + angle_input: &'a str, + sizing: crate::ui::modal::ModalSizing, +) -> Element<'a, Message> { + let heading = row![ + text(crate::t!("Priority")).width(70), + text(crate::t!("Constraint Type")).width(Fill), + text(crate::t!("Apply")).width(60), + ] + .spacing(8); + + let mut list = column![heading].spacing(3); + for (index, kind) in settings.priority.iter().copied().enumerate() { + let enabled = settings.enabled.contains(&kind); + let label = row![ + text(format!("{}", index + 1)).width(70), + text(crate::t!(kind.label())).width(Fill), + checkbox(enabled) + .on_toggle(move |_| Message::AutoConstrainToggleKind(kind)) + .width(60), + ] + .spacing(8) + .align_y(iced::Center); + list = list.push( + button(label) + .on_press(Message::AutoConstrainSelectRow(index)) + .style(if selected_row == index { + button::primary + } else { + button::text + }) + .width(Fill) + .padding([5, 8]), + ); + } + + let reorder = column![ + button(text(crate::t!("Move Up"))) + .on_press_maybe((selected_row > 0).then_some(Message::AutoConstrainMoveUp)) + .width(Fill), + button(text(crate::t!("Move Down"))) + .on_press_maybe( + (selected_row + 1 < settings.priority.len()) + .then_some(Message::AutoConstrainMoveDown) + ) + .width(Fill), + Space::new().height(8), + button(text(crate::t!("Select All"))) + .on_press(Message::AutoConstrainSelectAll) + .width(Fill), + button(text(crate::t!("Clear All"))) + .on_press(Message::AutoConstrainClearAll) + .width(Fill), + button(text(crate::t!("Reset"))) + .on_press(Message::AutoConstrainReset) + .width(Fill), + ] + .spacing(6) + .width(125); + + let types = row![ + container(scrollable(list).height(260)) + .padding(6) + .width(Fill), + reorder + ] + .spacing(12); + + let conditions = column![ + checkbox(settings.tangent_must_share_point) + .label(crate::t!("Tangent objects must share an intersection point")) + .on_toggle(|_| Message::AutoConstrainToggleTangentPoint), + checkbox(settings.perpendicular_must_intersect) + .label(crate::t!("Perpendicular objects must share an intersection point")) + .on_toggle(|_| Message::AutoConstrainTogglePerpendicularIntersection), + ] + .spacing(8); + + let tolerances = column![ + text(crate::t!("Tolerances")).size(14), + row![ + text(crate::t!("Distance")).width(130), + text_input("0.05", distance_input) + .on_input(Message::AutoConstrainDistanceChanged) + .width(150), + ] + .align_y(iced::Center), + row![ + text(crate::t!("Angle")).width(130), + text_input("1", angle_input) + .on_input(Message::AutoConstrainAngleChanged) + .width(150), + text("°"), + ] + .align_y(iced::Center), + ] + .spacing(7); + + let actions = row![ + Space::new().width(Fill), + button(text(crate::t!("OK"))) + .on_press(Message::AutoConstrainOk) + .style(button::primary) + .padding([6, 18]), + button(text(crate::t!("Apply"))) + .on_press(Message::AutoConstrainApply) + .style(button::secondary) + .padding([6, 18]), + button(text(crate::t!("Cancel"))) + .on_press(Message::AutoConstrainCancel) + .style(button::secondary) + .padding([6, 18]), + ] + .spacing(8) + .align_y(iced::Center); + + container( + column![ + text(crate::t!("Auto Constrain")).size(16), + types, + conditions, + tolerances, + Space::new().height(Fill), + actions, + ] + .spacing(14) + .width(sizing.width) + .height(sizing.height), + ) + .padding([14, 16]) + .width(sizing.width) + .height(sizing.height) + .style(container::rounded_box) + .into() +} diff --git a/src/ui/window/mod.rs b/src/ui/window/mod.rs index 32b0f5d3a..09b3b2314 100644 --- a/src/ui/window/mod.rs +++ b/src/ui/window/mod.rs @@ -5,6 +5,7 @@ pub mod layer_state_manager; pub mod drawing_units; pub mod geometric_tolerance; pub mod drafting_settings; +pub mod auto_constrain_settings; pub mod layer_translator; pub mod plot; pub mod print_all; From b9ef0e3286c1f571bba9a2cd9c92d7c66583df37 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 12:09:59 +0300 Subject: [PATCH 12/16] Match default automatic constraint set --- src/app/settings.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/app/settings.rs b/src/app/settings.rs index 12cf09c9d..6e075e9bb 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -67,7 +67,14 @@ impl Default for AutoConstrainSettings { fn default() -> Self { Self { priority: AutoConstraintKind::ALL.to_vec(), - enabled: AutoConstraintKind::ALL.to_vec(), + // Equal remains available in the priority list, but the reference + // application does not enable it in a fresh profile. Enabling it + // by default adds a redundant relation between equal-length line + // segments (including adjacent polyline segments). + enabled: AutoConstraintKind::ALL + .into_iter() + .filter(|kind| *kind != AutoConstraintKind::Equal) + .collect(), tangent_must_share_point: true, perpendicular_must_intersect: true, distance_tolerance: 0.05, From d9bad54e1ea8559af094de16b9fc4fe007a98e5a Mon Sep 17 00:00:00 2001 From: Felix Riestra Date: Tue, 15 Sep 2026 11:29:57 +0200 Subject: [PATCH 13/16] macOS DMG: add an Applications shortcut for drag-to-install The DMG dropped just the bare .app with no indication of where it's supposed to go, unlike the "drag the icon onto Applications" layout every other Mac app uses. Stage the .app next to a symlink to /Applications before building the image, then lay out the Finder window's icon view (via a temporary read-write DMG, since a compressed UDZO image can't be mounted for editing afterward) so both items are visible side by side. The Finder/AppleScript layout step is best-effort and fails gracefully: this script mirrors the CI build-macos job, which likely has no interactive Finder session for AppleScript to reach at all, so the essential fix (the symlink itself, which doesn't depend on Finder scripting) still lands even when the cosmetic pass can't run. Fixes #769. Co-Authored-By: Claude Sonnet 5 --- packaging/build_macos_signed.sh | 64 +++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index bbed9a20c..87173f229 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -112,13 +112,73 @@ codesign --verify --strict --verbose=2 "$APP" echo "==> dmg" DMG="$DIST/OpenCADStudio-v$VERSION-macos-arm64.dmg" rm -f "$DMG" + +# Stage the .app next to a symlink to /Applications so the DMG opens onto +# the familiar "drag the icon onto Applications" installer layout, rather +# than dropping just the bare .app into the user's Downloads-mounted volume +# with no indication of where it's supposed to go (#769). +STAGING="$DIST/dmg-staging" +rm -rf "$STAGING" +mkdir -p "$STAGING" +cp -R "$APP" "$STAGING/" +ln -s /Applications "$STAGING/Applications" + +# Build read-write first so Finder can save the window's icon layout, then +# convert to the compressed read-only image the CI job's own DMG step +# already produces — same two-stage approach that layout customization +# always needs, since a UDZO image can't be mounted for editing afterward. +RW_DMG="$DIST/OpenCADStudio-rw.dmg" +rm -f "$RW_DMG" for i in 1 2 3 4 5; do - if hdiutil create -volname "Open CAD Studio" -srcfolder "$APP" -ov -format UDZO "$DMG"; then + if hdiutil create -volname "Open CAD Studio" -srcfolder "$STAGING" -ov -format UDRW -fs HFS+ "$RW_DMG"; then break fi - echo "hdiutil failed (attempt $i), retrying..." >&2 + echo "hdiutil create (rw) failed (attempt $i), retrying..." >&2 sleep 3 done +[ -f "$RW_DMG" ] || { echo "hdiutil create (rw) failed permanently" >&2; exit 1; } + +MOUNT_DIR="$(mktemp -d /tmp/ocs-dmg-mount.XXXXXX)" +hdiutil attach "$RW_DMG" -mountpoint "$MOUNT_DIR" -quiet +# Finder's own volume list can lag a moment behind `hdiutil attach` +# returning, and "container window of " isn't a valid reference the +# way "container window of disk " is — so poll by name (up to 15s) +# before touching it, rather than a fixed sleep or a path-based reference. +osascript <&2 +tell application "Finder" + repeat 30 times + if (exists disk "Open CAD Studio") then exit repeat + delay 0.5 + end repeat + tell disk "Open CAD Studio" + open + set current view of container window to icon view + set toolbar visible of container window to false + set statusbar visible of container window to false + set the bounds of container window to {200, 120, 760, 480} + set viewOptions to the icon view options of container window + set arrangement of viewOptions to not arranged + set icon size of viewOptions to 96 + set position of item "OpenCADStudio.app" of container window to {140, 180} + set position of item "Applications" of container window to {420, 180} + close + open + update without registering applications + delay 1 + end tell +end tell +APPLESCRIPT +hdiutil detach "$MOUNT_DIR" -quiet + +for i in 1 2 3 4 5; do + if hdiutil convert "$RW_DMG" -format UDZO -ov -o "$DMG"; then + break + fi + echo "hdiutil convert failed (attempt $i), retrying..." >&2 + sleep 3 +done +rm -f "$RW_DMG" +rm -rf "$STAGING" [ -f "$DMG" ] || { echo "hdiutil failed permanently" >&2; exit 1; } if [ -n "${NOTARY_PROFILE:-}" ] && [ "$DEVELOPER_ID" != "-" ]; then From f61174eaadb4e210eda88adaf5428b78a587a7a1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 15 Sep 2026 12:54:00 +0300 Subject: [PATCH 14/16] Polish automatic constraint integration --- src/app/settings.rs | 6 ++---- src/scene/mod.rs | 2 +- src/scene/parametric_constraints.rs | 23 +++++++++++++++++------ 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/src/app/settings.rs b/src/app/settings.rs index 6e075e9bb..9c2757542 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -67,10 +67,8 @@ impl Default for AutoConstrainSettings { fn default() -> Self { Self { priority: AutoConstraintKind::ALL.to_vec(), - // Equal remains available in the priority list, but the reference - // application does not enable it in a fresh profile. Enabling it - // by default adds a redundant relation between equal-length line - // segments (including adjacent polyline segments). + // Keep Equal available without creating redundant relations + // between equal-length segments by default. enabled: AutoConstraintKind::ALL .into_iter() .filter(|kind| *kind != AutoConstraintKind::Equal) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9d3e03697..59eb787f3 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -12591,7 +12591,7 @@ mod layout_cache_tests { let mut arc = acadrust::entities::Arc::default(); arc.radius = 25.0; arc.start_angle = 0.0; - arc.end_angle = 3.14159; + arc.end_angle = std::f64::consts::PI; let a_h = s.add_entity(EntityType::Arc(arc)); let line = acadrust::entities::Line::from_points( diff --git a/src/scene/parametric_constraints.rs b/src/scene/parametric_constraints.rs index 116172030..c5843fa8b 100644 --- a/src/scene/parametric_constraints.rs +++ b/src/scene/parametric_constraints.rs @@ -1356,9 +1356,18 @@ mod tests { std::f64::consts::PI, )); - assert_eq!(driven_ref_for_grip(&arc, handle, 0), Some(ParametricRef::center(handle))); - assert_eq!(driven_ref_for_grip(&arc, handle, 1), Some(ParametricRef::point(handle, 0))); - assert_eq!(driven_ref_for_grip(&arc, handle, 2), Some(ParametricRef::point(handle, 1))); + assert_eq!( + driven_ref_for_grip(&arc, handle, 0), + Some(ParametricRef::center(handle)) + ); + assert_eq!( + driven_ref_for_grip(&arc, handle, 1), + Some(ParametricRef::point(handle, 0)) + ); + assert_eq!( + driven_ref_for_grip(&arc, handle, 2), + Some(ParametricRef::point(handle, 1)) + ); assert_eq!(driven_ref_for_grip(&arc, handle, 3), None); } @@ -1528,9 +1537,11 @@ mod tests { fn hidden_constraint_stays_hidden_when_related_entity_is_selected() { let mut scene = super::super::Scene::new(); let scope = ParametricScope::ModelSpace; - let id = scene - .parametric_constraint_set_mut(scope) - .add(ConstraintKind::Horizontal, vec![ParametricRef::whole(h(1))], None); + let id = scene.parametric_constraint_set_mut(scope).add( + ConstraintKind::Horizontal, + vec![ParametricRef::whole(h(1))], + None, + ); scene.hidden_parametric_constraints.insert((scope, id)); assert!(!scene.should_display_parametric_constraint(scope, id, true, 2)); From 31e769a948ae1b9971b6585b8ae5a0d474bc968b Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 15 Sep 2026 12:56:47 +0300 Subject: [PATCH 15/16] Simplify macOS DMG staging --- packaging/build_macos_signed.sh | 58 +++------------------------------ 1 file changed, 4 insertions(+), 54 deletions(-) diff --git a/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index 87173f229..97ef76563 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -113,71 +113,21 @@ echo "==> dmg" DMG="$DIST/OpenCADStudio-v$VERSION-macos-arm64.dmg" rm -f "$DMG" -# Stage the .app next to a symlink to /Applications so the DMG opens onto -# the familiar "drag the icon onto Applications" installer layout, rather -# than dropping just the bare .app into the user's Downloads-mounted volume -# with no indication of where it's supposed to go (#769). +# Stage the app next to the install-location shortcut (#769). STAGING="$DIST/dmg-staging" rm -rf "$STAGING" mkdir -p "$STAGING" cp -R "$APP" "$STAGING/" ln -s /Applications "$STAGING/Applications" -# Build read-write first so Finder can save the window's icon layout, then -# convert to the compressed read-only image the CI job's own DMG step -# already produces — same two-stage approach that layout customization -# always needs, since a UDZO image can't be mounted for editing afterward. -RW_DMG="$DIST/OpenCADStudio-rw.dmg" -rm -f "$RW_DMG" for i in 1 2 3 4 5; do - if hdiutil create -volname "Open CAD Studio" -srcfolder "$STAGING" -ov -format UDRW -fs HFS+ "$RW_DMG"; then + if hdiutil create -volname "Open CAD Studio" -srcfolder "$STAGING" -ov -format UDZO "$DMG"; then break fi - echo "hdiutil create (rw) failed (attempt $i), retrying..." >&2 + rm -f "$DMG" + echo "hdiutil failed (attempt $i), retrying..." >&2 sleep 3 done -[ -f "$RW_DMG" ] || { echo "hdiutil create (rw) failed permanently" >&2; exit 1; } - -MOUNT_DIR="$(mktemp -d /tmp/ocs-dmg-mount.XXXXXX)" -hdiutil attach "$RW_DMG" -mountpoint "$MOUNT_DIR" -quiet -# Finder's own volume list can lag a moment behind `hdiutil attach` -# returning, and "container window of " isn't a valid reference the -# way "container window of disk " is — so poll by name (up to 15s) -# before touching it, rather than a fixed sleep or a path-based reference. -osascript <&2 -tell application "Finder" - repeat 30 times - if (exists disk "Open CAD Studio") then exit repeat - delay 0.5 - end repeat - tell disk "Open CAD Studio" - open - set current view of container window to icon view - set toolbar visible of container window to false - set statusbar visible of container window to false - set the bounds of container window to {200, 120, 760, 480} - set viewOptions to the icon view options of container window - set arrangement of viewOptions to not arranged - set icon size of viewOptions to 96 - set position of item "OpenCADStudio.app" of container window to {140, 180} - set position of item "Applications" of container window to {420, 180} - close - open - update without registering applications - delay 1 - end tell -end tell -APPLESCRIPT -hdiutil detach "$MOUNT_DIR" -quiet - -for i in 1 2 3 4 5; do - if hdiutil convert "$RW_DMG" -format UDZO -ov -o "$DMG"; then - break - fi - echo "hdiutil convert failed (attempt $i), retrying..." >&2 - sleep 3 -done -rm -f "$RW_DMG" rm -rf "$STAGING" [ -f "$DMG" ] || { echo "hdiutil failed permanently" >&2; exit 1; } From 7c90b8ea7f7b232d0150fd99b0bfc6e9e9254911 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 15 Sep 2026 13:10:01 +0300 Subject: [PATCH 16/16] Center constraint glyph labels --- src/ui/overlay.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/ui/overlay.rs b/src/ui/overlay.rs index 3b6b9468c..131e445a1 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1757,11 +1757,13 @@ impl canvas::Program for SelectionCanvas { frame.fill_text(canvas::Text { content: label.clone(), position: Point::new( - top_left.x + CONSTRAINT_GLYPH_PAD_X, - top_left.y + CONSTRAINT_GLYPH_PAD_Y, + top_left.x + size.width * 0.5, + top_left.y + size.height * 0.5, ), color: fg, size: iced::Pixels(CONSTRAINT_GLYPH_SIZE), + align_x: iced::alignment::Horizontal::Center.into(), + align_y: iced::alignment::Vertical::Center, shaping: iced::advanced::text::Shaping::Advanced, ..Default::default() });