cad-editor/src/entities/multileader.rs

1733 lines
67 KiB
Rust
Raw Normal View History

use acadrust::entities::{LeaderContentType, MultiLeader, MultiLeaderPathType, TextAttachmentType};
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
feat(text): route every text-bearing entity through the run-aware MText pipeline Lift the MText parser + renderer into a reusable `layout_mtext` helper in `text_support` and re-point the remaining text-bearing entities at it, so every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked fractions, paragraph indents, tab stops, …) reaches the stroke output uniformly. Per-entity changes: - TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds `layout_mtext`; `\C7;Red`-style codes no longer render as literal glyph noise. - MLEADER MText content (both entity path in [entities/multileader.rs] and the LOD-aware path in [scene/tessellate.rs]) — drops the `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0 baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame / bg-fill rectangle now read line widths from the layout. - ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine the value goes through the MText pipeline (SingleLine stays on the Text-style anchor maths because the DXF spec disallows codes there). - DIMENSION text_override — `dimension_text_entity` emits a synthetic MText (instead of Text) whenever the value contains MText escapes; the LOD ladder via `tessellate_entity_dim_text` handles both kinds. Infrastructure: - `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`, `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly. - `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so callers that draw chrome (frames, fills, baselines, greek rects) share the same numbers as the rendered strokes. - `mtext_line_count` re-routed through the new parser so LOD row counts and rendered row counts can never disagree. - Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`, `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`) deleted — all callers migrated. Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
use crate::entities::text_support::{
layout_mtext, resolve_text_style, MTextRenderOpts, MTextVAnchor, ResolvedTextStyle,
feat(text): route every text-bearing entity through the run-aware MText pipeline Lift the MText parser + renderer into a reusable `layout_mtext` helper in `text_support` and re-point the remaining text-bearing entities at it, so every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked fractions, paragraph indents, tab stops, …) reaches the stroke output uniformly. Per-entity changes: - TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds `layout_mtext`; `\C7;Red`-style codes no longer render as literal glyph noise. - MLEADER MText content (both entity path in [entities/multileader.rs] and the LOD-aware path in [scene/tessellate.rs]) — drops the `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0 baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame / bg-fill rectangle now read line widths from the layout. - ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine the value goes through the MText pipeline (SingleLine stays on the Text-style anchor maths because the DXF spec disallows codes there). - DIMENSION text_override — `dimension_text_entity` emits a synthetic MText (instead of Text) whenever the value contains MText escapes; the LOD ladder via `tessellate_entity_dim_text` handles both kinds. Infrastructure: - `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`, `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly. - `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so callers that draw chrome (frames, fills, baselines, greek rects) share the same numbers as the rendered strokes. - `mtext_line_count` re-routed through the new parser so LOD row counts and rendered row counts can never disagree. - Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`, `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`) deleted — all callers migrated. Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
};
/// 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 {
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
match attach {
feat(text): route every text-bearing entity through the run-aware MText pipeline Lift the MText parser + renderer into a reusable `layout_mtext` helper in `text_support` and re-point the remaining text-bearing entities at it, so every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked fractions, paragraph indents, tab stops, …) reaches the stroke output uniformly. Per-entity changes: - TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds `layout_mtext`; `\C7;Red`-style codes no longer render as literal glyph noise. - MLEADER MText content (both entity path in [entities/multileader.rs] and the LOD-aware path in [scene/tessellate.rs]) — drops the `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0 baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame / bg-fill rectangle now read line widths from the layout. - ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine the value goes through the MText pipeline (SingleLine stays on the Text-style anchor maths because the DXF spec disallows codes there). - DIMENSION text_override — `dimension_text_entity` emits a synthetic MText (instead of Text) whenever the value contains MText escapes; the LOD ladder via `tessellate_entity_dim_text` handles both kinds. Infrastructure: - `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`, `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly. - `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so callers that draw chrome (frames, fills, baselines, greek rects) share the same numbers as the rendered strokes. - `mtext_line_count` re-routed through the new parser so LOD row counts and rendered row counts can never disagree. - Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`, `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`) deleted — all callers migrated. Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
TextAttachmentType::TopOfTopLine => MTextVAnchor::Top,
TextAttachmentType::MiddleOfTopLine => MTextVAnchor::MiddleOfTopLine,
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
TextAttachmentType::MiddleOfText
| TextAttachmentType::CenterOfText
feat(text): route every text-bearing entity through the run-aware MText pipeline Lift the MText parser + renderer into a reusable `layout_mtext` helper in `text_support` and re-point the remaining text-bearing entities at it, so every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked fractions, paragraph indents, tab stops, …) reaches the stroke output uniformly. Per-entity changes: - TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds `layout_mtext`; `\C7;Red`-style codes no longer render as literal glyph noise. - MLEADER MText content (both entity path in [entities/multileader.rs] and the LOD-aware path in [scene/tessellate.rs]) — drops the `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0 baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame / bg-fill rectangle now read line widths from the layout. - ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine the value goes through the MText pipeline (SingleLine stays on the Text-style anchor maths because the DXF spec disallows codes there). - DIMENSION text_override — `dimension_text_entity` emits a synthetic MText (instead of Text) whenever the value contains MText escapes; the LOD ladder via `tessellate_entity_dim_text` handles both kinds. Infrastructure: - `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`, `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly. - `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so callers that draw chrome (frames, fills, baselines, greek rects) share the same numbers as the rendered strokes. - `mtext_line_count` re-routed through the new parser so LOD row counts and rendered row counts can never disagree. - Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`, `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`) deleted — all callers migrated. Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
| TextAttachmentType::CenterOfTextOverline => MTextVAnchor::Middle,
TextAttachmentType::MiddleOfBottomLine => MTextVAnchor::MiddleOfBottomLine,
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
TextAttachmentType::BottomOfBottomLine | TextAttachmentType::BottomLine => {
feat(text): route every text-bearing entity through the run-aware MText pipeline Lift the MText parser + renderer into a reusable `layout_mtext` helper in `text_support` and re-point the remaining text-bearing entities at it, so every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked fractions, paragraph indents, tab stops, …) reaches the stroke output uniformly. Per-entity changes: - TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds `layout_mtext`; `\C7;Red`-style codes no longer render as literal glyph noise. - MLEADER MText content (both entity path in [entities/multileader.rs] and the LOD-aware path in [scene/tessellate.rs]) — drops the `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0 baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame / bg-fill rectangle now read line widths from the layout. - ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine the value goes through the MText pipeline (SingleLine stays on the Text-style anchor maths because the DXF spec disallows codes there). - DIMENSION text_override — `dimension_text_entity` emits a synthetic MText (instead of Text) whenever the value contains MText escapes; the LOD ladder via `tessellate_entity_dim_text` handles both kinds. Infrastructure: - `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`, `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly. - `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so callers that draw chrome (frames, fills, baselines, greek rects) share the same numbers as the rendered strokes. - `mtext_line_count` re-routed through the new parser so LOD row counts and rendered row counts can never disagree. - Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`, `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`) deleted — all callers migrated. Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
MTextVAnchor::Bottom
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
}
TextAttachmentType::BottomOfTopLineUnderlineBottomLine
| TextAttachmentType::BottomOfTopLineUnderlineTopLine
feat(text): route every text-bearing entity through the run-aware MText pipeline Lift the MText parser + renderer into a reusable `layout_mtext` helper in `text_support` and re-point the remaining text-bearing entities at it, so every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked fractions, paragraph indents, tab stops, …) reaches the stroke output uniformly. Per-entity changes: - TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds `layout_mtext`; `\C7;Red`-style codes no longer render as literal glyph noise. - MLEADER MText content (both entity path in [entities/multileader.rs] and the LOD-aware path in [scene/tessellate.rs]) — drops the `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0 baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame / bg-fill rectangle now read line widths from the layout. - ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine the value goes through the MText pipeline (SingleLine stays on the Text-style anchor maths because the DXF spec disallows codes there). - DIMENSION text_override — `dimension_text_entity` emits a synthetic MText (instead of Text) whenever the value contains MText escapes; the LOD ladder via `tessellate_entity_dim_text` handles both kinds. Infrastructure: - `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`, `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly. - `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so callers that draw chrome (frames, fills, baselines, greek rects) share the same numbers as the rendered strokes. - `mtext_line_count` re-routed through the new parser so LOD row counts and rendered row counts can never disagree. - Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`, `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`) deleted — all callers migrated. Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
| TextAttachmentType::BottomOfTopLineUnderlineAll => MTextVAnchor::BottomOfTopLine,
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
}
}
2026-03-22 14:56:27 +03:00
use glam::Vec3;
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};
2026-03-22 14:56:27 +03:00
// ── 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 {
2026-05-12 10:36:15 +03:00
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
}
2026-03-22 14:56:27 +03:00
fn to_truck(ml: &MultiLeader, document: &acadrust::CadDocument) -> Option<TruckEntity> {
let nan = [f64::NAN; 3];
let p3 = |v: &acadrust::types::Vector3| -> [f64; 3] { [v.x, v.y, v.z] };
2026-03-22 14:56:27 +03:00
let arrow_size = ml.arrowhead_size;
2026-03-22 14:56:27 +03:00
let draw_arrow = arrow_size > 0.0;
let invisible = ml.path_type == MultiLeaderPathType::Invisible;
let mut points: Vec<[f64; 3]> = Vec::new();
2026-03-22 14:56:27 +03:00
let mut tangents: Vec<TangentGeom> = Vec::new();
let mut key_verts: Vec<[f64; 3]> = Vec::new();
let mut snap_pts: Vec<(Vec3, SnapHint)> = Vec::new();
2026-03-22 14:56:27 +03:00
let mut first = true;
// snap_pts uses f32 (UI-only); cast at construction.
let node = |arr: [f64; 3]| {
(
Vec3::new(arr[0] as f32, arr[1] as f32, arr[2] as f32),
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,
];
2026-03-22 14:56:27 +03:00
for root in &ml.context.leader_roots {
let cp = &root.connection_point;
let cp_f = p3(cp);
snap_pts.push(node(cp_f));
2026-03-22 14:56:27 +03:00
for line in &root.lines {
2026-05-12 10:36:15 +03:00
if line.points.is_empty() {
continue;
}
2026-03-22 14:56:27 +03:00
if !invisible {
2026-05-12 10:36:15 +03:00
if !first {
points.push(nan);
}
2026-03-22 14:56:27 +03:00
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();
2026-03-22 14:56:27 +03:00
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);
}
2026-03-22 14:56:27 +03:00
}
for i in 0..ctrl.len().saturating_sub(1) {
let a = ctrl[i];
let b = ctrl[i + 1];
2026-05-12 10:36:15 +03:00
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],
2026-05-12 10:36:15 +03:00
});
2026-03-22 14:56:27 +03:00
}
}
// Arrowhead
if draw_arrow {
let tip = &line.points[0];
let tip_f = p3(tip);
2026-05-12 10:36:15 +03:00
let next = if line.points.len() >= 2 {
line.points[1]
} else {
*cp
};
let dx = next.x - tip.x;
let dy = next.y - tip.y;
2026-05-12 10:36:15 +03:00
let dl = (dx * dx + dy * dy).sqrt().max(1e-9);
2026-03-22 14:56:27 +03:00
let (dx, dy) = (dx / dl, dy / dl);
let a = std::f64::consts::PI / 6.0;
2026-03-22 14:56:27 +03:00
let (s, c) = a.sin_cos();
points.push(nan);
2026-05-12 10:36:15 +03:00
points.push([
tip_f[0] + (dx * c - dy * s) * arrow_size,
tip_f[1] + (dx * s + dy * c) * arrow_size,
tip_f[2],
]);
2026-03-22 14:56:27 +03:00
points.push(tip_f);
2026-05-12 10:36:15 +03:00
points.push([
tip_f[0] + (dx * c + dy * s) * arrow_size,
tip_f[1] + (-dx * s + dy * c) * arrow_size,
tip_f[2],
]);
2026-03-22 14:56:27 +03:00
}
}
// Horizontal landing from the leader end to the text's near edge.
if dogleg > 0.0 {
2026-03-22 14:56:27 +03:00
points.push(nan);
points.push(landing_pt);
points.push([text_loc.x, text_loc.y, text_loc.z]);
2026-03-22 14:56:27 +03:00
}
}
// Text strokes, drawn from the layout computed up front (centred on the
// text grip). The snap node is the grip itself.
if let Some(layout) = &text_layout {
snap_pts.push(node([text_loc.x, text_loc.y, text_loc.z]));
feat(text): route every text-bearing entity through the run-aware MText pipeline Lift the MText parser + renderer into a reusable `layout_mtext` helper in `text_support` and re-point the remaining text-bearing entities at it, so every inline format code (`\f`, `\C`/`\c`, `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked fractions, paragraph indents, tab stops, …) reaches the stroke output uniformly. Per-entity changes: - TABLE cell ([entities/table.rs]) — anchor + alignment math now feeds `layout_mtext`; `\C7;Red`-style codes no longer render as literal glyph noise. - MLEADER MText content (both entity path in [entities/multileader.rs] and the LOD-aware path in [scene/tessellate.rs]) — drops the `strip_mtext_codes` + per-line `tessellate_text_ex` chain; the LOD 0 baseline / LOD 1 greek rect / LOD 2 stroke ladder and the text-frame / bg-fill rectangle now read line widths from the layout. - ATTRIB / ATTDEF — when `mtext_flag` is MultiLine / ConstantMultiLine the value goes through the MText pipeline (SingleLine stays on the Text-style anchor maths because the DXF spec disallows codes there). - DIMENSION text_override — `dimension_text_entity` emits a synthetic MText (instead of Text) whenever the value contains MText escapes; the LOD ladder via `tessellate_entity_dim_text` handles both kinds. Infrastructure: - `MTextVAnchor` gains `MiddleOfTopLine`, `MiddleOfBottomLine`, `BottomOfTopLine` so MLEADER's 9 attachment variants all map cleanly. - `MTextLayout` returns `line_widths` / `line_height` / `v_offset` so callers that draw chrome (frames, fills, baselines, greek rects) share the same numbers as the rendered strokes. - `mtext_line_count` re-routed through the new parser so LOD row counts and rendered row counts can never disagree. - Legacy helpers (`strip_mtext_codes`, `split_mtext_lines`, `measure_mtext_chars`, `word_wrap`, `cxf::measure_text`, the `v_offset_for_attachment` MLEADER offset table, `MTextLine::plain_text`) deleted — all callers migrated. Audit (TEXT_FORMAT_CODE_INTEGRATION.md) removed; its plan is implemented here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 05:53:01 +03:00
for ts in &layout.strokes {
let ox = ts.origin[0];
let oy = ts.origin[1];
for stroke in &ts.strokes {
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
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]);
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
}
2026-03-22 14:56:27 +03:00
}
}
}
2026-05-12 10:36:15 +03:00
if points.is_empty() {
return None;
}
2026-03-22 14:56:27 +03:00
Some(TruckEntity {
object: TruckObject::Lines(points),
snap_pts,
2026-03-22 14:56:27 +03:00
tangent_geoms: tangents,
key_vertices: key_verts,
fill_tris: vec![],
2026-03-22 14:56:27 +03:00
})
}
// ── 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])
}
2026-03-22 14:56:27 +03:00
fn grips(ml: &MultiLeader) -> Vec<GripDef> {
let mut result: Vec<GripDef> = 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)));
2026-03-22 14:56:27 +03:00
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])));
2026-03-22 14:56:27 +03:00
}
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;
2026-03-22 14:56:27 +03:00
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;
}
2026-03-22 14:56:27 +03:00
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;
}
2026-03-22 14:56:27 +03:00
}
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);
2026-03-22 14:56:27 +03:00
}
}
}
// ── 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
2026-05-12 10:36:15 +03:00
choice(
"Content Type",
"content_type",
content_type_str(&ml.content_type),
&["None", "MText", "Block", "Tolerance"],
),
2026-03-22 14:56:27 +03:00
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
2026-05-12 10:36:15 +03:00
choice(
"Path Type",
"path_type",
path_type_str(&ml.path_type),
&["Straight", "Spline", "Invisible"],
),
2026-03-22 14:56:27 +03:00
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),
2026-05-12 10:36:15 +03:00
bool_toggle(
"Annotation Scale",
"enable_annotation_scale",
ml.enable_annotation_scale,
),
2026-03-22 14:56:27 +03:00
// Text attachment
2026-05-12 10:36:15 +03:00
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",
],
),
feat(entities): integrate remaining fields on block-based entities MultiLeader and Table were ignoring most of the styling and content fields that the parser populated. Bring both up to file-driven render: MultiLeader - line_color: leader stroke / arrow colour with ByBlock/ByLayer fallback - line_type_handle: dashed leader via line_types lookup by handle - line_weight (ml.line_weight): per-leader pixel width override - arrowhead_handle: arrow shape resolved through the dim arrow emitter (Closed Filled / Open / Dot / Origin / Box / Datum / Tick / None) - extend_leader_to_text: continue the leader past the landing to the text insertion point - text_angle_type: Horizontal forces 0, Optimized clamps to a readable half-plane, ParallelToLastLeaderLine keeps the stored direction - text_direction_negative: adds π to the rotation - text_alignment: drives the horizontal anchor when more specific than the context's text_attachment_point - text_attachment_direction + text_top_attachment + text_bottom_attachment: Vertical mode uses the top / bottom attachment values chosen from the first root's direction.y sign - has_block_contents + block_content_handle + block_content_location + block_rotation + block_scale + block_content_color: synthesise an Insert and explode it through the standard tessellator so block- content MultiLeaders render their referenced block - style_handle, arrowhead_handle, line_type_handle, text_style_handle, block_content_handle, property_override_flags, text_align_in_ipe, block_scale, block_rotation, extend_leader_to_text, text_direction_negative, text_top_attachment, text_bottom_attachment surfaced in the Properties panel Table - Per-cell borders: drop the always-grid pass, walk the cells and emit each border honouring its `invisible` flag with dedup so shared edges aren't doubled - table_style_handle, block_record_handle, data_version, value_flags, override_flag / override_border_* flags, break_options / break_flow_direction / break_spacing, normal surfaced in the Properties panel Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 21:17:05 +03:00
// 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",
],
),
2026-03-22 14:56:27 +03:00
// Stats
ro("Leader Pts", "total_pts", total_pts.to_string()),
ro("Roots", "root_count", ctx.leader_roots.len().to_string()),
feat(entities): integrate remaining fields on block-based entities MultiLeader and Table were ignoring most of the styling and content fields that the parser populated. Bring both up to file-driven render: MultiLeader - line_color: leader stroke / arrow colour with ByBlock/ByLayer fallback - line_type_handle: dashed leader via line_types lookup by handle - line_weight (ml.line_weight): per-leader pixel width override - arrowhead_handle: arrow shape resolved through the dim arrow emitter (Closed Filled / Open / Dot / Origin / Box / Datum / Tick / None) - extend_leader_to_text: continue the leader past the landing to the text insertion point - text_angle_type: Horizontal forces 0, Optimized clamps to a readable half-plane, ParallelToLastLeaderLine keeps the stored direction - text_direction_negative: adds π to the rotation - text_alignment: drives the horizontal anchor when more specific than the context's text_attachment_point - text_attachment_direction + text_top_attachment + text_bottom_attachment: Vertical mode uses the top / bottom attachment values chosen from the first root's direction.y sign - has_block_contents + block_content_handle + block_content_location + block_rotation + block_scale + block_content_color: synthesise an Insert and explode it through the standard tessellator so block- content MultiLeaders render their referenced block - style_handle, arrowhead_handle, line_type_handle, text_style_handle, block_content_handle, property_override_flags, text_align_in_ipe, block_scale, block_rotation, extend_leader_to_text, text_direction_negative, text_top_attachment, text_bottom_attachment surfaced in the Properties panel Table - Per-cell borders: drop the always-grid pass, walk the cells and emit each border honouring its `invisible` flag with dedup so shared edges aren't doubled - table_style_handle, block_record_handle, data_version, value_flags, override_flag / override_border_* flags, break_options / break_flow_direction / break_spacing, normal surfaced in the Properties panel Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 21:17:05 +03:00
// 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"
},
feat(entities): integrate remaining fields on block-based entities MultiLeader and Table were ignoring most of the styling and content fields that the parser populated. Bring both up to file-driven render: MultiLeader - line_color: leader stroke / arrow colour with ByBlock/ByLayer fallback - line_type_handle: dashed leader via line_types lookup by handle - line_weight (ml.line_weight): per-leader pixel width override - arrowhead_handle: arrow shape resolved through the dim arrow emitter (Closed Filled / Open / Dot / Origin / Box / Datum / Tick / None) - extend_leader_to_text: continue the leader past the landing to the text insertion point - text_angle_type: Horizontal forces 0, Optimized clamps to a readable half-plane, ParallelToLastLeaderLine keeps the stored direction - text_direction_negative: adds π to the rotation - text_alignment: drives the horizontal anchor when more specific than the context's text_attachment_point - text_attachment_direction + text_top_attachment + text_bottom_attachment: Vertical mode uses the top / bottom attachment values chosen from the first root's direction.y sign - has_block_contents + block_content_handle + block_content_location + block_rotation + block_scale + block_content_color: synthesise an Insert and explode it through the standard tessellator so block- content MultiLeaders render their referenced block - style_handle, arrowhead_handle, line_type_handle, text_style_handle, block_content_handle, property_override_flags, text_align_in_ipe, block_scale, block_rotation, extend_leader_to_text, text_direction_negative, text_top_attachment, text_bottom_attachment surfaced in the Properties panel Table - Per-cell borders: drop the always-grid pass, walk the cells and emit each border honouring its `invisible` flag with dedup so shared edges aren't doubled - table_style_handle, block_record_handle, data_version, value_flags, override_flag / override_border_* flags, break_options / break_flow_direction / break_spacing, normal surfaced in the Properties panel Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 21:17:05 +03:00
),
ro(
"Text Direction Negative",
"text_direction_negative",
if ml.text_direction_negative {
"Yes"
} else {
"No"
},
feat(entities): integrate remaining fields on block-based entities MultiLeader and Table were ignoring most of the styling and content fields that the parser populated. Bring both up to file-driven render: MultiLeader - line_color: leader stroke / arrow colour with ByBlock/ByLayer fallback - line_type_handle: dashed leader via line_types lookup by handle - line_weight (ml.line_weight): per-leader pixel width override - arrowhead_handle: arrow shape resolved through the dim arrow emitter (Closed Filled / Open / Dot / Origin / Box / Datum / Tick / None) - extend_leader_to_text: continue the leader past the landing to the text insertion point - text_angle_type: Horizontal forces 0, Optimized clamps to a readable half-plane, ParallelToLastLeaderLine keeps the stored direction - text_direction_negative: adds π to the rotation - text_alignment: drives the horizontal anchor when more specific than the context's text_attachment_point - text_attachment_direction + text_top_attachment + text_bottom_attachment: Vertical mode uses the top / bottom attachment values chosen from the first root's direction.y sign - has_block_contents + block_content_handle + block_content_location + block_rotation + block_scale + block_content_color: synthesise an Insert and explode it through the standard tessellator so block- content MultiLeaders render their referenced block - style_handle, arrowhead_handle, line_type_handle, text_style_handle, block_content_handle, property_override_flags, text_align_in_ipe, block_scale, block_rotation, extend_leader_to_text, text_direction_negative, text_top_attachment, text_bottom_attachment surfaced in the Properties panel Table - Per-cell borders: drop the always-grid pass, walk the cells and emit each border honouring its `invisible` flag with dedup so shared edges aren't doubled - table_style_handle, block_record_handle, data_version, value_flags, override_flag / override_border_* flags, break_options / break_flow_direction / break_spacing, normal surfaced in the Properties panel Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 21:17:05 +03:00
),
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()),
),
2026-03-22 14:56:27 +03:00
];
// 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));
}
2026-05-12 10:36:15 +03:00
PropSection {
title: "Geometry".into(),
props,
}
2026-03-22 14:56:27 +03:00
}
fn apply_geom_prop(ml: &mut MultiLeader, field: &str, value: &str) {
let f64 = |s: &str| -> Option<f64> { 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" => {
2026-05-12 10:36:15 +03:00
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"
}
2026-03-22 14:56:27 +03:00
}
"path_type" => {
ml.path_type = match value {
"Spline" => MultiLeaderPathType::Spline,
"Invisible" => MultiLeaderPathType::Invisible,
_ => MultiLeaderPathType::StraightLineSegments,
};
}
2026-05-12 10:36:15 +03:00
"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;
}
}
2026-03-22 14:56:27 +03:00
"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);
}
feat(entities): integrate remaining fields on block-based entities MultiLeader and Table were ignoring most of the styling and content fields that the parser populated. Bring both up to file-driven render: MultiLeader - line_color: leader stroke / arrow colour with ByBlock/ByLayer fallback - line_type_handle: dashed leader via line_types lookup by handle - line_weight (ml.line_weight): per-leader pixel width override - arrowhead_handle: arrow shape resolved through the dim arrow emitter (Closed Filled / Open / Dot / Origin / Box / Datum / Tick / None) - extend_leader_to_text: continue the leader past the landing to the text insertion point - text_angle_type: Horizontal forces 0, Optimized clamps to a readable half-plane, ParallelToLastLeaderLine keeps the stored direction - text_direction_negative: adds π to the rotation - text_alignment: drives the horizontal anchor when more specific than the context's text_attachment_point - text_attachment_direction + text_top_attachment + text_bottom_attachment: Vertical mode uses the top / bottom attachment values chosen from the first root's direction.y sign - has_block_contents + block_content_handle + block_content_location + block_rotation + block_scale + block_content_color: synthesise an Insert and explode it through the standard tessellator so block- content MultiLeaders render their referenced block - style_handle, arrowhead_handle, line_type_handle, text_style_handle, block_content_handle, property_override_flags, text_align_in_ipe, block_scale, block_rotation, extend_leader_to_text, text_direction_negative, text_top_attachment, text_bottom_attachment surfaced in the Properties panel Table - Per-cell borders: drop the always-grid pass, walk the cells and emit each border honouring its `invisible` flag with dedup so shared edges aren't doubled - table_style_handle, block_record_handle, data_version, value_flags, override_flag / override_border_* flags, break_options / break_flow_direction / break_spacing, normal surfaced in the Properties panel Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 21:17:05 +03:00
"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);
}
2026-03-22 14:56:27 +03:00
_ => {}
}
}
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| {
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
// 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.
2026-03-22 14:56:27 +03:00
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);
2026-03-22 14:56:27 +03:00
}
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
for bp in &mut line.break_points {
crate::scene::view::transform::reflect_xy_point(
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
&mut bp.start_point.x,
&mut bp.start_point.y,
p1,
p2,
);
crate::scene::view::transform::reflect_xy_point(
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
&mut bp.end_point.x,
&mut bp.end_point.y,
p1,
p2,
);
}
2026-03-22 14:56:27 +03:00
}
crate::scene::view::transform::reflect_xy_point(
2026-05-12 10:36:15 +03:00
&mut root.connection_point.x,
&mut root.connection_point.y,
p1,
p2,
);
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
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(
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
&mut bp.start_point.x,
&mut bp.start_point.y,
p1,
p2,
);
crate::scene::view::transform::reflect_xy_point(
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
&mut bp.end_point.x,
&mut bp.end_point.y,
p1,
p2,
);
}
2026-03-22 14:56:27 +03:00
}
crate::scene::view::transform::reflect_xy_point(
2026-05-12 10:36:15 +03:00
&mut entity.context.text_location.x,
&mut entity.context.text_location.y,
p1,
p2,
);
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
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,
);
2026-03-22 14:56:27 +03:00
});
}
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
/// 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: Vec3, p2: Vec3) {
let zero = Vec3::ZERO;
let p2_rel = Vec3::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);
fix(multileader): render text properly — strip MText codes, honor full layout Multileader text was emitted as a single literal stroke pass with no MText code handling, no rotation, no attachment, no line breaks, no per-piece color and bad f32 precision under world_offset. Two separate render paths both had the same bug, and one was masking the fix in the other. This rewrite, applied to both paths: - Strips MText format codes (`{\fArial Black|b0|i0|c162|p34;...}`) so the rendered text is the actual content, not the markup. - Honors text_rotation (preferring text_direction so transforms survive), text_attachment_point (Left/Center/Right horizontal anchor), and text_left_attachment for vertical alignment via a shared v_offset_for_attachment helper. - Splits on \P/\n/\N and word-wraps to text_width. - Applies line_spacing_factor with the same 5/3 × height convention as MText. - Resolves the text style via text_style_handle (fallback STANDARD). - Applies ml.scale_factor everywhere (arrow size, dogleg length, text height, text width). anno_scale is now only applied when enable_annotation_scale is set. - Splits the scene-level output into separate WireModels so leader, text, frame and background fill each carry their own colour (ctx.text_color, ml.text_frame, ctx.background_fill_color). - Mirrors text_direction, base_direction and root.direction in apply_transform so a mirrored multileader's text follows the geometry instead of staying at its original rotation. - Computes the text origin and frame corners in offset-relative f32 space — drawings sit at large absolute coordinates and casting to f32 before subtracting world_offset was wrecking the rotated sub-glyph precision.
2026-05-12 23:27:53 +03:00
*dx = tip_x;
*dy = tip_y;
}
2026-03-22 14:56:27 +03:00
// ── Trait impls ────────────────────────────────────────────────────────────
impl TruckConvertible for MultiLeader {
fn to_truck(&self, document: &acadrust::CadDocument) -> Option<TruckEntity> {
to_truck(self, document)
}
}
impl crate::entities::traits::Grippable for MultiLeader {
fn grips(&self) -> Vec<GripDef> {
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<crate::scene::model::object::GripMenuItem> {
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,
world_offset: [f64; 3],
anno_scale: f32,
world_per_pixel: Option<f32>,
) -> Vec<crate::scene::model::wire_model::WireModel>;
}
impl MultiLeaderTess for MultiLeader {
fn tessellate(
&self,
document: &acadrust::CadDocument,
handle: acadrust::Handle,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
world_per_pixel: Option<f32>,
) -> Vec<crate::scene::model::wire_model::WireModel> {
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 [ox, oy, oz] = world_offset;
let p3 = |v: &acadrust::types::Vector3| -> [f32; 3] {
[(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) 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<[f32; 3]> = Vec::new();
let mut snap_pts: Vec<(Vec3, SnapHint)> = Vec::new();
let mut tangents: Vec<TangentGeom> = 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), 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);
snap_pts.push((Vec3::from(c), 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 - ox) as f32,
(cp.y - oy) 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 - ox) as f32;
let ty = (ml.context.text_location.y - oy) 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<WireModel> = 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,
});
// ── 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,
world_offset,
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 - ox) as f32;
let local_ins_y = (ins.y - oy) as f32;
let z = (ins.z - oz) 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![(Vec3::new(local_ins_x, local_ins_y, z), SnapHint::Node)],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
} 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![(Vec3::new(local_ins_x, local_ins_y, z), SnapHint::Node)],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: greek_tris,
});
}
} 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();
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]);
}
}
}
if !text_points.is_empty() {
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: vec![(Vec3::new(local_ins_x, local_ins_y, z), SnapHint::Node)],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
}
// 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,
});
}
// 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![],
});
}
}
}
wires
}
}