forked from 1b-agent/opencadstudio-upstream
Real BT/Tj text objects ride alongside the vector outlines (Tr 3, zero visual change): runs preserved at layout time, TTF styles embedded as allsorts subsets with ToUnicode, width-matched Tm, clip-culled. WireModel literals moved to ..Default::default() tails.
3477 lines
138 KiB
Rust
3477 lines
138 KiB
Rust
// Tessellation — convert opencadcodec EntityType to GPU-ready WireModel or MeshModel.
|
||
//
|
||
// Flow:
|
||
// EntityType
|
||
// ↓ acad_to_render::convert()
|
||
// RenderEntity { object: RenderObject, snap_pts, tangent_geoms, key_vertices }
|
||
// ↓
|
||
// RenderObject::Lines → WireModel
|
||
// RenderObject::Dot → WireModel (a dot sized in pixels)
|
||
// RenderObject::Text → WireModel (glyph strokes) + SDF quads
|
||
// RenderObject::Text → one WireModel per glyph stroke (elevation from entity Z)
|
||
//
|
||
// Entities not handled by acad_to_render (Viewport, Insert, Hatch, Ole2Frame)
|
||
// are tessellated by the FallbackTess fallback_geometry() path.
|
||
|
||
use crate::entities::leader::LeaderTess;
|
||
use codec::types::Color as AcadColor;
|
||
use codec::{CadDocument, EntityType, Handle};
|
||
use glam::Vec3;
|
||
|
||
use crate::scene::convert::acad_to_render::{convert, RenderObject};
|
||
use crate::scene::model::wire_model::{SnapHint, TangentGeom, 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])
|
||
}
|
||
|
||
fn oriented_text_corners(
|
||
verts: &[crate::scene::pipeline::text_gpu::TextVertex],
|
||
origin: [f64; 2],
|
||
rotation: f64,
|
||
pad: f64,
|
||
) -> [[f64; 2]; 4] {
|
||
let (sin_r, cos_r) = rotation.sin_cos();
|
||
let mut bounds = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||
for vertex in verts {
|
||
let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0];
|
||
let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1];
|
||
let local_x = x * cos_r + y * sin_r;
|
||
let local_y = -x * sin_r + y * cos_r;
|
||
bounds[0] = bounds[0].min(local_x);
|
||
bounds[1] = bounds[1].min(local_y);
|
||
bounds[2] = bounds[2].max(local_x);
|
||
bounds[3] = bounds[3].max(local_y);
|
||
}
|
||
let [left, bottom, right, top] = [
|
||
bounds[0] - pad,
|
||
bounds[1] - pad,
|
||
bounds[2] + pad,
|
||
bounds[3] + pad,
|
||
];
|
||
let to_world = |x: f64, y: f64| {
|
||
[
|
||
origin[0] + x * cos_r - y * sin_r,
|
||
origin[1] + x * sin_r + y * cos_r,
|
||
]
|
||
};
|
||
[
|
||
to_world(left, bottom),
|
||
to_world(right, bottom),
|
||
to_world(right, top),
|
||
to_world(left, top),
|
||
]
|
||
}
|
||
|
||
fn oriented_mtext_corner_groups(
|
||
verts: &[crate::scene::pipeline::text_gpu::TextVertex],
|
||
text: &codec::MText,
|
||
rotation: f64,
|
||
pad: f64,
|
||
annotation_scale: f64,
|
||
) -> Vec<[[f64; 2]; 4]> {
|
||
let columns = &text.column_data;
|
||
let count = crate::entities::text_support::clamp_mtext_column_count(columns.column_count)
|
||
as usize;
|
||
if columns.column_type == 0 || count <= 1 || columns.width <= 0.0 {
|
||
return vec![oriented_text_corners(
|
||
verts,
|
||
[text.insertion_point.x, text.insertion_point.y],
|
||
rotation,
|
||
pad,
|
||
)];
|
||
}
|
||
|
||
let width = columns.width * annotation_scale;
|
||
let gutter = columns.gutter.max(0.0) * annotation_scale;
|
||
let total_width = width * count as f64 + gutter * count.saturating_sub(1) as f64;
|
||
let anchor = match text.attachment_point {
|
||
codec::entities::mtext::AttachmentPoint::TopCenter
|
||
| codec::entities::mtext::AttachmentPoint::MiddleCenter
|
||
| codec::entities::mtext::AttachmentPoint::BottomCenter => 0.5,
|
||
codec::entities::mtext::AttachmentPoint::TopRight
|
||
| codec::entities::mtext::AttachmentPoint::MiddleRight
|
||
| codec::entities::mtext::AttachmentPoint::BottomRight => 1.0,
|
||
_ => 0.0,
|
||
};
|
||
let block_left = -anchor * total_width;
|
||
let origin = [text.insertion_point.x, text.insertion_point.y];
|
||
let (sin_r, cos_r) = rotation.sin_cos();
|
||
let mut bounds = vec![[f64::MAX, f64::MAX, f64::MIN, f64::MIN]; count];
|
||
for vertex in verts {
|
||
let x = vertex.pos[0] as f64 + vertex.pos_low[0] as f64 - origin[0];
|
||
let y = vertex.pos[1] as f64 + vertex.pos_low[1] as f64 - origin[1];
|
||
let local_x = x * cos_r + y * sin_r;
|
||
let local_y = -x * sin_r + y * cos_r;
|
||
let stride = width + gutter;
|
||
let physical = ((local_x - block_left) / stride)
|
||
.floor()
|
||
.clamp(0.0, count.saturating_sub(1) as f64) as usize;
|
||
bounds[physical][0] = bounds[physical][0].min(local_x);
|
||
bounds[physical][1] = bounds[physical][1].min(local_y);
|
||
bounds[physical][2] = bounds[physical][2].max(local_x);
|
||
bounds[physical][3] = bounds[physical][3].max(local_y);
|
||
}
|
||
let to_world = |x: f64, y: f64| {
|
||
[
|
||
origin[0] + x * cos_r - y * sin_r,
|
||
origin[1] + x * sin_r + y * cos_r,
|
||
]
|
||
};
|
||
bounds
|
||
.into_iter()
|
||
.filter(|bounds| bounds[0] <= bounds[2] && bounds[1] <= bounds[3])
|
||
.map(|bounds| {
|
||
let [left, bottom, right, top] = [
|
||
bounds[0] - pad,
|
||
bounds[1] - pad,
|
||
bounds[2] + pad,
|
||
bounds[3] + pad,
|
||
];
|
||
[
|
||
to_world(left, bottom),
|
||
to_world(right, bottom),
|
||
to_world(right, top),
|
||
to_world(left, top),
|
||
]
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
pub(crate) fn explicit_mtext_background(entity: &EntityType) -> Option<[f32; 4]> {
|
||
let EntityType::MText(text) = entity else {
|
||
return None;
|
||
};
|
||
if text.background_fill_flags & 0x01 == 0 || text.background_fill_flags & 0x02 != 0 {
|
||
return None;
|
||
}
|
||
text.background_color.rgb().map(|(r, g, b)| {
|
||
[
|
||
r as f32 / 255.0,
|
||
g as f32 / 255.0,
|
||
b as f32 / 255.0,
|
||
1.0,
|
||
]
|
||
})
|
||
}
|
||
|
||
pub(crate) fn text_contrast_background(
|
||
entity: &EntityType,
|
||
canvas: [f32; 4],
|
||
) -> [f32; 4] {
|
||
explicit_mtext_background(entity).unwrap_or(canvas)
|
||
}
|
||
|
||
/// 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);
|
||
if cap == 0 {
|
||
return (Vec::new(), Vec::new());
|
||
}
|
||
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)
|
||
}
|
||
|
||
fn polyline_segment_widths(entity: &EntityType) -> Vec<(f32, f32)> {
|
||
match entity {
|
||
EntityType::LwPolyline(p) => {
|
||
let count = p.vertices.len();
|
||
let seg_count = if p.is_closed {
|
||
count
|
||
} else {
|
||
count.saturating_sub(1)
|
||
};
|
||
let c = p.constant_width;
|
||
(0..seg_count)
|
||
.map(|i| {
|
||
let v = &p.vertices[i];
|
||
let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 {
|
||
(v.start_width, v.end_width)
|
||
} else {
|
||
(c, c)
|
||
};
|
||
(sw as f32, ew as f32)
|
||
})
|
||
.collect()
|
||
}
|
||
EntityType::Polyline2D(p) => {
|
||
let filtered = crate::entities::polyline::drawn_vertices2d(p);
|
||
let verts: &[codec::entities::Vertex2D] =
|
||
filtered.as_deref().unwrap_or(&p.vertices);
|
||
let count = verts.len();
|
||
let seg_count = if p.is_closed() {
|
||
count
|
||
} else {
|
||
count.saturating_sub(1)
|
||
};
|
||
let def_start = p.start_width;
|
||
let def_end = p.end_width;
|
||
(0..seg_count)
|
||
.map(|i| {
|
||
let v = &verts[i];
|
||
let (sw, ew) = if v.start_width > 1e-9 || v.end_width > 1e-9 {
|
||
(v.start_width, v.end_width)
|
||
} else {
|
||
(def_start, def_end)
|
||
};
|
||
(sw as f32, ew as f32)
|
||
})
|
||
.collect()
|
||
}
|
||
_ => Vec::new(),
|
||
}
|
||
}
|
||
|
||
fn split_mixed_polyline(
|
||
tangent_geoms: &[TangentGeom],
|
||
key_vertices: &[[f64; 3]],
|
||
name: &str,
|
||
color: [f32; 4],
|
||
selected: bool,
|
||
pattern_length: f32,
|
||
pattern: [f32; 8],
|
||
line_weight_px: f32,
|
||
snap_pts: Vec<(glam::DVec3, SnapHint)>,
|
||
point_marker: Option<crate::scene::model::wire_model::PointMarker>,
|
||
plinegen: bool,
|
||
seg_widths: &[(f32, f32)],
|
||
global_world_width: f32,
|
||
mut pick_tris: Vec<[f32; 3]>,
|
||
mut pick_tris_low: Vec<[f32; 3]>,
|
||
) -> Vec<WireModel> {
|
||
let mut out = Vec::new();
|
||
|
||
// Emit each arc segment as an analytical wire (CircleGpu target)
|
||
for (i, tg) in tangent_geoms.iter().enumerate() {
|
||
if let TangentGeom::Arc {
|
||
center,
|
||
axis_x,
|
||
axis_y,
|
||
radius,
|
||
start_angle,
|
||
end_angle,
|
||
} = *tg
|
||
{
|
||
let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) {
|
||
(w0, w1)
|
||
} else if global_world_width > 1e-9 {
|
||
(global_world_width, global_world_width)
|
||
} else {
|
||
(0.0, 0.0)
|
||
};
|
||
|
||
// Check if the CCW start_angle of this TangentGeom::Arc corresponds
|
||
// to the segment's end vertex (clockwise bulge arc in CAD where
|
||
// bulge_arc_to_tangent inverted start_angle and end_angle to enforce CCW).
|
||
let (p_seg_start, p_seg_end) = if i + 1 < key_vertices.len() {
|
||
(key_vertices[i], key_vertices[i + 1])
|
||
} else if !key_vertices.is_empty() {
|
||
(
|
||
key_vertices[i % key_vertices.len()],
|
||
key_vertices[(i + 1) % key_vertices.len()],
|
||
)
|
||
} else {
|
||
([0.0; 3], [0.0; 3])
|
||
};
|
||
|
||
let p_sa = [
|
||
center[0] + radius * (start_angle.cos() * axis_x[0] + start_angle.sin() * axis_y[0]),
|
||
center[1] + radius * (start_angle.cos() * axis_x[1] + start_angle.sin() * axis_y[1]),
|
||
center[2] + radius * (start_angle.cos() * axis_x[2] + start_angle.sin() * axis_y[2]),
|
||
];
|
||
let is_reversed = if !key_vertices.is_empty() {
|
||
let d_start_sq = (p_sa[0] - p_seg_start[0]).powi(2)
|
||
+ (p_sa[1] - p_seg_start[1]).powi(2)
|
||
+ (p_sa[2] - p_seg_start[2]).powi(2);
|
||
let d_end_sq = (p_sa[0] - p_seg_end[0]).powi(2)
|
||
+ (p_sa[1] - p_seg_end[1]).powi(2)
|
||
+ (p_sa[2] - p_seg_end[2]).powi(2);
|
||
d_end_sq < d_start_sq
|
||
} else {
|
||
false
|
||
};
|
||
|
||
let (w_at_sa, w_at_ea) = if is_reversed {
|
||
(ew, sw)
|
||
} else {
|
||
(sw, ew)
|
||
};
|
||
|
||
let n = 16;
|
||
let mut points = Vec::with_capacity(n + 1);
|
||
let mut points_low = Vec::with_capacity(n + 1);
|
||
let mut arc_widths = Vec::with_capacity(n + 1);
|
||
let sweep = if end_angle >= start_angle {
|
||
end_angle - start_angle
|
||
} else {
|
||
end_angle - start_angle + std::f64::consts::TAU
|
||
};
|
||
for s in 0..=n {
|
||
let frac = s as f64 / n as f64;
|
||
let ang = start_angle + frac * sweep;
|
||
let (sin_a, cos_a) = ang.sin_cos();
|
||
let x = center[0] + radius * (cos_a * axis_x[0] + sin_a * axis_y[0]);
|
||
let y = center[1] + radius * (cos_a * axis_x[1] + sin_a * axis_y[1]);
|
||
let z = center[2] + radius * (cos_a * axis_x[2] + sin_a * axis_y[2]);
|
||
let (h, l) = split_ds_xyz(x, y, z);
|
||
points.push(h);
|
||
points_low.push(l);
|
||
arc_widths.push(w_at_sa + (w_at_ea - w_at_sa) * frac as f32);
|
||
}
|
||
|
||
let taper_widths = if (w_at_sa - w_at_ea).abs() > 1e-6 {
|
||
arc_widths
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
let world_width = sw.max(ew);
|
||
|
||
let (arc_pt, arc_ptl) = if out.is_empty() && !pick_tris.is_empty() {
|
||
(std::mem::take(&mut pick_tris), std::mem::take(&mut pick_tris_low))
|
||
} else {
|
||
(Vec::new(), Vec::new())
|
||
};
|
||
|
||
out.push(WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths,
|
||
pattern_stations: Vec::new(),
|
||
world_width,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris: arc_pt,
|
||
pick_tris_low: arc_ptl,
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.to_string(),
|
||
points,
|
||
points_low,
|
||
color,
|
||
selected,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
snap_pts: Vec::new(),
|
||
tangent_geoms: vec![tg.clone()],
|
||
aci: 0,
|
||
key_vertices: Vec::new(),
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
}
|
||
|
||
// Collect straight lines into a line wire
|
||
let mut straight_pts: Vec<[f64; 3]> = Vec::new();
|
||
let mut straight_widths: Vec<f32> = Vec::new();
|
||
let mut straight_tangents: Vec<TangentGeom> = Vec::new();
|
||
let mut last_end: Option<[f64; 3]> = None;
|
||
let mut last_end_w: f32 = 0.0;
|
||
|
||
for (i, tg) in tangent_geoms.iter().enumerate() {
|
||
if let TangentGeom::Line { p1, p2 } = tg {
|
||
straight_tangents.push(tg.clone());
|
||
let p_start = if i + 1 < key_vertices.len()
|
||
&& (key_vertices[i][0] as f32 - p1[0]).abs() < 1e-3
|
||
&& (key_vertices[i][1] as f32 - p1[1]).abs() < 1e-3
|
||
&& (key_vertices[i][2] as f32 - p1[2]).abs() < 1e-3
|
||
{
|
||
key_vertices[i]
|
||
} else {
|
||
[p1[0] as f64, p1[1] as f64, p1[2] as f64]
|
||
};
|
||
let p_end = if i + 1 < key_vertices.len()
|
||
&& (key_vertices[i + 1][0] as f32 - p2[0]).abs() < 1e-3
|
||
&& (key_vertices[i + 1][1] as f32 - p2[1]).abs() < 1e-3
|
||
&& (key_vertices[i + 1][2] as f32 - p2[2]).abs() < 1e-3
|
||
{
|
||
key_vertices[i + 1]
|
||
} else {
|
||
[p2[0] as f64, p2[1] as f64, p2[2] as f64]
|
||
};
|
||
let (sw, ew) = if let Some(&(w0, w1)) = seg_widths.get(i) {
|
||
(w0, w1)
|
||
} else if global_world_width > 1e-9 {
|
||
(global_world_width, global_world_width)
|
||
} else {
|
||
(0.0, 0.0)
|
||
};
|
||
if !plinegen {
|
||
if !straight_pts.is_empty() {
|
||
straight_pts.push([f64::NAN; 3]);
|
||
straight_widths.push(0.0);
|
||
}
|
||
straight_pts.push(p_start);
|
||
straight_widths.push(sw);
|
||
straight_pts.push(p_end);
|
||
straight_widths.push(ew);
|
||
} else {
|
||
if let Some(prev) = last_end {
|
||
if (prev[0] - p_start[0]).abs() < 1e-7
|
||
&& (prev[1] - p_start[1]).abs() < 1e-7
|
||
&& (prev[2] - p_start[2]).abs() < 1e-7
|
||
&& (last_end_w - sw).abs() < 1e-6
|
||
{
|
||
straight_pts.push(p_end);
|
||
straight_widths.push(ew);
|
||
} else {
|
||
straight_pts.push([f64::NAN; 3]);
|
||
straight_widths.push(0.0);
|
||
straight_pts.push(p_start);
|
||
straight_widths.push(sw);
|
||
straight_pts.push(p_end);
|
||
straight_widths.push(ew);
|
||
}
|
||
} else {
|
||
if !straight_pts.is_empty() {
|
||
straight_pts.push([f64::NAN; 3]);
|
||
straight_widths.push(0.0);
|
||
}
|
||
straight_pts.push(p_start);
|
||
straight_widths.push(sw);
|
||
straight_pts.push(p_end);
|
||
straight_widths.push(ew);
|
||
}
|
||
last_end = Some(p_end);
|
||
last_end_w = ew;
|
||
}
|
||
} else {
|
||
last_end = None;
|
||
}
|
||
}
|
||
|
||
let (line_pts, line_pts_low) = points_to_ds(straight_pts);
|
||
if !line_pts.is_empty() {
|
||
let (pt, ptl) = if !pick_tris.is_empty() {
|
||
(pick_tris, pick_tris_low)
|
||
} else {
|
||
(Vec::new(), Vec::new())
|
||
};
|
||
let line_world_width = straight_widths
|
||
.iter()
|
||
.copied()
|
||
.fold(0.0f32, f32::max);
|
||
let has_line_taper = straight_widths
|
||
.first()
|
||
.map_or(false, |&w0| straight_widths.iter().any(|&w| (w - w0).abs() > 1e-6));
|
||
let taper_widths = if has_line_taper || straight_widths.iter().any(|&w| w > 1e-6) {
|
||
straight_widths
|
||
} else {
|
||
Vec::new()
|
||
};
|
||
out.push(WireModel {
|
||
bg_adapt: None,
|
||
point_marker,
|
||
taper_widths,
|
||
pattern_stations: Vec::new(),
|
||
world_width: line_world_width,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris: pt,
|
||
pick_tris_low: ptl,
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: name.to_string(),
|
||
points: line_pts,
|
||
points_low: line_pts_low,
|
||
color,
|
||
selected,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
snap_pts,
|
||
tangent_geoms: straight_tangents,
|
||
aci: 0,
|
||
key_vertices: key_vertices.to_vec(),
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
});
|
||
} else if let Some(first_arc) = out.first_mut() {
|
||
first_arc.snap_pts = snap_pts;
|
||
first_arc.key_vertices = key_vertices.to_vec();
|
||
if first_arc.pick_tris.is_empty() && !pick_tris.is_empty() {
|
||
first_arc.pick_tris = pick_tris;
|
||
first_arc.pick_tris_low = pick_tris_low;
|
||
}
|
||
}
|
||
|
||
out
|
||
}
|
||
|
||
fn point_cloud_wires(
|
||
document: &CadDocument,
|
||
handle: Handle,
|
||
entity: &EntityType,
|
||
selected: bool,
|
||
color: [f32; 4],
|
||
line_weight_px: f32,
|
||
) -> Option<Vec<WireModel>> {
|
||
let EntityType::Extended(extended) = entity else {
|
||
return None;
|
||
};
|
||
let frame_points = crate::entities::extended::point_cloud_frame_lines(extended)?;
|
||
let rendered = convert(entity, document)?;
|
||
let RenderObject::Lines(body_points) = rendered.object else {
|
||
return None;
|
||
};
|
||
// A cloud whose scan file is found draws its points instead of its
|
||
// extents box; the box stays behind, unseen, to pick the cloud, with its
|
||
// faces so a click among the points selects it too.
|
||
let mut shown = true;
|
||
let (mut pick_tris, mut pick_tris_low) = (Vec::new(), Vec::new());
|
||
if let codec::entities::ExtendedEntityData::PointCloudEx(data) = &extended.data {
|
||
// Its box shows too with every point hidden (the reference does).
|
||
shown = crate::scene::model::point_cloud::shows_no_points(document, data);
|
||
let c = codec::entities::point_cloud_ex_corners(data).map(|c| [c.x, c.y, c.z]);
|
||
let mut tris = Vec::with_capacity(36);
|
||
for [a, b, d, e] in [[0, 1, 3, 2], [4, 5, 7, 6], [0, 1, 4, 5], [2, 3, 6, 7], [0, 2, 4, 6], [1, 3, 5, 7]] {
|
||
tris.extend([c[a], c[b], c[e], c[a], c[e], c[d]]);
|
||
}
|
||
(pick_tris, pick_tris_low) = points_to_ds(tris);
|
||
}
|
||
let (points, points_low) = points_to_ds(body_points);
|
||
let mut wires = vec![WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: shown,
|
||
snap_only: false,
|
||
plot_visible: shown,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris,
|
||
pick_tris_low,
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: Vec::new(),
|
||
name: handle.value().to_string(),
|
||
points,
|
||
points_low,
|
||
color,
|
||
selected,
|
||
pattern_length: 0.0,
|
||
pattern: [0.0; 8],
|
||
line_weight_px,
|
||
snap_pts: rendered.snap_pts,
|
||
tangent_geoms: rendered.tangent_geoms,
|
||
aci: 0,
|
||
key_vertices: rendered.key_vertices,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
fill_tris: Vec::new(),
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
}];
|
||
let mode = crate::scene::frame::mode(
|
||
document,
|
||
crate::scene::frame::FrameKind::PointCloudClip,
|
||
);
|
||
if !frame_points.is_empty() {
|
||
let (points, points_low) = points_to_ds(frame_points);
|
||
let mut frame = WireModel::solid(
|
||
handle.value().to_string(),
|
||
points,
|
||
color,
|
||
selected,
|
||
);
|
||
frame.points_low = points_low;
|
||
frame.line_weight_px = line_weight_px;
|
||
frame.display_visible = mode != 0;
|
||
frame.plot_visible = mode == 1;
|
||
wires.push(frame);
|
||
}
|
||
Some(wires)
|
||
}
|
||
|
||
/// 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.
|
||
pub(crate) 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,
|
||
annotation_scale_handle: Option<Handle>,
|
||
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 = crate::scene::annotative::effective_annotation_scale_for(
|
||
document,
|
||
entity,
|
||
anno_scale,
|
||
annotation_scale_handle,
|
||
);
|
||
|
||
// 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 the kernel `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();
|
||
}
|
||
if m.is_closed() && m.vertices.len() > 1 {
|
||
let first = &m.vertices[0].position;
|
||
let last = &m.vertices[m.vertices.len() - 1].position;
|
||
let dx = first.x - last.x;
|
||
let dy = first.y - last.y;
|
||
let dz = first.z - last.z;
|
||
acc += (dx * dx + dy * dy + dz * dz).sqrt();
|
||
}
|
||
acc as f32
|
||
};
|
||
let mut out: Vec<WireModel> = Vec::with_capacity(lines.len());
|
||
if let Some(style) = crate::entities::mline::resolved_mline_style(m, document) {
|
||
let triangles =
|
||
crate::entities::mline::mline_fill_triangles_with_style(m, style);
|
||
if !triangles.is_empty() {
|
||
let (fill_tris, fill_tris_low) = points_to_ds(triangles);
|
||
let fill_color = if selected {
|
||
WireModel::SELECTED
|
||
} else {
|
||
match style.fill_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]]
|
||
}
|
||
}
|
||
};
|
||
out.push(WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: true,
|
||
render_instance: None,
|
||
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: fill_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: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
fill_tris,
|
||
fill_tris_low,
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
}
|
||
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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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,
|
||
fill_tris: Vec::new(),
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
// 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;
|
||
}
|
||
|
||
if let Some(wires) = point_cloud_wires(
|
||
document,
|
||
handle,
|
||
entity,
|
||
selected,
|
||
color,
|
||
line_weight_px,
|
||
) {
|
||
return wires;
|
||
}
|
||
|
||
// ── Try the kernel path first ───────────────────────────────────────────
|
||
// Zoom-adaptive circles scale their segment count from the current zoom level and radius.
|
||
// 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::circle::relative_render(entity, document, world_per_pixel)
|
||
.or_else(|| crate::entities::point::relative_render(entity, document, world_per_pixel))
|
||
.or_else(|| crate::entities::light::relative_render(entity, document, world_per_pixel))
|
||
.or_else(|| match entity {
|
||
EntityType::Text(text) => Some(crate::entities::text::to_render_at_scale(
|
||
text,
|
||
document,
|
||
anno_scale,
|
||
)),
|
||
_ => 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.
|
||
RenderObject::Text(stroke_groups) => {
|
||
let entity_zf = entity_z(entity) as f64;
|
||
let elev_v = entity_zf;
|
||
|
||
// Scale MTEXT around its attachment point.
|
||
let ref_origin = match entity {
|
||
EntityType::MText(m) => [
|
||
m.insertion_point.x,
|
||
m.insertion_point.y,
|
||
],
|
||
_ => 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;
|
||
// Run groups normally render as textured quads. Web runs that
|
||
// require bidi or joined-script shaping keep their already
|
||
// shaped vector geometry because the per-glyph SDF path has no
|
||
// cluster-position data.
|
||
for group in stroke_groups
|
||
.iter()
|
||
.filter(|group| {
|
||
force_text_strokes
|
||
|| group.run.is_none()
|
||
|| group.run.as_ref().is_some_and(|run| {
|
||
crate::scene::text::web_font::requires_shaping(&run.text)
|
||
})
|
||
})
|
||
{
|
||
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 world = if let Some(plane) = group.plane {
|
||
let scaled_origin = [
|
||
plane.scale_origin[0]
|
||
+ (plane.origin[0] - plane.scale_origin[0]) * anno,
|
||
plane.scale_origin[1]
|
||
+ (plane.origin[1] - plane.scale_origin[1]) * anno,
|
||
plane.scale_origin[2]
|
||
+ (plane.origin[2] - plane.scale_origin[2]) * anno,
|
||
];
|
||
let x = x as f64 * anno;
|
||
let y = y as f64 * anno;
|
||
[
|
||
scaled_origin[0]
|
||
+ plane.x_axis[0] * x
|
||
+ plane.y_axis[0] * y,
|
||
scaled_origin[1]
|
||
+ plane.x_axis[1] * x
|
||
+ plane.y_axis[1] * y,
|
||
scaled_origin[2]
|
||
+ plane.x_axis[2] * x
|
||
+ plane.y_axis[2] * y,
|
||
]
|
||
} else {
|
||
[x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v]
|
||
};
|
||
let (h, l) = split_ds_xyz(world[0], world[1], world[2]);
|
||
bins[bi].pts.push(h);
|
||
bins[bi].pts_low.push(l);
|
||
}
|
||
}
|
||
|
||
// 2. Process fill triangles
|
||
for &[x, y] in &group.fill_tris {
|
||
let world = if let Some(plane) = group.plane {
|
||
let scaled_origin = [
|
||
plane.scale_origin[0]
|
||
+ (plane.origin[0] - plane.scale_origin[0]) * anno,
|
||
plane.scale_origin[1]
|
||
+ (plane.origin[1] - plane.scale_origin[1]) * anno,
|
||
plane.scale_origin[2]
|
||
+ (plane.origin[2] - plane.scale_origin[2]) * anno,
|
||
];
|
||
let x = x as f64 * anno;
|
||
let y = y as f64 * anno;
|
||
[
|
||
scaled_origin[0]
|
||
+ plane.x_axis[0] * x
|
||
+ plane.y_axis[0] * y,
|
||
scaled_origin[1]
|
||
+ plane.x_axis[1] * x
|
||
+ plane.y_axis[1] * y,
|
||
scaled_origin[2]
|
||
+ plane.x_axis[2] * x
|
||
+ plane.y_axis[2] * y,
|
||
]
|
||
} else {
|
||
[x as f64 * anno + slx_v, y as f64 * anno + sly_v, elev_v]
|
||
};
|
||
let (h, l) = split_ds_xyz(world[0], world[1], world[2]);
|
||
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();
|
||
let mut searchable: Vec<crate::scene::model::wire_model::SearchableTextRun> =
|
||
Vec::new();
|
||
{
|
||
if let Ok(mut atlas) = crate::scene::text::sdf_atlas::text_atlas().lock() {
|
||
use crate::scene::model::wire_model::{
|
||
clean_searchable_text, SearchableTextRun,
|
||
};
|
||
// 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 };
|
||
if crate::scene::text::web_font::requires_shaping(&run.text) {
|
||
continue;
|
||
}
|
||
// Searchable counterpart, cleaned once here and
|
||
// pushed after layout below (one run per laid-out
|
||
// group, so MTEXT wrap/attachment and rotation are
|
||
// already baked into the origin — no re-layout).
|
||
let visible = clean_searchable_text(&run.text);
|
||
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 gcolor = crate::scene::view::render::adapt_to_bg(
|
||
gcolor,
|
||
text_contrast_background(entity, bg_color),
|
||
);
|
||
let (quads, pen_adv) =
|
||
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,
|
||
);
|
||
if let Some(plane) = group.plane {
|
||
let scaled_origin = [
|
||
plane.scale_origin[0]
|
||
+ (plane.origin[0] - plane.scale_origin[0]) * anno,
|
||
plane.scale_origin[1]
|
||
+ (plane.origin[1] - plane.scale_origin[1]) * anno,
|
||
plane.scale_origin[2]
|
||
+ (plane.origin[2] - plane.scale_origin[2]) * anno,
|
||
];
|
||
crate::scene::pipeline::text_gpu::push_glyph_vertices_on_plane(
|
||
&mut sdf_verts,
|
||
&quads,
|
||
scaled_origin,
|
||
plane.x_axis,
|
||
plane.y_axis,
|
||
anno,
|
||
gcolor,
|
||
0.0,
|
||
);
|
||
} else {
|
||
crate::scene::pipeline::text_gpu::push_glyph_vertices(
|
||
&mut sdf_verts,
|
||
&quads,
|
||
[slx_v, sly_v, elev_v],
|
||
anno,
|
||
gcolor,
|
||
0.0,
|
||
);
|
||
}
|
||
// Searchable run: same adapted colour as the quads,
|
||
// exact pen advance in world units (layout's final
|
||
// pen position × annotation factor, like the quads).
|
||
if !visible.is_empty() {
|
||
let adv_world = (pen_adv as f64 * anno) as f32;
|
||
let (origin, rotation) = if let Some(plane) = group.plane {
|
||
(
|
||
[
|
||
plane.scale_origin[0]
|
||
+ (plane.origin[0] - plane.scale_origin[0])
|
||
* anno,
|
||
plane.scale_origin[1]
|
||
+ (plane.origin[1] - plane.scale_origin[1])
|
||
* anno,
|
||
plane.scale_origin[2]
|
||
+ (plane.origin[2] - plane.scale_origin[2])
|
||
* anno,
|
||
],
|
||
(plane.x_axis[1]).atan2(plane.x_axis[0]) as f32,
|
||
)
|
||
} else {
|
||
([slx_v, sly_v, elev_v], run.rotation)
|
||
};
|
||
searchable.push(SearchableTextRun {
|
||
text: visible,
|
||
origin,
|
||
height: (run.height as f64 * anno) as f32,
|
||
rotation,
|
||
color: gcolor,
|
||
bold: run.bold,
|
||
font: run.font.clone(),
|
||
adv_width: adv_world,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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();
|
||
|
||
// Derive the pick box from the rendered glyph quads.
|
||
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 and frame follow the glyph bounds.
|
||
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 {
|
||
let text_rotation = stroke_groups
|
||
.iter()
|
||
.find_map(|group| {
|
||
group.run.as_ref().map(|run| run.rotation as f64)
|
||
})
|
||
.unwrap_or(m.rotation);
|
||
let text_height = m.height * anno;
|
||
let pad = (m.background_scale - 1.0).max(0.0) * text_height;
|
||
let corner_groups = oriented_mtext_corner_groups(
|
||
&sdf_verts,
|
||
m,
|
||
text_rotation,
|
||
pad,
|
||
anno,
|
||
);
|
||
// Fill / mask — two triangles behind the glyphs.
|
||
if has_fill {
|
||
let fill_color = if m.background_fill_flags & 0x02 != 0 {
|
||
bg_color
|
||
} else {
|
||
color_or_inherit(&m.background_color, bg_color)
|
||
};
|
||
let mut ft = Vec::with_capacity(6 * corner_groups.len());
|
||
let mut ftl = Vec::with_capacity(6 * corner_groups.len());
|
||
for corners in &corner_groups {
|
||
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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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,
|
||
fill_tris: ft,
|
||
fill_tris_low: ftl,
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
// Text frame — a closed rectangle in the text
|
||
// colour around the same box.
|
||
if has_frame {
|
||
let mut fp = Vec::with_capacity(6 * corner_groups.len());
|
||
let mut fpl = Vec::with_capacity(6 * corner_groups.len());
|
||
for (group_index, corners) in corner_groups.iter().enumerate() {
|
||
if group_index > 0 {
|
||
fp.push([f32::NAN; 3]);
|
||
fpl.push([0.0; 3]);
|
||
}
|
||
for &[x, y] in &[
|
||
corners[0],
|
||
corners[1],
|
||
corners[2],
|
||
corners[3],
|
||
corners[0],
|
||
] {
|
||
let (h, lo) = split_ds_xyz(x, y, elev_v);
|
||
fp.push(h);
|
||
fpl.push(lo);
|
||
}
|
||
}
|
||
wires.push(WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// 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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
wires.push(WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: sdf_verts,
|
||
searchable_text: searchable,
|
||
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,
|
||
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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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,
|
||
fill_tris: bin.fill_tris,
|
||
fill_tris_low: bin.fill_tris_low,
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
}
|
||
|
||
// 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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris: Vec::new(),
|
||
pick_tris_low: Vec::new(),
|
||
dash_from_start: false,
|
||
dash_align_end: None,
|
||
text_verts: sdf_verts,
|
||
searchable_text: searchable,
|
||
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,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
});
|
||
}
|
||
|
||
if out.is_empty() {
|
||
out.push(WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── Standard topology objects ─────────────────────────────────
|
||
RenderObject::Dot(position) => {
|
||
{
|
||
{
|
||
// Split into a coarse float and a fine correction, so
|
||
// a point at survey coordinates keeps its last
|
||
// millimetres instead of losing them to f32.
|
||
let [x, y, z] = [
|
||
position[0] as f32,
|
||
position[1] as f32,
|
||
position[2] as f32,
|
||
];
|
||
let [xl, yl, zl] = [
|
||
(position[0] - x as f64) as f32,
|
||
(position[1] - y as f64) as f32,
|
||
(position[2] - z as f64) as f32,
|
||
];
|
||
// 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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
}];
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
|
||
RenderObject::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);
|
||
// Only a real 3-D mesh surface (PolyfaceMesh / PolygonMesh /
|
||
// the modern subdivision Mesh) fill renders shaded-only with
|
||
// hidden-surface depth; every other `fill_tris` here (a SOLID's
|
||
// filled quad — e.g. a `_BoxFilled` arrowhead) is a flat 2-D
|
||
// overlay visible in every view mode. Leaving `Mesh` out drops
|
||
// its face fill into the 2-D buffer, so it drew in wireframe too.
|
||
let fill_is_3d = matches!(
|
||
entity,
|
||
EntityType::Face3D(_)
|
||
| EntityType::PolyfaceMesh(_)
|
||
| EntityType::PolygonMesh(_)
|
||
| EntityType::Mesh(_)
|
||
) || matches!(entity, EntityType::Solid(solid) if solid.thickness.abs() > 1.0e-10);
|
||
// 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;
|
||
|
||
// A thickened polyline's extrusion — its corner / cap edges
|
||
// frame the solid tube and read black (like solid-with-edges
|
||
// outlines), while the tube fill keeps the entity colour. Both
|
||
// wide polyline kinds (LwPolyline + Polyline2D) extrude tubes.
|
||
let is_thick_extrusion = matches!(
|
||
entity,
|
||
EntityType::LwPolyline(p) if p.thickness.abs() > 1e-10
|
||
) || matches!(
|
||
entity,
|
||
EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10
|
||
);
|
||
let edge_color = if is_thick_extrusion {
|
||
[0.0, 0.0, 0.0, 1.0]
|
||
} else {
|
||
color
|
||
};
|
||
// Basic curves keep their resolved linetype.
|
||
let (edge_pattern_length, edge_pattern) =
|
||
if matches!(
|
||
entity,
|
||
EntityType::Line(_)
|
||
| EntityType::Circle(_)
|
||
| EntityType::Arc(_)
|
||
| EntityType::Ellipse(_)
|
||
| EntityType::Spline(_)
|
||
| EntityType::LwPolyline(_)
|
||
) {
|
||
(pattern_length, pattern)
|
||
} else {
|
||
(0.0, [0.0; 8])
|
||
};
|
||
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())
|
||
};
|
||
let point_marker =
|
||
crate::entities::point::relative_marker_spec(entity, document);
|
||
|
||
let has_arc = te
|
||
.tangent_geoms
|
||
.iter()
|
||
.any(|tg| matches!(tg, TangentGeom::Arc { .. }));
|
||
let can_split = !is_thick_extrusion
|
||
&& fill_tris.is_empty()
|
||
&& matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_));
|
||
|
||
if has_arc && can_split {
|
||
let seg_widths = polyline_segment_widths(entity);
|
||
let ww = polyline_band_width(entity, document.header.fill_mode);
|
||
out.extend(split_mixed_polyline(
|
||
&te.tangent_geoms,
|
||
&keys,
|
||
&name,
|
||
edge_color,
|
||
selected,
|
||
edge_pattern_length,
|
||
edge_pattern,
|
||
line_weight_px,
|
||
snap,
|
||
point_marker,
|
||
true,
|
||
&seg_widths,
|
||
ww,
|
||
pick_tris,
|
||
pick_tris_low,
|
||
));
|
||
} else {
|
||
out.push(WireModel {
|
||
bg_adapt: None,
|
||
point_marker,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: polyline_band_width(entity, document.header.fill_mode),
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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: edge_color,
|
||
selected,
|
||
pattern_length: edge_pattern_length,
|
||
pattern: edge_pattern,
|
||
line_weight_px,
|
||
snap_pts: snap,
|
||
tangent_geoms: tangents,
|
||
aci: 0,
|
||
key_vertices: keys,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
plinegen: true,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
}
|
||
|
||
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 {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
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,
|
||
fill_tris,
|
||
fill_tris_low,
|
||
fill_is_3d,
|
||
fill_is_2d_solid: matches!(entity, EntityType::Solid(_)),
|
||
render_instance: None,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
if out.is_empty() {
|
||
out.push(WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
});
|
||
}
|
||
|
||
return out;
|
||
}
|
||
|
||
RenderObject::BoundaryLines {
|
||
points,
|
||
stations,
|
||
point_segments,
|
||
station_pieces,
|
||
source_length,
|
||
plinegen,
|
||
} => {
|
||
let (local_pts, local_pts_low) = points_to_ds(points);
|
||
let key_vertices = te
|
||
.key_vertices
|
||
.into_iter()
|
||
.map(|[x, y, z]| [x, y, z])
|
||
.collect();
|
||
let station_data = crate::scene::model::wire_model::encode_pattern_stations(
|
||
stations,
|
||
source_length,
|
||
&point_segments,
|
||
&station_pieces,
|
||
);
|
||
return vec![WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: station_data,
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
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: te.snap_pts,
|
||
tangent_geoms: te.tangent_geoms,
|
||
aci: 0,
|
||
key_vertices,
|
||
plinegen,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
fill_tris: Vec::new(),
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
}];
|
||
}
|
||
|
||
RenderObject::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();
|
||
// A wide polyline with PLINEGEN=0 arrives here: same shader-band
|
||
// treatment as the Contour arm, restarting the dash per segment.
|
||
let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris);
|
||
let is_thick_extrusion = matches!(
|
||
entity,
|
||
EntityType::LwPolyline(p) if p.thickness.abs() > 1e-10
|
||
) || matches!(
|
||
entity,
|
||
EntityType::Polyline2D(p) if p.thickness.abs() > 1e-10
|
||
);
|
||
let can_split = !is_thick_extrusion
|
||
&& matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_));
|
||
let has_arc = te
|
||
.tangent_geoms
|
||
.iter()
|
||
.any(|tg| matches!(tg, TangentGeom::Arc { .. }));
|
||
|
||
if has_arc && can_split {
|
||
let edge_color = if is_thick_extrusion {
|
||
[0.0, 0.0, 0.0, 1.0]
|
||
} else {
|
||
color
|
||
};
|
||
let point_marker =
|
||
crate::entities::point::relative_marker_spec(entity, document);
|
||
let seg_widths = polyline_segment_widths(entity);
|
||
let ww = polyline_band_width(entity, document.header.fill_mode);
|
||
return split_mixed_polyline(
|
||
&te.tangent_geoms,
|
||
&key_vertices,
|
||
&name,
|
||
edge_color,
|
||
selected,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
snap_pts,
|
||
point_marker,
|
||
false,
|
||
&seg_widths,
|
||
ww,
|
||
pick_tris,
|
||
pick_tris_low,
|
||
);
|
||
}
|
||
return vec![WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: polyline_band_width(entity, document.header.fill_mode),
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris,
|
||
pick_tris_low,
|
||
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,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
}];
|
||
}
|
||
|
||
RenderObject::TaperedLines(points, widths) => {
|
||
let has_arc = te
|
||
.tangent_geoms
|
||
.iter()
|
||
.any(|tg| matches!(tg, TangentGeom::Arc { .. }));
|
||
let can_split = matches!(entity, EntityType::LwPolyline(_) | EntityType::Polyline2D(_));
|
||
let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris);
|
||
let world_width = widths.iter().copied().fold(0.0f32, f32::max);
|
||
let key_vertices: Vec<[f64; 3]> = te
|
||
.key_vertices
|
||
.into_iter()
|
||
.map(|[x, y, z]| [x, y, z])
|
||
.collect();
|
||
|
||
if has_arc && can_split {
|
||
let seg_widths = polyline_segment_widths(entity);
|
||
return split_mixed_polyline(
|
||
&te.tangent_geoms,
|
||
&key_vertices,
|
||
&name,
|
||
color,
|
||
selected,
|
||
pattern_length,
|
||
pattern,
|
||
line_weight_px,
|
||
te.snap_pts,
|
||
None,
|
||
true,
|
||
&seg_widths,
|
||
world_width,
|
||
pick_tris,
|
||
pick_tris_low,
|
||
);
|
||
}
|
||
|
||
// A wide polyline whose width varies: one continuous band wire
|
||
// carrying a per-point width; the shader interpolates each
|
||
// segment's two endpoint widths. `world_width` (the widest edge)
|
||
// stays as the constant fallback for PDF export + a hairline
|
||
// floor when zoomed out.
|
||
let (local_pts, local_pts_low) = points_to_ds(points);
|
||
let snap_pts = te.snap_pts;
|
||
return vec![WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: widths,
|
||
pattern_stations: Vec::new(),
|
||
world_width,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris,
|
||
pick_tris_low,
|
||
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: true,
|
||
aabb: WireModel::UNBOUNDED_AABB,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
}];
|
||
}
|
||
|
||
}
|
||
}
|
||
|
||
// ── Fallback for Viewport / Insert / Hatch / Ole2Frame ────────────────
|
||
let (mut points_f64, snap_pts, tangent_geoms, mut key_vertices) =
|
||
fallback_geometry(entity);
|
||
let clipped_viewport_polygon = match entity {
|
||
EntityType::Viewport(viewport) if !viewport.clip_boundary_handle.is_null() => {
|
||
let polygon = crate::scene::project::clip_boundary_polygon_for_document(
|
||
document,
|
||
viewport.clip_boundary_handle,
|
||
viewport.center.z as f32,
|
||
);
|
||
if polygon.len() >= 3 {
|
||
let polygon: Vec<[f64; 3]> = polygon
|
||
.into_iter()
|
||
.map(|point| {
|
||
[
|
||
point[0] as f64,
|
||
point[1] as f64,
|
||
point[2] as f64,
|
||
]
|
||
})
|
||
.collect();
|
||
points_f64 = polygon.clone();
|
||
points_f64.push(polygon[0]);
|
||
key_vertices = polygon.clone();
|
||
Some(polygon)
|
||
} else {
|
||
None
|
||
}
|
||
}
|
||
_ => None,
|
||
};
|
||
// `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();
|
||
// Paper viewports are pickable inside their frames; the sheet viewport is not.
|
||
let (pick_tris, pick_tris_low) = match entity {
|
||
EntityType::Viewport(vp) if !crate::scene::Scene::is_sheet_viewport(document, vp) => {
|
||
if let Some(polygon) = clipped_viewport_polygon.as_ref() {
|
||
points_to_ds(crate::entities::mesh::triangulate_planar(polygon))
|
||
} else {
|
||
let (cx, cy, cz) = (vp.center.x, vp.center.y, vp.center.z);
|
||
let (hw, hh) = (vp.width / 2.0, vp.height / 2.0);
|
||
points_to_ds(crate::entities::common::quad_pick_tris(&[
|
||
[cx - hw, cy - hh, cz],
|
||
[cx + hw, cy - hh, cz],
|
||
[cx + hw, cy + hh, cz],
|
||
[cx - hw, cy + hh, cz],
|
||
]))
|
||
}
|
||
}
|
||
_ => (Vec::new(), Vec::new()),
|
||
};
|
||
vec![WireModel {
|
||
bg_adapt: None,
|
||
point_marker: None,
|
||
taper_widths: Vec::new(),
|
||
pattern_stations: Vec::new(),
|
||
world_width: 0.0,
|
||
depth_override: None,
|
||
display_visible: true,
|
||
snap_only: false,
|
||
plot_visible: true,
|
||
fill_is_3d: false,
|
||
fill_is_2d_solid: false,
|
||
render_instance: None,
|
||
pick_tris,
|
||
pick_tris_low,
|
||
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,
|
||
fill_tris: vec![],
|
||
fill_tris_low: Vec::new(),
|
||
|
||
..Default::default()
|
||
}]
|
||
}
|
||
|
||
|
||
|
||
#[derive(Clone)]
|
||
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 },
|
||
Custom {
|
||
size: f32,
|
||
lines: Vec<[f32; 3]>,
|
||
fill: Vec<[f32; 3]>,
|
||
},
|
||
}
|
||
|
||
pub(crate) fn arrow_from_block(
|
||
doc: &CadDocument,
|
||
handle: codec::types::Handle,
|
||
dimasz: f32,
|
||
) -> ArrowKind {
|
||
arrow_from_block_with_deferred_hatch(doc, handle, dimasz, false)
|
||
}
|
||
|
||
pub(crate) fn arrow_from_block_with_deferred_hatch(
|
||
doc: &CadDocument,
|
||
handle: codec::types::Handle,
|
||
dimasz: f32,
|
||
defer_hatch: bool,
|
||
) -> ArrowKind {
|
||
if handle.is_null() {
|
||
return arrow_from_block_name(None, dimasz);
|
||
}
|
||
let Some(record) = doc.block_records.iter().find(|b| b.handle == handle) else {
|
||
return arrow_from_block_name(None, dimasz);
|
||
};
|
||
if let Some(arrow) = builtin_arrow_from_block_name(&record.name, dimasz) {
|
||
return arrow;
|
||
}
|
||
custom_arrow_from_block(doc, record, dimasz, defer_hatch)
|
||
.unwrap_or_else(|| arrow_from_block_name(None, dimasz))
|
||
}
|
||
|
||
fn arrow_from_block_name(name: Option<&str>, dimasz: f32) -> ArrowKind {
|
||
name.and_then(|name| builtin_arrow_from_block_name(name, dimasz))
|
||
.unwrap_or(ArrowKind::Triangle {
|
||
size: dimasz,
|
||
filled: true,
|
||
size_mul: 1.0,
|
||
})
|
||
}
|
||
|
||
fn builtin_arrow_from_block_name(name: &str, dimasz: f32) -> Option<ArrowKind> {
|
||
// Built-in arrow block names may carry a leading underscore. Normalize it
|
||
// before matching the canonical names.
|
||
let n = name
|
||
.trim()
|
||
.trim_start_matches('_')
|
||
.to_ascii_uppercase();
|
||
match n.as_str() {
|
||
"" | "CLOSEDFILLED" => Some(ArrowKind::Triangle {
|
||
size: dimasz,
|
||
filled: true,
|
||
size_mul: 1.0,
|
||
}),
|
||
"CLOSED" | "CLOSEDBLANK" => Some(ArrowKind::Triangle {
|
||
size: dimasz,
|
||
filled: false,
|
||
size_mul: 1.0,
|
||
}),
|
||
"SMALL" => Some(ArrowKind::Triangle {
|
||
size: dimasz,
|
||
filled: true,
|
||
size_mul: 0.5,
|
||
}),
|
||
"OPEN" => Some(ArrowKind::Open {
|
||
size: dimasz,
|
||
half_angle: 9.5_f32.to_radians(),
|
||
}),
|
||
"OPEN30" => Some(ArrowKind::Open {
|
||
size: dimasz,
|
||
half_angle: 15.0_f32.to_radians(),
|
||
}),
|
||
"OPEN90" => Some(ArrowKind::Open {
|
||
size: dimasz,
|
||
half_angle: 45.0_f32.to_radians(),
|
||
}),
|
||
"DOT" => Some(ArrowKind::Dot {
|
||
size: dimasz,
|
||
filled: true,
|
||
}),
|
||
"DOTSMALL" => Some(ArrowKind::Dot {
|
||
size: dimasz * 0.5,
|
||
filled: true,
|
||
}),
|
||
"DOTBLANK" => Some(ArrowKind::Dot {
|
||
size: dimasz,
|
||
filled: false,
|
||
}),
|
||
"DOTSMALLBLANK" => Some(ArrowKind::Dot {
|
||
size: dimasz * 0.5,
|
||
filled: false,
|
||
}),
|
||
"ORIGIN" | "ORIGIN2" | "ORIGININDICATOR" | "ORIGININDICATOR2" => {
|
||
Some(ArrowKind::Origin { size: dimasz })
|
||
}
|
||
// `ArrowKind::Tick` draws the stroke `size` to either side of the tip
|
||
// (total 2·size — its `size` is a half-length, matching DIMTSZ). The
|
||
// block's stroke runs (-0.5,-0.5)→(0.5,0.5) scaled by DIMASZ, so its
|
||
// half-length is DIMASZ/√2, not DIMASZ/2 — halving drew every
|
||
// oblique stroke √2 short of the file's own. (#898)
|
||
"OBLIQUE" | "ARCHTICK" => Some(ArrowKind::Tick {
|
||
size: dimasz * std::f32::consts::FRAC_1_SQRT_2,
|
||
}),
|
||
"BOXFILLED" => Some(ArrowKind::Box_ {
|
||
size: dimasz,
|
||
filled: true,
|
||
}),
|
||
"BOXBLANK" | "BOX" => Some(ArrowKind::Box_ {
|
||
size: dimasz,
|
||
filled: false,
|
||
}),
|
||
"DATUMFILLED" | "DATUMTRIANGLEFILLED" => Some(ArrowKind::Datum {
|
||
size: dimasz,
|
||
filled: true,
|
||
}),
|
||
"DATUMBLANK" | "DATUMTRIANGLE" => Some(ArrowKind::Datum {
|
||
size: dimasz,
|
||
filled: false,
|
||
}),
|
||
"NONE" => Some(ArrowKind::None),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
fn custom_arrow_from_block(
|
||
doc: &CadDocument,
|
||
record: &codec::tables::BlockRecord,
|
||
dimasz: f32,
|
||
defer_hatch: bool,
|
||
) -> Option<ArrowKind> {
|
||
if record.is_layout()
|
||
|| record.is_model_space()
|
||
|| record.is_paper_space()
|
||
|| record.flags.is_xref
|
||
|| record.flags.is_xref_overlay
|
||
|| record.flags.is_external
|
||
{
|
||
return None;
|
||
}
|
||
|
||
let depths = rustc_hash::FxHashMap::default();
|
||
let graph = crate::scene::render_graph::RenderSceneGraph::new(
|
||
doc,
|
||
None,
|
||
None,
|
||
true,
|
||
&depths,
|
||
);
|
||
let block_use = crate::scene::render_graph::block_use_from_handle(
|
||
doc,
|
||
record.handle,
|
||
crate::scene::render_graph::BlockRole::ArrowHead,
|
||
codec::types::Vector3::ZERO,
|
||
)?;
|
||
let mut lines = Vec::new();
|
||
let mut fill = Vec::new();
|
||
let mut deferred_hatch = false;
|
||
graph.walk_block_use(
|
||
&block_use,
|
||
record.handle,
|
||
|_, _| true,
|
||
|entity, context| {
|
||
if defer_hatch && matches!(entity, EntityType::Hatch(_)) {
|
||
deferred_hatch = true;
|
||
return;
|
||
}
|
||
let mut placed = entity.clone();
|
||
placed.apply_transform(&context.transform);
|
||
append_custom_arrow_leaf(
|
||
doc,
|
||
&placed,
|
||
&mut lines,
|
||
&mut fill,
|
||
);
|
||
},
|
||
);
|
||
if lines.is_empty() && fill.is_empty() && !deferred_hatch {
|
||
None
|
||
} else {
|
||
Some(ArrowKind::Custom {
|
||
size: dimasz,
|
||
lines,
|
||
fill,
|
||
})
|
||
}
|
||
}
|
||
|
||
fn append_custom_arrow_leaf(
|
||
doc: &CadDocument,
|
||
entity: &EntityType,
|
||
lines: &mut Vec<[f32; 3]>,
|
||
fill: &mut Vec<[f32; 3]>,
|
||
) {
|
||
match entity {
|
||
EntityType::Block(_)
|
||
| EntityType::BlockEnd(_)
|
||
| EntityType::AttributeDefinition(_)
|
||
| EntityType::Dimension(_)
|
||
| EntityType::Leader(_)
|
||
| EntityType::MultiLeader(_)
|
||
| EntityType::Insert(_) => return,
|
||
EntityType::Hatch(hatch) => {
|
||
append_custom_hatch_geometry(
|
||
hatch,
|
||
codec::types::Vector3::ZERO,
|
||
lines,
|
||
fill,
|
||
);
|
||
return;
|
||
}
|
||
_ => {}
|
||
}
|
||
|
||
let wires = tessellate(
|
||
doc,
|
||
entity.common().handle,
|
||
entity,
|
||
false,
|
||
[1.0; 4],
|
||
0.0,
|
||
[0.0; 8],
|
||
1.0,
|
||
1.0,
|
||
None,
|
||
None,
|
||
[0.0, 0.0, 0.0, 1.0],
|
||
true,
|
||
);
|
||
for wire in wires {
|
||
// A wide polyline's width rides on the wire as a GPU band; an arrow
|
||
// keeps only geometry, so turn the band into fill or it draws hairline.
|
||
// (#1130)
|
||
if wire.world_width > 0.0 || wire.taper_widths.iter().any(|width| *width > 0.0) {
|
||
append_custom_band(&wire, fill);
|
||
} else {
|
||
append_custom_wire_points(
|
||
&wire.points,
|
||
&wire.points_low,
|
||
codec::types::Vector3::ZERO,
|
||
lines,
|
||
);
|
||
}
|
||
append_custom_fill_points(
|
||
&wire.fill_tris,
|
||
&wire.fill_tris_low,
|
||
codec::types::Vector3::ZERO,
|
||
fill,
|
||
);
|
||
}
|
||
}
|
||
|
||
fn append_custom_hatch_geometry(
|
||
hatch: &codec::entities::Hatch,
|
||
base: codec::types::Vector3,
|
||
lines: &mut Vec<[f32; 3]>,
|
||
fill: &mut Vec<[f32; 3]>,
|
||
) {
|
||
use lyon_tessellation::math::point;
|
||
use lyon_tessellation::path::Path;
|
||
use lyon_tessellation::{
|
||
BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, VertexBuffers,
|
||
};
|
||
|
||
let Some(model) = crate::scene::Scene::hatch_model_from_dxf(hatch, [1.0; 4]) else {
|
||
return;
|
||
};
|
||
|
||
if matches!(
|
||
&model.pattern,
|
||
crate::scene::model::hatch_model::HatchPattern::Pattern(_)
|
||
) {
|
||
let z = -base.z as f32;
|
||
for [start, end] in model.pattern_segments() {
|
||
if !lines.is_empty() && !lines.last().is_some_and(|point| point[0].is_nan()) {
|
||
lines.push([f32::NAN; 3]);
|
||
}
|
||
lines.push([
|
||
(start[0] - base.x) as f32,
|
||
(start[1] - base.y) as f32,
|
||
z,
|
||
]);
|
||
lines.push([
|
||
(end[0] - base.x) as f32,
|
||
(end[1] - base.y) as f32,
|
||
z,
|
||
]);
|
||
}
|
||
return;
|
||
}
|
||
|
||
let mut builder = Path::builder();
|
||
let mut ring: Vec<[f32; 2]> = Vec::new();
|
||
let mut finish_ring = |ring: &mut Vec<[f32; 2]>| {
|
||
if ring.len() >= 3 {
|
||
builder.begin(point(ring[0][0], ring[0][1]));
|
||
for p in &ring[1..] {
|
||
builder.line_to(point(p[0], p[1]));
|
||
}
|
||
builder.end(true);
|
||
}
|
||
ring.clear();
|
||
};
|
||
for &[x, y] in model.boundary.iter() {
|
||
if x.is_nan() || y.is_nan() {
|
||
finish_ring(&mut ring);
|
||
continue;
|
||
}
|
||
ring.push([
|
||
(model.world_origin[0] + x as f64 - base.x) as f32,
|
||
(model.world_origin[1] + y as f64 - base.y) as f32,
|
||
]);
|
||
}
|
||
finish_ring(&mut ring);
|
||
|
||
let mut geometry: VertexBuffers<[f32; 2], u32> = VertexBuffers::new();
|
||
let mut tessellator = FillTessellator::new();
|
||
if tessellator
|
||
.tessellate_path(
|
||
&builder.build(),
|
||
&FillOptions::default().with_fill_rule(FillRule::EvenOdd),
|
||
&mut BuffersBuilder::new(&mut geometry, |vertex: FillVertex| {
|
||
vertex.position().to_array()
|
||
}),
|
||
)
|
||
.is_err()
|
||
{
|
||
return;
|
||
}
|
||
let z = -base.z as f32;
|
||
fill.extend(
|
||
geometry
|
||
.indices
|
||
.iter()
|
||
.filter_map(|&index| geometry.vertices.get(index as usize))
|
||
.map(|&[x, y]| [x, y, z]),
|
||
);
|
||
}
|
||
|
||
/// Triangles covering a wide polyline's band, one quad per segment.
|
||
// ponytail: no join wedges at corners; add miters if a thick arrow shows notches.
|
||
fn append_custom_band(wire: &WireModel, fill: &mut Vec<[f32; 3]>) {
|
||
let at = |index: usize| {
|
||
let point = wire.points[index];
|
||
let low = wire.points_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
(!point[0].is_nan()).then(|| {
|
||
[
|
||
point[0] as f64 + low[0] as f64,
|
||
point[1] as f64 + low[1] as f64,
|
||
point[2] as f64 + low[2] as f64,
|
||
]
|
||
})
|
||
};
|
||
let half = |index: usize| {
|
||
wire.taper_widths.get(index).copied().unwrap_or(wire.world_width).max(0.0) as f64 * 0.5
|
||
};
|
||
for index in 0..wire.points.len().saturating_sub(1) {
|
||
let (Some(a), Some(b)) = (at(index), at(index + 1)) else {
|
||
continue;
|
||
};
|
||
let (dx, dy) = (b[0] - a[0], b[1] - a[1]);
|
||
let length = dx.hypot(dy);
|
||
if length <= 0.0 {
|
||
continue;
|
||
}
|
||
let (nx, ny) = (-dy / length, dx / length);
|
||
let (ha, hb) = (half(index), half(index + 1));
|
||
let corner = |p: [f64; 3], h: f64, side: f64| {
|
||
[(p[0] + nx * h * side) as f32, (p[1] + ny * h * side) as f32, p[2] as f32]
|
||
};
|
||
let quad = [corner(a, ha, 1.0), corner(a, ha, -1.0), corner(b, hb, -1.0), corner(b, hb, 1.0)];
|
||
fill.extend([quad[0], quad[1], quad[2], quad[0], quad[2], quad[3]]);
|
||
}
|
||
}
|
||
|
||
fn append_custom_wire_points(
|
||
points: &[[f32; 3]],
|
||
points_low: &[[f32; 3]],
|
||
base: codec::types::Vector3,
|
||
out: &mut Vec<[f32; 3]>,
|
||
) {
|
||
if points.is_empty() {
|
||
return;
|
||
}
|
||
if !out.is_empty() && !out.last().is_some_and(|p| p[0].is_nan()) {
|
||
out.push([f32::NAN; 3]);
|
||
}
|
||
for (index, point) in points.iter().enumerate() {
|
||
if point[0].is_nan() {
|
||
if !out.last().is_some_and(|p| p[0].is_nan()) {
|
||
out.push([f32::NAN; 3]);
|
||
}
|
||
continue;
|
||
}
|
||
let low = points_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
out.push([
|
||
(point[0] as f64 + low[0] as f64 - base.x) as f32,
|
||
(point[1] as f64 + low[1] as f64 - base.y) as f32,
|
||
(point[2] as f64 + low[2] as f64 - base.z) as f32,
|
||
]);
|
||
}
|
||
}
|
||
|
||
fn append_custom_fill_points(
|
||
points: &[[f32; 3]],
|
||
points_low: &[[f32; 3]],
|
||
base: codec::types::Vector3,
|
||
out: &mut Vec<[f32; 3]>,
|
||
) {
|
||
for (index, point) in points.iter().enumerate() {
|
||
let low = points_low.get(index).copied().unwrap_or([0.0; 3]);
|
||
out.push([
|
||
(point[0] as f64 + low[0] as f64 - base.x) as f32,
|
||
(point[1] as f64 + low[1] as f64 - base.y) as f32,
|
||
(point[2] as f64 + low[2] as f64 - base.z) as f32,
|
||
]);
|
||
}
|
||
}
|
||
|
||
pub(crate) struct DimGeom {
|
||
pub(crate) ext_lines: Vec<[f32; 3]>,
|
||
pub(crate) dim_lines: Vec<[f32; 3]>,
|
||
/// Arrowhead / tick outlines of a dimension: drawn solid, never in the
|
||
/// dimension line's dashes.
|
||
pub(crate) arrow_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_lines: Vec::new(),
|
||
arrow_fill: Vec::new(),
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
/// Convert an opencadcodec `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(_) => {
|
||
let [r, g, b, _] = crate::scene::convert::tess_util::aci_to_rgba(c);
|
||
[r, g, b, 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,
|
||
}
|
||
}
|
||
|
||
/// Shader band width, or zero while FILLMODE draws the kernel boundary.
|
||
fn polyline_band_width(entity: &EntityType, fill_mode: bool) -> f32 {
|
||
if !fill_mode {
|
||
return 0.0;
|
||
}
|
||
let w = match entity {
|
||
EntityType::LwPolyline(p) if p.thickness.abs() <= 1e-10 => {
|
||
let mut w = p.constant_width;
|
||
for v in &p.vertices {
|
||
w = w.max(v.start_width).max(v.end_width);
|
||
}
|
||
w
|
||
}
|
||
EntityType::Polyline2D(p) if p.thickness.abs() <= 1e-10 => {
|
||
let mut w = p.start_width.max(p.end_width);
|
||
for v in &p.vertices {
|
||
w = w.max(v.start_width).max(v.end_width);
|
||
}
|
||
w
|
||
}
|
||
_ => 0.0,
|
||
};
|
||
if w > 1e-9 {
|
||
w as f32
|
||
} else {
|
||
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 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,
|
||
));
|
||
}
|
||
(vec![], snap, vec![], vec![])
|
||
}
|
||
_ => {
|
||
let s = 0.5_f64;
|
||
(vec![[-s, 0.0, 0.0], [s, 0.0, 0.0]], vec![], vec![], vec![])
|
||
}
|
||
}
|
||
}
|
||
|
||
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
|
||
// the standard closed-filled 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 used by DIMTSZ.
|
||
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);
|
||
}
|
||
}
|
||
ArrowKind::Custom { size, lines, fill } => {
|
||
let transform = |point: &[f32; 3]| {
|
||
tip - dir * (point[0] * *size) - perp * (point[1] * *size)
|
||
+ Vec3::Z * (point[2] * *size)
|
||
};
|
||
if !lines.is_empty()
|
||
&& !g.dim_lines.is_empty()
|
||
&& !g.dim_lines.last().is_some_and(|point| point[0].is_nan())
|
||
{
|
||
g.dim_lines.push([f32::NAN; 3]);
|
||
}
|
||
for point in lines {
|
||
if point[0].is_nan() {
|
||
if !g.dim_lines.last().is_some_and(|point| point[0].is_nan()) {
|
||
g.dim_lines.push([f32::NAN; 3]);
|
||
}
|
||
continue;
|
||
}
|
||
let point = transform(point);
|
||
g.dim_lines.push([point.x, point.y, point.z]);
|
||
}
|
||
for triangle in fill.chunks_exact(3) {
|
||
push_tri(
|
||
&mut g.arrow_fill,
|
||
transform(&triangle[0]),
|
||
transform(&triangle[1]),
|
||
transform(&triangle[2]),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
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 (no explicit point was written),
|
||
/// 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()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_split_mixed_polyline_straight_lines_have_taper_widths() {
|
||
let tg_line = TangentGeom::Line {
|
||
p1: [0.0, 0.0, 0.0],
|
||
p2: [10.0, 0.0, 0.0],
|
||
};
|
||
let tg_arc = TangentGeom::Arc {
|
||
center: [10.0, 5.0, 0.0],
|
||
axis_x: [1.0, 0.0, 0.0],
|
||
axis_y: [0.0, 1.0, 0.0],
|
||
radius: 5.0,
|
||
start_angle: -std::f64::consts::FRAC_PI_2,
|
||
end_angle: 0.0,
|
||
};
|
||
let tgs = vec![tg_line, tg_arc];
|
||
let kv = vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [15.0, 5.0, 0.0]];
|
||
let seg_widths = vec![(0.0f32, 10.0f32), (10.0f32, 20.0f32)];
|
||
|
||
let wires = split_mixed_polyline(
|
||
&tgs,
|
||
&kv,
|
||
"mixed",
|
||
[1.0, 1.0, 1.0, 1.0],
|
||
false,
|
||
0.0,
|
||
[0.0; 8],
|
||
1.0,
|
||
Vec::new(),
|
||
None,
|
||
true,
|
||
&seg_widths,
|
||
20.0,
|
||
Vec::new(),
|
||
Vec::new(),
|
||
);
|
||
|
||
// Arc wire + Line wire
|
||
assert_eq!(wires.len(), 2);
|
||
let arc_wire = &wires[0];
|
||
assert_eq!(arc_wire.taper_widths.first().copied(), Some(10.0));
|
||
assert_eq!(arc_wire.taper_widths.last().copied(), Some(20.0));
|
||
|
||
let line_wire = &wires[1];
|
||
assert_eq!(line_wire.taper_widths, vec![0.0, 10.0]);
|
||
assert_eq!(line_wire.world_width, 10.0);
|
||
}
|
||
|
||
#[test]
|
||
fn split_mixed_polyline_keeps_exact_line_intersections() {
|
||
let tangents = vec![
|
||
TangentGeom::Line {
|
||
p1: [-10.0, 0.0, 0.0],
|
||
p2: [0.0, 0.0, 0.0],
|
||
},
|
||
TangentGeom::Arc {
|
||
center: [0.0, 4.0, 0.0],
|
||
axis_x: [1.0, 0.0, 0.0],
|
||
axis_y: [0.0, 1.0, 0.0],
|
||
radius: 4.0,
|
||
start_angle: -std::f64::consts::FRAC_PI_2,
|
||
end_angle: std::f64::consts::FRAC_PI_2,
|
||
},
|
||
TangentGeom::Line {
|
||
p1: [0.0, 8.0, 0.0],
|
||
p2: [10.0, 8.0, 0.0],
|
||
},
|
||
];
|
||
let vertices = vec![
|
||
[-10.0, 0.0, 0.0],
|
||
[0.0, 0.0, 0.0],
|
||
[0.0, 8.0, 0.0],
|
||
[10.0, 8.0, 0.0],
|
||
];
|
||
let wires = split_mixed_polyline(
|
||
&tangents,
|
||
&vertices,
|
||
"mixed",
|
||
[1.0; 4],
|
||
false,
|
||
0.0,
|
||
[0.0; 8],
|
||
1.0,
|
||
Vec::new(),
|
||
None,
|
||
true,
|
||
&[(0.0, 0.0); 3],
|
||
0.0,
|
||
Vec::new(),
|
||
Vec::new(),
|
||
);
|
||
let line_wire = wires
|
||
.iter()
|
||
.find(|wire| {
|
||
wire.tangent_geoms
|
||
.iter()
|
||
.all(|geom| matches!(geom, TangentGeom::Line { .. }))
|
||
})
|
||
.expect("straight-segment wire");
|
||
let circle = WireModel {
|
||
tangent_geoms: vec![TangentGeom::PlanarCircle {
|
||
center: [5.0, 4.0, 0.0],
|
||
axis_x: [1.0, 0.0, 0.0],
|
||
axis_y: [0.0, 1.0, 0.0],
|
||
radius: 5.0,
|
||
}],
|
||
..Default::default()
|
||
};
|
||
|
||
let mut points = crate::snap::exact_curve_intersections(line_wire, &circle)
|
||
.expect("analytical intersections");
|
||
points.sort_by(|left, right| left.x.total_cmp(&right.x));
|
||
assert_eq!(points.len(), 2, "unexpected intersections: {points:?}");
|
||
assert!((points[0] - glam::DVec3::new(2.0, 8.0, 0.0)).length() < 1.0e-9);
|
||
assert!((points[1] - glam::DVec3::new(8.0, 8.0, 0.0)).length() < 1.0e-9);
|
||
}
|
||
|
||
#[test]
|
||
fn test_split_mixed_polyline_zero_width_segments_after_tapered_segment() {
|
||
let tg_line = TangentGeom::Line {
|
||
p1: [0.0, 0.0, 0.0],
|
||
p2: [10.0, 0.0, 0.0],
|
||
};
|
||
let tg_arc1 = TangentGeom::Arc {
|
||
center: [10.0, 5.0, 0.0],
|
||
axis_x: [1.0, 0.0, 0.0],
|
||
axis_y: [0.0, 1.0, 0.0],
|
||
radius: 5.0,
|
||
start_angle: -std::f64::consts::FRAC_PI_2,
|
||
end_angle: 0.0,
|
||
};
|
||
let tg_arc2 = TangentGeom::Arc {
|
||
center: [15.0, 10.0, 0.0],
|
||
axis_x: [1.0, 0.0, 0.0],
|
||
axis_y: [0.0, 1.0, 0.0],
|
||
radius: 5.0,
|
||
start_angle: -std::f64::consts::FRAC_PI_2,
|
||
end_angle: 0.0,
|
||
};
|
||
let tgs = vec![tg_line, tg_arc1, tg_arc2];
|
||
let kv = vec![
|
||
[0.0, 0.0, 0.0],
|
||
[10.0, 0.0, 0.0],
|
||
[15.0, 5.0, 0.0],
|
||
[20.0, 10.0, 0.0],
|
||
];
|
||
// Segment 0 has width (2.0, 0.0), segment 1 and 2 have width (0.0, 0.0)
|
||
let seg_widths = vec![(2.0f32, 0.0f32), (0.0f32, 0.0f32), (0.0f32, 0.0f32)];
|
||
|
||
let wires = split_mixed_polyline(
|
||
&tgs,
|
||
&kv,
|
||
"taper_then_zero",
|
||
[1.0, 1.0, 1.0, 1.0],
|
||
false,
|
||
0.0,
|
||
[0.0; 8],
|
||
1.0,
|
||
Vec::new(),
|
||
None,
|
||
true,
|
||
&seg_widths,
|
||
2.0,
|
||
Vec::new(),
|
||
Vec::new(),
|
||
);
|
||
|
||
// 2 arc wires + 1 line wire = 3 wires
|
||
assert_eq!(wires.len(), 3);
|
||
let arc1 = &wires[0];
|
||
let arc2 = &wires[1];
|
||
let line = &wires[2];
|
||
|
||
// Arcs must have world_width = 0.0 and empty taper_widths
|
||
assert_eq!(arc1.world_width, 0.0);
|
||
assert!(arc1.taper_widths.is_empty());
|
||
assert_eq!(arc2.world_width, 0.0);
|
||
assert!(arc2.taper_widths.is_empty());
|
||
|
||
let inst1 = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc1, 0.0)
|
||
.expect("arc1 instance");
|
||
assert_eq!(inst1.start_width, 0.0);
|
||
assert_eq!(inst1.params[3], 0.0);
|
||
|
||
// Straight line must have world_width = 2.0 and taper_widths = [2.0, 0.0]
|
||
assert_eq!(line.world_width, 2.0);
|
||
assert_eq!(line.taper_widths, vec![2.0, 0.0]);
|
||
}
|
||
|
||
#[test]
|
||
fn test_split_mixed_polyline_reversed_arc_aligns_endpoints() {
|
||
// Clockwise arc from (10.0, 0.0) to (15.0, 5.0).
|
||
// bulge_arc_to_tangent inverts angles so start_angle is at (15.0, 5.0) and end_angle at (10.0, 0.0).
|
||
let tg_arc_reversed = TangentGeom::Arc {
|
||
center: [10.0, 5.0, 0.0],
|
||
axis_x: [1.0, 0.0, 0.0],
|
||
axis_y: [0.0, 1.0, 0.0],
|
||
radius: 5.0,
|
||
start_angle: 0.0, // at (15.0, 5.0) - physical end!
|
||
end_angle: -std::f64::consts::FRAC_PI_2, // at (10.0, 0.0) - physical start!
|
||
};
|
||
let tgs = vec![tg_arc_reversed];
|
||
let kv = vec![[10.0, 0.0, 0.0], [15.0, 5.0, 0.0]];
|
||
let seg_widths = vec![(5.0f32, 25.0f32)]; // sw=5.0 at start, ew=25.0 at end
|
||
|
||
let wires = split_mixed_polyline(
|
||
&tgs,
|
||
&kv,
|
||
"arc_rev",
|
||
[1.0, 1.0, 1.0, 1.0],
|
||
false,
|
||
0.0,
|
||
[0.0; 8],
|
||
1.0,
|
||
Vec::new(),
|
||
None,
|
||
true,
|
||
&seg_widths,
|
||
25.0,
|
||
Vec::new(),
|
||
Vec::new(),
|
||
);
|
||
|
||
assert_eq!(wires.len(), 1);
|
||
let arc_wire = &wires[0];
|
||
// In CCW shader angles, start_angle is at (15.0, 5.0) which is physical end (ew = 25.0).
|
||
// end_angle is at (10.0, 0.0) which is physical start (sw = 5.0).
|
||
assert_eq!(arc_wire.taper_widths.first().copied(), Some(25.0));
|
||
assert_eq!(arc_wire.taper_widths.last().copied(), Some(5.0));
|
||
|
||
let inst = crate::scene::pipeline::circle_gpu::extract_circle_instance(arc_wire, 0.0)
|
||
.expect("arc instance extracted");
|
||
// start_width is at start_angle (physical end, 25.0)
|
||
assert_eq!(inst.start_width, 25.0);
|
||
// params.w (end_width) is at end_angle (physical start, 5.0)
|
||
assert_eq!(inst.params[3], 5.0);
|
||
}
|
||
}
|