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 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-06 14:43:44 +03:00
commit 6f19b4fd81
4 changed files with 366 additions and 0 deletions

View file

@ -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<acadrust::Handle> = 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::<u8>() {
ul.set_fade(v);
changed += 1;
}
}
"CONTRAST" => {
if let Ok(v) = arg.parse::<u8>() {
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 <n>|CONTRAST <n>|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",

View file

@ -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;

View file

@ -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),
_ => {}
}
}

263
src/entities/underlay.rs Normal file
View file

@ -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<TruckEntity> {
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<GripDef> {
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::<f64>() {
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;
}
}
}
}