cad-editor/src/scene/tessellate.rs

3482 lines
131 KiB
Rust
Raw Normal View History

2026-03-22 14:56:27 +03:00
// Tessellation — convert acadrust EntityType to GPU-ready WireModel or MeshModel.
//
// Flow:
// EntityType
// ↓ acad_to_truck::convert()
// TruckEntity { object: TruckObject, snap_pts, tangent_geoms, key_vertices }
// ↓ truck_tess::tessellate_*()
// TruckTessResult::Lines → WireModel
// TruckTessResult::Point → WireModel (small cross)
// TruckTessResult::Mesh → MeshModel
// TruckObject::Text → one WireModel per glyph stroke (elevation from entity Z)
//
// Entities not handled by acad_to_truck (Viewport, Hatch, …) are tessellated
// by the legacy geometry() path so nothing regresses.
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
use crate::entities::multileader::{catmull_rom_pts, v_offset_for_attachment};
2026-05-12 10:36:15 +03:00
use acadrust::entities::{
Dimension, Leader, LeaderContentType, MultiLeader, MultiLeaderPathType, Text,
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
TextAttachmentPointType,
2026-05-12 10:36:15 +03:00
};
use acadrust::tables::DimStyle;
2026-03-22 14:56:27 +03:00
use acadrust::types::{Color as AcadColor, Vector3};
use acadrust::{CadDocument, EntityType, Handle};
use glam::Vec3;
use crate::scene::acad_to_truck::{convert, TruckObject};
use crate::scene::truck_tess::{
tessellate_edge, tessellate_vertex, tessellate_wire, TruckTessResult,
2026-03-22 14:56:27 +03:00
};
use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel};
// ── Arc tessellation helpers ─────────────────────────────────────────────
/// Convert hatch-boundary arc `(start, end, ccw)` into a
/// `(start, signed_span)` ready for the sampling loop. Matches the
/// legacy `(TAU - sa, TAU - ea)` flip used here for years — direction
/// semantics are preserved on real files. (Wrap-through-2π is a known
/// edge case in that convention; do not "fix" it without a wider audit
/// of how upstream writers emit CW boundary arcs.)
pub(super) fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) {
const TAU: f64 = std::f64::consts::TAU;
let (sa, ea) = if ccw { (start, end) } else { (TAU - start, TAU - end) };
(sa, ea - sa)
}
/// Segment count for an arc, targeting `chord_tol_world` chord-height
/// error in world units. Floor 8, cap 512.
///
/// Two production callers:
/// - hatch fill polygon (built at load / on edit) passes a radius-
/// relative tol via [`fill_chord_tol`] — ~0.1% radius, zoom-free so
/// the polygon stays sharp at extreme zoom-in without re-tessellation.
/// - hatch wire outline (re-tessellated every frame inside the render
/// scope) passes a zoom-adaptive tol via [`wire_chord_tol`] — pulls
/// from the per-frame override set by `Scene::wires_for_block` so
/// far-out arcs collapse to a handful of segments.
pub(super) fn arc_segments(radius: f64, span_abs: f64, chord_tol_world: f64) -> u32 {
if span_abs < 1e-9 || radius < 1e-9 {
return 1;
}
let tol = chord_tol_world.max(1e-9).min(radius * 0.99);
// θ where r * (1 - cos(θ/2)) = tol → θ = 2 * acos(1 - tol/r).
let max_step = (2.0 * (1.0 - tol / radius).acos()).max(1e-6);
((span_abs / max_step).ceil() as u32).clamp(8, 512)
}
/// Chord tolerance for the load-time fill polygon: 0.1% of radius,
/// floor 1 µm so degenerate radii still produce a workable count.
pub(super) fn fill_chord_tol(radius: f64) -> f64 {
(radius * 0.001).max(1e-6)
}
/// Chord tolerance for the per-frame wire outline: pulls the active
/// `truck_tess::set_curve_tol_override` value (Scene sets it to
/// `world_per_pixel × 0.5` so curves stay at ~half-pixel chord error at
/// the current zoom). When no override is active (snap / hit-test
/// passes, load-time builds), falls back to [`fill_chord_tol`] so we
/// never under-sample.
pub(super) fn wire_chord_tol(radius: f64) -> f64 {
match crate::scene::truck_tess::active_curve_tol() {
Some(t) => t.min(fill_chord_tol(radius)).max(1e-9),
None => fill_chord_tol(radius),
}
}
2026-05-09 23:56:04 +03:00
2026-03-22 14:56:27 +03:00
// ── Colour helper ──────────────────────────────────────────────────────────
/// Convert an acadrust Color (ACI index or true-color) to a GPU RGBA value.
pub fn aci_to_rgba(color: &AcadColor) -> [f32; 4] {
if let Some((r, g, b)) = color.rgb() {
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
} else {
WireModel::WHITE
}
}
// ── Public entry points ────────────────────────────────────────────────────
/// Tessellate one entity into a WireModel.
/// For Text/MText entities this produces one WireModel with all glyph strokes
/// encoded as NaN-separated segments (wire_gpu skips NaN pairs).
/// For Solid3D entities this returns an empty wire; mesh tessellation lives
/// in `solid3d_tess` and is uploaded via the mesh pipeline instead.
2026-03-22 14:56:27 +03:00
pub fn tessellate(
document: &CadDocument,
handle: Handle,
entity: &EntityType,
selected: bool,
entity_color: [f32; 4],
pattern_length: f32,
pattern: [f32; 8],
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
2026-03-22 14:56:27 +03:00
) -> WireModel {
let color = if selected {
WireModel::SELECTED
} else {
entity_color
};
let name = handle.value().to_string();
// Determine effective annotation scale for this entity.
//
// AutoCAD's R2007+ "annotative" system marks objects via extension-
// dictionary records or "AcAnnoPO" / "AcAnnotativeData" xdata. Only
// entities so marked should be auto-scaled by the viewport's
// paper-scale; everything else is treated as manually pre-scaled
// (old DXF/DWG convention with $DIMSCALE and oversized text).
//
// Default: NOT annotative (anno_scale = 1.0). Opt-in via explicit
// xdata marker. Files that mark every entity annotative are rare; the
// pre-R2007 manual-scale convention is far more common in field data.
let anno_scale = {
let xdata = &entity.common().extended_data;
let is_annotative = xdata.get_record("AcAnnoPO").is_some()
|| xdata.get_record("AcAnnotativeData").is_some();
2026-05-12 10:36:15 +03:00
if is_annotative {
anno_scale
} else {
1.0
}
};
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
// MultiLeader is handled by scene/mod.rs since it emits multiple WireModels
// (leader, text, frame, fill) with distinct colors.
if let EntityType::Leader(leader) = entity {
return tessellate_leader_single(
document,
2026-05-12 10:36:15 +03:00
handle,
leader,
selected,
entity_color,
line_weight_px,
world_offset,
anno_scale,
);
}
2026-03-22 14:56:27 +03:00
// ── Try the truck path first ───────────────────────────────────────────
if let Some(te) = convert(entity, document) {
match te.object {
// ── Text / MText: pre-tessellated glyph strokes ───────────────
TruckObject::Text(stroke_groups) => {
// Each TextStroke keeps its strokes in glyph-local space and
// its world origin as f64. Subtract world_offset in f64 before
// casting to f32 so large UTM coordinates don't crush precision.
let [ox, oy, oz] = world_offset;
let elev = entity_z(entity) - oz as f32;
2026-03-22 14:56:27 +03:00
let mut points: Vec<[f32; 3]> = Vec::new();
let mut first = true;
// Annotation scale: scale glyph strokes relative to the text
// insertion point (first group's origin) so multi-line MText
// lines spread apart correctly as well as growing in size.
2026-05-12 10:36:15 +03:00
let ref_origin = stroke_groups
.first()
.map(|g| g.origin)
.unwrap_or([0.0, 0.0]);
let ref_lx = (ref_origin[0] - ox) as f32;
let ref_ly = (ref_origin[1] - oy) as f32;
for group in &stroke_groups {
let lx = (group.origin[0] - ox) as f32;
let ly = (group.origin[1] - oy) as f32;
let slx = (lx - ref_lx) * anno_scale + ref_lx;
let sly = (ly - ref_ly) * anno_scale + ref_ly;
for stroke in &group.strokes {
if stroke.len() < 2 {
continue;
}
if !first && !points.is_empty() {
points.push([f32::NAN, f32::NAN, f32::NAN]);
}
first = false;
for &[x, y] in stroke {
points.push([x * anno_scale + slx, y * anno_scale + sly, elev]);
}
2026-03-22 14:56:27 +03:00
}
}
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
let [ox, oy, oz] = world_offset;
let key_vertices: Vec<[f32; 3]> = te
.key_vertices
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
2026-03-22 14:56:27 +03:00
return WireModel {
name,
points,
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts,
2026-03-22 14:56:27 +03:00
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
2026-05-12 10:36:15 +03:00
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
2026-03-22 14:56:27 +03:00
};
}
// ── Standard topology objects ─────────────────────────────────
TruckObject::Point(v) => {
let result = tessellate_vertex(&v, world_offset);
2026-03-22 14:56:27 +03:00
match result {
TruckTessResult::Point([x, y, z]) => {
let s = 0.1_f32;
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
let [ox, oy, oz] = world_offset;
let key_vertices: Vec<[f32; 3]> = te
.key_vertices
.into_iter()
.map(|[kx, ky, kz]| {
[(kx - ox) as f32, (ky - oy) as f32, (kz - oz) as f32]
})
.collect();
2026-03-22 14:56:27 +03:00
return WireModel {
name,
points: vec![
[x - s, y, z],
[x + s, y, z],
[x, y - s, z],
[x, y + s, z],
],
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts,
2026-03-22 14:56:27 +03:00
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
2026-05-12 10:36:15 +03:00
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
2026-03-22 14:56:27 +03:00
};
}
_ => {}
}
}
TruckObject::Curve(e) => {
if let TruckTessResult::Lines(points) = tessellate_edge(&e, world_offset) {
let [ox, oy, oz] = world_offset;
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
2026-05-12 10:36:15 +03:00
let key_vertices: Vec<[f32; 3]> = te
.key_vertices
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
2026-03-22 14:56:27 +03:00
return WireModel {
name,
points,
color,
selected,
pattern_length,
pattern,
line_weight_px,
snap_pts,
2026-03-22 14:56:27 +03:00
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
2026-05-12 10:36:15 +03:00
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
2026-03-22 14:56:27 +03:00
};
}
}
TruckObject::Contour(w) => {
if let TruckTessResult::Lines(points) = tessellate_wire(&w, world_offset) {
let [ox, oy, oz] = world_offset;
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
2026-05-12 10:36:15 +03:00
let key_vertices: Vec<[f32; 3]> = te
.key_vertices
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
2026-03-22 14:56:27 +03:00
return WireModel {
name,
points,
color,
selected,
pattern_length,
pattern,
line_weight_px,
snap_pts,
2026-03-22 14:56:27 +03:00
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
2026-05-12 10:36:15 +03:00
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
2026-03-22 14:56:27 +03:00
};
}
}
TruckObject::Lines(points) => {
// Points are world-space f64 from entity converters (polyline,
// leader, mesh, solid2d, etc.). Subtract world_offset in f64
// before casting to f32 so drawings at large UTM-style
// coordinates keep sub-unit precision in the wire model.
let [ox, oy, oz] = world_offset;
2026-05-12 10:36:15 +03:00
let local_pts: Vec<[f32; 3]> = points
.into_iter()
.map(|[x, y, z]| {
if x.is_nan() {
[f32::NAN, f32::NAN, f32::NAN]
2026-05-12 10:36:15 +03:00
} else {
[(x - ox) as f32, (y - oy) as f32, (z - oz) as f32]
2026-05-12 10:36:15 +03:00
}
})
.collect();
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
2026-05-12 10:36:15 +03:00
let key_vertices: Vec<[f32; 3]> = te
.key_vertices
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
2026-05-12 10:36:15 +03:00
let fill_tris: Vec<[f32; 3]> = te
.fill_tris
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
2026-03-22 14:56:27 +03:00
return WireModel {
name,
points: local_pts,
2026-03-22 14:56:27 +03:00
color,
selected,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts,
2026-03-22 14:56:27 +03:00
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris,
};
}
TruckObject::SegmentedLines(points) => {
let [ox, oy, oz] = world_offset;
2026-05-12 10:36:15 +03:00
let local_pts: Vec<[f32; 3]> = points
.into_iter()
.map(|[x, y, z]| {
if x.is_nan() {
[f32::NAN, f32::NAN, f32::NAN]
2026-05-12 10:36:15 +03:00
} else {
[(x - ox) as f32, (y - oy) as f32, (z - oz) as f32]
2026-05-12 10:36:15 +03:00
}
})
.collect();
let snap_pts = offset_snap_pts(te.snap_pts, world_offset);
2026-05-12 10:36:15 +03:00
let key_vertices: Vec<[f32; 3]> = te
.key_vertices
.into_iter()
.map(|[x, y, z]| [(x - ox) as f32, (y - oy) as f32, (z - oz) as f32])
.collect();
return WireModel {
name,
points: local_pts,
color,
selected,
pattern_length,
pattern,
line_weight_px,
snap_pts,
tangent_geoms: te.tangent_geoms,
aci: 0,
key_vertices,
plinegen: false,
2026-05-12 10:36:15 +03:00
vp_scissor: None,
aabb: WireModel::UNBOUNDED_AABB,
fill_tris: vec![],
2026-03-22 14:56:27 +03:00
};
}
TruckObject::Volume(_) => {
// Solid3D / Region / Body → mesh tessellation lives in
// `solid3d_tess`. As a wire fallback, render the pre-computed
// edge wires stored in the entity when present (e.g. from
// SOLVIEW output or when the SAT kernel cannot parse the
// ACIS data).
let wire_pts = solid_wire_fallback(entity, world_offset);
let mut wm = WireModel::solid(name, wire_pts, color, selected);
// Add insertion snap at point_of_reference.
let [ox, oy, oz] = world_offset;
let por = match entity {
EntityType::Solid3D(s) => Some(&s.point_of_reference),
EntityType::Region(r) => Some(&r.point_of_reference),
EntityType::Body(b) => Some(&b.point_of_reference),
_ => None,
};
if let Some(p) = por {
2026-05-12 10:36:15 +03:00
let sp = Vec3::new((p.x - ox) as f32, (p.y - oy) as f32, (p.z - oz) as f32);
wm.snap_pts.push((sp, SnapHint::Insertion));
}
return wm;
2026-03-22 14:56:27 +03:00
}
}
}
// ── Legacy fallback for Viewport and other unhandled types ────────────
let (points, snap_pts, tangent_geoms, key_vertices) = legacy_geometry(entity, world_offset);
2026-03-22 14:56:27 +03:00
WireModel {
name,
points,
color,
selected,
aci: 0,
2026-03-22 14:56:27 +03:00
pattern_length,
pattern,
line_weight_px,
snap_pts,
tangent_geoms,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
2026-05-12 10:36:15 +03:00
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
2026-03-22 14:56:27 +03:00
}
}
pub fn tessellate_dimension(
document: &CadDocument,
handle: Handle,
dim: &Dimension,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
2026-03-22 14:56:27 +03:00
) -> Vec<WireModel> {
let name = handle.value().to_string();
// ── Anonymous-block fast path ───────────────────────────────────────────
//
// AutoCAD bakes each dimension's final geometry (extension lines, dim
// line, arrows, text MText) into a per-instance block — usually named
// `*D<n>`, but some authoring tools use a custom name like
// `DIMBLOCK###-4NP`. When that block exists we render its contents
// directly: the text height inside the block is the FINAL displayed
// height (DIMTXT × DIMSCALE already applied at creation), so
// synthesising it from the dim style would double-apply DIMSCALE.
let dim_block_name = &dim.base().block_name;
if !dim_block_name.trim().is_empty() {
if let Some(br) = document
.block_records
.iter()
.find(|br| br.name.eq_ignore_ascii_case(dim_block_name))
{
if !br.entity_handles.is_empty() {
let mut wires: Vec<WireModel> =
Vec::with_capacity(br.entity_handles.len());
for &eh in &br.entity_handles {
let Some(sub) = document.get_entity(eh) else { continue };
// Inherit dim's color + line weight when the sub-entity is
// ByBlock; ByLayer stays on its own layer.
let sub_color = if selected {
WireModel::SELECTED
} else {
entity_color
};
let mut wire = tessellate(
document,
handle,
sub,
selected,
sub_color,
0.0,
[0.0; 8],
line_weight_px,
world_offset,
// Sub-entities are already baked at the final WCS
// size; don't re-apply anno_scale.
1.0,
);
wire.name = name.clone();
wires.push(wire);
}
if !wires.is_empty() {
return wires;
}
}
}
}
let style_name = &dim.base().style_name;
let style = document.dim_styles.iter().find(|s| {
s.name.eq_ignore_ascii_case(style_name)
|| (style_name.trim().is_empty() && s.name.eq_ignore_ascii_case("Standard"))
});
// DIMSCALE rule:
// dimstyle.dimscale > 0 → final multiplier; ignore anno_scale.
// dimstyle.dimscale == 0 → annotative: use anno_scale (= 1/vp_scale).
let dim_scale = style
.map(|s| {
if s.dimscale > 1e-6 {
s.dimscale
} else {
anno_scale as f64
}
2026-05-12 10:36:15 +03:00
})
.unwrap_or(1.0);
let (
dimasz_raw,
dimexo,
dimexe,
dim_txt,
dimtsz_raw,
dimsah,
dimse1,
dimse2,
dimsd1,
dimsd2,
dimdle,
dimfxl,
dimfxlon,
dimsoxd,
dimcen,
) = style
.map(|s| {
(
s.dimasz * dim_scale,
(s.dimexo * dim_scale) as f32,
(s.dimexe * dim_scale) as f32,
s.dimtxt * dim_scale,
s.dimtsz * dim_scale,
s.dimsah,
s.dimse1,
s.dimse2,
s.dimsd1,
s.dimsd2,
(s.dimdle * dim_scale) as f32,
(s.dimfxl * dim_scale) as f32,
s.dimfxlon,
s.dimsoxd,
(s.dimcen * dim_scale) as f32,
)
})
.unwrap_or((
0.18, 0.0, 0.0, 2.5, 0.0, false, false, false, false, false, 0.0, 1.0, false, false,
0.09,
));
// Arrow selection precedence:
// 1. DIMTSZ>0 → oblique tick (overrides DIMBLK*).
// 2. DIMSAH false → DIMBLK on both ends.
// 3. DIMSAH true → DIMBLK1 (first end), DIMBLK2 (second end).
// Unknown / NULL block handles fall back to ClosedFilled.
let dimasz = (dimasz_raw as f32).max(0.001);
let (arrow1, arrow2) = if dimtsz_raw > 1e-9 {
let t = ArrowKind::Tick {
size: (dimtsz_raw as f32).max(0.001),
};
(t, t)
} else if let Some(s) = style {
if dimsah {
(
arrow_from_block(document, s.dimblk1, dimasz),
arrow_from_block(document, s.dimblk2, dimasz),
)
} else {
let a = arrow_from_block(document, s.dimblk, dimasz);
(a, a)
}
} else {
let a = ArrowKind::Triangle {
size: dimasz,
filled: true,
size_mul: 1.0,
};
(a, a)
};
let mut geom = dimension_geometry(
dim,
&arrow1,
&arrow2,
DimLineParams {
dimexo,
dimexe,
dimdle,
dimfxl,
dimfxlon,
dimsoxd,
dimcen,
ticks: dimtsz_raw > 1e-9,
},
SuppressFlags {
ext1: dimse1,
ext2: dimse2,
dim1: dimsd1,
dim2: dimsd2,
},
world_offset,
);
// DIMTMOVE = 1: when the saved text_middle_point sits far from the
// dim-line anchor, draw a short leader connecting them. (=0 anchors text
// to the dim line — no leader; =2 frees text without a leader.)
if let Some(s) = style {
if s.dimtmove == 1 {
if let Some((anchor, txt)) = dimtmove_leader_endpoints(dim, world_offset) {
let gap = dim_txt as f32 * 0.5;
if (txt - anchor).length() > gap * 2.0 {
add_segment(&mut geom.dim_lines, anchor, txt);
}
}
}
// DIMTOFL / DIMTIX / DIMATFIT / DIMUPT control autofit behaviour at
// dim *creation*. At render time we honour the saved text and arrow
// positions, so reading them here is a no-op — they shape geometry
// upstream rather than here.
let _ = (s.dimtofl, s.dimtix, s.dimatfit, s.dimupt);
// DIMTXTDIRECTION (RTL) needs per-instance text mirroring on the Text
// entity, which the current text struct can't carry. Tracked: read
// and ignore so the file round-trips on save.
let _ = s.dimtxtdirection;
// DIMARCSYM only applies to arc-length dims; DIMJOGANG only to
// jogged-radius dims. We don't ship those Dimension variants yet,
// so the values are read for round-trip but not drawn.
let _ = (s.dimarcsym, s.dimjogang);
// DIMUNIT is the obsolete pre-R2000 linear unit format; DIMLUNIT
// supersedes it. Read but not honoured.
let _ = s.dimunit;
}
// Dimension entity fields that the render path doesn't yet use but are
// preserved on save:
// - base.insertion_point: legacy anchor reference; render uses
// text_middle_point + dim-line geometry instead.
// - base.block_name: AutoCAD-style "*D..." anonymous block name for
// the dim graphics — we re-tessellate so don't need it.
// - base.version: DXF format marker (metadata only).
let _ = (
dim.base().insertion_point,
&dim.base().block_name,
dim.base().version,
);
// Per-spec colours: DIMCLRD (dim/arrows), DIMCLRE (ext), DIMCLRT (text).
// 0=ByBlock and 256=ByLayer fall through to entity_color.
let dim_color = if selected {
WireModel::SELECTED
} else {
resolve_dim_color(style.map(|s| s.dimclrd).unwrap_or(0), entity_color)
};
let ext_color = if selected {
WireModel::SELECTED
} else {
resolve_dim_color(style.map(|s| s.dimclre).unwrap_or(0), entity_color)
};
let text_color = if selected {
entity_color // text wire color set by inner tessellate; keep entity tint
} else {
resolve_dim_color(style.map(|s| s.dimclrt).unwrap_or(0), entity_color)
};
let snap_pts = dimension_snap_pts(dim, world_offset);
let key_vertices: Vec<[f32; 3]> = geom
.dim_lines
2026-03-22 14:56:27 +03:00
.iter()
.chain(geom.ext_lines.iter())
2026-03-22 14:56:27 +03:00
.copied()
.filter(|p| !(p[0].is_nan() || p[1].is_nan() || p[2].is_nan()))
.collect();
// DIMLWD (dim line + arrows) and DIMLWE (extension lines). Negative
// codes fall through to the entity's own resolved weight.
let lw_dim = resolve_dim_lineweight_px(
style.map(|s| s.dimlwd).unwrap_or(-2),
line_weight_px,
);
let lw_ext = resolve_dim_lineweight_px(
style.map(|s| s.dimlwe).unwrap_or(-2),
line_weight_px,
);
// DIMLTEX (dim line) / DIMLTEX1 (ext1) / DIMLTEX2 (ext2) — linetype
// handles → pattern. Looked up in document.line_types by handle.
let lt_scale = document.header.linetype_scale as f32 * dim.base().common.linetype_scale as f32;
let (dim_pat_len, dim_pat) = style
.map(|s| resolve_pattern_by_handle(document, s.dimltex_handle, lt_scale))
.unwrap_or((0.0, [0.0; 8]));
let (ext1_pat_len, ext1_pat) = style
.map(|s| resolve_pattern_by_handle(document, s.dimltex1_handle, lt_scale))
.unwrap_or((0.0, [0.0; 8]));
let (ext2_pat_len, ext2_pat) = style
.map(|s| resolve_pattern_by_handle(document, s.dimltex2_handle, lt_scale))
.unwrap_or((0.0, [0.0; 8]));
let mut wires = Vec::new();
if !geom.ext_lines.is_empty() {
// If ext1 and ext2 have different linetypes, split into two wires so
// each can carry its own pattern. Otherwise emit as a single wire.
let split = ext1_pat_len != ext2_pat_len || ext1_pat != ext2_pat;
if split {
let (ext1, ext2) = split_ext_lines(&geom.ext_lines);
if !ext1.is_empty() {
wires.push(WireModel {
name: name.clone(),
points: ext1,
color: ext_color,
selected,
aci: 0,
pattern_length: ext1_pat_len,
pattern: ext1_pat,
line_weight_px: lw_ext,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
if !ext2.is_empty() {
wires.push(WireModel {
name: name.clone(),
points: ext2,
color: ext_color,
selected,
aci: 0,
pattern_length: ext2_pat_len,
pattern: ext2_pat,
line_weight_px: lw_ext,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
} else {
wires.push(WireModel {
name: name.clone(),
points: geom.ext_lines,
color: ext_color,
selected,
aci: 0,
pattern_length: ext1_pat_len,
pattern: ext1_pat,
line_weight_px: lw_ext,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
}
wires.push(WireModel {
2026-03-22 14:56:27 +03:00
name: name.clone(),
points: geom.dim_lines,
color: dim_color,
2026-03-22 14:56:27 +03:00
selected,
aci: 0,
pattern_length: dim_pat_len,
pattern: dim_pat,
line_weight_px: lw_dim,
snap_pts,
2026-03-22 14:56:27 +03:00
tangent_geoms: vec![],
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
2026-05-12 10:36:15 +03:00
plinegen: true,
vp_scissor: None,
fill_tris: geom.arrow_fill,
});
2026-03-22 14:56:27 +03:00
// DIMTFILL: 0=none, 1=drawing background (transparent → skip), 2=DIMTFILLCLR.
if let Some(s) = style {
if s.dimtfill == 2 {
if let Some(rect) = text_fill_rect(dim, style, dim_txt, world_offset) {
let fill_color = if selected {
WireModel::SELECTED
} else {
let c = AcadColor::from_index(s.dimtfillclr);
aci_to_rgba(&c)
};
wires.push(WireModel {
name: name.clone(),
points: vec![],
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: rect,
});
}
}
}
if let Some(text) = dimension_text_entity(dim, dim_txt, style, document) {
// Tolerance Text rendered separately so DIMTFAC scales its height
// and DIMTOLJ aligns it vertically against the primary text.
let tol_entity = dimension_tolerance_entity(dim, style, &text, dim_txt);
2026-03-22 14:56:27 +03:00
let mut wire = tessellate(
document,
handle,
&EntityType::Text(text),
selected,
text_color,
2026-03-22 14:56:27 +03:00
0.0,
[0.0; 8],
line_weight_px,
world_offset,
// dim text already baked dim_scale into its height — don't
// let the inner tessellate re-apply anno_scale.
1.0,
2026-03-22 14:56:27 +03:00
);
wire.name = name.clone();
2026-03-22 14:56:27 +03:00
wires.push(wire);
if let Some(tol_text) = tol_entity {
let mut tol_wire = tessellate(
document,
handle,
&EntityType::Text(tol_text),
selected,
text_color,
0.0,
[0.0; 8],
line_weight_px,
world_offset,
1.0,
);
tol_wire.name = name;
wires.push(tol_wire);
}
2026-03-22 14:56:27 +03:00
}
wires
}
fn resolve_dim_color(idx: i16, fallback: [f32; 4]) -> [f32; 4] {
// DIMCLR* convention: 0 = BYBLOCK, 256 = BYLAYER → entity colour wins.
if idx == 0 || idx == 256 {
return fallback;
}
aci_to_rgba(&AcadColor::from_index(idx))
}
/// Resolve a DIMLWD / DIMLWE table value (the i16 lineweight code) into a
/// pixel width. -1 (ByLayer) / -2 (ByBlock) / -3 (Default) fall through to
/// the entity's already-resolved width.
fn resolve_dim_lineweight_px(code: i16, fallback_px: f32) -> f32 {
const MM_TO_PX: f32 = 96.0 / 25.4;
if code < 0 {
return fallback_px;
}
// i16 value 0..=211 represents 1/100 mm.
let mm = code as f32 / 100.0;
(mm * MM_TO_PX).max(1.0)
}
/// Look up a linetype in the document's line_types table by handle and
/// resolve it to a (pattern_length, pattern) pair compatible with WireModel.
fn resolve_pattern_by_handle(
doc: &CadDocument,
handle: acadrust::types::Handle,
scale: f32,
) -> (f32, [f32; 8]) {
if handle.is_null() {
return (0.0, [0.0; 8]);
}
let name = doc
.line_types
.iter()
.find(|lt| lt.handle == handle)
.map(|lt| lt.name.clone());
match name {
Some(n) => crate::scene::render::resolve_pattern(&doc.line_types, &n, scale),
None => (0.0, [0.0; 8]),
}
}
/// Split the combined ext-lines point list (NaN-separated segment pairs)
/// into "first" / "second" halves. `append_linear_dimension` writes ext1
/// before ext2, so the first segment is ext1 and the second is ext2.
fn split_ext_lines(points: &[[f32; 3]]) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
let mut groups: Vec<Vec<[f32; 3]>> = Vec::new();
let mut current: Vec<[f32; 3]> = Vec::new();
for &p in points {
if p[0].is_nan() {
if !current.is_empty() {
groups.push(std::mem::take(&mut current));
}
} else {
current.push(p);
}
}
if !current.is_empty() {
groups.push(current);
}
let mut iter = groups.into_iter();
let first = iter.next().unwrap_or_default();
let rest: Vec<[f32; 3]> = iter.flatten().collect();
(first, rest)
}
/// Endpoints for the DIMTMOVE=1 leader: (anchor on the dim line, saved
/// text_middle_point). Returns None when the dim has no saved text position
/// or has no well-defined dim-line midpoint (radius/diameter handled by
/// their own leg).
fn dimtmove_leader_endpoints(
dim: &Dimension,
world_offset: [f64; 3],
) -> Option<(Vec3, Vec3)> {
let base = dim.base();
let txt = base.text_middle_point;
if txt.x * txt.x + txt.y * txt.y + txt.z * txt.z <= 1e-16 {
return None;
}
let lv = |v| vec3_local(v, world_offset);
let anchor = match dim {
Dimension::Linear(d) => {
let perp = Vec3::new(-(d.rotation.sin() as f32), d.rotation.cos() as f32, 0.0);
let first = lv(d.first_point);
let second = lv(d.second_point);
let def = lv(d.definition_point);
let off1 = def.dot(perp) - first.dot(perp);
let off2 = def.dot(perp) - second.dot(perp);
(first + perp * off1 + second + perp * off2) * 0.5
}
Dimension::Aligned(d) => {
let first = lv(d.first_point);
let second = lv(d.second_point);
let axis = normalized_or(second - first, Vec3::X);
let perp = Vec3::new(-axis.y, axis.x, 0.0);
let def = lv(d.definition_point);
let off1 = def.dot(perp) - first.dot(perp);
let off2 = def.dot(perp) - second.dot(perp);
(first + perp * off1 + second + perp * off2) * 0.5
}
Dimension::Radius(d) => lv(d.definition_point),
Dimension::Diameter(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5,
_ => return None,
};
Some((anchor, lv(txt)))
}
/// Build a rectangle of filled triangles sitting under the dim text, used
/// when DIMTFILL = 2 (explicit fill colour). The rect width is estimated
/// from the formatted text length × character-cell width; an absolutely
/// correct box would need full text metrics from the font cache.
fn text_fill_rect(
dim: &Dimension,
style: Option<&DimStyle>,
text_height: f64,
world_offset: [f64; 3],
) -> Option<Vec<[f32; 3]>> {
let value = dimension_text_value(dim, style)?;
if value.is_empty() {
return None;
}
let pos = dimension_text_pos_f64(dim, style, text_height);
let dimgap = style.map(|s| s.dimgap).unwrap_or(0.0).max(0.0);
// ~0.6 × text_height per character; matches average glyph aspect for
// the bundled stick fonts. Inflate by 1 DIMGAP on each side.
let approx_w =
value.chars().count() as f64 * text_height * 0.6 + dimgap * 2.0;
let approx_h = text_height + dimgap * 2.0;
let rot = if dim.base().text_rotation.abs() > 1e-9 {
dim.base().text_rotation
} else {
dimension_text_natural_rotation(dim)
};
let (sr, cr) = rot.sin_cos();
let hx = approx_w * 0.5;
let hy = approx_h * 0.5;
let [ox, oy, oz] = world_offset;
let cx = (pos.x - ox) as f32;
let cy = (pos.y - oy) as f32;
let cz = (pos.z - oz) as f32;
let corner = |dx: f64, dy: f64| -> [f32; 3] {
let lx = dx * cr - dy * sr;
let ly = dx * sr + dy * cr;
[cx + lx as f32, cy + ly as f32, cz]
};
let p1 = corner(-hx, -hy);
let p2 = corner(hx, -hy);
let p3 = corner(hx, hy);
let p4 = corner(-hx, hy);
Some(vec![p1, p2, p3, p1, p3, p4])
}
#[derive(Clone, Copy)]
enum ArrowKind {
None,
Triangle { size: f32, filled: bool, size_mul: f32 },
Tick { size: f32 },
Open { size: f32, half_angle: f32 },
Dot { size: f32, filled: bool },
Origin { size: f32 },
Box_ { size: f32, filled: bool },
Datum { size: f32, filled: bool },
}
fn arrow_from_block(
doc: &CadDocument,
handle: acadrust::types::Handle,
dimasz: f32,
) -> ArrowKind {
let name = if handle.is_null() {
None
} else {
doc.block_records
.iter()
.find(|b| b.handle == handle)
.map(|b| b.name.as_str())
};
arrow_from_block_name(name, dimasz)
}
fn arrow_from_block_name(name: Option<&str>, dimasz: f32) -> ArrowKind {
// AutoCAD's standard arrow blocks are prefixed with "_" (e.g. "_OPEN").
// Strip the prefix, upper-case, and switch on canonical names. Unknown
// / missing names default to ClosedFilled.
let n = name
.map(|s| s.trim().trim_start_matches('_').to_ascii_uppercase())
.unwrap_or_default();
match n.as_str() {
"" | "CLOSEDFILLED" => ArrowKind::Triangle {
size: dimasz,
filled: true,
size_mul: 1.0,
},
"CLOSED" | "CLOSEDBLANK" => ArrowKind::Triangle {
size: dimasz,
filled: false,
size_mul: 1.0,
},
"SMALL" => ArrowKind::Triangle {
size: dimasz,
filled: true,
size_mul: 0.5,
},
"OPEN" => ArrowKind::Open {
size: dimasz,
half_angle: 9.5_f32.to_radians(),
},
"OPEN30" => ArrowKind::Open {
size: dimasz,
half_angle: 15.0_f32.to_radians(),
},
"OPEN90" => ArrowKind::Open {
size: dimasz,
half_angle: 45.0_f32.to_radians(),
},
"DOT" => ArrowKind::Dot {
size: dimasz,
filled: true,
},
"DOTSMALL" => ArrowKind::Dot {
size: dimasz * 0.5,
filled: true,
},
"DOTBLANK" => ArrowKind::Dot {
size: dimasz,
filled: false,
},
"DOTSMALLBLANK" => ArrowKind::Dot {
size: dimasz * 0.5,
filled: false,
},
"ORIGIN" | "ORIGIN2" | "ORIGININDICATOR" | "ORIGININDICATOR2" => {
ArrowKind::Origin { size: dimasz }
}
"OBLIQUE" | "ARCHTICK" => ArrowKind::Tick { size: dimasz },
"BOXFILLED" => ArrowKind::Box_ {
size: dimasz,
filled: true,
},
"BOXBLANK" | "BOX" => ArrowKind::Box_ {
size: dimasz,
filled: false,
},
"DATUMFILLED" | "DATUMTRIANGLEFILLED" => ArrowKind::Datum {
size: dimasz,
filled: true,
},
"DATUMBLANK" | "DATUMTRIANGLE" => ArrowKind::Datum {
size: dimasz,
filled: false,
},
"NONE" => ArrowKind::None,
// INTEGRAL and other complex glyphs aren't reproduced here; fall through.
_ => ArrowKind::Triangle {
size: dimasz,
filled: true,
size_mul: 1.0,
},
}
}
#[derive(Clone, Copy, Default)]
struct SuppressFlags {
ext1: bool,
ext2: bool,
dim1: bool,
dim2: bool,
}
#[derive(Clone, Copy)]
struct DimLineParams {
dimexo: f32,
dimexe: f32,
dimdle: f32,
dimfxl: f32,
dimfxlon: bool,
dimsoxd: bool,
dimcen: f32,
ticks: bool,
}
struct DimGeom {
ext_lines: Vec<[f32; 3]>,
dim_lines: Vec<[f32; 3]>,
arrow_fill: Vec<[f32; 3]>,
}
impl DimGeom {
fn new() -> Self {
Self {
ext_lines: Vec::new(),
dim_lines: Vec::new(),
arrow_fill: Vec::new(),
}
}
}
fn tessellate_leader_single(
document: &CadDocument,
2026-03-22 14:56:27 +03:00
handle: Handle,
leader: &Leader,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
) -> WireModel {
2026-05-12 10:36:15 +03:00
let color = if selected {
WireModel::SELECTED
} else {
entity_color
};
2026-03-22 14:56:27 +03:00
let name = handle.value().to_string();
let [ox, oy, oz] = world_offset;
2026-05-12 10:36:15 +03:00
let p3 =
|v: &Vector3| -> [f32; 3] { [(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32] };
let nan = [f32::NAN; 3];
2026-03-22 14:56:27 +03:00
let verts = &leader.vertices;
2026-03-22 14:56:27 +03:00
if verts.len() < 2 {
return WireModel {
2026-03-22 14:56:27 +03:00
name,
points: vec![],
color,
selected,
aci: 0,
2026-03-22 14:56:27 +03:00
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![],
};
2026-03-22 14:56:27 +03:00
}
let mut points: Vec<[f32; 3]> = verts.iter().map(|v| p3(v)).collect();
let mut tangents: Vec<TangentGeom> = Vec::new();
let key_vertices: Vec<[f32; 3]> = verts.iter().map(|v| p3(v)).collect();
let mut fill_tris: Vec<[f32; 3]> = Vec::new();
2026-03-22 14:56:27 +03:00
for i in 0..verts.len().saturating_sub(1) {
2026-05-12 10:36:15 +03:00
tangents.push(TangentGeom::Line {
p1: p3(&verts[i]),
p2: p3(&verts[i + 1]),
});
}
2026-03-22 14:56:27 +03:00
if leader.arrow_enabled {
// Resolve the active dim style → DIMLDRBLK to pick the arrow shape.
// DIMASZ × DIMSCALE drives the size when available; otherwise fall
// back to the legacy text-height heuristic.
let style = document.dim_styles.iter().find(|s| {
s.name.eq_ignore_ascii_case(&leader.dimension_style)
|| (leader.dimension_style.trim().is_empty()
&& s.name.eq_ignore_ascii_case("Standard"))
});
let dim_scale = style
.map(|s| {
if s.dimscale > 1e-6 {
s.dimscale
} else {
anno_scale as f64
}
})
.unwrap_or(anno_scale as f64);
let arrow_size = match style {
Some(s) => (s.dimasz * dim_scale) as f32,
None => (leader.text_height as f32).max(1.0) * 0.8 * anno_scale,
};
let arrow = match style {
Some(s) => arrow_from_block(document, s.dimldrblk, arrow_size.max(0.001)),
None => ArrowKind::Triangle {
size: arrow_size.max(0.001),
filled: true,
size_mul: 1.0,
},
};
let tip = &verts[0];
let next = &verts[1];
2026-03-22 14:56:27 +03:00
let dx = (next.x - tip.x) as f32;
let dy = (next.y - tip.y) as f32;
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let dir = Vec3::new(dx / len, dy / len, 0.0);
let tip_f = p3(tip);
let tip_v = Vec3::new(tip_f[0], tip_f[1], tip_f[2]);
// Reuse the dim arrow emitter so the leader shape matches the
// DIMSTYLE in use (Closed Filled by default, Dot, Tick, …).
let mut arrow_pts: Vec<[f32; 3]> = Vec::new();
let mut arrow_geom = DimGeom::new();
append_arrow(&mut arrow_geom, tip_v, dir, &arrow);
if !arrow_geom.dim_lines.is_empty() {
arrow_pts.push(nan);
arrow_pts.extend(arrow_geom.dim_lines);
}
points.extend(arrow_pts);
fill_tris.extend(arrow_geom.arrow_fill);
2026-03-22 14:56:27 +03:00
}
if leader.hookline_enabled {
let last = verts.last().unwrap();
let prev = &verts[verts.len() - 2];
2026-05-12 10:36:15 +03:00
let sign = if (last.x - prev.x) >= 0.0 {
1.0_f32
} else {
-1.0_f32
};
let land_len = leader.text_height as f32 * 1.5 * anno_scale;
let last_f = p3(last);
2026-03-22 14:56:27 +03:00
points.push(nan);
points.push(last_f);
points.push([last_f[0] + sign * land_len, last_f[1], last_f[2]]);
2026-03-22 14:56:27 +03:00
}
WireModel {
2026-03-22 14:56:27 +03:00
name,
points,
color,
selected,
aci: 0,
2026-03-22 14:56:27 +03:00
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: tangents,
2026-03-22 14:56:27 +03:00
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris,
}
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
/// Convert an acadrust `Color` to RGBA, falling back to `inherited` for
/// `ByLayer` / `ByBlock` (assumes those are already resolved upstream).
fn color_or_inherit(c: &AcadColor, inherited: [f32; 4]) -> [f32; 4] {
match c.rgb() {
Some((r, g, b)) => [
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
inherited[3],
],
None => inherited,
}
}
pub fn tessellate_multileader(
2026-03-22 14:56:27 +03:00
document: &CadDocument,
handle: Handle,
ml: &MultiLeader,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
world_per_pixel: Option<f32>,
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
) -> Vec<WireModel> {
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
// line_color falls back to the entity colour when the MultiLeader's own
// colour is ByBlock/ByLayer; otherwise the leader uses its stored hue.
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
let line_color = if selected {
2026-05-12 10:36:15 +03:00
WireModel::SELECTED
} else {
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
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,
2026-05-12 10:36:15 +03:00
};
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
// 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::render::resolve_pattern(&document.line_types, &n, lt_scale),
None => (0.0, [0.0; 8]),
}
}
_ => (0.0, [0.0; 8]),
};
2026-03-22 14:56:27 +03:00
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]
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
// ── 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;
2026-03-22 14:56:27 +03:00
let draw_arrow = arrow_size > 0.0;
let invisible = ml.path_type == MultiLeaderPathType::Invisible;
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
// 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,
},
};
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
// ── Leader / arrow / dogleg points ───────────────────────────────────────
2026-03-22 14:56:27 +03:00
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();
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
let mut arrow_fill: Vec<[f32; 3]> = Vec::new();
let mut first = true;
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((Vec3::from(cp_f), SnapHint::Node));
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);
}
first = false;
2026-03-22 14:56:27 +03:00
let mut ctrl: Vec<[f32; 3]> = line.points.iter().map(|p| p3(p)).collect();
let last_f = *ctrl.last().unwrap_or(&cp_f);
2026-05-12 10:36:15 +03:00
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));
2026-03-22 14:56:27 +03:00
}
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]);
2026-05-12 10:36:15 +03:00
}
} else {
2026-05-12 10:36:15 +03:00
for &c in &ctrl {
points.push(c);
}
}
for i in 0..ctrl.len().saturating_sub(1) {
2026-05-12 10:36:15 +03:00
tangents.push(TangentGeom::Line {
p1: ctrl[i],
p2: ctrl[i + 1],
});
2026-03-22 14:56:27 +03:00
}
}
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) as f32;
let dy = (next.y - tip.y) as f32;
let dl = (dx * dx + dy * dy).sqrt().max(1e-9);
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
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);
2026-03-22 14:56:27 +03:00
}
}
if ml.enable_landing && ml.enable_dogleg && ml.dogleg_length > 0.0 {
let dir = &root.direction;
let dl = (dir.x * dir.x + dir.y * dir.y).sqrt().max(1e-9);
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
let d = ml.dogleg_length * effective_scale as f64;
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
let landing_end = [
2026-05-12 10:36:15 +03:00
(cp.x + dir.x / dl * d - ox) as f32,
(cp.y + dir.y / dl * d - oy) as f32,
cp_f[2],
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
];
points.push(nan);
points.push(cp_f);
points.push(landing_end);
// extend_leader_to_text: continue past the landing to the text
// insertion point along the same direction.
if ml.extend_leader_to_text
&& 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]]);
}
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
// 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(),
2026-03-22 14:56:27 +03:00
points,
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
color: line_color,
2026-03-22 14:56:27 +03:00
selected,
aci: 0,
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
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,
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
fill_tris: arrow_fill,
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(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
// ── 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::home::modify::explode::normalize_insert_entity(sub);
let mut wire = tessellate(
document,
handle,
&normalized,
selected,
block_color,
leader_pat_len,
leader_pat,
leader_lw_px,
world_offset,
1.0,
);
wire.name = name.clone();
wires.push(wire);
}
// 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.
}
}
}
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
// ── 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
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
} else {
ml.text_height as f32 * effective_scale
};
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
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;
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
// 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.
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
let td = ctx.text_direction;
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
let mut rot = if td.x.abs() > 1e-9 || td.y.abs() > 1e-9 {
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
(td.y as f32).atan2(td.x as f32)
} else {
ctx.text_rotation as f32
};
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
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;
}
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
// 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 = crate::entities::text_support::resolve_text_style(&style_name, document);
let font_name = style.font_name;
let font = crate::scene::cxf::get_font(&font_name);
// MultiLeader text takes its width factor / oblique from the style
// (no entity-level override exists for MText-content multileaders).
// is_backward flips the sign on width_factor; is_upside_down adds π
// to the text rotation — same convention as Text / MText.
let base_wf = style.width_factor.max(0.01);
let width_factor = if style.is_backward { -base_wf } else { base_wf };
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
let oblique = style.oblique_angle;
if style.is_upside_down {
rot += std::f32::consts::PI;
}
let (cos_r, sin_r) = (rot.cos(), rot.sin());
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
// Strip MText format codes (e.g. `{\fArial Black|b0|i0|c162|p34;...}`),
// then split on \P / \n / \N and optionally word-wrap to text_width.
let plain = crate::entities::text_support::strip_mtext_codes(&ctx.text_string);
let explicit_lines = crate::entities::text_support::split_mtext_lines(&plain);
let lines: Vec<String> = if ctx.text_width > 0.0 {
// Measurement uses positive width magnitude; the sign on
// width_factor is a render-time mirror flag.
let scale = height / 9.0 * width_factor.abs();
// ctx.text_width lives in the same WCS frame as ctx.text_height;
// it does not need a second multiplication by effective_scale.
let max_w = ctx.text_width as f32;
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
explicit_lines
.iter()
.flat_map(|line| {
crate::entities::text_support::word_wrap(line, max_w, scale, font)
})
.collect()
} else {
explicit_lines
};
let ls_factor = if ctx.line_spacing_factor > 0.0 {
ctx.line_spacing_factor as f32
} else {
1.0
};
let line_h = height * ls_factor * (5.0 / 3.0) * font.line_spacing;
let n_lines = lines.len().max(1) as f32;
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
// ml.text_alignment overrides the per-context attachment_point when
// not Left (which is the default zero). Otherwise we honour the
// context's stored attachment_point (Left/Center/Right).
use acadrust::entities::multileader::{TextAlignmentType, TextAttachmentDirectionType};
let h_anchor = match (ml.text_alignment, ctx.text_attachment_point) {
(TextAlignmentType::Center, _) => 0.5_f32,
(TextAlignmentType::Right, _) => 1.0,
(TextAlignmentType::Left, TextAttachmentPointType::Center) => 0.5,
(TextAlignmentType::Left, TextAttachmentPointType::Right) => 1.0,
_ => 0.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,
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(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
let v_offset = v_offset_for_attachment(vertical_attach, n_lines, height, line_h);
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
// line_widths use positive magnitude for measurement; tessellate_text_ex
// gets the signed width_factor below to honour the backward flag.
let scale = height / 9.0 * width_factor.abs();
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
let line_widths: Vec<f32> = lines
.iter()
.map(|line| crate::entities::text_support::measure_mtext_chars(line, scale, font))
.collect();
let max_line_w = line_widths.iter().cloned().fold(0.0_f32, f32::max);
// 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,
};
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
// 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],
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(lines.len() * 6);
for (i, _) in lines.iter().enumerate() {
let li = i as f32;
let line_y_bottom = -li * line_h + v_offset;
let line_y_top = line_y_bottom + height;
let line_w = line_widths[i];
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![],
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 {
// Build per-line stroke points in WCS.
let mut text_points: Vec<[f32; 3]> = Vec::new();
for (i, line) in lines.iter().enumerate() {
let li = i as f32;
let line_y_local = -li * line_h + v_offset;
let line_w = line_widths[i];
let h_shift_local = -line_w * h_anchor;
let wcs_dx = h_shift_local * cos_r - line_y_local * sin_r;
let wcs_dy = h_shift_local * sin_r + line_y_local * cos_r;
// Origin already in offset-relative space — tessellator will rotate
// glyph offsets around it and produce points directly in render space.
let origin = [local_ins_x + wcs_dx, local_ins_y + wcs_dy];
let strokes = crate::scene::cxf::tessellate_text_ex(
origin,
height,
rot,
width_factor,
oblique,
&font_name,
line,
);
for stroke in &strokes {
if stroke.len() < 2 {
continue;
}
text_points.push(nan);
for &[x, y] in stroke {
text_points.push([x, y, z]);
}
}
}
if !text_points.is_empty() {
wires.push(WireModel {
name: name.clone(),
points: text_points,
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![],
});
}
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
}
// 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![],
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,
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![],
});
}
}
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
wires
2026-03-22 14:56:27 +03:00
}
// ── Entity Z helper ───────────────────────────────────────────────────────
/// Extract the Z elevation from a text/mtext entity.
fn entity_z(entity: &EntityType) -> f32 {
match entity {
EntityType::Text(t) => t.insertion_point.z as f32,
EntityType::MText(t) => t.insertion_point.z as f32,
_ => 0.0,
}
}
// ── Legacy geometry (Viewport, Hatch outline, unrecognised) ───────────────
type Geometry = (
Vec<[f32; 3]>,
Vec<(Vec3, SnapHint)>,
Vec<TangentGeom>,
Vec<[f32; 3]>,
);
fn legacy_geometry(entity: &EntityType, world_offset: [f64; 3]) -> Geometry {
let [ox, oy, oz] = world_offset;
2026-03-22 14:56:27 +03:00
match entity {
EntityType::Viewport(vp) => {
let cx = (vp.center.x - ox) as f32;
let cy = (vp.center.y - oy) as f32;
let cz = (vp.center.z - oz) as f32;
2026-03-22 14:56:27 +03:00
let hw = (vp.width / 2.0) as f32;
let hh = (vp.height / 2.0) as f32;
let pts = vec![
[cx - hw, cy - hh, cz],
[cx + hw, cy - hh, cz],
[cx + hw, cy + hh, cz],
[cx - hw, cy + hh, cz],
[cx - hw, cy - hh, cz],
];
(pts, vec![], vec![], vec![])
}
EntityType::Insert(ins) => {
let ip = Vec3::new(
(ins.insert_point.x - ox) as f32,
(ins.insert_point.y - oy) as f32,
(ins.insert_point.z - oz) as f32,
);
let s = 0.1_f32;
let pts = vec![
[ip.x - s, ip.y, ip.z],
[ip.x + s, ip.y, ip.z],
[ip.x, ip.y - s, ip.z],
[ip.x, ip.y + s, ip.z],
];
(pts, vec![(ip, SnapHint::Insertion)], vec![], vec![])
}
2026-03-22 14:56:27 +03:00
EntityType::Hatch(h) => {
let normal = (h.normal.x, h.normal.y, h.normal.z);
// Convert a 2D OCS hatch boundary point to WCS, then subtract world_offset.
let to_wcs = |x: f64, y: f64| -> [f32; 3] {
2026-05-12 10:36:15 +03:00
let (wx, wy, wz) =
crate::scene::transform::ocs_point_to_wcs((x, y, h.elevation), normal);
[(wx - ox) as f32, (wy - oy) as f32, (wz - oz) as f32]
};
2026-03-22 14:56:27 +03:00
let mut pts: Vec<[f32; 3]> = Vec::new();
let mut key_verts: Vec<[f32; 3]> = Vec::new();
let mut snap_pts: Vec<(Vec3, SnapHint)> = Vec::new();
for path in &h.paths {
2026-03-22 14:56:27 +03:00
for edge in &path.edges {
match edge {
acadrust::entities::BoundaryEdge::Polyline(poly) => {
// Hatch-boundary polyline vertices encode bulge in
// `Vector3.z`; straight segments emit just the
// start vertex, bulged segments tessellate the arc
// between v0 → v1.
let start_idx = pts.len();
let verts = &poly.vertices;
let count = verts.len();
let seg_count = if poly.is_closed {
count
} else {
count.saturating_sub(1)
};
if count > 0 && pts.len() != start_idx {
pts.push([f32::NAN; 3]);
}
for i in 0..seg_count {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % count];
let bulge = v0.z;
if bulge.abs() < 1e-9 {
let p = to_wcs(v0.x, v0.y);
pts.push(p);
key_verts.push(p);
continue;
}
let theta = 4.0 * bulge.atan();
let dx = v1.x - v0.x;
let dy = v1.y - v0.y;
let d = (dx * dx + dy * dy).sqrt();
if d < 1e-12 {
let p = to_wcs(v0.x, v0.y);
pts.push(p);
key_verts.push(p);
continue;
}
let r = (d * 0.5) / (theta * 0.5).sin().abs();
let mx = (v0.x + v1.x) * 0.5;
let my = (v0.y + v1.y) * 0.5;
let px = -dy / d;
let py = dx / d;
let sign = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 };
let center_offset = r * (theta * 0.5).cos();
let cx = mx + sign * px * center_offset;
let cy = my + sign * py * center_offset;
let a0 = (v0.y - cy).atan2(v0.x - cx);
let a1 = (v1.y - cy).atan2(v1.x - cx);
let mut sweep = a1 - a0;
const TAU: f64 = std::f64::consts::TAU;
if bulge > 0.0 {
if sweep <= 0.0 { sweep += TAU; }
} else if sweep >= 0.0 { sweep -= TAU; }
if sweep.abs() < 1e-9 {
sweep = if bulge > 0.0 { TAU } else { -TAU };
}
let segs = arc_segments(r, sweep.abs(), wire_chord_tol(r));
for j in 0..segs {
let a = a0 + sweep * (j as f64 / segs as f64);
let p = to_wcs(cx + r * a.cos(), cy + r * a.sin());
pts.push(p);
if j == 0 {
key_verts.push(p);
}
}
}
// Close the loop visually for closed polylines by
// returning to the first emitted point.
if poly.is_closed {
if let Some(first) = pts.get(start_idx).cloned() {
if first[0].is_finite() {
pts.push(first);
}
}
} else if let Some(last) = verts.last() {
let p = to_wcs(last.x, last.y);
pts.push(p);
key_verts.push(p);
}
2026-03-22 14:56:27 +03:00
}
acadrust::entities::BoundaryEdge::Line(ln) => {
let p0 = to_wcs(ln.start.x, ln.start.y);
let p1 = to_wcs(ln.end.x, ln.end.y);
2026-05-12 10:36:15 +03:00
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
pts.push(p0);
pts.push(p1);
key_verts.push(p0);
key_verts.push(p1);
2026-03-22 14:56:27 +03:00
}
acadrust::entities::BoundaryEdge::CircularArc(arc) => {
let (sa, span) =
arc_signed_span(arc.start_angle, arc.end_angle, arc.counter_clockwise);
let segs = arc_segments(arc.radius, span.abs(), wire_chord_tol(arc.radius));
2026-05-12 10:36:15 +03:00
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
for i in 0..=segs {
2026-05-09 23:56:04 +03:00
let t = sa + span * (i as f64 / segs as f64);
let p = to_wcs(
arc.center.x + arc.radius * t.cos(),
arc.center.y + arc.radius * t.sin(),
);
pts.push(p);
2026-05-12 10:36:15 +03:00
if i == 0 || i == segs {
key_verts.push(p);
}
}
2026-05-12 10:36:15 +03:00
snap_pts.push((
Vec3::from(to_wcs(arc.center.x, arc.center.y)),
SnapHint::Center,
));
}
acadrust::entities::BoundaryEdge::EllipticArc(ell) => {
let r_maj = (ell.major_axis_endpoint.x * ell.major_axis_endpoint.x
+ ell.major_axis_endpoint.y * ell.major_axis_endpoint.y)
.sqrt();
let r_min = r_maj * ell.minor_axis_ratio;
let rot = ell
.major_axis_endpoint
.y
.atan2(ell.major_axis_endpoint.x);
let (sa, span) =
arc_signed_span(ell.start_angle, ell.end_angle, ell.counter_clockwise);
let segs = arc_segments(r_maj, span.abs(), wire_chord_tol(r_maj));
2026-05-12 10:36:15 +03:00
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
let (cr, sr) = (rot.cos(), rot.sin());
for i in 0..=segs {
let t = sa + span * (i as f64 / segs as f64);
let lx = r_maj * t.cos();
let ly = r_min * t.sin();
let p = to_wcs(
ell.center.x + lx * cr - ly * sr,
ell.center.y + lx * sr + ly * cr,
);
pts.push(p);
2026-05-12 10:36:15 +03:00
if i == 0 || i == segs {
key_verts.push(p);
}
}
2026-05-12 10:36:15 +03:00
snap_pts.push((
Vec3::from(to_wcs(ell.center.x, ell.center.y)),
SnapHint::Center,
));
}
_ => {}
2026-03-22 14:56:27 +03:00
}
}
}
if pts.is_empty() {
pts = vec![[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]];
}
(pts, snap_pts, vec![], key_verts)
2026-03-22 14:56:27 +03:00
}
EntityType::Ole2Frame(ole) => {
// OLE objects carry a bounding rectangle in model space.
// Render a simple X-through-rectangle placeholder.
let x0 = (ole.upper_left_corner.x - ox) as f32;
let y0 = (ole.lower_right_corner.y - oy) as f32;
let x1 = (ole.lower_right_corner.x - ox) as f32;
let y1 = (ole.upper_left_corner.y - oy) as f32;
2026-05-12 10:36:15 +03:00
let z = (ole.upper_left_corner.z - oz) as f32;
if (x1 - x0).abs() < 1e-6 && (y1 - y0).abs() < 1e-6 {
// Degenerate / unknown size — show a small cross.
let s = 0.5_f32;
return (vec![[-s, 0.0, 0.0], [s, 0.0, 0.0]], vec![], vec![], vec![]);
}
let pts = vec![
// Outer rectangle
2026-05-12 10:36:15 +03:00
[x0, y0, z],
[x1, y0, z],
[x1, y0, z],
[x1, y1, z],
[x1, y1, z],
[x0, y1, z],
[x0, y1, z],
[x0, y0, z],
// Diagonal X
2026-05-12 10:36:15 +03:00
[x0, y0, z],
[x1, y1, z],
[f32::NAN, f32::NAN, f32::NAN],
2026-05-12 10:36:15 +03:00
[x1, y0, z],
[x0, y1, z],
];
(pts, vec![], vec![], vec![[x0, y0, z], [x1, y1, z]])
}
2026-03-22 14:56:27 +03:00
_ => {
let s = 0.5_f32;
(vec![[-s, 0.0, 0.0], [s, 0.0, 0.0]], vec![], vec![], vec![])
}
}
}
/// Extract pre-computed edge-wire points from Solid3D / Region / Body entities.
///
/// AutoCAD stores explicit wire geometry (from SOLVIEW / 3DPLOT) alongside the
/// ACIS data. We use this as a visible fallback when the SAT tessellator
/// produces no mesh (e.g. binary SAB data or unsupported geometry).
fn solid_wire_fallback(entity: &EntityType, world_offset: [f64; 3]) -> Vec<[f32; 3]> {
let [ox, oy, oz] = world_offset;
let wires: &[acadrust::entities::Wire] = match entity {
EntityType::Solid3D(s) => &s.wires,
2026-05-12 10:36:15 +03:00
EntityType::Region(r) => &r.wires,
EntityType::Body(b) => &b.wires,
_ => return vec![],
};
if wires.is_empty() {
return vec![];
}
let mut pts: Vec<[f32; 3]> = Vec::new();
for wire in wires {
if wire.points.len() < 2 {
continue;
}
for v in &wire.points {
pts.push([(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32]);
}
// NaN sentinel separates distinct wire segments.
pts.push([f32::NAN, f32::NAN, f32::NAN]);
}
pts
}
fn dimension_geometry(
dim: &Dimension,
arrow1: &ArrowKind,
arrow2: &ArrowKind,
params: DimLineParams,
suppress: SuppressFlags,
world_offset: [f64; 3],
) -> DimGeom {
let lv = |v| vec3_local(v, world_offset);
let mut g = DimGeom::new();
2026-03-22 14:56:27 +03:00
match dim {
Dimension::Aligned(d) => {
let first = lv(d.first_point);
let second = lv(d.second_point);
let def = lv(d.definition_point);
2026-03-22 14:56:27 +03:00
let axis = normalized_or(second - first, Vec3::X);
2026-05-12 10:36:15 +03:00
append_linear_dimension(
&mut g,
first,
second,
def,
axis,
arrow1,
arrow2,
params,
suppress,
d.ext_line_rotation as f32,
2026-05-12 10:36:15 +03:00
);
2026-03-22 14:56:27 +03:00
}
Dimension::Linear(d) => {
let first = lv(d.first_point);
let second = lv(d.second_point);
let def = lv(d.definition_point);
2026-03-22 14:56:27 +03:00
let axis = Vec3::new(d.rotation.cos() as f32, d.rotation.sin() as f32, 0.0);
2026-05-12 10:36:15 +03:00
append_linear_dimension(
&mut g,
2026-05-12 10:36:15 +03:00
first,
second,
def,
normalized_or(axis, Vec3::X),
arrow1,
arrow2,
params,
suppress,
d.ext_line_rotation as f32,
2026-05-12 10:36:15 +03:00
);
2026-03-22 14:56:27 +03:00
}
Dimension::Radius(d) => {
let center = lv(d.angle_vertex);
let point = lv(d.definition_point);
let text = dimension_text_position(dim, world_offset);
add_segment(&mut g.dim_lines, center, point);
// Honour leader_length: extend from the arrow tip past it
// toward the text by that distance along (text - point).
let leader_dir = normalized_or(text - point, Vec3::X);
let leader = if d.leader_length.abs() > 1e-9 {
point + leader_dir * (d.leader_length as f32)
} else {
text
};
add_segment(&mut g.dim_lines, point, leader);
append_arrow(&mut g, point, normalized_or(center - point, Vec3::X), arrow1);
let radius = (point - center).length();
append_center_mark(&mut g, center, params.dimcen, radius);
2026-03-22 14:56:27 +03:00
}
Dimension::Diameter(d) => {
let p1 = lv(d.angle_vertex);
let p2 = lv(d.definition_point);
add_segment(&mut g.dim_lines, p1, p2);
append_arrow(&mut g, p1, normalized_or(p2 - p1, Vec3::X), arrow1);
append_arrow(&mut g, p2, normalized_or(p1 - p2, Vec3::X), arrow2);
// DIMETER leader: continue past p2 toward the text.
if d.leader_length.abs() > 1e-9 {
let text = dimension_text_position(dim, world_offset);
let leader_dir = normalized_or(text - p2, p2 - p1);
add_segment(
&mut g.dim_lines,
p2,
p2 + leader_dir * (d.leader_length as f32),
);
}
let radius = (p2 - p1).length() * 0.5;
append_center_mark(&mut g, (p1 + p2) * 0.5, params.dimcen, radius);
2026-03-22 14:56:27 +03:00
}
Dimension::Angular2Ln(d) => {
append_angular_dimension(
&mut g,
lv(d.angle_vertex),
lv(d.first_point),
lv(d.second_point),
lv(d.dimension_arc),
arrow1,
arrow2,
2026-03-22 14:56:27 +03:00
);
}
Dimension::Angular3Pt(d) => {
append_angular_dimension(
&mut g,
lv(d.angle_vertex),
lv(d.first_point),
lv(d.second_point),
lv(d.definition_point),
arrow1,
arrow2,
2026-03-22 14:56:27 +03:00
);
}
Dimension::Ordinate(d) => {
add_segment(
&mut g.dim_lines,
lv(d.feature_location),
lv(d.definition_point),
);
add_segment(
&mut g.dim_lines,
lv(d.definition_point),
lv(d.leader_endpoint),
);
2026-03-22 14:56:27 +03:00
}
}
g
2026-03-22 14:56:27 +03:00
}
fn append_linear_dimension(
g: &mut DimGeom,
2026-03-22 14:56:27 +03:00
first: Vec3,
second: Vec3,
def: Vec3,
axis: Vec3,
arrow1: &ArrowKind,
arrow2: &ArrowKind,
params: DimLineParams,
suppress: SuppressFlags,
ext_line_rotation: f32,
2026-03-22 14:56:27 +03:00
) {
let perp = Vec3::new(-axis.y, axis.x, 0.0);
let dim_line_pos = def.dot(perp);
let offset1 = dim_line_pos - first.dot(perp);
let offset2 = dim_line_pos - second.dot(perp);
let d1 = first + perp * offset1;
let d2 = second + perp * offset2;
let sign1 = if offset1 >= 0.0 { 1.0_f32 } else { -1.0 };
let sign2 = if offset2 >= 0.0 { 1.0_f32 } else { -1.0 };
// ext_line_rotation (DIMEDIT "Oblique"): rotate the extension lines by
// this angle relative to perpendicular. The ext line still starts at
// the def point; only the direction differs.
let ext_dir = if ext_line_rotation.abs() > 1e-6 {
let c = ext_line_rotation.cos();
let s = ext_line_rotation.sin();
// Rotate `perp` by ext_line_rotation around Z.
Vec3::new(perp.x * c - perp.y * s, perp.x * s + perp.y * c, 0.0)
} else {
perp
};
// DIMFXLON / DIMFXL: fixed extension-line length from the dim line back
// toward (but not past) the definition point. Otherwise grow from the
// def point with DIMEXO gap, extending DIMEXE past the dim line.
// When oblique, lengths are measured along ext_dir instead of perp.
let (ext1_start, ext1_end, ext2_start, ext2_end) = if params.dimfxlon {
let fxl = params.dimfxl.max(0.0);
let s1 = d1 - ext_dir * (sign1 * fxl);
let e1 = d1 + ext_dir * (sign1 * params.dimexe);
let s2 = d2 - ext_dir * (sign2 * fxl);
let e2 = d2 + ext_dir * (sign2 * params.dimexe);
(s1, e1, s2, e2)
} else {
(
first + ext_dir * (sign1 * params.dimexo),
d1 + ext_dir * (sign1 * params.dimexe),
second + ext_dir * (sign2 * params.dimexo),
d2 + ext_dir * (sign2 * params.dimexe),
)
};
if !suppress.ext1 {
add_segment(&mut g.ext_lines, ext1_start, ext1_end);
}
if !suppress.ext2 {
add_segment(&mut g.ext_lines, ext2_start, ext2_end);
}
// DIMDLE: dim line overshoots the ext line by `dimdle` at each end,
// but only when ticks are in use (DIMTSZ > 0). With arrowheads this
// is ignored, matching AutoCAD.
let dle = if params.ticks { params.dimdle } else { 0.0 };
let dir_d1_to_d2 = normalized_or(d2 - d1, axis);
let d1_out = d1 - dir_d1_to_d2 * dle;
let d2_out = d2 + dir_d1_to_d2 * dle;
// DIMSD1/DIMSD2: when *both* set, omit the dim line entirely. AutoCAD
// splits at text otherwise — without that pivot info, leave as-is.
let _ = params.dimsoxd; // DIMSOXD: only meaningful when text is auto-placed
// outside the ext lines; we honour the saved
// text_middle_point so this is a no-op for files.
if !(suppress.dim1 && suppress.dim2) {
add_segment(&mut g.dim_lines, d1_out, d2_out);
}
append_arrow(g, d1, normalized_or(d2 - d1, axis), arrow1);
append_arrow(g, d2, normalized_or(d1 - d2, -axis), arrow2);
2026-03-22 14:56:27 +03:00
}
/// Draw a center mark for radius/diameter dimensions.
/// DIMCEN > 0 → small "+" of half-length |DIMCEN| at the centre.
/// DIMCEN < 0 → small "+" *plus* four line segments extending from the
/// circle (radius - |DIMCEN|) outward to (radius + |DIMCEN|).
/// DIMCEN = 0 → no mark.
fn append_center_mark(g: &mut DimGeom, center: Vec3, dimcen: f32, radius: f32) {
let mag = dimcen.abs();
if mag <= 1e-6 {
return;
}
// Small "+" at the centre.
let h = mag;
add_segment(
&mut g.dim_lines,
Vec3::new(center.x - h, center.y, center.z),
Vec3::new(center.x + h, center.y, center.z),
);
add_segment(
&mut g.dim_lines,
Vec3::new(center.x, center.y - h, center.z),
Vec3::new(center.x, center.y + h, center.z),
);
if dimcen < 0.0 && radius > mag + 1e-6 {
let inner = (radius - mag).max(0.0);
let outer = radius + mag;
// Four short radial strokes spanning the circle edge.
add_segment(
&mut g.dim_lines,
Vec3::new(center.x + inner, center.y, center.z),
Vec3::new(center.x + outer, center.y, center.z),
);
add_segment(
&mut g.dim_lines,
Vec3::new(center.x - inner, center.y, center.z),
Vec3::new(center.x - outer, center.y, center.z),
);
add_segment(
&mut g.dim_lines,
Vec3::new(center.x, center.y + inner, center.z),
Vec3::new(center.x, center.y + outer, center.z),
);
add_segment(
&mut g.dim_lines,
Vec3::new(center.x, center.y - inner, center.z),
Vec3::new(center.x, center.y - outer, center.z),
);
}
}
2026-03-22 14:56:27 +03:00
fn append_angular_dimension(
g: &mut DimGeom,
2026-03-22 14:56:27 +03:00
vertex: Vec3,
first: Vec3,
second: Vec3,
arc_point: Vec3,
arrow1: &ArrowKind,
arrow2: &ArrowKind,
2026-03-22 14:56:27 +03:00
) {
add_segment(&mut g.ext_lines, vertex, first);
add_segment(&mut g.ext_lines, vertex, second);
2026-03-22 14:56:27 +03:00
let radius = vertex.distance(arc_point);
if radius <= 1e-6 {
return;
}
let start = (first.y - vertex.y).atan2(first.x - vertex.x);
let mut end = (second.y - vertex.y).atan2(second.x - vertex.x);
let mut delta = end - start;
while delta <= 0.0 {
delta += std::f32::consts::TAU;
}
if delta > std::f32::consts::PI {
end -= std::f32::consts::TAU;
delta = end - start;
}
let steps = 32;
let mut arc_pts = Vec::with_capacity((steps + 1) as usize);
for i in 0..=steps {
let t = i as f32 / steps as f32;
let a = start + delta * t;
arc_pts.push(vertex + Vec3::new(a.cos() * radius, a.sin() * radius, 0.0));
}
add_polyline(&mut g.dim_lines, &arc_pts);
2026-03-22 14:56:27 +03:00
if arc_pts.len() >= 2 {
append_arrow(
g,
2026-03-22 14:56:27 +03:00
arc_pts[0],
normalized_or(arc_pts[1] - arc_pts[0], Vec3::X),
arrow1,
2026-03-22 14:56:27 +03:00
);
let n = arc_pts.len();
append_arrow(
g,
2026-03-22 14:56:27 +03:00
arc_pts[n - 1],
normalized_or(arc_pts[n - 2] - arc_pts[n - 1], Vec3::X),
arrow2,
2026-03-22 14:56:27 +03:00
);
}
}
fn push_tri(out: &mut Vec<[f32; 3]>, a: Vec3, b: Vec3, c: Vec3) {
out.push([a.x, a.y, a.z]);
out.push([b.x, b.y, b.z]);
out.push([c.x, c.y, c.z]);
}
fn append_arrow(g: &mut DimGeom, tip: Vec3, dir: Vec3, arrow: &ArrowKind) {
let dir = normalized_or(dir, Vec3::X);
let perp = Vec3::new(-dir.y, dir.x, 0.0);
match *arrow {
ArrowKind::None => {}
ArrowKind::Triangle {
size,
filled,
size_mul,
} => {
let size = size * size_mul;
let base = tip + dir * size;
// ~1:6 length:half-width ratio (≈9.5° half-angle) matches
// AutoCAD's standard ClosedFilled block.
let half_w = size / 6.0;
let left = base + perp * half_w;
let right = base - perp * half_w;
add_segment(&mut g.dim_lines, tip, left);
add_segment(&mut g.dim_lines, left, right);
add_segment(&mut g.dim_lines, right, tip);
if filled {
push_tri(&mut g.arrow_fill, tip, left, right);
}
}
ArrowKind::Tick { size } => {
// 45° oblique tick crossing the dim line at the tip; `size` is
// the half-length (matches AutoCAD's DIMTSZ semantics).
let off = (dir + perp).normalize_or_zero() * size;
add_segment(&mut g.dim_lines, tip - off, tip + off);
}
ArrowKind::Open { size, half_angle } => {
let base = tip + dir * size;
let half_w = size * half_angle.tan();
let left = base + perp * half_w;
let right = base - perp * half_w;
add_segment(&mut g.dim_lines, tip, left);
add_segment(&mut g.dim_lines, tip, right);
}
ArrowKind::Dot { size, filled } => {
let r = size * 0.5;
const N: usize = 16;
let mut ring: Vec<Vec3> = Vec::with_capacity(N + 1);
for i in 0..=N {
let a = i as f32 * std::f32::consts::TAU / N as f32;
ring.push(tip + Vec3::new(a.cos() * r, a.sin() * r, 0.0));
}
add_polyline(&mut g.dim_lines, &ring);
if filled {
for i in 0..N {
push_tri(&mut g.arrow_fill, tip, ring[i], ring[i + 1]);
}
}
}
ArrowKind::Origin { size } => {
// Small filled dot at the tip with a perpendicular tick crossing
// the dim line — matches "_ORIGIN" / "_ORIGIN2" blocks.
let r = size * 0.25;
const N: usize = 12;
let mut ring: Vec<Vec3> = Vec::with_capacity(N + 1);
for i in 0..=N {
let a = i as f32 * std::f32::consts::TAU / N as f32;
ring.push(tip + Vec3::new(a.cos() * r, a.sin() * r, 0.0));
}
add_polyline(&mut g.dim_lines, &ring);
for i in 0..N {
push_tri(&mut g.arrow_fill, tip, ring[i], ring[i + 1]);
}
let half = size * 0.5;
add_segment(&mut g.dim_lines, tip - perp * half, tip + perp * half);
}
ArrowKind::Box_ { size, filled } => {
let half = size * 0.5;
let p1 = tip - dir * half - perp * half;
let p2 = tip + dir * half - perp * half;
let p3 = tip + dir * half + perp * half;
let p4 = tip - dir * half + perp * half;
add_segment(&mut g.dim_lines, p1, p2);
add_segment(&mut g.dim_lines, p2, p3);
add_segment(&mut g.dim_lines, p3, p4);
add_segment(&mut g.dim_lines, p4, p1);
if filled {
push_tri(&mut g.arrow_fill, p1, p2, p3);
push_tri(&mut g.arrow_fill, p1, p3, p4);
}
}
ArrowKind::Datum { size, filled } => {
// Right-pointing triangle with the base perpendicular to the dim
// line at the tip and the apex along +dir.
let half = size * 0.5;
let base_a = tip + perp * half;
let base_b = tip - perp * half;
let apex = tip + dir * size;
add_segment(&mut g.dim_lines, base_a, apex);
add_segment(&mut g.dim_lines, apex, base_b);
add_segment(&mut g.dim_lines, base_b, base_a);
if filled {
push_tri(&mut g.arrow_fill, base_a, apex, base_b);
}
}
}
2026-03-22 14:56:27 +03:00
}
fn add_segment(points: &mut Vec<[f32; 3]>, a: Vec3, b: Vec3) {
if !points.is_empty() {
points.push([f32::NAN, f32::NAN, f32::NAN]);
}
points.push([a.x, a.y, a.z]);
points.push([b.x, b.y, b.z]);
}
fn add_polyline(points: &mut Vec<[f32; 3]>, polyline: &[Vec3]) {
if polyline.len() < 2 {
return;
}
if !points.is_empty() {
points.push([f32::NAN, f32::NAN, f32::NAN]);
}
points.extend(polyline.iter().map(|p| [p.x, p.y, p.z]));
}
fn dimension_snap_pts(dim: &Dimension, world_offset: [f64; 3]) -> Vec<(Vec3, SnapHint)> {
let lv = |v: acadrust::types::Vector3| {
Vec3::new(
(v.x - world_offset[0]) as f32,
(v.y - world_offset[1]) as f32,
(v.z - world_offset[2]) as f32,
)
};
let node = |v: acadrust::types::Vector3| (lv(v), SnapHint::Node);
match dim {
2026-05-12 10:36:15 +03:00
Dimension::Linear(d) => vec![
node(d.first_point),
node(d.second_point),
node(d.definition_point),
],
Dimension::Aligned(d) => vec![
node(d.first_point),
node(d.second_point),
node(d.definition_point),
],
Dimension::Radius(d) => vec![node(d.angle_vertex), node(d.definition_point)],
Dimension::Diameter(d) => vec![node(d.angle_vertex), node(d.definition_point)],
2026-05-12 10:36:15 +03:00
Dimension::Angular2Ln(d) => vec![
node(d.angle_vertex),
node(d.first_point),
node(d.second_point),
node(d.definition_point),
],
Dimension::Angular3Pt(d) => vec![
node(d.angle_vertex),
node(d.first_point),
node(d.second_point),
node(d.definition_point),
],
Dimension::Ordinate(d) => vec![
node(d.definition_point),
node(d.feature_location),
node(d.leader_endpoint),
],
}
}
fn dimension_text_entity(
dim: &Dimension,
text_height: f64,
style: Option<&DimStyle>,
document: &CadDocument,
) -> Option<Text> {
let value = dimension_text_value(dim, style)?;
// Use f64 position directly to avoid f32 round-trip precision loss at large
// coordinates (e.g. Turkish UTM ~4,000,000 m). tessellate() will apply
// world_offset when rendering this synthetic Text entity.
let pos_f64 = dimension_text_pos_f64(dim, style, text_height);
2026-03-22 14:56:27 +03:00
let base = dim.base();
// DIMTIH/DIMTOH: when set, text is forced horizontal (rotation = 0)
// regardless of the dim line angle. Honour explicit base.text_rotation
// first, then horizontal_direction override, then DIMTIH/DIMTOH.
let dimtih = style.map(|s| s.dimtih).unwrap_or(false);
let dimtoh = style.map(|s| s.dimtoh).unwrap_or(false);
let rotation = if base.text_rotation.abs() > 1e-9 {
base.text_rotation
} else if base.horizontal_direction.abs() > 1e-9 {
// horizontal_direction is the in-plane reading direction the writer
// baked in (used for vertical / oblique dims).
base.horizontal_direction
} else if dimtih || dimtoh {
0.0
} else {
dimension_text_natural_rotation(dim)
};
let mut text = Text::with_value(value, pos_f64)
.with_height(text_height)
.with_rotation(rotation);
// Text style resolution priority:
// 1. DIMTXSTY by handle (most reliable; survives rename)
// 2. DIMTXSTY by name
// 3. dim's own style_name (rare fallback)
text.style = style
.and_then(|s| {
if !s.dimtxsty_handle.is_null() {
document
.text_styles
.iter()
.find(|ts| ts.handle == s.dimtxsty_handle)
.map(|ts| ts.name.clone())
} else {
None
}
})
.or_else(|| {
style
.map(|s| s.dimtxsty.clone())
.filter(|n| !n.trim().is_empty())
})
.unwrap_or_else(|| base.style_name.clone());
// Map AttachmentPointType (1..9 grid) to Text horizontal + vertical
// alignments. 1=TopLeft … 9=BottomRight (column-major).
let (ha, va) = attachment_to_text_align(base.attachment_point);
text.horizontal_alignment = ha;
text.vertical_alignment = va;
// line_spacing_factor controls multi-line text spacing in MText. Our
// synthetic Text is single-line so this is a no-op, but pass through
// for completeness.
let _ = base.line_spacing_factor;
// normal would rotate the dim plane out of XY. The local 2D pipeline
// assumes XY, so non-XY normals are read but not applied here.
let _ = base.normal;
2026-03-22 14:56:27 +03:00
text.common = base.common.clone();
Some(text)
}
fn attachment_to_text_align(
attach: acadrust::entities::dimension::AttachmentPointType,
) -> (
acadrust::entities::text::TextHorizontalAlignment,
acadrust::entities::text::TextVerticalAlignment,
) {
use acadrust::entities::dimension::AttachmentPointType as A;
use acadrust::entities::text::{TextHorizontalAlignment as H, TextVerticalAlignment as V};
match attach {
A::TopLeft => (H::Left, V::Top),
A::TopCenter => (H::Center, V::Top),
A::TopRight => (H::Right, V::Top),
A::MiddleLeft => (H::Left, V::Middle),
A::MiddleCenter => (H::Center, V::Middle),
A::MiddleRight => (H::Right, V::Middle),
A::BottomLeft => (H::Left, V::Bottom),
A::BottomCenter => (H::Center, V::Bottom),
A::BottomRight => (H::Right, V::Bottom),
}
}
fn dimension_text_natural_rotation(dim: &Dimension) -> f64 {
let angle = match dim {
Dimension::Linear(d) => d.rotation,
Dimension::Aligned(d) => {
let dx = d.second_point.x - d.first_point.x;
let dy = d.second_point.y - d.first_point.y;
dy.atan2(dx)
}
_ => 0.0,
};
// Clamp to (-π/2, π/2] so text never appears upside-down.
let pi = std::f64::consts::PI;
if angle > pi / 2.0 {
angle - pi
} else if angle <= -pi / 2.0 {
angle + pi
} else {
angle
}
}
fn dimension_text_value(dim: &Dimension, style: Option<&DimStyle>) -> Option<String> {
let (main, tol) = dimension_text_parts(dim, style)?;
// Tolerance is appended inline for callers (e.g. fill rect width) that
// don't render a separate tolerance entity. The visual pipeline that
// does emit a separate tolerance text re-derives the parts itself.
match tol {
Some(t) => Some(format!("{} {}", main, t)),
None => Some(main),
}
}
/// Returns (primary_text, tolerance_suffix). The tolerance is emitted as a
/// separate Text entity so DIMTFAC can scale its height and DIMTOLJ can
/// align it vertically against the primary value.
fn dimension_text_parts(
dim: &Dimension,
style: Option<&DimStyle>,
) -> Option<(String, Option<String>)> {
2026-03-22 14:56:27 +03:00
let base = dim.base();
let is_angular = matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_));
// Auto-generated body (the value AutoCAD would emit if the user did not
// override it). Built first so user_text "<>" substitution can re-use it.
let primary_raw = if is_angular {
format_angular_value(dim.measurement(), style)
} else {
let v = format_linear_value(dim.measurement(), style);
match dim {
Dimension::Radius(_) => format!("R{}", v),
Dimension::Diameter(_) => format!("Ø{}", v),
_ => v,
}
};
// Build tolerance / limits suffix separately so the caller can render
// it as its own Text entity at DIMTFAC × DIMTXT height.
let tolerance_suffix = build_tolerance_suffix(dim.measurement(), style, is_angular);
let primary = apply_dimpost(&primary_raw, style);
// Alternate units appended in brackets when DIMALT is on (linear only).
let primary = if !is_angular {
match alternate_units_text(dim.measurement(), style) {
Some(alt) => format!("{} [{}]", primary, alt),
None => primary,
}
} else {
primary
};
// Explicit user override (mtext-style "user_text") wins, but "<>" inside
// it substitutes the measured value. " " (single space) suppresses text.
2026-03-22 14:56:27 +03:00
if let Some(user_text) = &base.user_text {
if user_text.is_empty() || user_text.trim().is_empty() {
return None;
2026-03-22 14:56:27 +03:00
}
return Some((user_text.replace("<>", &primary), tolerance_suffix));
2026-03-22 14:56:27 +03:00
}
if !base.text.trim().is_empty() {
return Some((base.text.replace("<>", &primary), tolerance_suffix));
}
Some((primary, tolerance_suffix))
}
fn build_tolerance_suffix(
measurement: f64,
style: Option<&DimStyle>,
is_angular: bool,
) -> Option<String> {
let s = style?;
let dimtdec = s.dimtdec.max(0) as usize;
let dimtzin = s.dimtzin;
let fmt = |v: f64| -> String {
let raw = format!("{:.*}", dimtdec, v);
apply_linear_zero_suppression(&raw, dimtzin)
};
if s.dimlim {
let high = measurement + s.dimtp;
let low = measurement - s.dimtm;
return Some(format!("{}/{}", fmt(high), fmt(low)));
2026-03-22 14:56:27 +03:00
}
if s.dimtol {
let unit = if is_angular { "°" } else { "" };
if (s.dimtp - s.dimtm).abs() < 1e-12 && s.dimtp.abs() > 1e-12 {
return Some(format!("±{}{}", fmt(s.dimtp), unit));
}
if s.dimtp.abs() > 1e-12 || s.dimtm.abs() > 1e-12 {
return Some(format!("+{}{} / -{}{}", fmt(s.dimtp), unit, fmt(s.dimtm), unit));
}
}
None
}
/// Build the bracketed alternate-units suffix when DIMALT is enabled.
/// When DIMTOL is also on, the bracketed text includes the tolerance
/// component formatted with DIMALTTD / DIMALTTZ.
fn alternate_units_text(measurement: f64, style: Option<&DimStyle>) -> Option<String> {
let s = style?;
if !s.dimalt {
return None;
}
let mut v = measurement * s.dimaltf;
if s.dimaltrnd > 1e-12 {
v = (v / s.dimaltrnd).round() * s.dimaltrnd;
}
let dec = s.dimaltd.max(0) as usize;
let raw = format_with_unit(v, s.dimaltu, dec, s.dimfrac);
let suppressed = apply_linear_zero_suppression(&raw, s.dimaltz);
let sep_swapped = swap_decimal_sep(&suppressed, s.dimdsep);
// Alt-unit tolerance suffix using DIMALTTD / DIMALTTZ.
let alt_value = if s.dimtol {
let alttdec = s.dimalttd.max(0) as usize;
let alttzin = s.dimalttz;
let fmt = |x: f64| -> String {
let raw = format!("{:.*}", alttdec, x * s.dimaltf);
swap_decimal_sep(&apply_linear_zero_suppression(&raw, alttzin), s.dimdsep)
};
if (s.dimtp - s.dimtm).abs() < 1e-12 && s.dimtp.abs() > 1e-12 {
format!("{}±{}", sep_swapped, fmt(s.dimtp))
} else if s.dimtp.abs() > 1e-12 || s.dimtm.abs() > 1e-12 {
format!("{} +{} / -{}", sep_swapped, fmt(s.dimtp), fmt(s.dimtm))
} else {
sep_swapped
}
} else if s.dimlim {
let alttdec = s.dimalttd.max(0) as usize;
let alttzin = s.dimalttz;
let fmt = |x: f64| -> String {
let raw = format!("{:.*}", alttdec, x * s.dimaltf);
swap_decimal_sep(&apply_linear_zero_suppression(&raw, alttzin), s.dimdsep)
};
format!("{}/{}", fmt(measurement + s.dimtp), fmt(measurement - s.dimtm))
} else {
sep_swapped
};
// DIMAPOST wraps the alt value (same "<>" convention as DIMPOST).
let wrapped = if s.dimapost.is_empty() {
alt_value
} else if s.dimapost.contains("<>") {
s.dimapost.replace("<>", &alt_value)
} else {
format!("{}{}", alt_value, s.dimapost)
};
Some(wrapped)
}
/// Build the secondary tolerance Text entity at `DIMTXT × DIMTFAC` height,
/// positioned to the right of the primary text and vertically aligned per
/// `DIMTOLJ` (0=bottom, 1=middle, 2=top). Returns None when DIMTOL/DIMLIM
/// produce no tolerance string (e.g. both DIMTP and DIMTM are zero).
fn dimension_tolerance_entity(
dim: &Dimension,
style: Option<&DimStyle>,
primary: &Text,
primary_height: f64,
) -> Option<Text> {
let s = style?;
let is_angular = matches!(dim, Dimension::Angular2Ln(_) | Dimension::Angular3Pt(_));
let tol = build_tolerance_suffix(dim.measurement(), style, is_angular)?;
let dimtfac = if s.dimtfac.abs() < 1e-12 {
1.0
} else {
s.dimtfac
};
let tol_height = primary_height * dimtfac;
// Approximate widths from glyph counts (~0.6 × cell size per char).
let primary_w = primary.value.chars().count() as f64 * primary_height * 0.6;
let tol_w = tol.chars().count() as f64 * tol_height * 0.6;
let gap = primary_height * 0.2;
let dx_local = primary_w * 0.5 + tol_w * 0.5 + gap;
let dy_local = match s.dimtolj {
0 => -primary_height * 0.5 + tol_height * 0.5, // bottom-aligned with primary baseline
2 => primary_height * 0.5 - tol_height * 0.5, // top-aligned with primary top
_ => 0.0, // centred (default for ±)
};
let rot = primary.rotation;
let (sr, cr) = rot.sin_cos();
let pos = Vector3::new(
primary.insertion_point.x + dx_local * cr - dy_local * sr,
primary.insertion_point.y + dx_local * sr + dy_local * cr,
primary.insertion_point.z,
);
let mut t = Text::with_value(tol, pos)
.with_height(tol_height)
.with_rotation(rot);
t.style = primary.style.clone();
t.common = primary.common.clone();
t.horizontal_alignment = acadrust::entities::text::TextHorizontalAlignment::Center;
t.vertical_alignment = acadrust::entities::text::TextVerticalAlignment::Middle;
Some(t)
}
/// Wrap a measured value with the style's DIMPOST prefix/suffix template.
/// "<>" inside DIMPOST is replaced by the value; absent "<>" appends.
fn apply_dimpost(value: &str, style: Option<&DimStyle>) -> String {
let post = style.map(|s| s.dimpost.as_str()).unwrap_or("");
if post.is_empty() {
return value.to_string();
}
if post.contains("<>") {
post.replace("<>", value)
} else {
format!("{}{}", value, post)
}
}
/// Format a linear measurement honouring DIMLFAC, DIMRND, DIMDEC, DIMZIN, DIMDSEP, DIMLUNIT.
fn format_linear_value(measurement: f64, style: Option<&DimStyle>) -> String {
let (dec, zin, lfac, rnd, dsep, lunit, frac) = style
.map(|s| {
(
s.dimdec, s.dimzin, s.dimlfac, s.dimrnd, s.dimdsep, s.dimlunit, s.dimfrac,
)
})
.unwrap_or((4, 8, 1.0, 0.0, 46, 2, 0));
let lfac = if lfac.abs() < 1e-12 { 1.0 } else { lfac };
let mut v = measurement * lfac;
if rnd > 1e-12 {
v = (v / rnd).round() * rnd;
}
let dec = dec.max(0) as usize;
let raw = format_with_unit(v, lunit, dec, frac);
let suppressed = apply_linear_zero_suppression(&raw, zin);
swap_decimal_sep(&suppressed, dsep)
}
/// Dispatch on DIMLUNIT / DIMALTU.
/// 1 = Scientific
/// 2 = Decimal (default)
/// 3 = Engineering (feet + decimal inches; 1 unit = 1 inch)
/// 4 = Architectural (feet + fractional inches)
/// 5 = Fractional (integer + fractional inches)
/// 6 = Windows desktop → falls back to Decimal
/// `dimfrac` controls denominator power for arch/fractional output (0/1/2);
/// rendered inline as "n/d" (stacked glyphs require MText support).
fn format_with_unit(value: f64, unit: i16, dec: usize, dimfrac: i16) -> String {
match unit {
1 => format!("{:.*e}", dec, value),
3 => format_engineering(value, dec),
4 => format_architectural(value, dimfrac),
5 => format_fractional(value, dimfrac),
_ => format!("{:.*}", dec, value),
}
}
fn format_engineering(inches: f64, dec: usize) -> String {
let sign = if inches < 0.0 { "-" } else { "" };
let abs = inches.abs();
let feet = (abs / 12.0).trunc();
let rem_in = abs - feet * 12.0;
format!("{}{:.0}'-{:.*}\"", sign, feet, dec, rem_in)
}
fn format_architectural(inches: f64, dimfrac: i16) -> String {
let sign = if inches < 0.0 { "-" } else { "" };
let abs = inches.abs();
let feet = (abs / 12.0).trunc();
let rem_in_total = abs - feet * 12.0;
let whole = rem_in_total.trunc();
let frac = rem_in_total - whole;
let frac_str = fraction_string(frac, dimfrac);
if frac_str.is_empty() {
format!("{}{:.0}'-{:.0}\"", sign, feet, whole)
} else {
format!("{}{:.0}'-{:.0} {}\"", sign, feet, whole, frac_str)
}
}
fn format_fractional(value: f64, dimfrac: i16) -> String {
let sign = if value < 0.0 { "-" } else { "" };
let abs = value.abs();
let whole = abs.trunc();
let frac = abs - whole;
let frac_str = fraction_string(frac, dimfrac);
if frac_str.is_empty() {
format!("{}{:.0}", sign, whole)
} else if whole == 0.0 {
format!("{}{}", sign, frac_str)
} else {
format!("{}{:.0} {}", sign, whole, frac_str)
}
}
fn fraction_string(frac: f64, dimfrac: i16) -> String {
// DIMFRAC denominator: AutoCAD encodes this on DIMSTYLE via DIMLUNIT
// pairing — the value we accept is the *power-of-2* exponent (1..=6 ish).
// Pick a sensible cap so the printed fraction stays readable.
let exp = (dimfrac.clamp(0, 8) as u32).max(2) + 2; // 2..=10 → 4..=1024
let denom = 1u64 << exp;
let numer = (frac * denom as f64).round() as i64;
if numer <= 0 {
return String::new();
}
let mut n = numer as u64;
let mut d = denom;
while n % 2 == 0 && d % 2 == 0 {
n /= 2;
d /= 2;
}
if n == 0 {
String::new()
} else if d == 1 {
format!("{}", n) // whole-number overflow back to caller
} else {
format!("{}/{}", n, d)
}
}
/// Format an angular measurement (input in degrees as Dimension::measurement
/// returns for angular variants) honouring DIMAUNIT, DIMADEC, DIMAZIN.
fn format_angular_value(measurement_deg: f64, style: Option<&DimStyle>) -> String {
let (aunit, adec, azin) = style
.map(|s| (s.dimaunit, s.dimadec, s.dimazin))
.unwrap_or((0, 2, 0));
let adec = adec.max(0) as usize;
match aunit {
// 1 = Degrees / Minutes / Seconds
1 => format_dms(measurement_deg, adec, azin),
// 2 = Gradians
2 => {
let g = measurement_deg / 0.9;
let raw = format!("{:.*}", adec, g);
format!("{}g", apply_angular_zero_suppression(&raw, azin))
}
// 3 = Radians
3 => {
let r = measurement_deg.to_radians();
let raw = format!("{:.*}", adec, r);
format!("{}r", apply_angular_zero_suppression(&raw, azin))
}
// 0 or unknown = Decimal Degrees
_ => {
let raw = format!("{:.*}", adec, measurement_deg);
format!("{}°", apply_angular_zero_suppression(&raw, azin))
2026-03-22 14:56:27 +03:00
}
}
}
fn format_dms(deg: f64, sec_dec: usize, azin: i16) -> String {
let sign = if deg < 0.0 { "-" } else { "" };
let abs = deg.abs();
let d = abs.floor();
let m_full = (abs - d) * 60.0;
let m = m_full.floor();
let s = (m_full - m) * 60.0;
let s_str = format!("{:.*}", sec_dec, s);
let mut out = format!("{}{:.0}°{:.0}'{}\"", sign, d, m, s_str);
if azin & 4 != 0 {
// suppress 0° / 0' parts
if d == 0.0 {
out = out.trim_start_matches('0').to_string();
out = out.replacen("°", "", 1);
}
}
out
}
/// Apply DIMZIN bit flags to a formatted linear value.
/// bit 0 (1) suppress 0' (imperial feet) — not applicable for decimal
/// bit 1 (2) suppress 0" (imperial inches) — not applicable for decimal
/// bit 2 (4) suppress leading zeros (e.g. ".5" not "0.5")
/// bit 3 (8) suppress trailing zeros (e.g. "1.5" not "1.50")
/// Default = 8 (trailing-zero suppression on).
fn apply_linear_zero_suppression(s: &str, zin: i16) -> String {
let mut out = s.to_string();
if zin & 8 != 0 {
out = strip_trailing_zeros(&out);
}
if zin & 4 != 0 {
out = strip_leading_zero(&out);
}
out
}
fn apply_angular_zero_suppression(s: &str, azin: i16) -> String {
// DIMAZIN: 0=neither, 1=leading, 2=trailing, 3=both.
let mut out = s.to_string();
if azin & 2 != 0 {
out = strip_trailing_zeros(&out);
}
if azin & 1 != 0 {
out = strip_leading_zero(&out);
}
out
}
fn strip_trailing_zeros(s: &str) -> String {
if !s.contains('.') {
return s.to_string();
}
let trimmed = s.trim_end_matches('0').trim_end_matches('.');
if trimmed.is_empty() || trimmed == "-" {
"0".to_string()
} else {
trimmed.to_string()
}
}
fn strip_leading_zero(s: &str) -> String {
// "0.5" → ".5", "-0.5" → "-.5", "0" stays.
if let Some(rest) = s.strip_prefix("-0.") {
return format!("-.{rest}");
}
if let Some(rest) = s.strip_prefix("0.") {
return format!(".{rest}");
}
s.to_string()
}
fn swap_decimal_sep(s: &str, dsep_code: i16) -> String {
// DIMDSEP holds an ASCII code (0 means default '.'). 46='.', 44=',', etc.
if dsep_code <= 0 || dsep_code == 46 {
return s.to_string();
}
let ch = char::from_u32(dsep_code as u32).unwrap_or('.');
s.replace('.', &ch.to_string())
2026-03-22 14:56:27 +03:00
}
fn dimension_text_position(dim: &Dimension, world_offset: [f64; 3]) -> Vec3 {
let lv = |v| vec3_local(v, world_offset);
2026-03-22 14:56:27 +03:00
let base = dim.base();
let pos = lv(base.text_middle_point);
2026-03-22 14:56:27 +03:00
if pos.length_squared() > 1e-8 {
return pos;
}
match dim {
Dimension::Aligned(d) => (lv(d.first_point) + lv(d.second_point)) * 0.5,
Dimension::Linear(d) => (lv(d.first_point) + lv(d.second_point)) * 0.5,
Dimension::Radius(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5,
Dimension::Diameter(d) => (lv(d.angle_vertex) + lv(d.definition_point)) * 0.5,
Dimension::Angular2Ln(d) => lv(d.dimension_arc),
Dimension::Angular3Pt(d) => lv(d.definition_point),
Dimension::Ordinate(d) => lv(d.leader_endpoint),
2026-03-22 14:56:27 +03:00
}
}
fn vec3_local(v: Vector3, off: [f64; 3]) -> Vec3 {
2026-05-12 10:36:15 +03:00
Vec3::new(
(v.x - off[0]) as f32,
(v.y - off[1]) as f32,
(v.z - off[2]) as f32,
)
}
fn offset_snap_pts(pts: Vec<(Vec3, SnapHint)>, off: [f64; 3]) -> Vec<(Vec3, SnapHint)> {
let [ox, oy, oz] = off;
pts.into_iter()
2026-05-12 10:36:15 +03:00
.map(|(p, h)| {
(
Vec3::new(p.x - ox as f32, p.y - oy as f32, p.z - oz as f32),
h,
)
})
.collect()
}
/// Returns the text position of a dimension in DXF world-space (f64, no offset applied).
/// Used when building a synthetic Text entity so tessellate() can apply world_offset itself.
/// When the saved `text_middle_point` is zero (i.e. AutoCAD never wrote one),
/// computes a fallback from the dim geometry and applies DIMTAD/DIMGAP.
fn dimension_text_pos_f64(
dim: &Dimension,
style: Option<&DimStyle>,
text_height: f64,
) -> Vector3 {
let base = dim.base();
let p = base.text_middle_point;
if p.x * p.x + p.y * p.y + p.z * p.z > 1e-16 {
return p;
}
let mid = match dim {
Dimension::Aligned(d) => Vector3::new(
(d.first_point.x + d.second_point.x) * 0.5,
(d.first_point.y + d.second_point.y) * 0.5,
(d.first_point.z + d.second_point.z) * 0.5,
),
Dimension::Linear(d) => Vector3::new(
(d.first_point.x + d.second_point.x) * 0.5,
(d.first_point.y + d.second_point.y) * 0.5,
(d.first_point.z + d.second_point.z) * 0.5,
),
Dimension::Radius(d) => Vector3::new(
(d.angle_vertex.x + d.definition_point.x) * 0.5,
(d.angle_vertex.y + d.definition_point.y) * 0.5,
(d.angle_vertex.z + d.definition_point.z) * 0.5,
),
Dimension::Diameter(d) => Vector3::new(
(d.angle_vertex.x + d.definition_point.x) * 0.5,
(d.angle_vertex.y + d.definition_point.y) * 0.5,
(d.angle_vertex.z + d.definition_point.z) * 0.5,
),
Dimension::Angular2Ln(d) => d.dimension_arc,
Dimension::Angular3Pt(d) => d.definition_point,
Dimension::Ordinate(d) => d.leader_endpoint,
};
// DIMTAD: 0=centred (on the line), 1=above (offset perpendicular), 2=outside,
// 3=JIS. We honour 0 and 1; 2/3 fall back to "above".
let dimtad = style.map(|s| s.dimtad).unwrap_or(1);
let dimgap = style.map(|s| s.dimgap).unwrap_or(0.0);
// DIMJUST horizontal placement on the dim line (only meaningful for
// linear/aligned dims). 0=centred, 1=near first ext, 2=near second ext,
// 3=above first ext (perpendicular text), 4=above second ext.
let dimjust = style.map(|s| s.dimjust).unwrap_or(0);
// DIMTVP vertical-position multiplier (units of dimtxt). Only honoured
// when DIMTAD == 0; offsets text perpendicular to the dim line.
let dimtvp = style.map(|s| s.dimtvp).unwrap_or(0.0);
// Need axis + perp_sign (toward "above").
let (axis_x, axis_y, perp_sign, p1, p2) = match dim {
Dimension::Linear(d) => {
let ax = d.rotation.cos();
let ay = d.rotation.sin();
let px = -ay;
let py = ax;
let off = (d.definition_point.x - d.first_point.x) * px
+ (d.definition_point.y - d.first_point.y) * py;
(
ax,
ay,
if off >= 0.0 { 1.0 } else { -1.0 },
d.first_point,
d.second_point,
)
}
Dimension::Aligned(d) => {
let dx = d.second_point.x - d.first_point.x;
let dy = d.second_point.y - d.first_point.y;
let len = (dx * dx + dy * dy).sqrt().max(1e-12);
let ax = dx / len;
let ay = dy / len;
let px = -ay;
let py = ax;
let off = (d.definition_point.x - d.first_point.x) * px
+ (d.definition_point.y - d.first_point.y) * py;
(
ax,
ay,
if off >= 0.0 { 1.0 } else { -1.0 },
d.first_point,
d.second_point,
)
}
_ => {
// Non-linear: only DIMTAD offset applies; no horizontal shift along axis.
let off_perp = if dimtad == 0 {
dimtvp * text_height
} else {
text_height * 0.5 + dimgap
};
return Vector3::new(mid.x, mid.y + off_perp * perp_sign_default(), mid.z);
}
};
// Horizontal slide along the dim axis to honour DIMJUST. Slide endpoints
// are the dim-line endpoints (projection of p1/p2 onto the dim line),
// approximated here as the def-points themselves (we don't have axis-
// projected d1/d2 here without more plumbing).
let along_offset = match dimjust {
1 => (p1.x - mid.x) * axis_x + (p1.y - mid.y) * axis_y,
2 => (p2.x - mid.x) * axis_x + (p2.y - mid.y) * axis_y,
3 => (p1.x - mid.x) * axis_x + (p1.y - mid.y) * axis_y,
4 => (p2.x - mid.x) * axis_x + (p2.y - mid.y) * axis_y,
_ => 0.0,
};
// Perpendicular offset: DIMTAD 0 → DIMTVP * dimtxt, else above-line gap.
let perp_offset = if dimtad == 0 {
dimtvp * text_height * perp_sign
} else {
(text_height * 0.5 + dimgap) * perp_sign
};
Vector3::new(
mid.x + axis_x * along_offset + (-axis_y) * perp_offset,
mid.y + axis_y * along_offset + (axis_x) * perp_offset,
mid.z,
)
2026-03-22 14:56:27 +03:00
}
fn perp_sign_default() -> f64 {
1.0
}
2026-03-22 14:56:27 +03:00
fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 {
if v.length_squared() <= 1e-12 {
fallback
} else {
v.normalize()
}
}