forked from 1b-agent/opencadstudio-upstream
Selection tested a flat 8 px radius around each wire's centreline and nothing else, so two things the user can see were not pickable. A wire renders as a band `line_weight_px` wide, so pick now widens to that rendered half-width via `pick_tolerance_px`, mirroring the wire shader's `select(0.5, half_width, lwdisplay_enable)`. Behaviour is unchanged for every standard weight (the widest, 2.11 mm, renders 7.97 px half-width — just under the 8 px threshold); this keeps the two sides from drifting apart if the display boost in `lineweight_to_px` ever changes. Entities extruded by a DXF thickness draw their wall as four edges with nothing in between, so the interior was a hole the cursor fell through. WireModel gains `pick_tris`: triangles hit-testing treats as solid and the renderer never uploads. Kept apart from `fill_tris` because that channel reaches the GPU, and a wall that rendered shaded would change how every thickness drawing looks. Ranked below `fill_tris` — those are drawn, these are not, so where they overlap the drawn face is what the user means. Walls are dropped when a wire is projected into a floating viewport: `points` are reprojected and clipped into paper coords there, so cloned model-space triangles would hit-test at a model-scale offset. Extruded entities stay selectable by their edges, which do get projected. The wall loop runs on every hover that misses everything else, so it rejects on the wire's AABB first — an extruded circle is ~128 triangles and a drawing full of thickness would otherwise project every wall on each mouse move. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1662 lines
70 KiB
Rust
1662 lines
70 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.
|
||
pub(crate) 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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
// 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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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);
|
||
// Thickness walls ride on the wire that carries their edges, not
|
||
// on a wire of their own: they are pick geometry for that entity,
|
||
// and `fill_tris` below deliberately splits off into a fill-only
|
||
// wire (`is_fill_only`) which has no `points` to hang them from.
|
||
let (pick_tris, pick_tris_low) = points_to_ds(te.pick_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 {
|
||
pick_tris,
|
||
pick_tris_low,
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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 {
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
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()
|
||
}
|
||
}
|
||
|