From 6f19b4fd81626f3759f84918effececdb11cd1e5 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 6 Apr 2026 14:43:44 +0300 Subject: [PATCH] feat: add Underlay entity support (PDF/DWF/DGN) - New src/entities/underlay.rs: TruckConvertible (clip boundary polygon or cross marker), Grippable (insertion + boundary vertex grips), PropertyEditable (position, scale, rotation, contrast, fade, flags), Transformable (translate, mirror, scale, rotate) - Wire EntityType::Underlay into all four match arms in traits.rs - UNDERLAY command: FADE/CONTRAST/ON/OFF/CLIP ON|OFF/MONO ON|OFF subcommands to edit selected underlay entities - Entity type name map entry for UNDERLAY Co-Authored-By: Claude Sonnet 4.6 --- src/app/commands.rs | 93 ++++++++++++++ src/entities/mod.rs | 1 + src/entities/traits.rs | 9 ++ src/entities/underlay.rs | 263 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 366 insertions(+) create mode 100644 src/entities/underlay.rs diff --git a/src/app/commands.rs b/src/app/commands.rs index 69edee6e6..1aaf996f2 100644 --- a/src/app/commands.rs +++ b/src/app/commands.rs @@ -2821,6 +2821,98 @@ impl H7CAD { } } } + // UNDERLAY — edit properties of selected PDF/DWF/DGN underlay entities. + // Usage: + // UNDERLAY FADE <0-80> + // UNDERLAY CONTRAST <0-100> + // UNDERLAY ON | OFF + // UNDERLAY CLIP ON | OFF + // UNDERLAY MONO ON | OFF + cmd if cmd == "UNDERLAY" || cmd.starts_with("UNDERLAY ") => { + let sub = cmd.split_once(' ') + .map(|(_, r)| r.trim().to_uppercase()) + .unwrap_or_default(); + let handles: Vec = self.tabs[i].scene + .selected_entities() + .iter() + .map(|(h, _)| *h) + .collect(); + if handles.is_empty() { + self.command_line.push_error("UNDERLAY: select underlay entities first."); + } else { + let parts: Vec<&str> = sub.splitn(2, char::is_whitespace).collect(); + let action = parts.first().copied().unwrap_or(""); + let arg = parts.get(1).copied().unwrap_or("").trim(); + let mut changed = 0usize; + self.push_undo_snapshot(i, "UNDERLAY"); + for h in &handles { + if let Some(acadrust::EntityType::Underlay(ul)) = self.tabs[i].scene + .document.entities_mut() + .find(|e| e.common().handle == *h) + { + match action { + "FADE" => { + if let Ok(v) = arg.parse::() { + ul.set_fade(v); + changed += 1; + } + } + "CONTRAST" => { + if let Ok(v) = arg.parse::() { + ul.set_contrast(v); + changed += 1; + } + } + "ON" => { ul.set_on(true); changed += 1; } + "OFF" => { ul.set_on(false); changed += 1; } + "CLIP" => { + match arg { + "ON" => { + ul.flags |= acadrust::entities::UnderlayDisplayFlags::CLIPPING; + changed += 1; + } + "OFF" => { + ul.clear_clip(); + changed += 1; + } + _ => {} + } + } + "MONO" => { + match arg { + "ON" => { ul.set_monochrome(true); changed += 1; } + "OFF" => { ul.set_monochrome(false); changed += 1; } + _ => {} + } + } + _ => { + // No sub-command: print status. + self.command_line.push_output(&format!( + "Underlay {:x}: fade={}, contrast={}, on={}, clip={}, mono={}", + h.value(), + ul.fade, + ul.contrast, + ul.is_on(), + ul.is_clipping(), + ul.is_monochrome(), + )); + } + } + } + } + if changed > 0 { + self.tabs[i].dirty = true; + self.command_line.push_info(&format!( + "Updated {changed} underlay(s)." + )); + } else if !action.is_empty() { + self.command_line.push_error( + "Usage: UNDERLAY [FADE |CONTRAST |ON|OFF|CLIP ON|OFF|MONO ON|OFF]" + ); + } + } + } + "PAGESETUP" => { if self.tabs[i].scene.current_layout == "Model" { self.command_line.push_error("PAGESETUP: switch to a paper space layout first."); @@ -2978,6 +3070,7 @@ fn entity_type_name(entity: &acadrust::EntityType) -> &'static str { acadrust::EntityType::MLine(_) => "MLINE", acadrust::EntityType::RasterImage(_) => "RASTERIMAGE", acadrust::EntityType::Wipeout(_) => "WIPEOUT", + acadrust::EntityType::Underlay(_) => "UNDERLAY", acadrust::EntityType::AttributeDefinition(_)=> "ATTDEF", acadrust::EntityType::AttributeEntity(_) => "ATTRIB", acadrust::EntityType::Leader(_) => "LEADER", diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 79c9f2f0d..fe5f604fe 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -16,6 +16,7 @@ pub mod mtext; pub mod point; pub mod polyline; pub mod raster_image; +pub mod underlay; pub mod ray; pub mod solid; pub mod spline; diff --git a/src/entities/traits.rs b/src/entities/traits.rs index 593dc3e37..30605a77f 100644 --- a/src/entities/traits.rs +++ b/src/entities/traits.rs @@ -61,6 +61,7 @@ impl EntityTypeOps for EntityType { EntityType::MText(text) => TruckConvertible::to_truck(text, document), EntityType::Leader(leader) => TruckConvertible::to_truck(leader, document), EntityType::MultiLeader(ml) => TruckConvertible::to_truck(ml, document), + EntityType::Underlay(ul) => TruckConvertible::to_truck(ul, document), _ => None, } } @@ -98,6 +99,7 @@ impl EntityTypeOps for EntityType { EntityType::MultiLeader(ml) => Grippable::grips(ml), EntityType::Dimension(dim) => Grippable::grips(dim), EntityType::Hatch(hatch) => Grippable::grips(hatch), + EntityType::Underlay(ul) => Grippable::grips(ul), _ => vec![], } } @@ -224,6 +226,10 @@ impl EntityTypeOps for EntityType { ml, text_style_names, )), + EntityType::Underlay(ul) => Some(PropertyEditable::geometry_properties( + ul, + text_style_names, + )), _ => None, } } @@ -263,6 +269,7 @@ impl EntityTypeOps for EntityType { EntityType::Dimension(dim) => PropertyEditable::apply_geom_prop(dim, field, value), EntityType::Leader(leader) => PropertyEditable::apply_geom_prop(leader, field, value), EntityType::MultiLeader(ml) => PropertyEditable::apply_geom_prop(ml, field, value), + EntityType::Underlay(ul) => PropertyEditable::apply_geom_prop(ul, field, value), _ => {} } } @@ -300,6 +307,7 @@ impl EntityTypeOps for EntityType { EntityType::MultiLeader(ml) => Grippable::apply_grip(ml, grip_id, apply), EntityType::Dimension(dim) => Grippable::apply_grip(dim, grip_id, apply), EntityType::Hatch(hatch) => Grippable::apply_grip(hatch, grip_id, apply), + EntityType::Underlay(ul) => Grippable::apply_grip(ul, grip_id, apply), _ => {} } } @@ -337,6 +345,7 @@ impl EntityTypeOps for EntityType { EntityType::Dimension(dim) => Transformable::apply_transform(dim, t), EntityType::Leader(leader) => Transformable::apply_transform(leader, t), EntityType::MultiLeader(ml) => Transformable::apply_transform(ml, t), + EntityType::Underlay(ul) => Transformable::apply_transform(ul, t), _ => {} } } diff --git a/src/entities/underlay.rs b/src/entities/underlay.rs new file mode 100644 index 000000000..d1fd2a7eb --- /dev/null +++ b/src/entities/underlay.rs @@ -0,0 +1,263 @@ +// Underlay entity — PDF/DWF/DGN reference. +// +// Render: clip boundary polygon (or cross at insertion if no boundary). +// Grips: insertion point + clip boundary vertices. +// Props: position, scales, rotation, contrast, fade, flags. + +use acadrust::entities::{Underlay, UnderlayDisplayFlags}; +use glam::Vec3; + +use crate::command::EntityTransform; +use crate::entities::common::{diamond_grip, edit_prop as edit, ro_prop as ro, square_grip}; +use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; +use crate::scene::acad_to_truck::{TruckEntity, TruckObject}; +use crate::scene::object::{GripApply, GripDef, PropSection}; +use crate::scene::wire_model::SnapHint; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn v3(v: &acadrust::types::Vector3) -> [f32; 3] { + [v.x as f32, v.y as f32, v.z as f32] +} + +/// Small cross marker at the insertion point (used when no clip boundary). +fn cross_wire(origin: [f32; 3], size: f32) -> Vec<[f32; 3]> { + let [ox, oy, oz] = origin; + vec![ + [ox - size, oy, oz], + [ox + size, oy, oz], + [f32::NAN; 3], + [ox, oy - size, oz], + [ox, oy + size, oz], + ] +} + +// ── TruckConvertible ────────────────────────────────────────────────────────── + +impl TruckConvertible for Underlay { + fn to_truck(&self, _document: &acadrust::CadDocument) -> Option { + let origin = v3(&self.insertion_point); + + if !self.clip_boundary_vertices.is_empty() { + // Draw clip boundary polygon + close it. + let world_verts = self.world_clip_boundary(); + let mut pts: Vec<[f32; 3]> = world_verts + .iter() + .map(|v| [v.x as f32, v.y as f32, v.z as f32]) + .collect(); + // Close polygon. + if let Some(&first) = pts.first() { + pts.push(first); + } + // Insertion grip. + let key: Vec<[f32; 3]> = pts.clone(); + Some(TruckEntity { + object: TruckObject::Lines(pts), + snap_pts: vec![(Vec3::from(origin), SnapHint::Node)], + tangent_geoms: vec![], + key_vertices: key, + }) + } else { + // No clip boundary: draw a cross at insertion point. + let pts = cross_wire(origin, 1.0); + Some(TruckEntity { + object: TruckObject::Lines(pts), + snap_pts: vec![(Vec3::from(origin), SnapHint::Node)], + tangent_geoms: vec![], + key_vertices: vec![origin], + }) + } + } +} + +// ── Grippable ───────────────────────────────────────────────────────────────── + +impl Grippable for Underlay { + fn grips(&self) -> Vec { + let origin = Vec3::from(v3(&self.insertion_point)); + let mut grips = vec![square_grip(0, origin)]; + + if !self.clip_boundary_vertices.is_empty() { + let world_verts = self.world_clip_boundary(); + for (i, v) in world_verts.iter().enumerate() { + grips.push(diamond_grip( + i + 1, + Vec3::new(v.x as f32, v.y as f32, v.z as f32), + )); + } + } + + grips + } + + fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + if grip_id == 0 { + // Insertion point grip. + match apply { + GripApply::Translate(d) => { + self.insertion_point.x += d.x as f64; + self.insertion_point.y += d.y as f64; + self.insertion_point.z += d.z as f64; + } + GripApply::Absolute(p) => { + self.insertion_point.x = p.x as f64; + self.insertion_point.y = p.y as f64; + self.insertion_point.z = p.z as f64; + } + } + } else { + // Clip boundary vertex grip (grip_id = vertex_index + 1). + let idx = grip_id - 1; + if idx >= self.clip_boundary_vertices.len() { + return; + } + // Clip boundary vertices are in local (underlay) space. + // We need to invert the world transform to apply the grip. + let cos_r = self.rotation.cos(); + let sin_r = self.rotation.sin(); + let new_world = match apply { + GripApply::Absolute(p) => { + // world → local: translate, un-rotate, un-scale + let wx = p.x as f64 - self.insertion_point.x; + let wy = p.y as f64 - self.insertion_point.y; + let lx = (wx * cos_r + wy * sin_r) / self.x_scale.max(1e-10); + let ly = (-wx * sin_r + wy * cos_r) / self.y_scale.max(1e-10); + (lx, ly) + } + GripApply::Translate(d) => { + let v = &self.clip_boundary_vertices[idx]; + let wx = d.x as f64 / self.x_scale.max(1e-10); + let wy = d.y as f64 / self.y_scale.max(1e-10); + let lx = wx * cos_r + wy * sin_r; + let ly = -wx * sin_r + wy * cos_r; + (v.x + lx, v.y + ly) + } + }; + self.clip_boundary_vertices[idx].x = new_world.0; + self.clip_boundary_vertices[idx].y = new_world.1; + } + } +} + +// ── PropertyEditable ────────────────────────────────────────────────────────── + +impl PropertyEditable for Underlay { + fn geometry_properties(&self, _text_style_names: &[String]) -> PropSection { + let type_str = match self.underlay_type { + acadrust::entities::UnderlayType::Pdf => "PDF", + acadrust::entities::UnderlayType::Dwf => "DWF", + acadrust::entities::UnderlayType::Dgn => "DGN", + }; + PropSection { + title: "Geometry".into(), + props: vec![ + ro("Type", "ul_type", type_str), + edit("Ins X", "ul_ix", self.insertion_point.x), + edit("Ins Y", "ul_iy", self.insertion_point.y), + edit("Ins Z", "ul_iz", self.insertion_point.z), + edit("X Scale", "ul_sx", self.x_scale), + edit("Y Scale", "ul_sy", self.y_scale), + edit("Z Scale", "ul_sz", self.z_scale), + edit("Rotation", "ul_rot", self.rotation.to_degrees()), + edit("Contrast", "ul_contrast", self.contrast as f64), + edit("Fade", "ul_fade", self.fade as f64), + ro( + "On", + "ul_on", + if self.flags.contains(UnderlayDisplayFlags::ON) { + "Yes" + } else { + "No" + }, + ), + ro( + "Clipping", + "ul_clip", + if self.flags.contains(UnderlayDisplayFlags::CLIPPING) { + "Yes" + } else { + "No" + }, + ), + ro( + "Monochrome", + "ul_mono", + if self.flags.contains(UnderlayDisplayFlags::MONOCHROME) { + "Yes" + } else { + "No" + }, + ), + ], + } + } + + fn apply_geom_prop(&mut self, field: &str, value: &str) { + if let Ok(v) = value.trim().parse::() { + match field { + "ul_ix" => self.insertion_point.x = v, + "ul_iy" => self.insertion_point.y = v, + "ul_iz" => self.insertion_point.z = v, + "ul_sx" => self.x_scale = v, + "ul_sy" => self.y_scale = v, + "ul_sz" => self.z_scale = v, + "ul_rot" => self.rotation = v.to_radians(), + "ul_contrast" => self.set_contrast(v.clamp(0.0, 100.0) as u8), + "ul_fade" => self.set_fade(v.clamp(0.0, 80.0) as u8), + _ => {} + } + } + } +} + +// ── Transformable ───────────────────────────────────────────────────────────── + +impl Transformable for Underlay { + fn apply_transform(&mut self, t: &EntityTransform) { + use crate::scene::transform::reflect_xy_point; + match t { + EntityTransform::Translate(d) => { + self.insertion_point.x += d.x as f64; + self.insertion_point.y += d.y as f64; + self.insertion_point.z += d.z as f64; + } + EntityTransform::Mirror { p1, p2 } => { + reflect_xy_point( + &mut self.insertion_point.x, + &mut self.insertion_point.y, + *p1, + *p2, + ); + // Reflect rotation angle. + let dx = (p2.x - p1.x) as f64; + let dy = (p2.y - p1.y) as f64; + let axis_angle = dy.atan2(dx); + self.rotation = 2.0 * axis_angle - self.rotation; + } + EntityTransform::Scale { center, factor } => { + let bx = center.x as f64; + let by = center.y as f64; + let bz = center.z as f64; + let f = *factor as f64; + self.insertion_point.x = bx + (self.insertion_point.x - bx) * f; + self.insertion_point.y = by + (self.insertion_point.y - by) * f; + self.insertion_point.z = bz + (self.insertion_point.z - bz) * f; + self.x_scale *= f; + self.y_scale *= f; + self.z_scale *= f; + } + EntityTransform::Rotate { center, angle_rad } => { + let bx = center.x as f64; + let by = center.y as f64; + let a = *angle_rad as f64; + let cos_a = a.cos(); + let sin_a = a.sin(); + let dx = self.insertion_point.x - bx; + let dy = self.insertion_point.y - by; + self.insertion_point.x = bx + dx * cos_a - dy * sin_a; + self.insertion_point.y = by + dx * sin_a + dy * cos_a; + self.rotation += a; + } + } + } +}