cad-editor/src/entities/mtext.rs

656 lines
24 KiB
Rust
Raw Normal View History

2026-03-22 14:56:27 +03:00
use acadrust::entities::{AttachmentPoint, DrawingDirection, MText};
use crate::command::EntityTransform;
use crate::entities::common::{
edit_angle_prop as edit_angle, edit_prop as edit, num_prop as num_row, ro_prop as ro, square_grip, triangle_grip,
};
2026-03-22 14:56:27 +03:00
use crate::entities::text_support::{
feat(mtext): lay out `\N` columns and stack `\S` fractions Two things an MTEXT can ask for that the layout ignored, both found on the same drawing (budweiser2018.dwg handles 8782 and 2609). Columns: `column_data` was read but only ever reached the properties panel, so a three-column MTEXT ran as one stacked block. Each line now carries the column it belongs to, the row counter restarts per column, and wrapping measures against the column rather than the block — measuring against the block would let a line run across the gutter and over its neighbour. Paragraph alignment already worked, which is what made the bug read as "columns are there but misplaced": `\pxqr;` pushed col3 to the right edge either way. Only explicit `\N` breaks move to the next column. A column that fills up does not spill into the next on its own — `heights` / `auto_height` are not read, so a column runs as long as its content. Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the line then overflowed its 7.26-wide box and dropped `°C ª` onto a second row. The halves now share one horizontal slot, drawn at a shrunk height with the numerator lifted, so the slot is as wide as the wider half. `^` stacks without a rule; `#` stays inline `num/den`, which is what a slanted fraction reads as and needs no second baseline. The fraction rule rides a run-less stroke group: a group carrying a `GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one keeps them, and the rule is plain geometry rather than a glyph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
layout_mtext, resolve_text_style, GlyphBox, MTextColumns, MTextRenderOpts, MTextVAnchor,
2026-03-22 14:56:27 +03:00
};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable, 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;
2026-03-22 14:56:27 +03:00
feat(mtext): lay out `\N` columns and stack `\S` fractions Two things an MTEXT can ask for that the layout ignored, both found on the same drawing (budweiser2018.dwg handles 8782 and 2609). Columns: `column_data` was read but only ever reached the properties panel, so a three-column MTEXT ran as one stacked block. Each line now carries the column it belongs to, the row counter restarts per column, and wrapping measures against the column rather than the block — measuring against the block would let a line run across the gutter and over its neighbour. Paragraph alignment already worked, which is what made the bug read as "columns are there but misplaced": `\pxqr;` pushed col3 to the right edge either way. Only explicit `\N` breaks move to the next column. A column that fills up does not spill into the next on its own — `heights` / `auto_height` are not read, so a column runs as long as its content. Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the line then overflowed its 7.26-wide box and dropped `°C ª` onto a second row. The halves now share one horizontal slot, drawn at a shrunk height with the numerator lifted, so the slot is as wide as the wider half. `^` stacks without a rule; `#` stays inline `num/den`, which is what a slanted fraction reads as and needs no second baseline. The fraction rule rides a run-less stroke group: a group carrying a `GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one keeps them, and the rule is plain geometry rather than a glyph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
/// The entity's `column_data` as the layout's column description.
///
/// `column_type` 0 means the MTEXT never opted into columns, so ignore whatever
/// stale width/count sit alongside it. Static (1) and dynamic (2) both flow at
/// the `\N` breaks in the value; the difference between them is how a *full*
/// column spills into the next, which the layout does not model.
fn columns_of(t: &MText) -> MTextColumns {
let c = &t.column_data;
if c.column_type == 0 {
return MTextColumns::default();
}
MTextColumns {
count: c.column_count.max(0) as usize,
width: c.width as f32,
gutter: c.gutter as f32,
}
}
/// Combined attachment point shown as a single justify dropdown value.
2026-03-22 14:56:27 +03:00
fn attachment_str(a: &AttachmentPoint) -> &'static str {
match a {
AttachmentPoint::TopLeft => "Top left",
AttachmentPoint::TopCenter => "Top center",
AttachmentPoint::TopRight => "Top right",
AttachmentPoint::MiddleLeft => "Middle left",
AttachmentPoint::MiddleCenter => "Middle center",
AttachmentPoint::MiddleRight => "Middle right",
AttachmentPoint::BottomLeft => "Bottom left",
AttachmentPoint::BottomCenter => "Bottom center",
AttachmentPoint::BottomRight => "Bottom right",
2026-03-22 14:56:27 +03:00
}
}
fn attachment_from_justify(value: &str) -> Option<AttachmentPoint> {
Some(match value {
"Top left" => AttachmentPoint::TopLeft,
"Top center" => AttachmentPoint::TopCenter,
"Top right" => AttachmentPoint::TopRight,
"Middle left" => AttachmentPoint::MiddleLeft,
"Middle center" => AttachmentPoint::MiddleCenter,
"Middle right" => AttachmentPoint::MiddleRight,
"Bottom left" => AttachmentPoint::BottomLeft,
"Bottom center" => AttachmentPoint::BottomCenter,
"Bottom right" => AttachmentPoint::BottomRight,
2026-03-22 14:56:27 +03:00
_ => return None,
})
}
fn drawing_dir_str(d: &DrawingDirection) -> &'static str {
match d {
DrawingDirection::LeftToRight => "Left to right",
DrawingDirection::TopToBottom => "Top to bottom",
DrawingDirection::ByStyle => "By style",
2026-03-22 14:56:27 +03:00
}
}
/// Per-visible-character world-space boxes for the MText editor's
/// click-to-select preview. Uses the exact same layout opts as `to_truck`
/// so the boxes line up with the rendered glyphs.
/// The MTEXT string to render: the live re-evaluated value when the entity
/// hosts a dynamic field, otherwise its stored (cached) value.
fn display_value(t: &MText, document: &acadrust::CadDocument) -> String {
crate::entities::field::resolve(document, t.common.handle).unwrap_or_else(|| t.value.clone())
}
pub fn glyph_boxes(t: &MText, document: &acadrust::CadDocument) -> Vec<GlyphBox> {
let resolved_style = resolve_text_style(&t.style, document);
let attach_h_anchor: f32 = match t.attachment_point {
AttachmentPoint::TopCenter
| AttachmentPoint::MiddleCenter
| AttachmentPoint::BottomCenter => 0.5,
AttachmentPoint::TopRight | AttachmentPoint::MiddleRight | AttachmentPoint::BottomRight => {
1.0
}
_ => 0.0,
};
let v_anchor = match t.attachment_point {
AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => {
MTextVAnchor::Top
}
AttachmentPoint::MiddleLeft
| AttachmentPoint::MiddleCenter
| AttachmentPoint::MiddleRight => MTextVAnchor::Middle,
AttachmentPoint::BottomLeft
| AttachmentPoint::BottomCenter
| AttachmentPoint::BottomRight => MTextVAnchor::Bottom,
};
let rotation = if resolved_style.is_upside_down {
t.rotation as f32 + std::f32::consts::PI
} else {
t.rotation as f32
};
let display = display_value(t, document);
let layout = layout_mtext(&MTextRenderOpts {
value: &display,
insertion: [
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
],
height: t.height as f32,
rect_w: t.rectangle_width as f32,
rotation,
style: &resolved_style,
attach_h_anchor,
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
want_glyph_boxes: true,
feat(mtext): lay out `\N` columns and stack `\S` fractions Two things an MTEXT can ask for that the layout ignored, both found on the same drawing (budweiser2018.dwg handles 8782 and 2609). Columns: `column_data` was read but only ever reached the properties panel, so a three-column MTEXT ran as one stacked block. Each line now carries the column it belongs to, the row counter restarts per column, and wrapping measures against the column rather than the block — measuring against the block would let a line run across the gutter and over its neighbour. Paragraph alignment already worked, which is what made the bug read as "columns are there but misplaced": `\pxqr;` pushed col3 to the right edge either way. Only explicit `\N` breaks move to the next column. A column that fills up does not spill into the next on its own — `heights` / `auto_height` are not read, so a column runs as long as its content. Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the line then overflowed its 7.26-wide box and dropped `°C ª` onto a second row. The halves now share one horizontal slot, drawn at a shrunk height with the numerator lifted, so the slot is as wide as the wider half. `^` stacks without a rule; `#` stays inline `num/den`, which is what a slanted fraction reads as and needs no second baseline. The fraction rule rides a run-less stroke group: a group carrying a `GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one keeps them, and the rule is plain geometry rather than a glyph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
columns: columns_of(t),
});
layout.glyph_boxes
}
2026-03-22 14:56:27 +03:00
fn to_truck(t: &MText, document: &acadrust::CadDocument) -> TruckEntity {
let resolved_style = resolve_text_style(&t.style, document);
let attach_h_anchor: f32 = match t.attachment_point {
2026-03-22 14:56:27 +03:00
AttachmentPoint::TopCenter
| AttachmentPoint::MiddleCenter
| AttachmentPoint::BottomCenter => 0.5,
AttachmentPoint::TopRight | AttachmentPoint::MiddleRight | AttachmentPoint::BottomRight => {
1.0
}
_ => 0.0,
};
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
let v_anchor = match t.attachment_point {
AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => {
MTextVAnchor::Top
}
AttachmentPoint::MiddleLeft
| AttachmentPoint::MiddleCenter
| AttachmentPoint::MiddleRight => MTextVAnchor::Middle,
AttachmentPoint::BottomLeft
| AttachmentPoint::BottomCenter
| AttachmentPoint::BottomRight => MTextVAnchor::Bottom,
};
let rotation = if resolved_style.is_upside_down {
t.rotation as f32 + std::f32::consts::PI
} else {
t.rotation as f32
};
let display = display_value(t, document);
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
let layout = layout_mtext(&MTextRenderOpts {
value: &display,
insertion: [
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.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
height: t.height as f32,
rect_w: t.rectangle_width as f32,
rotation,
style: &resolved_style,
attach_h_anchor,
v_anchor,
line_spacing_factor: t.line_spacing_factor as f32,
vertical_text: matches!(t.drawing_direction, DrawingDirection::TopToBottom),
want_glyph_boxes: false,
feat(mtext): lay out `\N` columns and stack `\S` fractions Two things an MTEXT can ask for that the layout ignored, both found on the same drawing (budweiser2018.dwg handles 8782 and 2609). Columns: `column_data` was read but only ever reached the properties panel, so a three-column MTEXT ran as one stacked block. Each line now carries the column it belongs to, the row counter restarts per column, and wrapping measures against the column rather than the block — measuring against the block would let a line run across the gutter and over its neighbour. Paragraph alignment already worked, which is what made the bug read as "columns are there but misplaced": `\pxqr;` pushed col3 to the right edge either way. Only explicit `\N` breaks move to the next column. A column that fills up does not spill into the next on its own — `heights` / `auto_height` are not read, so a column runs as long as its content. Stacking: `\S1/2;` was flattened to the literal "1/2", which is why the line then overflowed its 7.26-wide box and dropped `°C ª` onto a second row. The halves now share one horizontal slot, drawn at a shrunk height with the numerator lifted, so the slot is as wide as the wider half. `^` stacks without a rule; `#` stays inline `num/den`, which is what a slanted fraction reads as and needs no second baseline. The fraction rule rides a run-less stroke group: a group carrying a `GlyphRun` renders as SDF quads with its strokes suppressed, a run-less one keeps them, and the rule is plain geometry rather than a glyph. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:48:10 +03:00
columns: columns_of(t),
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
});
let insertion = glam::DVec3::new(
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.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
);
2026-03-22 14:56:27 +03:00
TruckEntity {
fix(pick): hit-test lineweight width and thickness walls Selection tested a flat 8 px radius around each wire's centreline and nothing else, so two things the user can see were not pickable. A wire renders as a band `line_weight_px` wide, so pick now widens to that rendered half-width via `pick_tolerance_px`, mirroring the wire shader's `select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for every standard weight (the widest, 2.11 mm, renders 7.97 px half-width — just under the 8 px threshold); this keeps the two sides from drifting apart if the display boost in `lineweight_to_px` ever changes. Entities extruded by a DXF thickness draw their wall as four edges with nothing in between, so the interior was a hole the cursor fell through. WireModel gains `pick_tris`: triangles hit-testing treats as solid and the renderer never uploads. Kept apart from `fill_tris` because that channel reaches the GPU, and a wall that rendered shaded would change how every thickness drawing looks. Ranked below `fill_tris` — those are drawn, these are not, so where they overlap the drawn face is what the user means. Walls are dropped when a wire is projected into a floating viewport: `points` are reprojected and clipped into paper coords there, so cloned model-space triangles would hit-test at a model-scale offset. Extruded entities stay selectable by their edges, which do get projected. The wall loop runs on every hover that misses everything else, so it rejects on the wire's AABB first — an extruded circle is ~128 triangles and a drawing full of thickness would otherwise project every wall on each mouse move. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 14:58:55 +03:00
pick_tris: Vec::new(),
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
object: TruckObject::Text(layout.strokes),
2026-03-22 14:56:27 +03:00
snap_pts: vec![(insertion, SnapHint::Insertion)],
tangent_geoms: vec![],
key_vertices: vec![],
fill_tris: vec![],
2026-03-22 14:56:27 +03:00
}
}
/// The MTEXT's attachment anchors in the shared layout vocabulary.
fn attach_anchors(t: &MText) -> (f32, MTextVAnchor) {
let h: f32 = match t.attachment_point {
AttachmentPoint::TopCenter
| AttachmentPoint::MiddleCenter
| AttachmentPoint::BottomCenter => 0.5,
AttachmentPoint::TopRight | AttachmentPoint::MiddleRight | AttachmentPoint::BottomRight => {
1.0
}
_ => 0.0,
};
let v = match t.attachment_point {
AttachmentPoint::TopLeft | AttachmentPoint::TopCenter | AttachmentPoint::TopRight => {
MTextVAnchor::Top
}
AttachmentPoint::MiddleLeft
| AttachmentPoint::MiddleCenter
| AttachmentPoint::MiddleRight => MTextVAnchor::Middle,
AttachmentPoint::BottomLeft
| AttachmentPoint::BottomCenter
| AttachmentPoint::BottomRight => MTextVAnchor::Bottom,
};
(h, v)
}
/// Flow axis + attachment-side factor, from the shared text-grip helper.
fn width_grip_axis(t: &MText) -> (glam::DVec3, f64) {
let (h, v) = attach_anchors(t);
crate::entities::text_support::flow_grip_axis(
t.rotation,
matches!(t.drawing_direction, DrawingDirection::TopToBottom),
h,
v,
)
}
2026-03-22 14:56:27 +03:00
fn grips(t: &MText) -> Vec<GripDef> {
let p = glam::DVec3::new(
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
2026-03-22 14:56:27 +03:00
);
let (dir, k) = width_grip_axis(t);
let width_grip = p + dir * (k * t.rectangle_width.max(0.0));
2026-03-22 14:56:27 +03:00
vec![square_grip(0, p), triangle_grip(1, width_grip)]
}
fn columns_str(c: &acadrust::entities::MTextColumnData) -> &'static str {
match c.column_type {
1 => "Static",
2 => "Dynamic",
_ => "No columns",
}
}
fn properties(t: &MText, text_style_names: &[String]) -> Vec<PropSection> {
// Absolute line-space distance for single spacing (~1.66 * height) scaled
// by the line-spacing factor.
let line_space_distance = t.height * 1.666_666_666_666_667 * t.line_spacing_factor;
let text_frame_on = (t.background_fill_flags & 0x10) != 0;
// Defined width is only live without columns; defined height is live for
// static columns or manual-height dynamic columns, grayed otherwise.
let col_type = t.column_data.column_type;
let width_editable = col_type == 0;
let height_editable = col_type == 1 || (col_type == 2 && !t.column_data.auto_height);
vec![
PropSection {
title: "Text".into(),
props: vec![
Property {
label: "Contents".into(),
field: "content",
value: PropValue::EditText(t.value.clone()),
2026-03-22 14:56:27 +03:00
},
Property {
label: "Style".into(),
field: "style",
value: PropValue::Choice {
selected: if t.style.trim().is_empty() {
"Standard".into()
} else {
t.style.clone()
},
options: text_style_names.to_vec(),
},
2026-03-22 14:56:27 +03:00
},
ro(
"Annotative",
"annotative",
if t.is_annotative { "Yes" } else { "No" },
),
Property {
label: "Justify".into(),
field: "justify",
value: PropValue::Choice {
selected: attachment_str(&t.attachment_point).to_string(),
options: [
"Top left",
"Top center",
"Top right",
"Middle left",
"Middle center",
"Middle right",
"Bottom left",
"Bottom center",
"Bottom right",
]
.into_iter()
.map(str::to_string)
.collect(),
2026-03-22 14:56:27 +03:00
},
},
Property {
label: "Direction".into(),
field: "direction",
value: PropValue::Choice {
selected: drawing_dir_str(&t.drawing_direction).to_string(),
options: ["By style", "Left to right", "Top to bottom"]
.into_iter()
.map(str::to_string)
.collect(),
},
},
edit("Text height", "height", t.height),
edit_angle("Rotation", "rotation", t.rotation.to_degrees()),
edit("Line space factor", "line_spacing", t.line_spacing_factor),
edit("Line space distance", "line_space_distance", line_space_distance),
Property {
label: "Line space style".into(),
field: "line_space_style",
value: PropValue::Choice {
selected: match t.line_spacing_style {
acadrust::entities::LineSpacingStyle::Exactly => "Exactly",
_ => "At least",
}
.to_string(),
options: ["At least", "Exactly"]
.into_iter()
.map(str::to_string)
.collect(),
},
},
Property {
label: "Background mask".into(),
field: "background_mask",
value: PropValue::Choice {
selected: if t.background_fill_flags & 0x01 != 0 {
"Fill".into()
} else if t.background_fill_flags & 0x02 != 0 {
"Mask".into()
} else {
"Off".into()
},
options: ["Off", "Fill", "Mask"]
.into_iter()
.map(str::to_string)
.collect(),
},
},
num_row("Defined width", "rect_w", t.rectangle_width, width_editable),
num_row(
"Defined height",
"rect_h",
t.rectangle_height.unwrap_or(0.0),
height_editable,
),
Property {
label: "Columns".into(),
field: "columns",
value: PropValue::Choice {
selected: columns_str(&t.column_data).to_string(),
options: ["No columns", "Static", "Dynamic"]
.into_iter()
.map(str::to_string)
.collect(),
},
},
// Count / width / gutter are live only when columns are on.
num_row(
"Column count",
"col_count",
t.column_data.column_count as f64,
col_type != 0,
),
num_row("Column width", "col_width", t.column_data.width, col_type != 0),
num_row(
"Column gutter",
"col_gutter",
t.column_data.gutter,
col_type != 0,
),
Property {
label: "Text frame".into(),
field: "text_frame",
value: PropValue::BoolToggle {
field: "text_frame",
value: text_frame_on,
},
},
],
},
PropSection {
title: "Geometry".into(),
props: vec![
edit("Position X", "ins_x", t.insertion_point.x),
edit("Position Y", "ins_y", t.insertion_point.y),
edit("Position Z", "ins_z", t.insertion_point.z),
],
},
]
2026-03-22 14:56:27 +03:00
}
fn apply_geom_prop(t: &mut MText, field: &str, value: &str) {
match field {
"content" => {
t.value = value.to_string();
return;
}
"style" => {
t.style = value.to_string();
return;
}
"justify" => {
if let Some(next) = attachment_from_justify(value) {
2026-03-22 14:56:27 +03:00
t.attachment_point = next;
}
return;
}
"background_mask" => {
// Clear both fill bits, then set the chosen one. 0x01 = use the
// background-fill colour, 0x02 = use the drawing-window colour (mask).
t.background_fill_flags &= !0x03;
match value {
"Fill" => t.background_fill_flags |= 0x01,
"Mask" => t.background_fill_flags |= 0x02,
_ => {}
}
return;
}
"text_frame" => {
// Rendered as a checkbox (BoolToggle) → the toggle sends "toggle";
// flip the frame bit. Accept explicit "On"/"Off" too for safety.
match value {
"toggle" => t.background_fill_flags ^= 0x10,
"On" => t.background_fill_flags |= 0x10,
_ => t.background_fill_flags &= !0x10,
}
return;
}
"direction" => {
t.drawing_direction = match value {
"Left to right" => DrawingDirection::LeftToRight,
"Top to bottom" => DrawingDirection::TopToBottom,
"By style" => DrawingDirection::ByStyle,
_ => return,
};
return;
}
"line_space_style" => {
t.line_spacing_style = match value {
"Exactly" => acadrust::entities::LineSpacingStyle::Exactly,
"At least" => acadrust::entities::LineSpacingStyle::AtLeast,
_ => return,
};
return;
}
"columns" => {
let new_type = match value {
"Static" => 1,
"Dynamic" => 2,
_ => 0,
};
// Compute defaults before borrowing column_data mutably.
let default_w = if t.rectangle_width > 0.0 {
t.rectangle_width
} else {
t.height * 10.0
};
let default_gut = t.height.max(1.0);
let cd = &mut t.column_data;
// Turning columns on from none seeds a consistent layout so the
// stored data isn't a half-set column definition; the count / width
// / gutter rows refine it. Dynamic columns default to auto-height.
if new_type != 0 && cd.column_type == 0 {
if cd.column_count < 2 {
cd.column_count = 2;
}
if cd.width <= 0.0 {
cd.width = default_w;
}
if cd.gutter <= 0.0 {
cd.gutter = default_gut;
}
cd.auto_height = new_type == 2;
}
cd.column_type = new_type;
return;
}
2026-03-22 14:56:27 +03:00
_ => {}
}
let Some(v) = crate::entities::common::parse_f64(value) else {
return;
};
match field {
"ins_x" => t.insertion_point.x = v,
"ins_y" => t.insertion_point.y = v,
"ins_z" => t.insertion_point.z = v,
"height" if v > 0.0 => t.height = v,
"rect_w" if v > 0.0 => t.rectangle_width = v,
"rect_h" if v > 0.0 => t.rectangle_height = Some(v),
"rotation" => t.rotation = v.to_radians(),
"line_spacing" if v > 0.0 => t.line_spacing_factor = v,
// Editing the absolute distance back-solves the line-spacing factor so
// the two stay consistent (distance = height × 5/3 × factor).
"line_space_distance" if v > 0.0 => {
let denom = t.height * 1.666_666_666_666_667;
if denom > 0.0 {
t.line_spacing_factor = v / denom;
}
}
"col_count" if v >= 1.0 => t.column_data.column_count = v.round() as i32,
"col_width" if v > 0.0 => t.column_data.width = v,
"col_gutter" if v >= 0.0 => t.column_data.gutter = v,
2026-03-22 14:56:27 +03:00
_ => {}
}
}
fn apply_grip(t: &mut MText, grip_id: usize, apply: GripApply) {
match (grip_id, apply) {
(0, GripApply::Absolute(p)) => {
t.insertion_point.x = p.x as f64;
t.insertion_point.y = p.y as f64;
t.insertion_point.z = p.z as f64;
}
(0, GripApply::Translate(d)) => {
t.insertion_point.x += d.x as f64;
t.insertion_point.y += d.y as f64;
t.insertion_point.z += d.z as f64;
}
(1, GripApply::Absolute(p)) => {
// Project onto the flow axis the grip is drawn on, undoing the
// attachment-side factor so dragging the grip on either side of
// the insertion widens the same box.
let (dir, k) = width_grip_axis(t);
2026-03-22 14:56:27 +03:00
let dx = p.x as f64 - t.insertion_point.x;
let dy = p.y as f64 - t.insertion_point.y;
let projected = dx * dir.x + dy * dir.y;
t.rectangle_width = (projected / k).max(0.01);
2026-03-22 14:56:27 +03:00
}
_ => {}
}
}
fn apply_transform(t: &mut MText, tr: &EntityTransform) {
crate::scene::view::transform::apply_standard_entity_transform(t, tr, |entity, p1, p2| {
crate::scene::view::transform::reflect_xy_point(
2026-03-22 14:56:27 +03:00
&mut entity.insertion_point.x,
&mut entity.insertion_point.y,
p1,
p2,
);
let dx = (p2.x - p1.x) as f64;
let dy = (p2.y - p1.y) as f64;
let line_angle = dy.atan2(dx);
entity.rotation = 2.0 * line_angle - entity.rotation;
});
}
impl TruckConvertible for MText {
fn to_truck(&self, document: &acadrust::CadDocument) -> Option<TruckEntity> {
Some(to_truck(self, document))
}
}
impl Grippable for MText {
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};
if grip_id == 0 {
// Insertion point
vec![
GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
},
GripMenuItem {
label: "Move with Text",
action: GripMenuAction::MoveWithText,
},
GripMenuItem {
label: "Rotate",
action: GripMenuAction::RotateText,
},
]
} else {
// Width grip
vec![GripMenuItem {
label: "Stretch",
action: GripMenuAction::Stretch,
}]
}
}
fn apply_grip_menu(&mut self, _grip_id: usize, _action: crate::scene::model::object::GripMenuAction) {
// Rotate needs a follow-up angle handled by
// `apply_grip_menu_value`; Move-with-Text is the default drag.
}
fn grip_menu_value_prompt(
&self,
_grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
) -> Option<&'static str> {
use crate::scene::model::object::GripMenuAction as A;
match action {
A::RotateText => Some("Rotation (deg)"),
_ => None,
}
}
fn apply_grip_menu_value(
&mut self,
_grip_id: usize,
action: crate::scene::model::object::GripMenuAction,
value: f64,
) {
use crate::scene::model::object::GripMenuAction as A;
if matches!(action, A::RotateText) {
self.rotation = value.to_radians();
}
}
2026-03-22 14:56:27 +03:00
}
impl PropertyEditable for MText {
fn geometry_properties(&self, text_style_names: &[String]) -> Vec<PropSection> {
2026-03-22 14:56:27 +03:00
properties(self, text_style_names)
}
fn apply_geom_prop(&mut self, field: &str, value: &str) {
apply_geom_prop(self, field, value);
}
}
impl Transformable for MText {
fn apply_transform(&mut self, t: &EntityTransform) {
apply_transform(self, t);
}
}
impl crate::entities::traits::TextContent for acadrust::entities::MText {
fn text_content(&self) -> Option<String> {
Some(self.value.clone())
}
fn replace_text(&mut self, search: &str, rep: &str) {
let search_lc = search.to_lowercase();
if self.value.to_lowercase().contains(&search_lc) {
self.value = self.value.replace(search, rep);
}
}
}