diff --git a/Cargo.lock b/Cargo.lock index 946a0eb3e..96ae7c765 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=ea7ace7#ea7ace7047c5c5ba170d8257080b5b881df6c063" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=eb3cfe7#eb3cfe7b478230897bfb9094e7bc70aa4d00411b" 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=ea7ace7#ea7ace7047c5c5ba170d8257080b5b881df6c063" +source = "git+https://github.com/HakanSeven12/cadkernel.git?rev=eb3cfe7#eb3cfe7b478230897bfb9094e7bc70aa4d00411b" dependencies = [ "nalgebra", ] @@ -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 89a10af13..8441fc21f 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 = "ea7ace7", features = ["acis", "offset"] } -cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "ea7ace7" } +cadkernel = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "eb3cfe7", features = ["acis", "offset"] } +cadkernel-constraints = { git = "https://github.com/HakanSeven12/cadkernel.git", rev = "eb3cfe7" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } @@ -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"] } @@ -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/packaging/build_macos_signed.sh b/packaging/build_macos_signed.sh index bbed9a20c..97ef76563 100755 --- a/packaging/build_macos_signed.sh +++ b/packaging/build_macos_signed.sh @@ -112,13 +112,23 @@ codesign --verify --strict --verbose=2 "$APP" echo "==> dmg" DMG="$DIST/OpenCADStudio-v$VERSION-macos-arm64.dmg" rm -f "$DMG" + +# 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" + 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 UDZO "$DMG"; then break fi + rm -f "$DMG" echo "hdiutil failed (attempt $i), retrying..." >&2 sleep 3 done +rm -rf "$STAGING" [ -f "$DMG" ] || { echo "hdiutil failed permanently" >&2; exit 1; } if [ -n "${NOTARY_PROFILE:-}" ] && [ "$DEVELOPER_ID" != "-" ]; then diff --git a/src/app/command_driver.rs b/src/app/command_driver.rs index 59a54b549..1f59e9db5 100644 --- a/src/app/command_driver.rs +++ b/src/app/command_driver.rs @@ -1617,6 +1617,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] @@ -1638,6 +1724,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 @@ -1745,6 +1840,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 { @@ -1785,15 +1883,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()); @@ -1824,15 +1925,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; @@ -2095,6 +2199,7 @@ impl OpenCADStudio { // transforms retain their selected-object behavior. 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; @@ -2213,6 +2318,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; @@ -2942,6 +3050,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, @@ -2951,7 +3060,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; @@ -4620,6 +4733,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). @@ -4638,6 +4752,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; }; @@ -4860,7 +5022,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 05769fbcb..d021b1d6b 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 8523c5101..69e09fc03 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). @@ -3633,6 +3656,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 50c485ab9..a942c5ca6 100644 --- a/src/app/properties.rs +++ b/src/app/properties.rs @@ -2753,6 +2753,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 @@ -2782,7 +2801,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..9c2757542 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -11,6 +11,108 @@ 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(), + // Keep Equal available without creating redundant relations + // between equal-length segments by default. + enabled: AutoConstraintKind::ALL + .into_iter() + .filter(|kind| *kind != AutoConstraintKind::Equal) + .collect(), + 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 +391,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 +474,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 +538,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..8f7ca88f2 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -86,33 +86,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}; @@ -143,11 +117,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, @@ -162,7 +133,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); @@ -175,6 +146,29 @@ fn plot_content_extents( ); } } + 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() @@ -188,12 +182,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() @@ -227,56 +216,66 @@ 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) = scene.viewport_plot_fills(); + 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 .into_iter() .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 { @@ -429,6 +428,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 +513,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(); @@ -2951,46 +2963,18 @@ impl OpenCADStudio { 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, - ) - .map(|_| crate::tf!("Exported: {}", worker_path.display()).into_owned()) - .map_err(|e| crate::tf!("Export failed: {e}").into_owned()) + 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()) }; self.run_plot_work(self.plot_dialog.background, false, work) } @@ -3006,55 +2990,27 @@ impl OpenCADStudio { "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, - )) = job + 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, - ) - .map(|_| { - crate::tf!( - "Plotted window to {}", - worker_path - .file_name() - .unwrap_or_default() - .to_string_lossy() - ) - .into_owned() - }) - .map_err(|e| crate::tf!("Plot failed: {e}").into_owned()) + crate::io::pdf_export::export_pdf(&page, &worker_path) + .map(|_| { + crate::tf!( + "Plotted window to {}", + worker_path + .file_name() + .unwrap_or_default() + .to_string_lossy() + ) + .into_owned() + }) + .map_err(|e| crate::tf!("Plot failed: {e}").into_owned()) }; self.run_plot_work(self.plot_dialog.background, false, work) } @@ -3182,8 +3138,7 @@ impl OpenCADStudio { ) .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(), @@ -3193,64 +3148,14 @@ impl OpenCADStudio { } _ => 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(|| { + area_page.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, - ) + })? }; - 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) })(); @@ -3328,7 +3233,7 @@ impl OpenCADStudio { 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(); @@ -3375,41 +3280,31 @@ impl OpenCADStudio { } } - /// 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. @@ -3434,7 +3329,7 @@ impl OpenCADStudio { let (scale, offset_x, offset_y) = if plot_extents { let (min_x, min_y, max_x, max_y) = - plot_content_extents(&wires, &hatches, &wipeouts).unwrap_or((x0, y0, x1, y1)); + 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); let scale = if self.plot_dialog.fit_to_paper { @@ -3513,19 +3408,18 @@ impl OpenCADStudio { } 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. @@ -3545,77 +3439,52 @@ impl OpenCADStudio { } 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, - )) + iced::futures::executor::block_on( + crate::io::print_to_printer::print_wires_with(page, options), + ) }, Message::PrintResult, ) @@ -4499,7 +4368,6 @@ impl OpenCADStudio { 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" { @@ -4513,19 +4381,7 @@ impl OpenCADStudio { } _ => 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()); @@ -4536,26 +4392,11 @@ impl OpenCADStudio { // 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)) - .map(|_| crate::t!("Opened plot preview.").into_owned()) - .map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) + 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); } @@ -4564,56 +4405,27 @@ impl OpenCADStudio { 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)) - .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) - .map_err(|e| crate::tf!("Print failed: {e}").into_owned()) + 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)) - .map(|_| crate::t!("Opened plot preview.").into_owned()) - .map_err(|e| crate::tf!("Preview failed: {e}").into_owned()) + 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); } @@ -4623,16 +4435,15 @@ impl OpenCADStudio { 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, + page, opts, )) - .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) - .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) + .map(|printer| crate::tf!("Sent to printer: {printer}").into_owned()) + .map_err(|error| crate::tf!("Print failed: {error}").into_owned()) }; self.run_plot_work(true, false, work) } @@ -4642,19 +4453,16 @@ impl OpenCADStudio { 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, @@ -4662,7 +4470,6 @@ impl OpenCADStudio { transparency: d.transparency, stamp: d.stamp, merge_lines: d.merge_lines, - group_splits, } } @@ -4679,20 +4486,20 @@ impl OpenCADStudio { .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 @@ -4714,26 +4521,18 @@ impl OpenCADStudio { )) } - 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()?; return self.area_plot_job((min.x as f64, min.y as f64, max.x as f64, max.y as f64)); } - let (wires, hatches, wipeouts, _) = plot_scene_content( + let content = 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 extents = plot_content_extents(&content)?; self.area_plot_job(extents) } @@ -4790,7 +4589,7 @@ impl OpenCADStudio { /// 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; @@ -4829,27 +4628,22 @@ impl OpenCADStudio { }; 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())] + 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(|w| w.name != "paper_printable_area") - .filter(|w| { - w.aabb[0] <= wx1 && w.aabb[2] >= wx0 && w.aabb[1] <= wy1 && w.aabb[3] >= wy0 - }) + .filter(|wire| in_window(wire)) .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; + 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(( @@ -4863,19 +4657,18 @@ impl OpenCADStudio { 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/app/update/mod.rs b/src/app/update/mod.rs index 2d7c6c690..299d060ac 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) => { @@ -6857,6 +6963,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 8d8c9ec98..958df1fd6 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,34 @@ 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 vp_size = self.tabs[i].scene.selection.borrow().vp_size; + let scope = self.tabs[i].current_parametric_scope(); + let id = self.tabs[i].scene.constraint_glyph_hit( + scope, + vp_size, + self.show_constraint_values, + self.constraint_bar_display, + cursor, + )?; + let set = self.tabs[i].scene.parametric_constraint_set(scope)?; + let constraint = set.get(id)?; + 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 +3060,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 @@ -4432,6 +4505,7 @@ properties={:.1}ms picked={}", scope, canvas_sz, self.show_constraint_values, + self.constraint_bar_display, p_full, ) }) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 8666a23ee..6730f4509 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -822,6 +822,7 @@ bg={bg_ms:.1}ms n={view_count}" scope, sel_ref.vp_size, self.show_constraint_values, + self.constraint_bar_display, ) .into_iter() .map(|(id, point, direction, label, is_conflicting)| { 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 b01e58558..b5adb586d 100644 --- a/src/command.rs +++ b/src/command.rs @@ -1477,9 +1477,11 @@ pub enum CmdResult { /// Undo-history label, e.g. `"Horizontal constraint"`. label: &'static str, }, - /// Adds an ordered Coincident relation. Point/point selections create a - /// Coincident constraint; point/curve selections create the corresponding - /// point-on-curve relation while retaining Coincident command semantics. + /// 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. AddCoincidentConstraint { first: CoincidentPick, second: CoincidentPick, 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/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/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)); + } } diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index 6f4d3a680..35129dd61 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()) @@ -89,8 +73,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 +94,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()); } } @@ -172,46 +288,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/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/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. 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 07dcde806..fd82ee55b 100644 --- a/src/modules/parametric/mod.rs +++ b/src/modules/parametric/mod.rs @@ -130,7 +130,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/entity.rs b/src/scene/entity.rs index dc42e7636..8a5ee23f4 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -1834,7 +1834,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()); } @@ -1847,7 +1847,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) @@ -1865,8 +1866,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); } @@ -2068,6 +2075,26 @@ 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) = + cadkernel::geom2d::refine_spline_boundary(&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..87c277195 --- /dev/null +++ b/src/scene/hatch_boundary.rs @@ -0,0 +1,2 @@ +#[cfg(test)] +mod tests; diff --git a/src/scene/hatch_boundary/tests.rs b/src/scene/hatch_boundary/tests.rs new file mode 100644 index 000000000..e6362d232 --- /dev/null +++ b/src/scene/hatch_boundary/tests.rs @@ -0,0 +1,236 @@ +use crate::scene::Scene; +use acadrust::entities::hatch::{BoundaryEdge, BoundaryPath, Hatch, PolylineEdge, SplineEdge}; +use acadrust::types::{Vector2, Vector3}; +use cadkernel::geom2d::{refine_spline_boundary, triangulate_rings, Curve, NurbsCurve}; +use cadkernel::tessellation::DEFAULT_ANGLE; + +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_spline_boundary( + &curve.tessellate_angle(DEFAULT_ANGLE), + || panic!("valid path resampled"), + |p| p + ) + .is_none()); + assert!(refine_spline_boundary( + &vec![[0.0; 2]; 513], + || panic!("size cap ignored"), + |p| p + ) + .is_none()); +} + +#[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_spline_boundary(&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 f6536d716..59eb787f3 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -33,6 +33,8 @@ pub(crate) mod centermark; pub(crate) mod dimension_assoc; mod dwg_native_constraints; mod entity; +#[cfg(test)] +mod hatch_boundary; mod group_layer; mod layout; mod limits; @@ -1679,6 +1681,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). @@ -1959,6 +1963,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, @@ -2203,6 +2212,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, @@ -2277,6 +2287,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()), @@ -2836,13 +2847,32 @@ impl Scene { } pub fn bump_entities(&mut self, changes: &[(Handle, ChangeKind)]) { - self.bump_entities_with_parametric_driven(changes, &[]); + 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) @@ -2877,7 +2907,12 @@ impl Scene { } } if !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints_with_driven(&changes, driven_refs) { + 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); } @@ -5081,9 +5116,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 @@ -6661,6 +6707,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, @@ -6686,7 +6775,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; @@ -6745,7 +6837,7 @@ impl Scene { }); } placed.draw_depth = context.draw_depth(handle, depth_map.as_ref()); - models.push(placed); + models.push(collect(placed, context)); }, ); models @@ -12499,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 6cbd648a6..57d83c8fc 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)), + 2 => 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 @@ -803,6 +842,19 @@ 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.is_parametric_constraint_visible(scope, id) + && (self.shown_parametric_constraints.contains(&(scope, id)) + || display_mode & 1 != 0 + || (display_mode & 2 != 0 && related_entity_selected)) + } + pub fn set_parametric_constraint_visibility( &mut self, scope: ParametricScope, @@ -828,8 +880,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() @@ -840,40 +894,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, @@ -883,53 +1052,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) { @@ -1095,6 +1268,7 @@ impl super::Scene { scope: ParametricScope, vp_size: (f32, f32), show_values: bool, + display_mode: i16, ) -> Vec<(ConstraintId, iced::Point, [f32; 2], String, bool)> { let Some(set) = self.parametric_constraint_set(scope) else { return Vec::new(); @@ -1116,7 +1290,13 @@ impl super::Scene { set.constraints .iter() .filter(|c| c.enabled) - .filter(|c| self.is_parametric_constraint_visible(scope, c.id)) + .filter(|c| { + let selected = c + .refs + .iter() + .any(|reference| self.selected.contains(&reference.entity)); + self.should_display_parametric_constraint(scope, c.id, selected, display_mode) + }) .filter_map(|c| { let (anchor, outward) = glyph_placement(&self.document, c)?; let screen = crate::scene::pick::grip::project_rte( @@ -1158,9 +1338,15 @@ impl super::Scene { scope: ParametricScope, vp_size: (f32, f32), show_values: bool, + display_mode: i16, p: iced::Point, ) -> Option { - let placements = self.constraint_glyph_placements_screen(scope, vp_size, show_values); + let placements = self.constraint_glyph_placements_screen( + scope, + vp_size, + show_values, + display_mode, + ); let glyphs: Vec<(iced::Point, [f32; 2], String, bool)> = placements .iter() .map(|(_, point, direction, label, is_conflicting)| { @@ -1447,6 +1633,33 @@ mod tests { ); } + #[test] + fn arc_grips_drive_center_start_and_end_but_not_midpoint() { + let handle = h(8); + let arc = acadrust::EntityType::Arc(acadrust::entities::Arc::from_coords( + 0.0, + 0.0, + 0.0, + 5.0, + 0.0, + 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, 3), None); + } + #[test] fn parameter_usage_finds_every_constraint_driven_by_the_named_parameter() { let mut scene = super::super::Scene::new(); @@ -1555,7 +1768,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)] @@ -1566,7 +1783,7 @@ mod tests { assert!(inferred .iter() .any(|(kind, _)| *kind == ConstraintKind::Coincident)); - assert!(inferred + assert!(!inferred .iter() .any(|(kind, _)| *kind == ConstraintKind::Colinear)); } @@ -1604,4 +1821,18 @@ mod tests { assert!(!scene.is_parametric_constraint_visible(ParametricScope::ModelSpace, geometric)); assert!(scene.is_parametric_constraint_visible(ParametricScope::ModelSpace, dimensional)); } + + #[test] + 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, + ); + scene.hidden_parametric_constraints.insert((scope, id)); + + assert!(!scene.should_display_parametric_constraint(scope, id, true, 2)); + } } diff --git a/src/scene/parametric_solve.rs b/src/scene/parametric_solve.rs index a92273eb0..af5a32af8 100644 --- a/src/scene/parametric_solve.rs +++ b/src/scene/parametric_solve.rs @@ -1692,6 +1692,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 @@ -1708,6 +1746,8 @@ fn solve_scope( 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 @@ -1741,6 +1781,82 @@ 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 @@ -2375,18 +2491,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. - #[cfg(test)] + #[allow(dead_code)] pub(crate) fn refresh_parametric_constraints( &mut self, changes: &[(Handle, ChangeKind)], ) -> Vec<(Handle, ChangeKind)> { - self.refresh_parametric_constraints_with_driven(changes, &[]) + 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, @@ -2418,6 +2559,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, _)| { @@ -2434,6 +2594,8 @@ impl Scene { &self.named_parameters, &self.parametric_constraints[i], driven_refs, + retain_size, + &retained_before, ) else { continue; }; @@ -2458,10 +2620,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 @@ -2470,8 +2639,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; }; @@ -2749,7 +2924,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/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/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/src/scene/selection.rs b/src/scene/selection.rs index ef48c55d8..4ebab412c 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1007,6 +1007,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..82bfd2eb7 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 @@ -3435,7 +3692,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 b2d582f2a..4e9b2fab7 100644 --- a/src/ui/overlay.rs +++ b/src/ui/overlay.rs @@ -1017,6 +1017,18 @@ impl canvas::Program for SelectionCanvas { } } } + if let Some(pos) = cursor.position_in(bounds) { + let glyphs: Vec<_> = self + .constraint_glyphs + .iter() + .map(|(anchor, outward, label, conflict, _)| { + (*anchor, *outward, label.clone(), *conflict) + }) + .collect(); + if constraint_glyph_hit_test(&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`). @@ -1777,11 +1789,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() }); 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); } diff --git a/src/ui/ribbon/modify_panel.rs b/src/ui/ribbon/modify_panel.rs index e7256acce..3223fd9b0 100644 --- a/src/ui/ribbon/modify_panel.rs +++ b/src/ui/ribbon/modify_panel.rs @@ -108,12 +108,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", 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; 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!(