use acadrust::entities::{LeaderContentType, MultiLeader, MultiLeaderPathType, TextAttachmentType}; use crate::entities::text_support::{ layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle, }; /// Map MLEADER's vertical attachment enum onto the shared `MTextVAnchor` /// used by `layout_mtext`. Replaces the old `v_offset_for_attachment` /// (which inlined the offset math); the shared pipeline now derives the /// offset from the variant + n_lines / line_h. pub(crate) fn mleader_v_anchor(attach: TextAttachmentType) -> MTextVAnchor { match attach { TextAttachmentType::TopOfTopLine => MTextVAnchor::Top, TextAttachmentType::MiddleOfTopLine => MTextVAnchor::MiddleOfTopLine, TextAttachmentType::MiddleOfText | TextAttachmentType::CenterOfText | TextAttachmentType::CenterOfTextOverline => MTextVAnchor::Middle, TextAttachmentType::MiddleOfBottomLine => MTextVAnchor::MiddleOfBottomLine, TextAttachmentType::BottomOfBottomLine | TextAttachmentType::BottomLine => { MTextVAnchor::Bottom } TextAttachmentType::BottomOfTopLineUnderlineBottomLine | TextAttachmentType::BottomOfTopLineUnderlineTopLine | TextAttachmentType::BottomOfTopLineUnderlineAll => MTextVAnchor::BottomOfTopLine, } } use glam::DVec3; use crate::command::EntityTransform; use crate::entities::common::{ center_grip, edit_prop as edit, ro_prop as ro, square_grip, triangle_grip, }; use crate::entities::traits::TruckConvertible; use crate::scene::convert::acad_to_truck::{TruckEntity, TruckObject}; use crate::scene::model::object::{GripApply, GripDef, PropSection, PropValue, Property}; use crate::scene::model::wire_model::{SnapHint, TangentGeom}; // ── TruckConvertible ──────────────────────────────────────────────────────── /// Catmull-Rom spline tessellation through `ctrl` points, `segs_per_span` segments each. /// Operates in f64 so it can be applied to either WCS-direct coordinates (entity path) /// or offset-relative coordinates (scene path) without precision loss. pub(crate) fn catmull_rom_pts(ctrl: &[[f64; 3]], segs_per_span: u32) -> Vec<[f64; 3]> { let n = ctrl.len(); let mut out = Vec::new(); for i in 0..n.saturating_sub(1) { let p0 = if i == 0 { ctrl[0] } else { ctrl[i - 1] }; let p1 = ctrl[i]; let p2 = ctrl[i + 1]; let p3 = if i + 2 < n { ctrl[i + 2] } else { ctrl[n - 1] }; for j in 0..=segs_per_span { let t = j as f64 / segs_per_span as f64; let t2 = t * t; let t3 = t2 * t; let mut pt = [0.0_f64; 3]; for k in 0..3 { pt[k] = 0.5 * ((2.0 * p1[k]) + (-p0[k] + p2[k]) * t + (2.0 * p0[k] - 5.0 * p1[k] + 4.0 * p2[k] - p3[k]) * t2 + (-p0[k] + 3.0 * p1[k] - 3.0 * p2[k] + p3[k]) * t3); } out.push(pt); } } out } fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option { let nan = [f64::NAN; 3]; let p3 = |v: &acadrust::types::Vector3| -> [f64; 3] { [v.x, v.y, v.z] }; let arrow_size = ml.arrowhead_size; let draw_arrow = arrow_size > 0.0; let invisible = ml.path_type == MultiLeaderPathType::Invisible; let mut points: Vec<[f64; 3]> = Vec::new(); let mut tangents: Vec = Vec::new(); let mut key_verts: Vec<[f64; 3]> = Vec::new(); let mut snap_pts: Vec<(glam::DVec3, SnapHint)> = Vec::new(); let mut first = true; let node = |arr: [f64; 3]| (glam::DVec3::new(arr[0], arr[1], arr[2]), SnapHint::Node); // Text-side geometry, recomputed every frame so dragging the arrow or the // text re-mirrors the whole layout. The text block is centred on its grip // (text_location); the landing meets the text's near edge and the leader // line ends one dogleg before that. let text_loc = ml.context.text_location; let leader_ref = ml .context .leader_roots .first() .and_then(|r| r.lines.first()) .and_then(|l| l.points.last()) .copied() .or_else(|| ml.context.leader_roots.first().map(|r| r.connection_point)) .unwrap_or(text_loc); // Reading direction of the text / landing — the stored text_direction // (UCS X for UCS-placed leaders, world X otherwise), so the whole text-side // layout follows the UCS instead of world horizontal. let (tdx, tdy) = { let td = ml.context.text_direction; let l = (td.x * td.x + td.y * td.y).sqrt(); if l > 1e-9 { (td.x / l, td.y / l) } else { let a = ml.context.text_rotation; (a.cos(), a.sin()) } }; // Which side of the leader the text sits on, measured along that direction. let rel_dot = (text_loc.x - leader_ref.x) * tdx + (text_loc.y - leader_ref.y) * tdy; let text_sign: f64 = if rel_dot >= 0.0 { 1.0 } else { -1.0 }; // Lay the text out once, up front: its width fixes where the landing meets // the text. The same layout is reused below to emit the glyph strokes. let text_layout = if ml.content_type == LeaderContentType::MText && !ml.context.text_string.is_empty() { let ctx = &ml.context; let height = if ctx.text_height > 0.0 { ctx.text_height as f32 } else { ml.text_height as f32 * ml.scale_factor as f32 }; let td = ctx.text_direction; let mut rot = if td.x.abs() > 1e-9 || td.y.abs() > 1e-9 { (td.y as f32).atan2(td.x as f32) } else { ctx.text_rotation as f32 }; let style_name = ctx .text_style_handle .as_ref() .and_then(|h| { document .text_styles .iter() .find(|s| s.handle == *h) .map(|s| s.name.clone()) }) .unwrap_or_else(|| "STANDARD".to_string()); let resolved = resolve_text_style(&style_name, document); if resolved.is_upside_down { rot += std::f32::consts::PI; } Some(layout_mtext(&MTextRenderOpts { value: &ctx.text_string, insertion: [text_loc.x, text_loc.y, text_loc.z], height, rect_w: ctx.text_width as f32, rotation: rot, style: &resolved, // Side-anchored on the leader-facing edge so the text reads // outward; flips live with the leader/text side. attach_h_anchor: if text_sign >= 0.0 { 0.0 } else { 1.0 }, v_anchor: mleader_v_anchor(ctx.text_left_attachment), line_spacing_factor: ctx.line_spacing_factor as f32, vertical_text: false, want_glyph_boxes: false, })) } else { None }; let dogleg = if ml.enable_landing && ml.enable_dogleg { ml.dogleg_length.max(0.0) } else { 0.0 }; // text_location is the leader-facing (near) edge; the landing runs one // dogleg from there back toward the leader. let landing_pt = [ text_loc.x - text_sign * dogleg * tdx, text_loc.y - text_sign * dogleg * tdy, text_loc.z, ]; for root in &ml.context.leader_roots { let cp = &root.connection_point; let cp_f = p3(cp); snap_pts.push(node(cp_f)); for line in &root.lines { if line.points.is_empty() { continue; } if !invisible { if !first { points.push(nan); } first = false; // Build the full control-point list: line.points + landing point let mut ctrl: Vec<[f64; 3]> = line.points.iter().map(|p| p3(p)).collect(); let last_f = *ctrl.last().unwrap_or(&landing_pt); let dist = ((last_f[0] - landing_pt[0]).powi(2) + (last_f[1] - landing_pt[1]).powi(2)) .sqrt(); if dist > 1e-9 { ctrl.push(landing_pt); } for &c in &ctrl { key_verts.push(c); snap_pts.push(node(c)); } if ml.path_type == MultiLeaderPathType::Spline && ctrl.len() >= 2 { // Catmull-Rom spline through the bend points. let pts = catmull_rom_pts(&ctrl, 8); for &pt in &pts { points.push(pt); } } else { for &c in &ctrl { points.push(c); } } for i in 0..ctrl.len().saturating_sub(1) { let a = ctrl[i]; let b = ctrl[i + 1]; tangents.push(TangentGeom::Line { p1: [a[0] as f32, a[1] as f32, a[2] as f32], p2: [b[0] as f32, b[1] as f32, b[2] as f32], }); } } // Arrowhead if draw_arrow { let tip = &line.points[0]; let tip_f = p3(tip); let next = if line.points.len() >= 2 { line.points[1] } else { *cp }; let dx = next.x - tip.x; let dy = next.y - tip.y; let dl = (dx * dx + dy * dy).sqrt().max(1e-9); let (dx, dy) = (dx / dl, dy / dl); let a = std::f64::consts::PI / 6.0; let (s, c) = a.sin_cos(); points.push(nan); points.push([ tip_f[0] + (dx * c - dy * s) * arrow_size, tip_f[1] + (dx * s + dy * c) * arrow_size, tip_f[2], ]); points.push(tip_f); points.push([ tip_f[0] + (dx * c + dy * s) * arrow_size, tip_f[1] + (-dx * s + dy * c) * arrow_size, tip_f[2], ]); } } // Horizontal landing from the leader end to the text's near edge. if dogleg > 0.0 { points.push(nan); points.push(landing_pt); points.push([text_loc.x, text_loc.y, text_loc.z]); } } // Text strokes, drawn from the layout computed up front (centred on the // text grip). The snap node is the grip itself. let mut fill_tris = Vec::new(); if let Some(layout) = &text_layout { snap_pts.push(node([text_loc.x, text_loc.y, text_loc.z])); for ts in &layout.strokes { let ox = ts.origin[0]; let oy = ts.origin[1]; for stroke in &ts.strokes { if stroke.len() < 2 { continue; } points.push(nan); for &[x, y] in stroke { points.push([ox + x as f64, oy + y as f64, text_loc.z]); } } for &[x, y] in &ts.fill_tris { fill_tris.push([ox + x as f64, oy + y as f64, text_loc.z]); } } } if points.is_empty() { return None; } Some(TruckEntity { object: TruckObject::Lines(points), snap_pts, tangent_geoms: tangents, key_vertices: key_verts, fill_tris, }) } // ── Grips ────────────────────────────────────────────────────────────────── // // IDs are assigned in two passes: // 0 .. total_line_pts - 1 : square grips on every LeaderLine vertex // total_line_pts : diamond grip on context.text_location (if MText) /// Side the text reads toward (+1 right / -1 left) and the text box's far /// edge (the wrap-width grip position). The box width is the explicit wrap /// width when set, else the natural laid-out width. fn text_box_geom(ml: &MultiLeader) -> (f64, [f64; 3]) { let tl = ml.context.text_location; let leader_ref = ml .context .leader_roots .first() .and_then(|r| r.lines.first()) .and_then(|l| l.points.last()) .copied() .or_else(|| ml.context.leader_roots.first().map(|r| r.connection_point)) .unwrap_or(tl); let sign = if tl.x >= leader_ref.x { 1.0 } else { -1.0 }; let height = if ml.context.text_height > 0.0 { ml.context.text_height as f32 } else { ml.text_height as f32 * ml.scale_factor as f32 }; let style = ResolvedTextStyle { font_name: "STANDARD".to_string(), width_factor: 1.0, oblique_angle: 0.0, is_backward: false, is_upside_down: false, }; let layout = layout_mtext(&MTextRenderOpts { value: &ml.context.text_string, insertion: [0.0, 0.0, 0.0], height, rect_w: ml.context.text_width as f32, rotation: 0.0, style: &style, attach_h_anchor: if sign >= 0.0 { 0.0 } else { 1.0 }, v_anchor: MTextVAnchor::Middle, line_spacing_factor: ml.context.line_spacing_factor as f32, vertical_text: false, want_glyph_boxes: false, }); let nat_w = layout.line_widths.iter().cloned().fold(0.0_f32, f32::max) as f64; let box_w = if ml.context.text_width > 1e-6 { ml.context.text_width } else { nat_w.max(height as f64) }; (sign, [tl.x + sign * box_w, tl.y, tl.z]) } fn grips(ml: &MultiLeader) -> Vec { let mut result: Vec = Vec::new(); let mut id = 0usize; for root in &ml.context.leader_roots { for line in &root.lines { for p in &line.points { result.push(square_grip(id, glam::DVec3::new(p.x, p.y, p.z))); id += 1; } } } if ml.content_type == LeaderContentType::MText { let tl = &ml.context.text_location; // Text-location grip, then the wrap-width grip at the box's far edge. result.push(center_grip(id, glam::DVec3::new(tl.x, tl.y, tl.z))); id += 1; let (_, far) = text_box_geom(ml); result.push(triangle_grip(id, glam::DVec3::new(far[0], far[1], far[2]))); } result } /// Sentinel grip id meaning "translate the whole multileader" — used by the /// text grip's "Move with Leader" action so the leader follows the text. pub(crate) const MOVE_ALL_GRIP: usize = usize::MAX; fn apply_grip(ml: &mut MultiLeader, grip_id: usize, apply: GripApply) { if grip_id == MOVE_ALL_GRIP { let (dx, dy, dz) = match apply { GripApply::Translate(d) => (d.x as f64, d.y as f64, d.z as f64), GripApply::Absolute(a) => ( a.x as f64 - ml.context.text_location.x, a.y as f64 - ml.context.text_location.y, a.z as f64 - ml.context.text_location.z, ), }; for root in &mut ml.context.leader_roots { for line in &mut root.lines { for p in &mut line.points { p.x += dx; p.y += dy; p.z += dz; } } root.connection_point.x += dx; root.connection_point.y += dy; root.connection_point.z += dz; } ml.context.text_location.x += dx; ml.context.text_location.y += dy; ml.context.text_location.z += dz; return; } let mut idx = 0usize; for root in &mut ml.context.leader_roots { for line in &mut root.lines { for p in &mut line.points { if idx == grip_id { match apply { GripApply::Absolute(a) => { p.x = a.x as f64; p.y = a.y as f64; p.z = a.z as f64; } GripApply::Translate(d) => { p.x += d.x as f64; p.y += d.y as f64; p.z += d.z as f64; } } return; } idx += 1; } } } // Text-location grip (idx == n_vertices), then the wrap-width grip. if ml.content_type == LeaderContentType::MText { if idx == grip_id { let tl = &mut ml.context.text_location; match apply { GripApply::Absolute(a) => { tl.x = a.x as f64; tl.y = a.y as f64; tl.z = a.z as f64; } GripApply::Translate(d) => { tl.x += d.x as f64; tl.y += d.y as f64; tl.z += d.z as f64; } } return; } idx += 1; if idx == grip_id { // Dragging the box edge sets the MText wrap width. let (sign, far) = text_box_geom(ml); let new_far_x = match apply { GripApply::Absolute(a) => a.x as f64, GripApply::Translate(d) => far[0] + d.x as f64, }; let min_w = ml.text_height.max(1.0) * 0.5; ml.context.text_width = (sign * (new_far_x - ml.context.text_location.x)).max(min_w); } } } // ── Properties ───────────────────────────────────────────────────────────── fn content_type_str(ct: &LeaderContentType) -> &'static str { match ct { LeaderContentType::None => "None", LeaderContentType::Block => "Block", LeaderContentType::MText => "MText", LeaderContentType::Tolerance => "Tolerance", } } fn path_type_str(pt: &MultiLeaderPathType) -> &'static str { match pt { MultiLeaderPathType::Invisible => "Invisible", MultiLeaderPathType::StraightLineSegments => "Straight", MultiLeaderPathType::Spline => "Spline", } } fn attachment_str(a: &TextAttachmentType) -> &'static str { match a { TextAttachmentType::TopOfTopLine => "Top of Top", TextAttachmentType::MiddleOfTopLine => "Mid of Top", TextAttachmentType::MiddleOfText => "Mid of Text", TextAttachmentType::MiddleOfBottomLine => "Mid of Bot", TextAttachmentType::BottomOfBottomLine => "Bot of Bot", TextAttachmentType::BottomLine => "Bottom Line", _ => "Other", } } fn bool_toggle(label: &str, field: &'static str, value: bool) -> Property { Property { label: label.into(), field, value: PropValue::BoolToggle { field, value }, } } fn choice(label: &str, field: &'static str, selected: &str, opts: &[&str]) -> Property { Property { label: label.into(), field, value: PropValue::Choice { selected: selected.to_string(), options: opts.iter().map(|s| s.to_string()).collect(), }, } } fn properties(ml: &MultiLeader) -> PropSection { let ctx = &ml.context; let total_pts: usize = ctx .leader_roots .iter() .flat_map(|r| r.lines.iter()) .map(|l| l.points.len()) .sum(); let mut props = vec![ // Content choice( "Content Type", "content_type", content_type_str(&ml.content_type), &["None", "MText", "Block", "Tolerance"], ), Property { label: "Text".into(), field: "text_string", value: PropValue::EditText(ctx.text_string.clone()), }, edit("Text Height", "text_height", ml.text_height), edit("Text X", "text_x", ctx.text_location.x), edit("Text Y", "text_y", ctx.text_location.y), edit("Text Z", "text_z", ctx.text_location.z), bool_toggle("Text Frame", "text_frame", ml.text_frame), // Leader line choice( "Path Type", "path_type", path_type_str(&ml.path_type), &["Straight", "Spline", "Invisible"], ), bool_toggle("Landing", "enable_landing", ml.enable_landing), bool_toggle("Dogleg", "enable_dogleg", ml.enable_dogleg), edit("Dogleg Length", "dogleg_length", ml.dogleg_length), edit("Arrow Size", "arrowhead_size", ml.arrowhead_size), edit("Scale", "scale_factor", ml.scale_factor), bool_toggle( "Annotation Scale", "enable_annotation_scale", ml.enable_annotation_scale, ), // Text attachment choice( "Left Attach", "text_left_attachment", attachment_str(&ml.text_left_attachment), &[ "Top of Top", "Mid of Top", "Mid of Text", "Mid of Bot", "Bot of Bot", "Bottom Line", ], ), choice( "Right Attach", "text_right_attachment", attachment_str(&ml.text_right_attachment), &[ "Top of Top", "Mid of Top", "Mid of Text", "Mid of Bot", "Bot of Bot", "Bottom Line", ], ), // Top / Bottom attach (used when text_attachment_direction = Vertical) choice( "Top Attach", "text_top_attachment", attachment_str(&ml.text_top_attachment), &[ "Top of Top", "Mid of Top", "Mid of Text", "Mid of Bot", "Bot of Bot", "Bottom Line", ], ), choice( "Bottom Attach", "text_bottom_attachment", attachment_str(&ml.text_bottom_attachment), &[ "Top of Top", "Mid of Top", "Mid of Text", "Mid of Bot", "Bot of Bot", "Bottom Line", ], ), // Stats ro("Leader Pts", "total_pts", total_pts.to_string()), ro("Roots", "root_count", ctx.leader_roots.len().to_string()), // Style references / handles (read-only — the multileader's own // copies of the style values are the authoritative render inputs). ro( "Style Handle", "style_handle", match ml.style_handle { Some(h) if !h.is_null() => format!("{:X}", h.value()), _ => "(none)".to_string(), }, ), ro( "Text Style Handle", "text_style_handle", match ml.text_style_handle { Some(h) if !h.is_null() => format!("{:X}", h.value()), _ => "(none)".to_string(), }, ), ro( "Arrow Handle", "arrowhead_handle", match ml.arrowhead_handle { Some(h) if !h.is_null() => format!("{:X}", h.value()), _ => "(none)".to_string(), }, ), ro( "Line Type Handle", "line_type_handle", match ml.line_type_handle { Some(h) if !h.is_null() => format!("{:X}", h.value()), _ => "(none)".to_string(), }, ), ro( "Block Content Handle", "block_content_handle", match ml.block_content_handle { Some(h) if !h.is_null() => format!("{:X}", h.value()), _ => "(none)".to_string(), }, ), // Less common toggles surfaced read-only. ro( "Extend Leader", "extend_leader_to_text", if ml.extend_leader_to_text { "Yes" } else { "No" }, ), ro( "Text Direction Negative", "text_direction_negative", if ml.text_direction_negative { "Yes" } else { "No" }, ), ro( "Text Align In IPE", "text_align_in_ipe", ml.text_align_in_ipe.to_string(), ), ro( "Property Override Flags", "property_override_flags", format!("{:#018b}", ml.property_override_flags.bits()), ), ro( "Block Scale", "block_scale", format!( "{:.3} × {:.3} × {:.3}", ml.block_scale.x, ml.block_scale.y, ml.block_scale.z ), ), ro( "Block Rotation", "block_rotation", format!("{:.3}°", ml.block_rotation.to_degrees()), ), ]; // Connection point for first root (most common case) if let Some(root) = ctx.leader_roots.first() { props.push(edit("Root Conn X", "conn_x", root.connection_point.x)); props.push(edit("Root Conn Y", "conn_y", root.connection_point.y)); props.push(edit("Root Conn Z", "conn_z", root.connection_point.z)); } PropSection { title: "Geometry".into(), props, } } fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) { let f64 = |s: &str| -> Option { s.trim().parse().ok() }; match field { "content_type" => { ml.content_type = match value { "Block" => LeaderContentType::Block, "MText" => LeaderContentType::MText, "Tolerance" => LeaderContentType::Tolerance, _ => LeaderContentType::None, }; } "text_string" => ml.context.text_string = value.to_string(), "text_height" => { if let Some(v) = f64(value) { ml.text_height = v; ml.context.text_height = v; } } "text_x" => { if let Some(v) = f64(value) { ml.context.text_location.x = v; } } "text_y" => { if let Some(v) = f64(value) { ml.context.text_location.y = v; } } "text_z" => { if let Some(v) = f64(value) { ml.context.text_location.z = v; } } "text_frame" => { ml.text_frame = if value == "toggle" { !ml.text_frame } else { value == "true" } } "path_type" => { ml.path_type = match value { "Spline" => MultiLeaderPathType::Spline, "Invisible" => MultiLeaderPathType::Invisible, _ => MultiLeaderPathType::StraightLineSegments, }; } "enable_landing" => { ml.enable_landing = if value == "toggle" { !ml.enable_landing } else { value == "true" } } "enable_dogleg" => { ml.enable_dogleg = if value == "toggle" { !ml.enable_dogleg } else { value == "true" } } "enable_annotation_scale" => { ml.enable_annotation_scale = if value == "toggle" { !ml.enable_annotation_scale } else { value == "true" } } "dogleg_length" => { if let Some(v) = f64(value) { ml.dogleg_length = v; } } "arrowhead_size" => { if let Some(v) = f64(value) { ml.arrowhead_size = v; } } "scale_factor" => { if let Some(v) = f64(value) { ml.scale_factor = v; } } "conn_x" => { if let (Some(v), Some(root)) = (f64(value), ml.context.leader_roots.first_mut()) { root.connection_point.x = v; } } "conn_y" => { if let (Some(v), Some(root)) = (f64(value), ml.context.leader_roots.first_mut()) { root.connection_point.y = v; } } "conn_z" => { if let (Some(v), Some(root)) = (f64(value), ml.context.leader_roots.first_mut()) { root.connection_point.z = v; } } "text_left_attachment" => { ml.text_left_attachment = parse_attachment(value); ml.context.text_left_attachment = parse_attachment(value); } "text_right_attachment" => { ml.text_right_attachment = parse_attachment(value); ml.context.text_right_attachment = parse_attachment(value); } "text_top_attachment" => { ml.text_top_attachment = parse_attachment(value); ml.context.text_top_attachment = parse_attachment(value); } "text_bottom_attachment" => { ml.text_bottom_attachment = parse_attachment(value); ml.context.text_bottom_attachment = parse_attachment(value); } _ => {} } } fn parse_attachment(s: &str) -> TextAttachmentType { match s { "Top of Top" => TextAttachmentType::TopOfTopLine, "Mid of Top" => TextAttachmentType::MiddleOfTopLine, "Mid of Bot" => TextAttachmentType::MiddleOfBottomLine, "Bot of Bot" => TextAttachmentType::BottomOfBottomLine, "Bottom Line" => TextAttachmentType::BottomLine, _ => TextAttachmentType::MiddleOfText, } } // ── Transform ────────────────────────────────────────────────────────────── fn apply_transform(ml: &mut MultiLeader, t: &EntityTransform) { crate::scene::view::transform::apply_standard_entity_transform(ml, t, |entity, p1, p2| { // Reflect every point on the leader (line points, connection points, // break-point endpoints) AND every direction vector that drives the // text orientation. Without the direction reflection text would keep // its original rotation while the leader appears mirrored. for root in &mut entity.context.leader_roots { for line in &mut root.lines { for p in &mut line.points { crate::scene::view::transform::reflect_xy_point(&mut p.x, &mut p.y, p1, p2); } for bp in &mut line.break_points { crate::scene::view::transform::reflect_xy_point( &mut bp.start_point.x, &mut bp.start_point.y, p1, p2, ); crate::scene::view::transform::reflect_xy_point( &mut bp.end_point.x, &mut bp.end_point.y, p1, p2, ); } } crate::scene::view::transform::reflect_xy_point( &mut root.connection_point.x, &mut root.connection_point.y, p1, p2, ); reflect_xy_direction(&mut root.direction.x, &mut root.direction.y, p1, p2); for bp in &mut root.break_points { crate::scene::view::transform::reflect_xy_point( &mut bp.start_point.x, &mut bp.start_point.y, p1, p2, ); crate::scene::view::transform::reflect_xy_point( &mut bp.end_point.x, &mut bp.end_point.y, p1, p2, ); } } crate::scene::view::transform::reflect_xy_point( &mut entity.context.text_location.x, &mut entity.context.text_location.y, p1, p2, ); reflect_xy_direction( &mut entity.context.text_direction.x, &mut entity.context.text_direction.y, p1, p2, ); reflect_xy_direction( &mut entity.context.base_direction.x, &mut entity.context.base_direction.y, p1, p2, ); }); } /// Reflect a direction (not position) vector across the mirror line p1→p2. /// Reflecting a direction is the same as reflecting `p1 + dir` then subtracting /// the reflection of `p1`, which simplifies to reflecting around the origin. fn reflect_xy_direction(dx: &mut f64, dy: &mut f64, p1: DVec3, p2: DVec3) { let zero = DVec3::ZERO; let p2_rel = DVec3::new(p2.x - p1.x, p2.y - p1.y, 0.0); let mut tip_x = *dx; let mut tip_y = *dy; crate::scene::view::transform::reflect_xy_point(&mut tip_x, &mut tip_y, zero, p2_rel); *dx = tip_x; *dy = tip_y; } // ── Trait impls ──────────────────────────────────────────────────────────── impl TruckConvertible for MultiLeader { fn to_truck(&self, document: &acadrust::CadDocument) -> Option { to_truck(self, document) } } impl crate::entities::traits::Grippable for MultiLeader { fn grips(&self) -> Vec { grips(self) } fn apply_grip(&mut self, grip_id: usize, apply: GripApply) { apply_grip(self, grip_id, apply); } fn grip_menu(&self, grip_id: usize) -> Vec { use crate::scene::model::object::{GripMenuAction, GripMenuItem}; let n_vertices: usize = self .context .leader_roots .iter() .flat_map(|r| r.lines.iter()) .map(|l| l.points.len()) .sum(); if self.content_type == LeaderContentType::MText && grip_id >= n_vertices { if grip_id == n_vertices { // Text-location grip. vec![ GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch, }, GripMenuItem { label: "Move with Leader", action: GripMenuAction::MoveWithLeader, }, GripMenuItem { label: "Move Independent", action: GripMenuAction::MoveIndependent, }, ] } else { // Wrap-width grip: drag only. Vec::new() } } else { // Leader-line vertex. vec![ GripMenuItem { label: "Stretch", action: GripMenuAction::Stretch, }, GripMenuItem { label: "Add Leader", action: GripMenuAction::AddLeader, }, GripMenuItem { label: "Remove Leader", action: GripMenuAction::RemoveLeader, }, ] } } fn apply_grip_menu(&mut self, grip_id: usize, action: crate::scene::model::object::GripMenuAction) { use crate::scene::model::object::GripMenuAction as A; // Locate the (root, line) and vertex position owning this grip id. let mut idx = 0usize; let mut loc: Option<(usize, usize, acadrust::types::Vector3)> = None; 'find: for (ri, root) in self.context.leader_roots.iter().enumerate() { for (li, line) in root.lines.iter().enumerate() { let n = line.points.len(); if grip_id < idx + n { loc = Some((ri, li, line.points[grip_id - idx])); break 'find; } idx += n; } } let Some((ri, li, vpos)) = loc else { return }; match action { A::RemoveLeader => { let total: usize = self .context .leader_roots .iter() .map(|r| r.lines.len()) .sum(); if total > 1 { self.context.leader_roots[ri].lines.remove(li); if self.context.leader_roots[ri].lines.is_empty() && self.context.leader_roots.len() > 1 { self.context.leader_roots.remove(ri); } } } A::AddLeader => { // Append to the last root so the new arrow is the last grip id // (so the caller can immediately grab it for placement). Seed it // below the picked vertex; the user drags it to the final spot. let _ = ri; let off = self.text_height.max(1.0) * 4.0; let arrow = acadrust::types::Vector3::new(vpos.x, vpos.y - off, vpos.z); if let Some(root) = self.context.leader_roots.last_mut() { root.create_line(vec![arrow]); } } _ => {} } } } impl crate::entities::traits::PropertyEditable for MultiLeader { fn geometry_properties(&self, _text_style_names: &[String]) -> PropSection { properties(self) } fn apply_geom_prop(&mut self, field: &str, value: &str) { apply_geom_prop(self, field, value); } } impl crate::entities::traits::Transformable for MultiLeader { fn apply_transform(&mut self, t: &EntityTransform) { apply_transform(self, t); } } /// Per-entity tessellation entry for `MultiLeader`. Returns multiple /// `WireModel`s (leader/dogleg + arrow fill, optional block content, /// text strokes, frame, background fill) so each piece can carry its /// own colour. pub trait MultiLeaderTess { fn tessellate( &self, document: &acadrust::CadDocument, handle: acadrust::Handle, selected: bool, entity_color: [f32; 4], line_weight_px: f32, anno_scale: f32, world_per_pixel: Option, ) -> Vec; } impl MultiLeaderTess for MultiLeader { fn tessellate( &self, document: &acadrust::CadDocument, handle: acadrust::Handle, selected: bool, entity_color: [f32; 4], line_weight_px: f32, anno_scale: f32, world_per_pixel: Option, ) -> Vec { use crate::scene::convert::tessellate::{ append_arrow, arrow_from_block, color_or_inherit, tessellate, ArrowKind, DimGeom, }; use crate::scene::model::wire_model::{SnapHint, TangentGeom, WireModel}; use glam::Vec3; let ml = self; // line_color falls back to the entity colour when the MultiLeader's own // colour is ByBlock/ByLayer; otherwise the leader uses its stored hue. let line_color = if selected { WireModel::SELECTED } else { color_or_inherit(&ml.line_color, entity_color) }; // ml.line_weight is the leader-line weight override. Negative codes // (ByLayer/ByBlock/Default) fall through to the entity's already-resolved // pixel width. let leader_lw_px = match ml.line_weight { acadrust::types::LineWeight::Value(v) if v >= 0 => (v as f32 / 100.0) * (96.0 / 25.4), _ => line_weight_px, }; // ml.line_type_handle — resolve via line_types table by handle and apply // the resulting dash pattern to the leader wire. let lt_scale = document.header.linetype_scale as f32 * ml.common.linetype_scale as f32; let (leader_pat_len, leader_pat) = match ml.line_type_handle { Some(h) if !h.is_null() => { let name = document .line_types .iter() .find(|lt| lt.handle == h) .map(|lt| lt.name.clone()); match name { Some(n) => { crate::scene::view::render::resolve_pattern(&document.line_types, &n, lt_scale) } None => (0.0, [0.0; 8]), } } _ => (0.0, [0.0; 8]), }; let name = handle.value().to_string(); let nan = [f32::NAN; 3]; let p3 = |v: &acadrust::types::Vector3| -> [f32; 3] { [(v.x) as f32, (v.y) as f32, (v.z) as f32] }; // ── Scaling ────────────────────────────────────────────────────────────── // ml.scale_factor is always applied; anno_scale is only applied when the // multileader is marked annotative. let effective_scale = (ml.scale_factor as f32) * if ml.enable_annotation_scale { anno_scale } else { 1.0 }; let arrow_size = ml.arrowhead_size as f32 * effective_scale; let draw_arrow = arrow_size > 0.0; let invisible = ml.path_type == MultiLeaderPathType::Invisible; // arrowhead_handle resolves through the block records to a named arrow // block (matches DIMLDRBLK on Dimension). Null handle / unknown name → // ClosedFilled triangle. let arrow_kind = match ml.arrowhead_handle { Some(h) if !h.is_null() => arrow_from_block(document, h, arrow_size.max(0.001)), _ => ArrowKind::Triangle { size: arrow_size.max(0.001), filled: true, size_mul: 1.0, }, }; // ── Leader / arrow / dogleg points ─────────────────────────────────────── let mut points: Vec<[f32; 3]> = Vec::new(); let mut key_verts: Vec<[f64; 3]> = Vec::new(); let mut snap_pts: Vec<(glam::DVec3, SnapHint)> = Vec::new(); let mut tangents: Vec = Vec::new(); let mut arrow_fill: Vec<[f32; 3]> = Vec::new(); let mut first = true; // Which side the text grip sits on, recomputed every frame so the text // alignment and the landing mirror live when the arrow or text moves. let text_loc_w = ml.context.text_location; let leader_ref_w = ml .context .leader_roots .first() .and_then(|r| r.lines.first()) .and_then(|l| l.points.last()) .copied() .or_else(|| ml.context.leader_roots.first().map(|r| r.connection_point)) .unwrap_or(text_loc_w); let text_sign_w: f64 = if text_loc_w.x >= leader_ref_w.x { 1.0 } else { -1.0 }; for root in &ml.context.leader_roots { let cp = &root.connection_point; let cp_f = p3(cp); snap_pts.push((Vec3::from(cp_f).as_dvec3(), SnapHint::Node)); for line in &root.lines { if line.points.is_empty() { continue; } if !invisible { if !first { points.push(nan); } first = false; let mut ctrl: Vec<[f32; 3]> = line.points.iter().map(|p| p3(p)).collect(); let last_f = *ctrl.last().unwrap_or(&cp_f); let dist = ((last_f[0] - cp_f[0]).powi(2) + (last_f[1] - cp_f[1]).powi(2)).sqrt(); if dist > 1e-9 { ctrl.push(cp_f); } for &c in &ctrl { key_verts.push([c[0] as f64, c[1] as f64, c[2] as f64]); snap_pts.push((Vec3::from(c).as_dvec3(), SnapHint::Node)); } if ml.path_type == MultiLeaderPathType::Spline && ctrl.len() >= 2 { let ctrl_f64: Vec<[f64; 3]> = ctrl .iter() .map(|c| [c[0] as f64, c[1] as f64, c[2] as f64]) .collect(); for pt in catmull_rom_pts(&ctrl_f64, 8) { points.push([pt[0] as f32, pt[1] as f32, pt[2] as f32]); } } else { for &c in &ctrl { points.push(c); } } for i in 0..ctrl.len().saturating_sub(1) { tangents.push(TangentGeom::Line { p1: ctrl[i], p2: ctrl[i + 1], }); } } if draw_arrow { let tip = &line.points[0]; let tip_f = p3(tip); let next = if line.points.len() >= 2 { line.points[1] } else { *cp }; let dx = (next.x - tip.x) as f32; let dy = (next.y - tip.y) as f32; let dl = (dx * dx + dy * dy).sqrt().max(1e-9); let dir = Vec3::new(dx / dl, dy / dl, 0.0); let tip_v = Vec3::new(tip_f[0], tip_f[1], tip_f[2]); // Reuse the dim/leader arrow emitter so MultiLeader's arrow // matches the block referenced by arrowhead_handle. let mut arrow_geom = DimGeom::new(); append_arrow(&mut arrow_geom, tip_v, dir, &arrow_kind); if !arrow_geom.dim_lines.is_empty() { points.push(nan); points.extend(arrow_geom.dim_lines); } arrow_fill.extend(arrow_geom.arrow_fill); } } if ml.enable_landing && ml.enable_dogleg && ml.dogleg_length > 0.0 { // Horizontal landing toward the text's side (mirrors live). let d = ml.dogleg_length * effective_scale as f64; let landing_end = [ (cp.x + text_sign_w * d) as f32, (cp.y) as f32, cp_f[2], ]; points.push(nan); points.push(cp_f); points.push(landing_end); // Continue the landing to the text's near edge so it stays // attached as the text moves. if ml.content_type == LeaderContentType::MText && !ml.context.text_string.is_empty() { let tx = (ml.context.text_location.x) as f32; let ty = (ml.context.text_location.y) as f32; points.push(landing_end); points.push([tx, ty, cp_f[2]]); } } } // The leader/arrow/dogleg wire goes out as a single WireModel. Text, frame, // and background fill (each with their own color) are appended as separate // WireModels so the renderer respects per-piece coloring. let mut wires: Vec = Vec::new(); wires.push(WireModel { name: name.clone(), points, points_low: Vec::new(), color: line_color, selected, aci: 0, pattern_length: leader_pat_len, pattern: leader_pat, line_weight_px: leader_lw_px, snap_pts, tangent_geoms: tangents, key_vertices: key_verts, aabb: WireModel::UNBOUNDED_AABB, plinegen: true, vp_scissor: None, fill_tris: arrow_fill, // FIXME: fill_tris_low left empty — needs double-single split to match // fill_tris. Any geometry from this path inside a block definition will // trip the debug_assert_eq! in emit_wire (block_cache.rs:1397). fill_tris_low: Vec::new(), }); // ── Block content ─────────────────────────────────────────────────────── // When content_type == Block, the MultiLeader displays a block reference // at block_content_location with the recorded rotation/scale. Synthesize // an Insert and explode it through the standard tessellator. The block // resolves via block_content_handle (handle → block_record name). if ml.content_type == LeaderContentType::Block && ml.context.has_block_contents { let block_name = match ml.block_content_handle { Some(h) if !h.is_null() => document .block_records .iter() .find(|br| br.handle == h) .map(|br| br.name.clone()), _ => None, }; if let Some(block_name) = block_name { let block_color = if selected { line_color } else { color_or_inherit(&ml.block_content_color, entity_color) }; let mut synth_ins = acadrust::entities::Insert::new(block_name, ml.context.block_content_location); synth_ins.set_x_scale(ml.block_scale.x); synth_ins.set_y_scale(ml.block_scale.y); synth_ins.set_z_scale(ml.block_scale.z); synth_ins.rotation = ml.block_rotation; synth_ins.common.layer = ml.common.layer.clone(); // block_connection_type (BlockExtents vs BasePoint) chooses the // anchor when *creating* the multileader; at render time the // file's stored leader endpoints already encode that choice. let _ = ml.block_connection_type; for sub in synth_ins.explode_from_document(document) { let normalized = crate::modules::draw::modify::explode::normalize_insert_entity(sub); let mut sub_wires = tessellate( document, handle, &normalized, selected, block_color, leader_pat_len, leader_pat, leader_lw_px, 1.0, None, ); for w in &mut sub_wires { w.name = name.clone(); } wires.extend(sub_wires); } // Block attributes attached to the multileader — render each as // its own attribute entity at WCS location like INSERT does. for ba in &ml.block_attributes { let _ = ba; // BlockAttribute carries only the value override // string; we'd need the AttributeDefinition handle // to materialise it as ATTRIB geometry. Skipped // until that wiring exists. } } } // ── Text strokes / frame / background fill ────────────────────────────── // Strip inline format codes, split / word-wrap into lines, then place each // line according to text_attachment_point (horizontal) and // text_left_attachment (vertical), with text_rotation/text_direction applied. if ml.content_type == LeaderContentType::MText && !ml.context.text_string.is_empty() { let ctx = &ml.context; // `ctx.text_height` (when > 0) is the already-resolved WCS text height // stored in the per-instance context — style × scale_factor × anno // scale are all already baked in. Multiplying by `effective_scale` // again would double-scale (e.g., a context height of 100 in a file // with scale_factor=20 was rendering at 2000 units — 20× too big). // Only the fallback path (file omits the context value) needs // scale_factor + annotation scale applied. let height = if ctx.text_height > 0.0 { ctx.text_height as f32 } else { ml.text_height as f32 * effective_scale }; let ins = &ctx.text_location; // Subtract world_offset in f64 before casting to f32: drawings often // sit at large absolute coordinates and casting first then subtracting // throws away the precision needed for the rotated sub-glyph offsets. let local_ins_x = (ins.x) as f32; let local_ins_y = (ins.y) as f32; let z = (ins.z) as f32; // Rotation: prefer text_direction (transforms survive rotations / // mirrors when acadrust updates it) and fall back to text_rotation. // ml.text_angle_type then constrains the final angle: // - Horizontal: always 0 // - ParallelToLastLeaderLine: keep stored direction // - Optimized: clamp to (-π/2, π/2] (flip upside-down) // text_direction_negative finally adds π so reading goes the other way. let td = ctx.text_direction; let mut rot = if td.x.abs() > 1e-9 || td.y.abs() > 1e-9 { (td.y as f32).atan2(td.x as f32) } else { ctx.text_rotation as f32 }; match ml.text_angle_type { acadrust::entities::multileader::TextAngleType::Horizontal => rot = 0.0, acadrust::entities::multileader::TextAngleType::Optimized => { let pi = std::f32::consts::PI; if rot > pi / 2.0 { rot -= pi; } else if rot <= -pi / 2.0 { rot += pi; } } acadrust::entities::multileader::TextAngleType::ParallelToLastLeaderLine => {} } if ml.text_direction_negative { rot += std::f32::consts::PI; } // Resolve text style via handle when available, falling back to STANDARD. let style_name = ctx .text_style_handle .as_ref() .and_then(|h| { document .text_styles .iter() .find(|s| s.handle == *h) .map(|s| s.name.clone()) }) .unwrap_or_else(|| "STANDARD".to_string()); let style = resolve_text_style(&style_name, document); let mut rot = rot; if style.is_upside_down { rot += std::f32::consts::PI; } let (cos_r, sin_r) = (rot.cos(), rot.sin()); // Side-anchored on the leader-facing edge so the text reads // outward; flips live with the leader/text side. use acadrust::entities::multileader::TextAttachmentDirectionType; let h_anchor: f32 = if text_sign_w >= 0.0 { 0.0 } else { 1.0 }; // Pick the vertical-anchor attachment based on text_attachment_direction: // Horizontal — leader attaches left/right; use ml.text_left_attachment // (matches the file's stored ctx.text_left_attachment). // Vertical — leader attaches top/bottom; use ml.text_top_attachment // or ml.text_bottom_attachment depending on which side // the leader is coming from (chosen via the first // root.direction.y sign). let leader_from_top = ml .context .leader_roots .first() .map(|r| r.direction.y < 0.0) .unwrap_or(true); let vertical_attach = match ml.text_attachment_direction { TextAttachmentDirectionType::Vertical => { if leader_from_top { ml.text_top_attachment } else { ml.text_bottom_attachment } } TextAttachmentDirectionType::Horizontal => ctx.text_left_attachment, }; let v_anchor = mleader_v_anchor(vertical_attach); // Shared MText pipeline — every inline format code (`\f`, `\C`/`\c`, // `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked // fractions, …) reaches the stroke output. Stroke origins are // already in offset-relative space because we pass local_ins_x/y. let layout = layout_mtext(&MTextRenderOpts { value: &ctx.text_string, insertion: [local_ins_x as f64, local_ins_y as f64, z as f64], height, rect_w: ctx.text_width as f32, rotation: rot, style: &style, attach_h_anchor: h_anchor, v_anchor, line_spacing_factor: ctx.line_spacing_factor as f32, vertical_text: false, want_glyph_boxes: false, }); let line_widths = &layout.line_widths; let max_line_w = line_widths.iter().cloned().fold(0.0_f32, f32::max); let line_h = layout.line_height; let v_offset = layout.v_offset; let n_lines = layout.line_count.max(1) as f32; // Resolve text color (falls back to entity color for ByLayer / ByBlock). let text_color = if selected { line_color } else { color_or_inherit(&ctx.text_color, entity_color) }; // Same LOD ladder used for top-level Text / MText (see scene/mod.rs): // h_px < 1 → baseline line (skip glyphs) // 1 ≤ h < 5 → greeked rect in text color (skip glyphs) // h_px ≥ 5 → full per-glyph stroke tessellation let lod_h_px = world_per_pixel.map(|wpp| height / wpp); let lod_mode = match lod_h_px { Some(h) if h < 1.0 => 0, Some(h) if h < 5.0 => 1, _ => 2, }; // Helper: map a (local_x, local_y) in the text's pre-rotation frame // (origin at the insertion point) into WCS render space. let to_wcs = |lx: f32, ly: f32| -> [f32; 3] { [ local_ins_x + lx * cos_r - ly * sin_r, local_ins_y + lx * sin_r + ly * cos_r, z, ] }; if lod_mode == 0 { // Baseline of the top line only. let line_w = line_widths.first().copied().unwrap_or(0.0); let len_px = world_per_pixel .map(|wpp| line_w / wpp) .unwrap_or(f32::INFINITY); if len_px >= 2.0 { let line_y_local = v_offset; let p0 = to_wcs(-line_w * h_anchor, line_y_local); let p1 = to_wcs(line_w * (1.0 - h_anchor), line_y_local); wires.push(WireModel { name: name.clone(), points: vec![p0, p1], points_low: Vec::new(), color: text_color, selected, aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: vec![(glam::DVec3::new(local_ins_x as f64, local_ins_y as f64, z as f64), SnapHint::Node)], tangent_geoms: vec![], key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, vp_scissor: None, fill_tris: vec![], fill_tris_low: Vec::new(), }); } } else if lod_mode == 1 { // One filled rect per line — keeps the visual "text lives here // per row" hint that multi-line MText carries, in the text's // own color. Empty `points` opts out of the face3d 0.45 dim so // the fill renders at full intensity. let mut greek_tris: Vec<[f32; 3]> = Vec::with_capacity(line_widths.len() * 6); for (i, &line_w) in line_widths.iter().enumerate() { let li = i as f32; let line_y_bottom = -li * line_h + v_offset; let line_y_top = line_y_bottom + height; if line_w <= 0.0 { continue; } let left = -line_w * h_anchor; let right = line_w * (1.0 - h_anchor); let bl = to_wcs(left, line_y_bottom); let br = to_wcs(right, line_y_bottom); let tr = to_wcs(right, line_y_top); let tl = to_wcs(left, line_y_top); greek_tris.extend_from_slice(&[bl, br, tr, bl, tr, tl]); } if !greek_tris.is_empty() { wires.push(WireModel { name: name.clone(), points: vec![], points_low: Vec::new(), color: text_color, selected, aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px: 1.0, snap_pts: vec![(glam::DVec3::new(local_ins_x as f64, local_ins_y as f64, z as f64), SnapHint::Node)], tangent_geoms: vec![], key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, vp_scissor: None, fill_tris: greek_tris, // FIXME: fill_tris_low left empty — needs double-single split to match // fill_tris. Any geometry from this path inside a block definition will // trip the debug_assert_eq! in emit_wire (block_cache.rs:1397). fill_tris_low: Vec::new(), }); } } else { // Pre-tessellated by `layout_mtext`. Each TextStroke is in // local glyph space with its world origin (already offset- // relative because we passed local_ins_x/y) stored as f64. let mut text_points: Vec<[f32; 3]> = Vec::new(); let mut text_fill_tris: Vec<[f32; 3]> = Vec::new(); for ts in &layout.strokes { let ox = ts.origin[0] as f32; let oy = ts.origin[1] as f32; for stroke in &ts.strokes { if stroke.len() < 2 { continue; } text_points.push(nan); for &[x, y] in stroke { text_points.push([x + ox, y + oy, z]); } } for &[x, y] in &ts.fill_tris { text_fill_tris.push([x + ox, y + oy, z]); } } let mut is_first = true; if !text_points.is_empty() { let snap = if is_first { is_first = false; vec![(glam::DVec3::new(local_ins_x as f64, local_ins_y as f64, z as f64), SnapHint::Node)] } else { vec![] }; wires.push(WireModel { name: name.clone(), points: text_points, points_low: Vec::new(), color: text_color, selected, aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: snap, tangent_geoms: vec![], key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, vp_scissor: None, fill_tris: vec![], fill_tris_low: Vec::new(), }); } if !text_fill_tris.is_empty() { let snap = if is_first { vec![(glam::DVec3::new(local_ins_x as f64, local_ins_y as f64, z as f64), SnapHint::Node)] } else { vec![] }; wires.push(WireModel { name: name.clone(), points: vec![], points_low: Vec::new(), color: text_color, selected, aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: snap, tangent_geoms: vec![], key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, vp_scissor: None, fill_tris: text_fill_tris, // FIXME: fill_tris_low left empty — needs double-single split to match // fill_tris. Any geometry from this path inside a block definition will // trip the debug_assert_eq! in emit_wire (block_cache.rs:1397). fill_tris_low: Vec::new(), }); } } // Text frame / background-fill rectangle in local frame, then rotated to WCS. if ml.text_frame || ctx.background_fill_enabled { // Visual gap so the frame/fill doesn't touch glyph caps. let pad = height * 0.25; let block_top = v_offset + height + pad; let block_bottom = v_offset - (n_lines - 1.0) * line_h - pad; let block_left = -max_line_w * h_anchor - pad; let block_right = max_line_w * (1.0 - h_anchor) + pad; let local_corners: [[f32; 2]; 4] = [ [block_left, block_bottom], [block_right, block_bottom], [block_right, block_top], [block_left, block_top], ]; let wcs_corners: [[f32; 3]; 4] = std::array::from_fn(|i| { let lx = local_corners[i][0]; let ly = local_corners[i][1]; let wx = local_ins_x + lx * cos_r - ly * sin_r; let wy = local_ins_y + lx * sin_r + ly * cos_r; [wx, wy, z] }); // Background fill — emit two triangles; renders under the text strokes. if ctx.background_fill_enabled { let fill_color = if selected { line_color } else { color_or_inherit(&ctx.background_fill_color, entity_color) }; let fill_tris: Vec<[f32; 3]> = vec![ wcs_corners[0], wcs_corners[1], wcs_corners[2], wcs_corners[0], wcs_corners[2], wcs_corners[3], ]; wires.push(WireModel { name: name.clone(), points: vec![], points_low: Vec::new(), color: fill_color, selected, aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px: 1.0, snap_pts: vec![], tangent_geoms: vec![], key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, vp_scissor: None, fill_tris, fill_tris_low: Vec::new(), }); } // Text frame — closed rectangle, matches text color. if ml.text_frame { let frame_points: Vec<[f32; 3]> = vec![ wcs_corners[0], wcs_corners[1], wcs_corners[2], wcs_corners[3], wcs_corners[0], ]; wires.push(WireModel { name, points: frame_points, points_low: Vec::new(), color: text_color, selected, aci: 0, pattern_length: 0.0, pattern: [0.0; 8], line_weight_px, snap_pts: vec![], tangent_geoms: vec![], key_vertices: vec![], aabb: WireModel::UNBOUNDED_AABB, plinegen: true, vp_scissor: None, fill_tris: vec![], fill_tris_low: Vec::new(), }); } } } wires } }