MLINE dashed elements were fully CPU-expanded (apply_along) — one wire per dash — to keep the parallel lines in phase. Move pure-dash elements onto the GPU dash shader and end-align every element to the multiline's centre-line so they share one interior phase. - wire_distances now accumulates arc-length from the double-single (high+low) points, so the dash phase stays precise at UTM coordinates. f32-high-only deltas quantised ~0.1 there and drifted the parallel lines apart — the original reason MLINE dashes were CPU-side. - New WireModel.dash_align_end carries a shared begin/end dash length. The shader uses it as align_end for every parallel element (the interior phase depends on align_end, not the wire's own length) while align_total stays the element's own length, so each still ends on a dash at its own endpoint. - MLINE tessellation derives that length once from the centre-line, routes pure dash/space/dot elements to the GPU pattern and text-bearing elements to apply_along. - apply_along gained an "A"-type mode (shared reference length): a solid begin/end dash and a phased interior, so its dashes — and embedded text — line up with the GPU-dashed sibling elements instead of tiling from start. Pure-dash MLINEs now cost one WireModel + pattern per element instead of N segments; single (non-MLINE) entities keep the from-start tiling unchanged. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1622 lines
68 KiB
Rust
1622 lines
68 KiB
Rust
// 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, Insert, Hatch, Ole2Frame)
|
||
// are tessellated by the FallbackTess fallback_geometry() path.
|
||
|
||
use crate::entities::leader::LeaderTess;
|
||
use acadrust::types::Color as AcadColor;
|
||
use acadrust::{CadDocument, EntityType, Handle};
|
||
use glam::Vec3;
|
||
|
||
use crate::scene::convert::acad_to_truck::{convert, TruckObject};
|
||
use crate::scene::convert::truck_tess::{
|
||
tessellate_edge, tessellate_vertex, tessellate_wire, TruckTessResult,
|
||
};
|
||
use crate::scene::model::wire_model::{SnapHint, WireModel};
|
||
|
||
/// Split an f64 offset-relative coordinate into the double-single (high, low)
|
||
/// f32 pair the renderer consumes. `high + low ≈ value` to ~f64 precision; the
|
||
/// RTE shader subtracts the eye's own high/low so vertices stay smooth even at
|
||
/// coordinates where a plain f32 cast would quantize to half a metre.
|
||
#[inline]
|
||
fn split_ds(v: f64) -> (f32, f32) {
|
||
let h = v as f32;
|
||
let l = (v - h as f64) as f32;
|
||
(h, l)
|
||
}
|
||
|
||
#[inline]
|
||
fn split_ds_xyz(x: f64, y: f64, z: f64) -> ([f32; 3], [f32; 3]) {
|
||
let (xh, xl) = split_ds(x);
|
||
let (yh, yl) = split_ds(y);
|
||
let (zh, zl) = split_ds(z);
|
||
([xh, yh, zh], [xl, yl, zl])
|
||
}
|
||
|
||
/// Split each absolute f64 source point into double-single (high, low) f32
|
||
/// buffers in one pass — the relative-to-eye residual the GPU/CPU reconstruct
|
||
/// to f64 precision at UTM-scale coordinates.
|
||
fn points_to_ds(
|
||
src: impl IntoIterator<Item = [f64; 3]>,
|
||
) -> (Vec<[f32; 3]>, Vec<[f32; 3]>) {
|
||
let it = src.into_iter();
|
||
let (lo, hi) = it.size_hint();
|
||
let cap = hi.unwrap_or(lo);
|
||
let mut high = Vec::with_capacity(cap);
|
||
let mut low = Vec::with_capacity(cap);
|
||
for [x, y, z] in it {
|
||
if x.is_nan() {
|
||
// Wire-model NaN-separator: keep both buffers index-paired.
|
||
high.push([f32::NAN; 3]);
|
||
low.push([0.0; 3]);
|
||
continue;
|
||
}
|
||
let (h, l) = split_ds_xyz(x, y, z);
|
||
high.push(h);
|
||
low.push(l);
|
||
}
|
||
(high, low)
|
||
}
|
||
|
||
/// Lift a WireModel built in a local frame (a fixed f64 origin subtracted) back
|
||
/// to absolute world coordinates, re-splitting every position — polyline points
|
||
/// and SDF glyph vertices — into double-single so it stays precise at UTM scale.
|
||
/// The MLINE complex-linetype path uses this because `apply_along` lays out its
|
||
/// dashes and glyphs in f32, which would otherwise quantise fine spacing far
|
||
/// from the origin.
|
||
fn shift_wire_to_world(w: &mut WireModel, origin: [f64; 3]) {
|
||
if !w.points.is_empty() {
|
||
let mut hi = Vec::with_capacity(w.points.len());
|
||
let mut lo = Vec::with_capacity(w.points.len());
|
||
for p in &w.points {
|
||
if p[0].is_nan() {
|
||
hi.push([f32::NAN; 3]);
|
||
lo.push([0.0; 3]);
|
||
continue;
|
||
}
|
||
let (h, l) = split_ds_xyz(
|
||
p[0] as f64 + origin[0],
|
||
p[1] as f64 + origin[1],
|
||
p[2] as f64 + origin[2],
|
||
);
|
||
hi.push(h);
|
||
lo.push(l);
|
||
}
|
||
w.points = hi;
|
||
w.points_low = lo;
|
||
}
|
||
for tv in &mut w.text_verts {
|
||
let (h, l) = split_ds_xyz(
|
||
tv.pos[0] as f64 + tv.pos_low[0] as f64 + origin[0],
|
||
tv.pos[1] as f64 + tv.pos_low[1] as f64 + origin[1],
|
||
tv.pos[2] as f64 + tv.pos_low[2] as f64 + origin[2],
|
||
);
|
||
tv.pos = h;
|
||
tv.pos_low = l;
|
||
}
|
||
if w.aabb != WireModel::UNBOUNDED_AABB {
|
||
w.aabb = [
|
||
w.aabb[0] + origin[0] as f32,
|
||
w.aabb[1] + origin[1] as f32,
|
||
w.aabb[2] + origin[0] as f32,
|
||
w.aabb[3] + origin[1] as f32,
|
||
];
|
||
}
|
||
}
|
||
|
||
// ── 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.
|
||
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,
|
||
anno_scale: f32,
|
||
world_per_pixel: Option<f32>,
|
||
// Canvas background colour — used for the MTEXT background *mask* fill
|
||
// (flag 0x02, "use drawing window colour") so the mask erases geometry
|
||
// behind the text the way a wipeout does.
|
||
bg_color: [f32; 4],
|
||
// When true, TEXT/MTEXT run-groups ALSO emit their glyph outline strokes as
|
||
// polyline points (not just SDF quads). The in-app MTEXT editor preview
|
||
// draws those strokes on a 2D canvas that can't run the SDF shader; every
|
||
// other caller passes false and gets the normal SDF-only text.
|
||
force_text_strokes: bool,
|
||
) -> Vec<WireModel> {
|
||
let color = if selected {
|
||
WireModel::SELECTED
|
||
} else {
|
||
entity_color
|
||
};
|
||
let name = handle.value().to_string();
|
||
|
||
// Determine the effective annotation scale for this entity.
|
||
//
|
||
// Only annotative entities are auto-scaled by the current annotation scale;
|
||
// everything else is manually pre-scaled (old convention with $DIMSCALE and
|
||
// oversized text). Annotative-ness is resolved centrally from the entity's
|
||
// per-object context, legacy XDATA, or annotative style (see
|
||
// `scene::annotative::is_annotative`) so the bake and the panel agree.
|
||
let anno_scale = if crate::scene::annotative::is_annotative(document, entity) {
|
||
anno_scale
|
||
} else {
|
||
1.0
|
||
};
|
||
|
||
// A HATCH is drawn as a fill by the hatch pipeline and highlighted via a
|
||
// fill tint when selected (issue #71), so it carries no boundary outline in
|
||
// the wire set. Skipping it here drops the dense boundary polyline — the
|
||
// dominant wire-instance cost on hatch-heavy drawings (issue #131). Picking
|
||
// is unaffected: hatches are caught by their fill area through the existing
|
||
// `pick::hit_test::click_hit_hatch` path, not this outline.
|
||
if matches!(entity, EntityType::Hatch(_)) {
|
||
return vec![];
|
||
}
|
||
|
||
// 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 vec![leader.tessellate(
|
||
document,
|
||
handle,
|
||
selected,
|
||
entity_color,
|
||
line_weight_px,
|
||
anno_scale,
|
||
)];
|
||
}
|
||
|
||
// MLINE emits one WireModel per style element so each parallel line keeps
|
||
// its own colour and linetype — a red Continuous line under a yellow dashed
|
||
// line reads as the two-tone multiline the style defines. Handled here, like
|
||
// Leader, because the single-colour truck `Lines` path can't carry
|
||
// per-element colour.
|
||
if let EntityType::MLine(m) = entity {
|
||
let lines = crate::entities::mline::mline_lines(m, document);
|
||
if lines.is_empty() {
|
||
return vec![];
|
||
}
|
||
let lt_scale =
|
||
document.header.linetype_scale as f32 * m.common.linetype_scale as f32;
|
||
let snap_pts: Vec<(glam::DVec3, SnapHint)> = m
|
||
.vertices
|
||
.iter()
|
||
.map(|v| {
|
||
(
|
||
glam::DVec3::new(v.position.x, v.position.y, v.position.z),
|
||
SnapHint::Node,
|
||
)
|
||
})
|
||
.collect();
|
||
let key_vertices: Vec<[f64; 3]> = m
|
||
.vertices
|
||
.iter()
|
||
.map(|v| [v.position.x, v.position.y, v.position.z])
|
||
.collect();
|
||
|
||
// Local-frame origin (mline start) for the CPU-dashed / glyph-laid
|
||
// elements. `apply_along` walks positions in f32, which quantises fine
|
||
// spacing — dash gaps AND inter-glyph advance — at UTM coordinates
|
||
// (the low half of the double-single is dropped). Subtracting this f64
|
||
// origin first keeps the walk near zero and precise; the result is
|
||
// shifted back to absolute double-single afterwards. Mirrors the
|
||
// Tolerance frame, which also builds geometry locally and applies its
|
||
// f64 origin later.
|
||
let origin = [
|
||
m.vertices[0].position.x,
|
||
m.vertices[0].position.y,
|
||
m.vertices[0].position.z,
|
||
];
|
||
// Centre-line (vertex path) length — the shared "A"-type reference so
|
||
// every parallel element uses the same end-dash length and thus the same
|
||
// interior phase (perpendicular dashes line up). f64 deltas so it stays
|
||
// precise at UTM coordinates.
|
||
let ref_total: f32 = {
|
||
let mut acc = 0.0_f64;
|
||
for w in m.vertices.windows(2) {
|
||
let dx = w[1].position.x - w[0].position.x;
|
||
let dy = w[1].position.y - w[0].position.y;
|
||
let dz = w[1].position.z - w[0].position.z;
|
||
acc += (dx * dx + dy * dy + dz * dz).sqrt();
|
||
}
|
||
acc as f32
|
||
};
|
||
let mut out: Vec<WireModel> = Vec::with_capacity(lines.len());
|
||
let mut snap_attached = false;
|
||
for l in lines {
|
||
if l.points.is_empty() {
|
||
continue;
|
||
}
|
||
// Element colour: ByLayer / ByBlock inherit the entity's resolved
|
||
// colour; an explicit ACI / true-colour is used as-is.
|
||
let wcolor = if selected {
|
||
WireModel::SELECTED
|
||
} else {
|
||
match l.color {
|
||
AcadColor::ByLayer | AcadColor::ByBlock => entity_color,
|
||
other => {
|
||
let [r, g, b, _] =
|
||
crate::scene::convert::tess_util::aci_to_rgba(&other);
|
||
[r, g, b, entity_color[3]]
|
||
}
|
||
}
|
||
};
|
||
let aci = match l.color {
|
||
AcadColor::Index(i) => i,
|
||
_ => 0,
|
||
};
|
||
|
||
// Every dashed element is CPU-dashed by `apply_along` (not the GPU
|
||
// pattern) so all parallel lines walk the polyline with the *same*
|
||
// arithmetic and stay in phase — otherwise a shader-dashed line and an
|
||
// apply_along-dashed sibling drift apart at large (UTM) coordinates and
|
||
// one line's dash lands in the other's gap, striking through embedded
|
||
// text. Document definition wins over the bundled catalog; a
|
||
// continuous element yields `None` and falls to the solid path below.
|
||
// Selection forces a plain solid highlight, so skip dashing then.
|
||
let clt = if selected {
|
||
None
|
||
} else if let Some(doc_seg) =
|
||
crate::io::linetypes::document_lt_segments(document, &l.linetype)
|
||
{
|
||
// In-document linetype: CPU-expand (apply_along) only when it
|
||
// embeds TEXT glyphs that must be laid out along the curve. Pure
|
||
// dash / space / dot (and undrawn shape) elements fall through to
|
||
// the GPU dash shader below — cheaper (one WireModel + pattern
|
||
// instead of N CPU segments) and now UTM-precise, so they stay in
|
||
// phase with any glyph-bearing sibling element.
|
||
if doc_seg
|
||
.segments
|
||
.iter()
|
||
.any(|s| matches!(s, crate::io::linetypes::LtSegment::Text { .. }))
|
||
{
|
||
Some(doc_seg)
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
// Not in the document: bundled-catalog linetype (may embed
|
||
// text / shape) → keep the CPU path; `resolve_pattern` below can't
|
||
// see it, so GPU-dashing would drop the pattern to solid.
|
||
crate::io::linetypes::complex_lt(&l.linetype).cloned()
|
||
};
|
||
|
||
let mut elem_wires: Vec<WireModel> = if let Some(clt) = clt {
|
||
// Walk the dash / glyph layout in a local frame so the f32 math
|
||
// stays precise, then lift each wire back to world DS.
|
||
let local: Vec<[f32; 3]> = l
|
||
.points
|
||
.iter()
|
||
.map(|p| {
|
||
[
|
||
(p[0] - origin[0]) as f32,
|
||
(p[1] - origin[1]) as f32,
|
||
(p[2] - origin[2]) as f32,
|
||
]
|
||
})
|
||
.collect();
|
||
let mut w = crate::scene::text::complex_lt::apply_along(
|
||
&name,
|
||
&local,
|
||
&clt,
|
||
lt_scale.max(1e-4),
|
||
wcolor,
|
||
selected,
|
||
line_weight_px,
|
||
// Shared "A"-type reference: this text-bearing element aligns
|
||
// with the GPU-dashed sibling elements (same centre-line
|
||
// reference) instead of tiling independently from the start.
|
||
Some(ref_total),
|
||
);
|
||
for wm in &mut w {
|
||
wm.aci = aci;
|
||
shift_wire_to_world(wm, origin);
|
||
}
|
||
w
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
|
||
// Simple path: the linetype isn't complex, or `apply_along` bailed
|
||
// (pattern blow-up guard) and returned nothing — draw the element as a
|
||
// dashed / solid polyline so it is never lost.
|
||
if elem_wires.is_empty() {
|
||
let (pts, pts_low) = points_to_ds(l.points);
|
||
let (pattern_length, pattern) = if selected {
|
||
(0.0, [0.0; 8])
|
||
} else {
|
||
crate::scene::view::render::resolve_pattern(
|
||
&document.line_types,
|
||
&l.linetype,
|
||
lt_scale,
|
||
)
|
||
};
|
||
// Shared "A"-type: derive the begin/end solid-dash length ONCE
|
||
// from the multiline centre-line (`ref_total`) so every parallel
|
||
// element runs the same interior phase and its dashes line up
|
||
// perpendicular; `align_total` stays each element's own length in
|
||
// the shader so each still ends on a dash. Dash-first patterns
|
||
// only (`+dash, -gap, …`); shorter-than-a-period lines fall back
|
||
// to the per-wire path (solid).
|
||
let dash_align_end = if pattern_length > 1e-6
|
||
&& pattern[0] > 0.0
|
||
&& pattern[1] < 0.0
|
||
&& ref_total > pattern_length
|
||
{
|
||
let a = pattern[0];
|
||
let p = pattern_length;
|
||
let k = ((ref_total - a) / p).round().max(1.0);
|
||
Some(((ref_total - k * p + a) * 0.5).max(1e-4))
|
||
} else {
|
||
None
|
||
};
|
||
elem_wires.push(WireModel {
|
||
// MLINE dashes: A-type aligned, but the end-dash length is
|
||
// shared across all parallel elements (`dash_align_end`) so
|
||
// their interiors stay in phase (perpendicular dashes line up)
|
||
// while each still ends on a dash at its own endpoint.
|
||
dash_from_start: false,
|
||
dash_align_end,
|
||
text_verts: Vec::new(),
|
||
name: name.clone(),
|
||
points: pts,
|
||
points_low: pts_low,
|
||
color: wcolor,
|
||
selected,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
snap_pts: Vec::new(),
|
||
tangent_geoms: Vec::new(),
|
||
aci,
|
||
key_vertices: Vec::new(),
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: false,
|
||
vp_scissor: None,
|
||
fill_tris: Vec::new(),
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
}
|
||
|
||
// Snap / key vertices ride the first emitted wire only (they describe
|
||
// the whole entity, not one element).
|
||
if !snap_attached {
|
||
if let Some(w0) = elem_wires.first_mut() {
|
||
w0.snap_pts = snap_pts.clone();
|
||
w0.key_vertices = key_vertices.clone();
|
||
snap_attached = true;
|
||
}
|
||
}
|
||
out.append(&mut elem_wires);
|
||
}
|
||
if out.is_empty() {
|
||
return vec![];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── Try the truck path first ───────────────────────────────────────────
|
||
// Relative-PDSIZE points size their glyph from the current zoom so they
|
||
// stay a roughly constant on-screen size; otherwise the header-driven path.
|
||
let te = crate::entities::point::relative_truck(entity, document, world_per_pixel)
|
||
.or_else(|| convert(entity, document));
|
||
if let Some(te) = te {
|
||
match te.object {
|
||
// ── Text / MText: pre-tessellated glyph strokes ───────────────
|
||
//
|
||
// Strokes are pre-grouped by world origin (one TextStroke per
|
||
// line / per run / per fragment), each carrying an optional
|
||
// colour override produced by MTEXT inline `\C` / `\c`. We bin
|
||
// groups by override colour and emit one WireModel per bin so a
|
||
// single MTEXT can hand back N colour-distinct wires when the
|
||
// value mixes inline colours.
|
||
TruckObject::Text(stroke_groups) => {
|
||
let entity_zf = entity_z(entity) as f64;
|
||
let elev_v = entity_zf;
|
||
|
||
// anno_scale anchors at the first group's origin so multi-line
|
||
// MText lines spread apart correctly as they grow.
|
||
let ref_origin = stroke_groups
|
||
.first()
|
||
.map(|g| g.origin)
|
||
.unwrap_or([0.0, 0.0]);
|
||
let ref_lx_v = ref_origin[0];
|
||
let ref_ly_v = ref_origin[1];
|
||
|
||
// Selection forces a single uniform colour — never split.
|
||
let split_by_color = !selected;
|
||
|
||
// Bins: key = (Some(rgb), bold). Bold strokes bin separately so
|
||
// the editor preview can draw them with a wider pen.
|
||
struct TextBin {
|
||
color: Option<[f32; 3]>,
|
||
bold: bool,
|
||
pts: Vec<[f32; 3]>,
|
||
pts_low: Vec<[f32; 3]>,
|
||
fill_tris: Vec<[f32; 3]>,
|
||
fill_tris_low: Vec<[f32; 3]>,
|
||
}
|
||
let mut bins: Vec<TextBin> = Vec::new();
|
||
let mut bin_first: Vec<bool> = Vec::new();
|
||
let find_or_make = |key: Option<[f32; 3]>,
|
||
bold: bool,
|
||
bins: &mut Vec<TextBin>,
|
||
firsts: &mut Vec<bool>|
|
||
-> usize {
|
||
if let Some(i) = bins.iter().position(|b| b.color == key && b.bold == bold) {
|
||
i
|
||
} else {
|
||
bins.push(TextBin {
|
||
color: key,
|
||
bold,
|
||
pts: Vec::new(),
|
||
pts_low: Vec::new(),
|
||
fill_tris: Vec::new(),
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
firsts.push(true);
|
||
bins.len() - 1
|
||
}
|
||
};
|
||
|
||
let anno = anno_scale as f64;
|
||
// SDF text is decided PER GROUP by whether the group carries a
|
||
// `GlyphRun`: a run-group renders as textured quads (built
|
||
// below) so its strokes are suppressed; a run-less group keeps
|
||
// its strokes. TEXT / MTEXT / dim / block / mleader text are all
|
||
// run-groups → fully SDF; a composite object that packs geometry
|
||
// and text into one Text object (a tolerance frame: box lines =
|
||
// run-less, cell text = run-groups) keeps the geometry as
|
||
// strokes and draws only the text as SDF.
|
||
for group in stroke_groups
|
||
.iter()
|
||
.filter(|g| force_text_strokes || g.run.is_none())
|
||
{
|
||
let lx_v = group.origin[0];
|
||
let ly_v = group.origin[1];
|
||
let slx_v = (lx_v - ref_lx_v) * anno + ref_lx_v;
|
||
let sly_v = (ly_v - ref_ly_v) * anno + ref_ly_v;
|
||
let bin_key = if split_by_color { group.color } else { None };
|
||
let group_bold = group.run.as_ref().is_some_and(|r| r.bold);
|
||
let bi = find_or_make(bin_key, group_bold, &mut bins, &mut bin_first);
|
||
|
||
// 1. Process outline strokes
|
||
for stroke in &group.strokes {
|
||
if stroke.len() < 2 {
|
||
continue;
|
||
}
|
||
if !bin_first[bi] && !bins[bi].pts.is_empty() {
|
||
bins[bi].pts.push([f32::NAN, f32::NAN, f32::NAN]);
|
||
bins[bi].pts_low.push([0.0; 3]);
|
||
}
|
||
bin_first[bi] = false;
|
||
for &[x, y] in stroke {
|
||
let xv = x as f64 * anno + slx_v;
|
||
let yv = y as f64 * anno + sly_v;
|
||
let (h, l) = split_ds_xyz(xv, yv, elev_v);
|
||
bins[bi].pts.push(h);
|
||
bins[bi].pts_low.push(l);
|
||
}
|
||
}
|
||
|
||
// 2. Process fill triangles
|
||
for &[x, y] in &group.fill_tris {
|
||
let xv = x as f64 * anno + slx_v;
|
||
let yv = y as f64 * anno + sly_v;
|
||
let (h, l) = split_ds_xyz(xv, yv, elev_v);
|
||
bins[bi].fill_tris.push(h);
|
||
bins[bi].fill_tris_low.push(l);
|
||
}
|
||
}
|
||
|
||
// ── SDF glyph quads ──────────────────────────────────────
|
||
// Build each run's glyph quads here and carry them on the wire.
|
||
// They ride with the wire through the tess memo, hit-
|
||
// materialisation and (for block content) the block-expand
|
||
// transform — no separate document-wide collector.
|
||
let mut sdf_verts: Vec<crate::scene::pipeline::text_gpu::TextVertex> = Vec::new();
|
||
{
|
||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||
// Selection tints the whole run; otherwise inline `\C`
|
||
// colours (bin key) win, falling back to entity colour.
|
||
for group in &stroke_groups {
|
||
let Some(run) = &group.run else { continue };
|
||
let slx_v = (group.origin[0] - ref_lx_v) * anno + ref_lx_v;
|
||
let sly_v = (group.origin[1] - ref_ly_v) * anno + ref_ly_v;
|
||
// Base colour only (inline `\C` wins). Selection /
|
||
// hover recolouring is done by the text-highlight
|
||
// overlay, so the base glyphs stay neutral — else a
|
||
// deselect would leave stale-tinted glyphs until the
|
||
// next geometry rebuild (the base text buffer only
|
||
// rebuilds on geometry, not on a pick).
|
||
let gcolor = group
|
||
.color
|
||
.map(|c| [c[0], c[1], c[2], entity_color[3]])
|
||
.unwrap_or(entity_color);
|
||
let quads = crate::scene::text::glyph_quads::layout_glyph_quads(
|
||
&mut atlas,
|
||
run.height,
|
||
run.rotation,
|
||
run.width_factor,
|
||
run.oblique,
|
||
run.tracking,
|
||
&run.font,
|
||
run.bold,
|
||
&run.text,
|
||
);
|
||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||
&mut sdf_verts,
|
||
&quads,
|
||
[slx_v, sly_v, elev_v],
|
||
anno,
|
||
gcolor,
|
||
0.0,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
let snap_pts = te.snap_pts;
|
||
let key_vertices: Vec<[f64; 3]> = te
|
||
.key_vertices
|
||
.into_iter()
|
||
.map(|[x, y, z]| [x, y, z])
|
||
.collect();
|
||
|
||
// Pick box straight from the rendered glyph quads — the true
|
||
// text extent. entity_aabb is unreliable for MTEXT (its box sits
|
||
// beside the laid-out glyphs), so derive the AABB from
|
||
// `sdf_verts` (accumulate in f64, reconstruct high+low, then cast
|
||
// to f32 once) and stop the generic stamp clobbering it (tess.rs
|
||
// guards on `text_verts`). Computed here so both the bins-empty
|
||
// and the bins-non-empty (tolerance box + text) paths can stamp
|
||
// it on the SDF text wire.
|
||
let text_aabb = if !sdf_verts.is_empty() {
|
||
let (mut nx, mut ny, mut xx, mut xy) =
|
||
(f64::MAX, f64::MAX, f64::MIN, f64::MIN);
|
||
for v in &sdf_verts {
|
||
let x = v.pos[0] as f64 + v.pos_low[0] as f64;
|
||
let y = v.pos[1] as f64 + v.pos_low[1] as f64;
|
||
nx = nx.min(x);
|
||
xx = xx.max(x);
|
||
ny = ny.min(y);
|
||
xy = xy.max(y);
|
||
}
|
||
[nx as f32, ny as f32, xx as f32, xy as f32]
|
||
} else {
|
||
WireModel::UNBOUNDED_AABB
|
||
};
|
||
|
||
// Empty input (no glyphs) → emit a single empty wire so the
|
||
// entity still has a hit-test target via snap_pts. With SDF on
|
||
// this is the normal path (strokes suppressed) and the wire
|
||
// also carries the glyph quads built above.
|
||
if bins.is_empty() {
|
||
let mut wires: Vec<WireModel> = Vec::new();
|
||
// MTEXT background fill / mask: an opaque rectangle behind
|
||
// the glyphs, emitted first so it renders under the text.
|
||
// Flag 0x01 → the entity's background-fill colour; 0x02 →
|
||
// the drawing-window (canvas) colour, which masks geometry
|
||
// behind the text like a wipeout. Box = glyph bounds padded
|
||
// by (background_scale - 1) × text height.
|
||
if text_aabb != WireModel::UNBOUNDED_AABB {
|
||
if let EntityType::MText(m) = entity {
|
||
let has_fill = m.background_fill_flags & 0x03 != 0;
|
||
let has_frame = m.background_fill_flags & 0x10 != 0;
|
||
if has_fill || has_frame {
|
||
// Padded box shared by the fill and the frame.
|
||
let th = (m.height * anno) as f32;
|
||
let pad = ((m.background_scale as f32) - 1.0).max(0.0) * th;
|
||
let [bnx, bny, bxx, bxy] = text_aabb;
|
||
let (l, b, r, t) = (
|
||
(bnx - pad) as f64,
|
||
(bny - pad) as f64,
|
||
(bxx + pad) as f64,
|
||
(bxy + pad) as f64,
|
||
);
|
||
// Fill / mask — two triangles behind the glyphs.
|
||
if has_fill {
|
||
let fill_color = if m.background_fill_flags & 0x01 != 0 {
|
||
color_or_inherit(&m.background_color, bg_color)
|
||
} else {
|
||
bg_color
|
||
};
|
||
let corners = [[l, b], [r, b], [r, t], [l, t]];
|
||
let mut ft = Vec::with_capacity(6);
|
||
let mut ftl = Vec::with_capacity(6);
|
||
for &k in &[0usize, 1, 2, 0, 2, 3] {
|
||
let (h, lo) =
|
||
split_ds_xyz(corners[k][0], corners[k][1], elev_v);
|
||
ft.push(h);
|
||
ftl.push(lo);
|
||
}
|
||
wires.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.clone(),
|
||
points: vec![],
|
||
points_low: Vec::new(),
|
||
color: fill_color,
|
||
selected,
|
||
aci: 0,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts: vec![],
|
||
tangent_geoms: vec![],
|
||
key_vertices: vec![],
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: ft,
|
||
fill_tris_low: ftl,
|
||
});
|
||
}
|
||
// Text frame — a closed rectangle in the text
|
||
// colour around the same box.
|
||
if has_frame {
|
||
let loop_xy =
|
||
[[l, b], [r, b], [r, t], [l, t], [l, b]];
|
||
let mut fp = Vec::with_capacity(5);
|
||
let mut fpl = Vec::with_capacity(5);
|
||
for &[x, y] in &loop_xy {
|
||
let (h, lo) = split_ds_xyz(x, y, elev_v);
|
||
fp.push(h);
|
||
fpl.push(lo);
|
||
}
|
||
wires.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.clone(),
|
||
points: fp,
|
||
points_low: fpl,
|
||
color: entity_color,
|
||
selected,
|
||
aci: 0,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts: vec![],
|
||
tangent_geoms: vec![],
|
||
key_vertices: vec![],
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// Debug (env OCS_TEXT_BOX): draw a rectangle around the glyph
|
||
// bounds as a separate outline wire so the text box is
|
||
// visible for testing. The empty text wire below is left
|
||
// untouched (still the SDF + pick target).
|
||
if !sdf_verts.is_empty()
|
||
&& crate::scene::text::sdf_atlas::text_box_debug()
|
||
{
|
||
let [nx, ny, xx, xy] = text_aabb;
|
||
let (nx, ny, xx, xy) = (nx as f64, ny as f64, xx as f64, xy as f64);
|
||
let mut pts = Vec::with_capacity(5);
|
||
let mut low = Vec::with_capacity(5);
|
||
for (x, y) in [(nx, ny), (xx, ny), (xx, xy), (nx, xy), (nx, ny)] {
|
||
let (hh, ll) = split_ds_xyz(x, y, elev_v);
|
||
pts.push(hh);
|
||
low.push(ll);
|
||
}
|
||
wires.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.clone(),
|
||
points: pts,
|
||
points_low: low,
|
||
color: [1.0, 0.0, 1.0, 1.0],
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts: Vec::new(),
|
||
tangent_geoms: Vec::new(),
|
||
aci: 0,
|
||
key_vertices: Vec::new(),
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
}
|
||
wires.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: sdf_verts,
|
||
name,
|
||
points: Vec::new(),
|
||
points_low: Vec::new(),
|
||
color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts,
|
||
tangent_geoms: te.tangent_geoms,
|
||
aci: 0,
|
||
key_vertices,
|
||
aabb: text_aabb,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
return wires;
|
||
}
|
||
|
||
let mut out: Vec<WireModel> = Vec::new();
|
||
let mut is_first = true;
|
||
for bin in bins {
|
||
let wire_color = match bin.color {
|
||
Some([r, g, b]) => [r, g, b, color[3]],
|
||
None => color,
|
||
};
|
||
// Bold text strokes carry a wider pen so the editor preview
|
||
// draws them thicker (these stroke wires exist only when
|
||
// strokes are forced, i.e. the preview; the main render uses
|
||
// SDF where bold is a wider baked pen).
|
||
let bin_lw = if bin.bold {
|
||
line_weight_px.max(1.0) * 2.4
|
||
} else {
|
||
line_weight_px
|
||
};
|
||
|
||
if !bin.pts.is_empty() {
|
||
let (snap, keys, tangents) = if is_first {
|
||
is_first = false;
|
||
(
|
||
snap_pts.clone(),
|
||
key_vertices.clone(),
|
||
te.tangent_geoms.clone(),
|
||
)
|
||
} else {
|
||
(Vec::new(), Vec::new(), Vec::new())
|
||
};
|
||
out.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.clone(),
|
||
points: bin.pts,
|
||
points_low: bin.pts_low,
|
||
color: wire_color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: bin_lw,
|
||
snap_pts: snap,
|
||
tangent_geoms: tangents,
|
||
aci: 0,
|
||
key_vertices: keys,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
}
|
||
|
||
if !bin.fill_tris.is_empty() {
|
||
let (snap, keys, tangents) = if is_first {
|
||
is_first = false;
|
||
(
|
||
snap_pts.clone(),
|
||
key_vertices.clone(),
|
||
te.tangent_geoms.clone(),
|
||
)
|
||
} else {
|
||
(Vec::new(), Vec::new(), Vec::new())
|
||
};
|
||
out.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.clone(),
|
||
points: Vec::new(),
|
||
points_low: Vec::new(),
|
||
color: wire_color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts: snap,
|
||
tangent_geoms: tangents,
|
||
aci: 0,
|
||
key_vertices: keys,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: bin.fill_tris,
|
||
fill_tris_low: bin.fill_tris_low,
|
||
});
|
||
}
|
||
}
|
||
|
||
// Composite Text objects (e.g. a tolerance frame) keep geometry
|
||
// in `bins` (run-less groups) and text in `sdf_verts` (run
|
||
// groups). Emit the glyphs on their own wire carrying the tight
|
||
// glyph-box AABB so the text draws + picks alongside the box
|
||
// strokes. TEXT / MTEXT never reach here with SDF on (their bins
|
||
// are empty → the early-return path above).
|
||
if !sdf_verts.is_empty() {
|
||
out.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: sdf_verts,
|
||
name: name.clone(),
|
||
points: Vec::new(),
|
||
points_low: Vec::new(),
|
||
color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts: Vec::new(),
|
||
tangent_geoms: Vec::new(),
|
||
aci: 0,
|
||
key_vertices: Vec::new(),
|
||
aabb: text_aabb,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
}
|
||
|
||
if out.is_empty() {
|
||
out.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name,
|
||
points: Vec::new(),
|
||
points_low: Vec::new(),
|
||
color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts,
|
||
tangent_geoms: te.tangent_geoms,
|
||
aci: 0,
|
||
key_vertices,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── Standard topology objects ─────────────────────────────────
|
||
TruckObject::Point(v) => {
|
||
let result = tessellate_vertex(&v);
|
||
match result {
|
||
TruckTessResult::Point([x, y, z], [xl, yl, zl]) => {
|
||
// A PDMODE=0 point is a single dot. Size its marker to
|
||
// ~1 px so it reads as a dot rather than a large
|
||
// world-space "+" in small drawings — otherwise the
|
||
// dimension def-points on the Defpoints layer litter
|
||
// the view with crosses. The tessellation cache keys on
|
||
// world-per-pixel, so this re-sizes on zoom and stays a
|
||
// constant on-screen size. (#139)
|
||
let s = world_per_pixel
|
||
.map(|w| (w * 0.75).max(1e-6))
|
||
.unwrap_or(0.1);
|
||
let snap_pts = te.snap_pts;
|
||
let key_vertices: Vec<[f64; 3]> = te
|
||
.key_vertices
|
||
.into_iter()
|
||
.map(|[kx, ky, kz]| [kx, ky, kz])
|
||
.collect();
|
||
return vec![WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name,
|
||
points: vec![
|
||
[x - s, y, z],
|
||
[x + s, y, z],
|
||
[x, y - s, z],
|
||
[x, y + s, z],
|
||
],
|
||
// All four cross points share the Point's residual
|
||
// (the cross arms are tiny, < 0.1 m, so the low
|
||
// component of the centre is also the right one
|
||
// for the arm tips at f32 precision).
|
||
points_low: vec![[xl, yl, zl]; 4],
|
||
color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px: 1.0,
|
||
snap_pts,
|
||
tangent_geoms: te.tangent_geoms,
|
||
aci: 0,
|
||
key_vertices,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
}];
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
TruckObject::Curve(e) => {
|
||
if let TruckTessResult::Lines(points, points_low) =
|
||
tessellate_edge(&e)
|
||
{
|
||
let snap_pts = te.snap_pts;
|
||
let key_vertices: Vec<[f64; 3]> = te
|
||
.key_vertices
|
||
.into_iter()
|
||
.map(|[x, y, z]| [x, y, z])
|
||
.collect();
|
||
return vec![WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name,
|
||
points,
|
||
points_low,
|
||
color,
|
||
selected,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
snap_pts,
|
||
tangent_geoms: te.tangent_geoms,
|
||
aci: 0,
|
||
key_vertices,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
}];
|
||
}
|
||
}
|
||
|
||
TruckObject::Contour(w) => {
|
||
if let TruckTessResult::Lines(points, points_low) =
|
||
tessellate_wire(&w)
|
||
{
|
||
let snap_pts = te.snap_pts;
|
||
let key_vertices: Vec<[f64; 3]> = te
|
||
.key_vertices
|
||
.into_iter()
|
||
.map(|[x, y, z]| [x, y, z])
|
||
.collect();
|
||
return vec![WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name,
|
||
points,
|
||
points_low,
|
||
color,
|
||
selected,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
snap_pts,
|
||
tangent_geoms: te.tangent_geoms,
|
||
aci: 0,
|
||
key_vertices,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
}];
|
||
}
|
||
}
|
||
|
||
TruckObject::Lines(points) => {
|
||
// Points are world-space f64 from entity converters (polyline,
|
||
// leader, mesh, solid2d, etc.). Subtract world_offset in f64
|
||
// and split into double-single (high, low) f32 buffers — the
|
||
// GPU shader pairs them so drawings at large UTM-style
|
||
// coordinates keep sub-unit precision in the wire model and
|
||
// don't jitter on camera movement.
|
||
let (local_pts, local_pts_low) = points_to_ds(points);
|
||
let snap_pts = te.snap_pts;
|
||
let key_vertices: Vec<[f64; 3]> = te
|
||
.key_vertices
|
||
.into_iter()
|
||
.map(|[x, y, z]| [x, y, z])
|
||
.collect();
|
||
let (fill_tris, fill_tris_low) = points_to_ds(te.fill_tris);
|
||
let mut out = Vec::new();
|
||
let mut is_first = true;
|
||
|
||
if !local_pts.is_empty() {
|
||
let (snap, keys, tangents) = if is_first {
|
||
is_first = false;
|
||
(
|
||
snap_pts.clone(),
|
||
key_vertices.clone(),
|
||
te.tangent_geoms.clone(),
|
||
)
|
||
} else {
|
||
(Vec::new(), Vec::new(), Vec::new())
|
||
};
|
||
out.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.clone(),
|
||
points: local_pts,
|
||
points_low: local_pts_low,
|
||
color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts: snap,
|
||
tangent_geoms: tangents,
|
||
aci: 0,
|
||
key_vertices: keys,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
}
|
||
|
||
if !fill_tris.is_empty() {
|
||
let (snap, keys, tangents) = if is_first {
|
||
(
|
||
snap_pts.clone(),
|
||
key_vertices.clone(),
|
||
te.tangent_geoms.clone(),
|
||
)
|
||
} else {
|
||
(Vec::new(), Vec::new(), Vec::new())
|
||
};
|
||
out.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.clone(),
|
||
points: Vec::new(),
|
||
points_low: Vec::new(),
|
||
color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts: snap,
|
||
tangent_geoms: tangents,
|
||
aci: 0,
|
||
key_vertices: keys,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris,
|
||
fill_tris_low,
|
||
});
|
||
}
|
||
|
||
if out.is_empty() {
|
||
out.push(WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name,
|
||
points: Vec::new(),
|
||
points_low: Vec::new(),
|
||
color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts,
|
||
tangent_geoms: te.tangent_geoms,
|
||
aci: 0,
|
||
key_vertices,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
TruckObject::SegmentedLines(points) => {
|
||
let (local_pts, local_pts_low) = points_to_ds(points);
|
||
let snap_pts = te.snap_pts;
|
||
let key_vertices: Vec<[f64; 3]> = te
|
||
.key_vertices
|
||
.into_iter()
|
||
.map(|[x, y, z]| [x, y, z])
|
||
.collect();
|
||
return vec![WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name,
|
||
points: local_pts,
|
||
points_low: local_pts_low,
|
||
color,
|
||
selected,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
snap_pts,
|
||
tangent_geoms: te.tangent_geoms,
|
||
aci: 0,
|
||
key_vertices,
|
||
plinegen: false,
|
||
vp_scissor: None,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
}];
|
||
}
|
||
|
||
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);
|
||
let mut wm = WireModel::solid_f64(name, wire_pts, color, selected);
|
||
// Add insertion snap at point_of_reference.
|
||
if let Some(p) = crate::entities::solid3d::point_of_reference(entity) {
|
||
let sp = glam::DVec3::new(p.x, p.y, p.z);
|
||
wm.snap_pts.push((sp, SnapHint::Insertion));
|
||
}
|
||
return vec![wm];
|
||
}
|
||
}
|
||
}
|
||
|
||
// ── Fallback for Viewport / Insert / Hatch / Ole2Frame ────────────────
|
||
let (points_f64, snap_pts, tangent_geoms, key_vertices) =
|
||
fallback_geometry(entity);
|
||
// `points_f64` are absolute world coords; split into the double-single
|
||
// high/low pair so the outline reconstructs to f64 precision at UTM scale
|
||
// (a NaN separator stays NaN in both buffers).
|
||
let mut points: Vec<[f32; 3]> = Vec::with_capacity(points_f64.len());
|
||
let mut points_low: Vec<[f32; 3]> = Vec::with_capacity(points_f64.len());
|
||
for [x, y, z] in &points_f64 {
|
||
if !x.is_finite() || !y.is_finite() {
|
||
points.push([f32::NAN, f32::NAN, f32::NAN]);
|
||
points_low.push([0.0; 3]);
|
||
continue;
|
||
}
|
||
let (hx, lx) = split_ds(*x);
|
||
let (hy, ly) = split_ds(*y);
|
||
let (hz, lz) = split_ds(*z);
|
||
points.push([hx, hy, hz]);
|
||
points_low.push([lx, ly, lz]);
|
||
}
|
||
// fallback_geometry still emits offset-relative f32 snap points; widen to
|
||
// f64 for the WireModel's double-single-era snap buffer.
|
||
let snap_pts: Vec<(glam::DVec3, SnapHint)> =
|
||
snap_pts.into_iter().map(|(p, h)| (p.as_dvec3(), h)).collect();
|
||
vec![WireModel {
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name,
|
||
points,
|
||
points_low,
|
||
color,
|
||
selected,
|
||
aci: 0,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
snap_pts,
|
||
tangent_geoms,
|
||
key_vertices,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
vp_scissor: None,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
}]
|
||
}
|
||
|
||
|
||
|
||
#[derive(Clone, Copy)]
|
||
pub(crate) 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 },
|
||
}
|
||
|
||
pub(crate) 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,
|
||
},
|
||
}
|
||
}
|
||
|
||
pub(crate) struct DimGeom {
|
||
pub(crate) ext_lines: Vec<[f32; 3]>,
|
||
pub(crate) dim_lines: Vec<[f32; 3]>,
|
||
pub(crate) arrow_fill: Vec<[f32; 3]>,
|
||
}
|
||
|
||
impl DimGeom {
|
||
pub(crate) fn new() -> Self {
|
||
Self {
|
||
ext_lines: Vec::new(),
|
||
dim_lines: Vec::new(),
|
||
arrow_fill: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/// Convert an acadrust `Color` to RGBA, falling back to `inherited` for
|
||
/// `ByLayer` / `ByBlock` (assumes those are already resolved upstream).
|
||
pub(crate) 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,
|
||
}
|
||
}
|
||
|
||
|
||
// ── Entity Z helper ───────────────────────────────────────────────────────
|
||
|
||
/// Extract the Z elevation from a text/mtext entity.
|
||
pub(crate) 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,
|
||
}
|
||
}
|
||
|
||
// ── Fallback geometry (Viewport, Insert, Hatch outline, Ole2Frame) ───────
|
||
//
|
||
// Per-entity blocks have moved to their respective `entities/*.rs` files
|
||
// (Viewport, Insert, Hatch, Ole2Frame) via the `FallbackTess` trait. This
|
||
// function stays as the dispatcher used by the main `tessellate()` path.
|
||
|
||
use crate::entities::traits::FallbackTess;
|
||
use crate::scene::convert::tess_util::FallbackGeometry as Geometry;
|
||
|
||
fn fallback_geometry(entity: &EntityType) -> Geometry {
|
||
match entity {
|
||
EntityType::Viewport(vp) => vp.fallback_geometry(),
|
||
EntityType::Insert(ins) => ins.fallback_geometry(),
|
||
EntityType::Hatch(h) => h.fallback_geometry(),
|
||
EntityType::Ole2Frame(ole) => ole.fallback_geometry(),
|
||
// Modeler solids render as meshes (solid3d_tess). Their wire path
|
||
// contributes only the pre-computed edge wires (empty for binary SAB)
|
||
// plus an insertion snap — never the placeholder segment below, which
|
||
// would otherwise draw a stray 1-unit line at the origin next to the
|
||
// solid.
|
||
EntityType::Solid3D(_)
|
||
| EntityType::Region(_)
|
||
| EntityType::Body(_)
|
||
| EntityType::Surface(_) => {
|
||
let pts = solid_wire_fallback(entity);
|
||
let mut snap = vec![];
|
||
if let Some(p) = crate::entities::solid3d::point_of_reference(entity) {
|
||
snap.push((
|
||
Vec3::new((p.x) as f32, (p.y) as f32, (p.z) as f32),
|
||
SnapHint::Insertion,
|
||
));
|
||
}
|
||
(pts, snap, vec![], vec![])
|
||
}
|
||
_ => {
|
||
let s = 0.5_f64;
|
||
(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) -> Vec<[f64; 3]> {
|
||
let Some(wires) = crate::entities::solid3d::fallback_wires(entity) else {
|
||
return vec![];
|
||
};
|
||
if wires.is_empty() {
|
||
return vec![];
|
||
}
|
||
|
||
let mut pts: Vec<[f64; 3]> = Vec::new();
|
||
for wire in wires {
|
||
if wire.points.len() < 2 {
|
||
continue;
|
||
}
|
||
for v in &wire.points {
|
||
pts.push([v.x, v.y, v.z]);
|
||
}
|
||
// NaN sentinel separates distinct wire segments.
|
||
pts.push([f64::NAN, f64::NAN, f64::NAN]);
|
||
}
|
||
pts
|
||
}
|
||
|
||
pub(crate) 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]);
|
||
}
|
||
|
||
pub(crate) 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);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) 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]);
|
||
}
|
||
|
||
pub(crate) 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]));
|
||
}
|
||
|
||
/// 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.
|
||
pub(crate) fn normalized_or(v: Vec3, fallback: Vec3) -> Vec3 {
|
||
if v.length_squared() <= 1e-12 {
|
||
fallback
|
||
} else {
|
||
v.normalize()
|
||
}
|
||
}
|
||
|