diff --git a/src/entities/attribute.rs b/src/entities/attribute.rs index 1417b5506..d4914028c 100644 --- a/src/entities/attribute.rs +++ b/src/entities/attribute.rs @@ -1,58 +1,268 @@ +use acadrust::entities::attribute_definition::{ + HorizontalAlignment as AHA, MTextFlag, VerticalAlignment as AVA, +}; use acadrust::entities::{AttributeDefinition, AttributeEntity}; +use acadrust::types::Vector3; use glam::Vec3; use crate::command::EntityTransform; -use crate::entities::common::{edit_prop as edit, ro_prop as ro, square_grip}; -use crate::entities::text_support::resolve_text_style; +use crate::entities::common::{edit_prop as edit, parse_f64, ro_prop as ro, square_grip}; +use crate::entities::text_support::{resolve_dxf_special_chars, resolve_text_style, text_local_bounds}; use crate::entities::traits::{Grippable, PropertyEditable, Transformable, TruckConvertible}; use crate::scene::acad_to_truck::{TextStroke, TruckEntity, TruckObject}; -use crate::scene::object::{GripApply, GripDef, PropSection}; +use crate::scene::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::wire_model::SnapHint; use crate::scene::{cxf, transform}; +// ── Shared helpers ──────────────────────────────────────────────────────────── + +/// Bundle of the fields both attribute kinds carry. Lets the truck builder +/// stay generic over ATTDEF vs ATTRIB. +struct AttrTextInputs<'a> { + value: &'a str, + insertion_point: Vector3, + alignment_point: Vector3, + height: f64, + rotation: f64, + width_factor: f64, + oblique_angle: f64, + text_style: &'a str, + text_generation_flags: i16, + horizontal_alignment: AHA, + vertical_alignment: AVA, + normal: Vector3, + mtext_flag: MTextFlag, + is_multiline: bool, + line_count: i16, +} + +fn halign_str(a: AHA) -> &'static str { + match a { + AHA::Left => "Left", + AHA::Center => "Center", + AHA::Right => "Right", + AHA::Aligned => "Aligned", + AHA::Middle => "Middle", + AHA::Fit => "Fit", + } +} + +fn valign_str(a: AVA) -> &'static str { + match a { + AVA::Baseline => "Baseline", + AVA::Bottom => "Bottom", + AVA::Middle => "Middle", + AVA::Top => "Top", + } +} + +fn parse_halign(s: &str) -> Option { + Some(match s { + "Left" => AHA::Left, + "Center" => AHA::Center, + "Right" => AHA::Right, + "Aligned" => AHA::Aligned, + "Middle" => AHA::Middle, + "Fit" => AHA::Fit, + _ => return None, + }) +} + +fn parse_valign(s: &str) -> Option { + Some(match s { + "Baseline" => AVA::Baseline, + "Bottom" => AVA::Bottom, + "Middle" => AVA::Middle, + "Top" => AVA::Top, + _ => return None, + }) +} + +fn bool_yn(b: bool) -> &'static str { + if b { + "Yes" + } else { + "No" + } +} + +fn mtext_flag_str(f: MTextFlag) -> &'static str { + match f { + MTextFlag::SingleLine => "SingleLine", + MTextFlag::MultiLine => "MultiLine", + MTextFlag::ConstantMultiLine => "ConstantMultiLine", + } +} + +/// Render text strokes for an attribute, honouring alignment, oblique angle, +/// width factor, generation flags (backward / upside-down), text-style +/// resolution, and basic multiline splitting on `\n` / `\\P`. +fn build_attr_truck(input: AttrTextInputs<'_>, document: &acadrust::CadDocument) -> TruckEntity { + let normal = (input.normal.x, input.normal.y, input.normal.z); + let (wsx, wsy, wsz) = transform::ocs_point_to_wcs( + ( + input.insertion_point.x, + input.insertion_point.y, + input.insertion_point.z, + ), + normal, + ); + let snap_pt = Vec3::new(wsx as f32, wsy as f32, wsz as f32); + + let resolved = resolve_text_style(input.text_style, document); + + // width_factor sign also flips on text_generation_flags bit 2 (backward). + // The TextStyle's own is_backward flag is multiplicative — if both are set + // they cancel (mirror twice → forward), matching AutoCAD. + let base_wf = + ((input.width_factor as f32).max(0.01) * resolved.width_factor.max(0.01)).clamp(0.01, 100.0); + let attr_backward = (input.text_generation_flags & 2) != 0; + let mut width_factor = base_wf; + if attr_backward ^ resolved.is_backward { + width_factor = -width_factor; + } + + // Upside-down (bit 4 / TextStyle.is_upside_down) rotates by π around the + // insertion point. Combined with rotation we get a 180° flip about the + // anchor — same as Text. + let attr_upside_down = (input.text_generation_flags & 4) != 0; + let upside_down = attr_upside_down ^ resolved.is_upside_down; + let rotation = if upside_down { + input.rotation as f32 + std::f32::consts::PI + } else { + input.rotation as f32 + }; + let oblique_angle = input.oblique_angle as f32 + resolved.oblique_angle; + + // Anchor selection mirrors Text: only Left/Baseline uses insertion_point; + // every other alignment uses alignment_point. + let needs_align_pt = !(matches!(input.horizontal_alignment, AHA::Left) + && matches!(input.vertical_alignment, AVA::Baseline)); + let anchor_f64 = if needs_align_pt { + [input.alignment_point.x, input.alignment_point.y] + } else { + [input.insertion_point.x, input.insertion_point.y] + }; + + // Multi-line: split on \\P or \n. line_count is informational and we + // recompute lines from the actual value. + let raw_value = if input.is_multiline || !matches!(input.mtext_flag, MTextFlag::SingleLine) { + input.value.to_string() + } else { + input.value.to_string() + }; + let plain: Vec = raw_value + .replace("\\P", "\n") + .split('\n') + .map(|l| l.to_string()) + .collect(); + // Tag-style fallback when there is no value (ATTDEF preview style). + let lines: Vec = if plain.iter().all(|l| l.is_empty()) { + vec![format!("[{}]", input.value)] + } else { + plain + }; + + let line_height = (input.height as f32) * 1.4; // typical CXF inter-line gap + let (cos_r, sin_r) = (rotation.cos() as f64, rotation.sin() as f64); + + let mut strokes_all = Vec::with_capacity(lines.len()); + for (i, line) in lines.iter().enumerate() { + // For width calculations strip MText decorations / DXF specials. + let value_for_bounds = resolve_dxf_special_chars(line); + let bounds = text_local_bounds( + &resolved.font_name, + &value_for_bounds, + input.height as f32, + width_factor, + oblique_angle, + ); + let (anchor_local_x, anchor_local_y) = + if let Some(([min_x, min_y], [max_x, max_y])) = bounds { + let ax = match input.horizontal_alignment { + AHA::Left => min_x, + AHA::Center | AHA::Middle => (min_x + max_x) * 0.5, + AHA::Right | AHA::Aligned | AHA::Fit => max_x, + }; + let ay = match input.vertical_alignment { + AVA::Baseline => 0.0, + AVA::Bottom => min_y, + AVA::Middle => (min_y + max_y) * 0.5, + AVA::Top => max_y, + }; + (ax, ay) + } else { + (0.0, 0.0) + }; + let line_offset_y = -(i as f32) * line_height; + let local_y_for_line = anchor_local_y - line_offset_y; + let origin: [f64; 2] = [ + anchor_f64[0] - (anchor_local_x as f64 * cos_r - local_y_for_line as f64 * sin_r), + anchor_f64[1] - (anchor_local_x as f64 * sin_r + local_y_for_line as f64 * cos_r), + ]; + let strokes = cxf::tessellate_text_ex( + [0.0, 0.0], + input.height as f32, + rotation, + width_factor, + oblique_angle, + &resolved.font_name, + line, + ); + strokes_all.push(TextStroke { strokes, origin }); + } + let _ = input.line_count; // round-trip only — recomputed above + + TruckEntity { + object: TruckObject::Text(strokes_all), + snap_pts: vec![(snap_pt, SnapHint::Insertion)], + tangent_geoms: vec![], + key_vertices: vec![], + fill_tris: vec![], + } +} + // ── AttributeDefinition ─────────────────────────────────────────────────────── impl TruckConvertible for AttributeDefinition { fn to_truck(&self, document: &acadrust::CadDocument) -> Option { - let normal = (self.normal.x, self.normal.y, self.normal.z); - let (wsx, wsy, wsz) = transform::ocs_point_to_wcs( - ( - self.insertion_point.x, - self.insertion_point.y, - self.insertion_point.z, - ), - normal, - ); - let snap_pt = Vec3::new(wsx as f32, wsy as f32, wsz as f32); - let resolved = resolve_text_style(&self.text_style, document); - let display = if self.default_value.is_empty() { - format!("[{}]", self.tag) + let display_value = if self.default_value.is_empty() { + // tag-only preview path: build_attr_truck wraps in brackets. + self.tag.clone() } else { self.default_value.clone() }; - let wf = (self.width_factor as f32).max(0.01); - let origin = [self.insertion_point.x, self.insertion_point.y]; - let strokes = cxf::tessellate_text_ex( - [0.0, 0.0], - self.height as f32, - self.rotation as f32, - wf * resolved.width_factor.max(0.01), - self.oblique_angle as f32 + resolved.oblique_angle, - &resolved.font_name, - &display, - ); - Some(TruckEntity { - object: TruckObject::Text(vec![TextStroke { strokes, origin }]), - snap_pts: vec![(snap_pt, SnapHint::Insertion)], - tangent_geoms: vec![], - key_vertices: vec![], - fill_tris: vec![], - }) + Some(build_attr_truck( + AttrTextInputs { + value: &display_value, + insertion_point: self.insertion_point, + alignment_point: self.alignment_point, + height: self.height, + rotation: self.rotation, + width_factor: self.width_factor, + oblique_angle: self.oblique_angle, + text_style: &self.text_style, + text_generation_flags: self.text_generation_flags, + horizontal_alignment: self.horizontal_alignment, + vertical_alignment: self.vertical_alignment, + normal: self.normal, + mtext_flag: self.mtext_flag, + is_multiline: self.is_multiline, + line_count: self.line_count, + }, + document, + )) } } impl Grippable for AttributeDefinition { fn grips(&self) -> Vec { + // lock_position blocks the position grip. Show an empty grip set so + // the renderer can't drag the entity in either AttributeFlags or + // top-level lock_position is on. + if self.lock_position || self.flags.locked_position { + return vec![]; + } vec![square_grip( 0, Vec3::new( @@ -64,17 +274,26 @@ impl Grippable for AttributeDefinition { } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + if self.lock_position || self.flags.locked_position { + return; + } if grip_id == 0 { 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; + self.alignment_point.x += d.x as f64; + self.alignment_point.y += d.y as f64; + self.alignment_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; + self.alignment_point.x = p.x as f64; + self.alignment_point.y = p.y as f64; + self.alignment_point.z = p.z as f64; } } } @@ -82,32 +301,165 @@ impl Grippable for AttributeDefinition { } impl PropertyEditable for AttributeDefinition { - fn geometry_properties(&self, _text_style_names: &[String]) -> PropSection { + fn geometry_properties(&self, text_style_names: &[String]) -> PropSection { + let mut props = vec![ + ro("Tag", "att_tag", self.tag.clone()), + ro("Prompt", "att_prompt", self.prompt.clone()), + Property { + label: "Default".into(), + field: "att_default", + value: PropValue::EditText(self.default_value.clone()), + }, + edit("Insert X", "att_ix", self.insertion_point.x), + edit("Insert Y", "att_iy", self.insertion_point.y), + edit("Insert Z", "att_iz", self.insertion_point.z), + edit("Align X", "att_ax", self.alignment_point.x), + edit("Align Y", "att_ay", self.alignment_point.y), + edit("Align Z", "att_az", self.alignment_point.z), + edit("Height", "att_h", self.height), + edit("Rotation", "att_rot", self.rotation.to_degrees()), + edit("Width Factor", "att_wf", self.width_factor), + edit("Oblique Angle", "att_ob", self.oblique_angle.to_degrees()), + Property { + label: "H-Align".into(), + field: "att_halign", + value: PropValue::Choice { + selected: halign_str(self.horizontal_alignment).to_string(), + options: ["Left", "Center", "Right", "Aligned", "Middle", "Fit"] + .into_iter() + .map(str::to_string) + .collect(), + }, + }, + Property { + label: "V-Align".into(), + field: "att_valign", + value: PropValue::Choice { + selected: valign_str(self.vertical_alignment).to_string(), + options: ["Baseline", "Bottom", "Middle", "Top"] + .into_iter() + .map(str::to_string) + .collect(), + }, + }, + Property { + label: "Style".into(), + field: "att_style", + value: PropValue::Choice { + selected: if self.text_style.trim().is_empty() { + "Standard".into() + } else { + self.text_style.clone() + }, + options: text_style_names.to_vec(), + }, + }, + ro( + "Generation", + "att_gen_flags", + format!("{:#06b}", self.text_generation_flags & 0xff), + ), + ro( + "Field Length", + "att_field_len", + self.field_length.to_string(), + ), + Property { + label: "MText Mode".into(), + field: "att_mtext_flag", + value: PropValue::Choice { + selected: mtext_flag_str(self.mtext_flag).to_string(), + options: ["SingleLine", "MultiLine", "ConstantMultiLine"] + .into_iter() + .map(str::to_string) + .collect(), + }, + }, + ro( + "Multiline", + "att_is_multiline", + bool_yn(self.is_multiline), + ), + ro( + "Line Count", + "att_line_count", + self.line_count.to_string(), + ), + ro( + "Lock Position", + "att_lock_pos", + bool_yn(self.lock_position), + ), + ro("Invisible", "att_invisible", bool_yn(self.flags.invisible)), + ro("Constant", "att_constant", bool_yn(self.flags.constant)), + ro("Verify", "att_verify", bool_yn(self.flags.verify)), + ro("Preset", "att_preset", bool_yn(self.flags.preset)), + ro( + "Annotative", + "att_annotative", + bool_yn(self.flags.annotative), + ), + ]; + // Constant attributes can't be edited at insert time — surface that + // by marking the Default field read-only. + if self.flags.constant { + if let Some(p) = props.iter_mut().find(|p| p.field == "att_default") { + p.value = PropValue::ReadOnly(self.default_value.clone()); + } + } PropSection { title: "Geometry".into(), - props: vec![ - ro("Tag", "att_tag", self.tag.clone()), - ro("Prompt", "att_prompt", self.prompt.clone()), - edit("Default", "att_default", 0.0), // String — handled as text - edit("Insert X", "att_ix", self.insertion_point.x), - edit("Insert Y", "att_iy", self.insertion_point.y), - edit("Insert Z", "att_iz", self.insertion_point.z), - edit("Height", "att_h", self.height), - edit("Rotation", "att_rot", self.rotation.to_degrees()), - ], + props, } } fn apply_geom_prop(&mut self, field: &str, value: &str) { - let Ok(v) = value.trim().parse::() else { + // String / enum fields first. + if field == "att_default" { + if !self.flags.constant { + self.default_value = value.to_string(); + } + return; + } + if field == "att_style" { + self.text_style = value.to_string(); + return; + } + if field == "att_halign" { + if let Some(a) = parse_halign(value) { + self.horizontal_alignment = a; + } + return; + } + if field == "att_valign" { + if let Some(a) = parse_valign(value) { + self.vertical_alignment = a; + } + return; + } + if field == "att_mtext_flag" { + self.mtext_flag = match value { + "MultiLine" => MTextFlag::MultiLine, + "ConstantMultiLine" => MTextFlag::ConstantMultiLine, + _ => MTextFlag::SingleLine, + }; + return; + } + // Numeric scalars. + let Some(v) = parse_f64(value) else { return; }; match field { "att_ix" => self.insertion_point.x = v, "att_iy" => self.insertion_point.y = v, "att_iz" => self.insertion_point.z = v, + "att_ax" => self.alignment_point.x = v, + "att_ay" => self.alignment_point.y = v, + "att_az" => self.alignment_point.z = v, "att_h" if v > 0.0 => self.height = v, "att_rot" => self.rotation = v.to_radians(), + "att_wf" if v.abs() > 1e-9 => self.width_factor = v, + "att_ob" => self.oblique_angle = v.to_radians(), _ => {} } } @@ -122,6 +474,12 @@ impl Transformable for AttributeDefinition { p1, p2, ); + transform::reflect_xy_point( + &mut entity.alignment_point.x, + &mut entity.alignment_point.y, + p1, + p2, + ); }); } } @@ -130,40 +488,34 @@ impl Transformable for AttributeDefinition { impl TruckConvertible for AttributeEntity { fn to_truck(&self, document: &acadrust::CadDocument) -> Option { - let normal = (self.normal.x, self.normal.y, self.normal.z); - let (wsx, wsy, wsz) = transform::ocs_point_to_wcs( - ( - self.insertion_point.x, - self.insertion_point.y, - self.insertion_point.z, - ), - normal, - ); - let snap_pt = Vec3::new(wsx as f32, wsy as f32, wsz as f32); - let resolved = resolve_text_style(&self.text_style, document); - let wf = (self.width_factor as f32).max(0.01); - let origin = [self.insertion_point.x, self.insertion_point.y]; - let strokes = cxf::tessellate_text_ex( - [0.0, 0.0], - self.height as f32, - self.rotation as f32, - wf * resolved.width_factor.max(0.01), - self.oblique_angle as f32 + resolved.oblique_angle, - &resolved.font_name, - &self.value, - ); - Some(TruckEntity { - object: TruckObject::Text(vec![TextStroke { strokes, origin }]), - snap_pts: vec![(snap_pt, SnapHint::Insertion)], - tangent_geoms: vec![], - key_vertices: vec![], - fill_tris: vec![], - }) + Some(build_attr_truck( + AttrTextInputs { + value: &self.value, + insertion_point: self.insertion_point, + alignment_point: self.alignment_point, + height: self.height, + rotation: self.rotation, + width_factor: self.width_factor, + oblique_angle: self.oblique_angle, + text_style: &self.text_style, + text_generation_flags: self.text_generation_flags, + horizontal_alignment: self.horizontal_alignment, + vertical_alignment: self.vertical_alignment, + normal: self.normal, + mtext_flag: self.mtext_flag, + is_multiline: self.is_multiline, + line_count: self.line_count, + }, + document, + )) } } impl Grippable for AttributeEntity { fn grips(&self) -> Vec { + if self.lock_position || self.flags.locked_position { + return vec![]; + } vec![square_grip( 0, Vec3::new( @@ -175,17 +527,26 @@ impl Grippable for AttributeEntity { } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { + if self.lock_position || self.flags.locked_position { + return; + } if grip_id == 0 { 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; + self.alignment_point.x += d.x as f64; + self.alignment_point.y += d.y as f64; + self.alignment_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; + self.alignment_point.x = p.x as f64; + self.alignment_point.y = p.y as f64; + self.alignment_point.z = p.z as f64; } } } @@ -193,31 +554,149 @@ impl Grippable for AttributeEntity { } impl PropertyEditable for AttributeEntity { - fn geometry_properties(&self, _text_style_names: &[String]) -> PropSection { + fn geometry_properties(&self, text_style_names: &[String]) -> PropSection { PropSection { title: "Geometry".into(), props: vec![ ro("Tag", "atte_tag", self.tag.clone()), - ro("Value", "atte_val", self.value.clone()), + Property { + label: "Value".into(), + field: "atte_val", + value: PropValue::EditText(self.value.clone()), + }, edit("Insert X", "atte_ix", self.insertion_point.x), edit("Insert Y", "atte_iy", self.insertion_point.y), edit("Insert Z", "atte_iz", self.insertion_point.z), + edit("Align X", "atte_ax", self.alignment_point.x), + edit("Align Y", "atte_ay", self.alignment_point.y), + edit("Align Z", "atte_az", self.alignment_point.z), edit("Height", "atte_h", self.height), edit("Rotation", "atte_rot", self.rotation.to_degrees()), + edit("Width Factor", "atte_wf", self.width_factor), + edit("Oblique Angle", "atte_ob", self.oblique_angle.to_degrees()), + Property { + label: "H-Align".into(), + field: "atte_halign", + value: PropValue::Choice { + selected: halign_str(self.horizontal_alignment).to_string(), + options: ["Left", "Center", "Right", "Aligned", "Middle", "Fit"] + .into_iter() + .map(str::to_string) + .collect(), + }, + }, + Property { + label: "V-Align".into(), + field: "atte_valign", + value: PropValue::Choice { + selected: valign_str(self.vertical_alignment).to_string(), + options: ["Baseline", "Bottom", "Middle", "Top"] + .into_iter() + .map(str::to_string) + .collect(), + }, + }, + Property { + label: "Style".into(), + field: "atte_style", + value: PropValue::Choice { + selected: if self.text_style.trim().is_empty() { + "Standard".into() + } else { + self.text_style.clone() + }, + options: text_style_names.to_vec(), + }, + }, + ro( + "Generation", + "atte_gen_flags", + format!("{:#06b}", self.text_generation_flags & 0xff), + ), + ro( + "Field Length", + "atte_field_len", + self.field_length.to_string(), + ), + ro("MText Mode", "atte_mtext_flag", mtext_flag_str(self.mtext_flag)), + ro( + "Multiline", + "atte_is_multiline", + bool_yn(self.is_multiline), + ), + ro( + "Line Count", + "atte_line_count", + self.line_count.to_string(), + ), + ro( + "Lock Position", + "atte_lock_pos", + bool_yn(self.lock_position), + ), + ro( + "Definition", + "atte_attdef", + if self.attdef_handle.is_null() { + "(none)".into() + } else { + format!("{:X}", self.attdef_handle.value()) + }, + ), + ro( + "Invisible", + "atte_invisible", + bool_yn(self.flags.invisible), + ), + ro("Constant", "atte_constant", bool_yn(self.flags.constant)), + ro("Verify", "atte_verify", bool_yn(self.flags.verify)), + ro("Preset", "atte_preset", bool_yn(self.flags.preset)), + ro( + "Annotative", + "atte_annotative", + bool_yn(self.flags.annotative), + ), ], } } fn apply_geom_prop(&mut self, field: &str, value: &str) { - let Ok(v) = value.trim().parse::() else { + if field == "atte_val" { + if !self.flags.constant { + self.value = value.to_string(); + } + return; + } + if field == "atte_style" { + self.text_style = value.to_string(); + return; + } + if field == "atte_halign" { + if let Some(a) = parse_halign(value) { + self.horizontal_alignment = a; + } + return; + } + if field == "atte_valign" { + if let Some(a) = parse_valign(value) { + self.vertical_alignment = a; + } + return; + } + let Some(v) = parse_f64(value) else { return; }; match field { "atte_ix" => self.insertion_point.x = v, "atte_iy" => self.insertion_point.y = v, "atte_iz" => self.insertion_point.z = v, + "atte_ax" => self.alignment_point.x = v, + "atte_ay" => self.alignment_point.y = v, + "atte_az" => self.alignment_point.z = v, "atte_h" if v > 0.0 => self.height = v, "atte_rot" => self.rotation = v.to_radians(), + "atte_wf" if v.abs() > 1e-9 => self.width_factor = v, + "atte_ob" => self.oblique_angle = v.to_radians(), _ => {} } } @@ -232,6 +711,12 @@ impl Transformable for AttributeEntity { p1, p2, ); + transform::reflect_xy_point( + &mut entity.alignment_point.x, + &mut entity.alignment_point.y, + p1, + p2, + ); }); } } diff --git a/src/entities/shape.rs b/src/entities/shape.rs index f7e7f6642..87d9cc296 100644 --- a/src/entities/shape.rs +++ b/src/entities/shape.rs @@ -1,8 +1,9 @@ // SHAPE entity — reference to an .SHX shape-file glyph. // // Since .SHX binary files are not parsed, we render a small diamond marker -// at the insertion point (same approach as unknown/unsupported entities). -// The shape_name and style_name are surfaced as read-only properties. +// at the insertion point (same approach as unknown/unsupported entities) +// but apply the SHAPE's own rotation / oblique / width factor so the marker +// gives a rough indication of the glyph orientation. use acadrust::entities::Shape; use glam::Vec3; @@ -17,17 +18,36 @@ use crate::scene::wire_model::SnapHint; // ── Marker geometry ─────────────────────────────────────────────────────────── -/// Small diamond marker at the shape insertion point. -fn shape_marker(ox: f64, oy: f64, oz: f64, size: f64) -> Vec<[f64; 3]> { - let s = size * 0.5; - vec![ - [ox, oy + s, oz], // top - [ox + s, oy, oz], // right - [ox, oy - s, oz], // bottom - [ox - s, oy, oz], // left - [ox, oy + s, oz], // close - [f64::NAN; 3], - ] +/// Small diamond marker at the shape insertion point. The diamond is sized +/// by `size`, stretched horizontally by `relative_x_scale`, sheared by +/// `oblique_angle`, and rotated by `rotation`. +fn shape_marker( + ox: f64, + oy: f64, + oz: f64, + size: f64, + rotation: f64, + rel_x_scale: f64, + oblique_angle: f64, +) -> Vec<[f64; 3]> { + let s = size.abs().max(0.001) * 0.5; + let rx = s * if rel_x_scale.abs() < 1e-9 { 1.0 } else { rel_x_scale }; + let ry = s; + let local = [(0.0, ry), (rx, 0.0), (0.0, -ry), (-rx, 0.0)]; + let (sin_r, cos_r) = (rotation.sin(), rotation.cos()); + let ob = oblique_angle.tan(); + let mut out: Vec<[f64; 3]> = Vec::with_capacity(6); + for &(x, y) in &local { + let sx = x + y * ob; + let lx = sx * cos_r - y * sin_r + ox; + let ly = sx * sin_r + y * cos_r + oy; + out.push([lx, ly, oz]); + } + // Close polygon + segment separator. + let first = out[0]; + out.push(first); + out.push([f64::NAN; 3]); + out } // ── TruckConvertible ────────────────────────────────────────────────────────── @@ -40,7 +60,15 @@ impl TruckConvertible for Shape { let size = self.size.abs().max(0.5); let snap_pt = Vec3::new(ox as f32, oy as f32, oz as f32); - let pts = shape_marker(ox, oy, oz, size); + let pts = shape_marker( + ox, + oy, + oz, + size, + self.rotation, + self.relative_x_scale, + self.oblique_angle, + ); Some(TruckEntity { object: TruckObject::Lines(pts), @@ -88,16 +116,28 @@ impl Grippable for Shape { impl PropertyEditable for Shape { fn geometry_properties(&self, _text_style_names: &[String]) -> PropSection { + let style_handle_display = match self.style_handle { + Some(h) if !h.is_null() => format!("{:X}", h.value()), + _ => "(none)".to_string(), + }; PropSection { title: "Geometry".into(), props: vec![ ro("Name", "shp_name", self.shape_name.clone()), + ro("Number", "shp_number", self.shape_number.to_string()), ro("Style", "shp_style", self.style_name.clone()), + ro("Style Handle", "shp_style_handle", style_handle_display), edit("Insert X", "shp_ix", self.insertion_point.x), edit("Insert Y", "shp_iy", self.insertion_point.y), edit("Insert Z", "shp_iz", self.insertion_point.z), edit("Size", "shp_sz", self.size), edit("Rotation", "shp_rot", self.rotation.to_degrees()), + edit("Width Factor", "shp_xs", self.relative_x_scale), + edit("Oblique Angle", "shp_ob", self.oblique_angle.to_degrees()), + edit("Thickness", "shp_th", self.thickness), + edit("Normal X", "shp_nx", self.normal.x), + edit("Normal Y", "shp_ny", self.normal.y), + edit("Normal Z", "shp_nz", self.normal.z), ], } } @@ -112,6 +152,12 @@ impl PropertyEditable for Shape { "shp_iz" => self.insertion_point.z = v, "shp_sz" => self.size = v.max(0.001), "shp_rot" => self.rotation = v.to_radians(), + "shp_xs" if v.abs() > 1e-9 => self.relative_x_scale = v, + "shp_ob" => self.oblique_angle = v.to_radians(), + "shp_th" => self.thickness = v, + "shp_nx" => self.normal.x = v, + "shp_ny" => self.normal.y = v, + "shp_nz" => self.normal.z = v, _ => {} } }