refactor(tess): move per-entity tessellation into entities/

Each entity's tessellation now lives alongside its definition in
entities/*.rs, attached to the entity type via a local trait:

  LegacyTess     — Viewport / Insert / Hatch / Ole2Frame
                   (fallback path for entities not on the truck pipeline)
  LeaderTess     — Leader
  MultiLeaderTess — MultiLeader
  DimensionTess  — Dimension (carries its dim-only helpers too:
                   ~1700 lines of dim_geometry, dim_text_*, format_*,
                   tolerance, suppression, etc.)

scene/tessellate.rs keeps only the cross-entity dispatcher plus the
shared dim machinery reused by leader/multileader/dimension
(ArrowKind, DimGeom, append_arrow, arrow_from_block, add_segment,
add_polyline, normalized_or, push_tri, color_or_inherit, entity_z,
offset_snap_pts). Cross-entity arc helpers + aci_to_rgba moved to
scene/tess_util.rs.

scene/tessellate.rs shrinks from ~3300 to 809 lines. Compile clean,
all 9 tests pass.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-23 23:12:27 +03:00
1 parent 163725ca22
commit aadb64860d
12 files changed
+3016 -2832

No files matched your search

File diff suppressed because it is too large. Load diff
+181 -1
View File
@@ -3,8 +3,10 @@ use glam::Vec3;
use crate::command::EntityTransform;
use crate::entities::common::{diamond_grip, edit_prop as edit, parse_f64, ro_prop as ro};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable};
use crate::entities::traits::{Grippable, LegacyTess, PropertyEditable, Transformable};
use crate::scene::object::{GripApply, GripDef, PropSection, PropValue, Property};
use crate::scene::tess_util::{arc_segments, arc_signed_span, wire_chord_tol, LegacyGeometry};
use crate::scene::wire_model::SnapHint;
fn properties(h: &Hatch) -> PropSection {
let pattern_type = match h.pattern_type {
@@ -347,3 +349,181 @@ impl Grippable for Hatch {
}
}
}
impl LegacyTess for Hatch {
fn legacy_geometry(&self, world_offset: [f64; 3]) -> LegacyGeometry {
let [ox, oy, oz] = world_offset;
let normal = (self.normal.x, self.normal.y, self.normal.z);
// Convert a 2D OCS hatch boundary point to WCS, then subtract world_offset.
let to_wcs = |x: f64, y: f64| -> [f32; 3] {
let (wx, wy, wz) =
crate::scene::transform::ocs_point_to_wcs((x, y, self.elevation), normal);
[(wx - ox) as f32, (wy - oy) as f32, (wz - oz) as f32]
};
let mut pts: Vec<[f32; 3]> = Vec::new();
let mut key_verts: Vec<[f32; 3]> = Vec::new();
let mut snap_pts: Vec<(Vec3, SnapHint)> = Vec::new();
for path in &self.paths {
for edge in &path.edges {
match edge {
BoundaryEdge::Polyline(poly) => {
// Hatch-boundary polyline vertices encode bulge in
// `Vector3.z`; straight segments emit just the
// start vertex, bulged segments tessellate the arc
// between v0 → v1.
let start_idx = pts.len();
let verts = &poly.vertices;
let count = verts.len();
let seg_count = if poly.is_closed {
count
} else {
count.saturating_sub(1)
};
if count > 0 && pts.len() != start_idx {
pts.push([f32::NAN; 3]);
}
for i in 0..seg_count {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % count];
let bulge = v0.z;
if bulge.abs() < 1e-9 {
let p = to_wcs(v0.x, v0.y);
pts.push(p);
key_verts.push(p);
continue;
}
let theta = 4.0 * bulge.atan();
let dx = v1.x - v0.x;
let dy = v1.y - v0.y;
let d = (dx * dx + dy * dy).sqrt();
if d < 1e-12 {
let p = to_wcs(v0.x, v0.y);
pts.push(p);
key_verts.push(p);
continue;
}
let r = (d * 0.5) / (theta * 0.5).sin().abs();
let mx = (v0.x + v1.x) * 0.5;
let my = (v0.y + v1.y) * 0.5;
let px = -dy / d;
let py = dx / d;
let sign = if bulge > 0.0 { 1.0_f64 } else { -1.0_f64 };
let center_offset = r * (theta * 0.5).cos();
let cx = mx + sign * px * center_offset;
let cy = my + sign * py * center_offset;
let a0 = (v0.y - cy).atan2(v0.x - cx);
let a1 = (v1.y - cy).atan2(v1.x - cx);
let mut sweep = a1 - a0;
const TAU: f64 = std::f64::consts::TAU;
if bulge > 0.0 {
if sweep <= 0.0 {
sweep += TAU;
}
} else if sweep >= 0.0 {
sweep -= TAU;
}
if sweep.abs() < 1e-9 {
sweep = if bulge > 0.0 { TAU } else { -TAU };
}
let segs = arc_segments(r, sweep.abs(), wire_chord_tol(r));
for j in 0..segs {
let a = a0 + sweep * (j as f64 / segs as f64);
let p = to_wcs(cx + r * a.cos(), cy + r * a.sin());
pts.push(p);
if j == 0 {
key_verts.push(p);
}
}
}
// Close the loop visually for closed polylines by
// returning to the first emitted point.
if poly.is_closed {
if let Some(first) = pts.get(start_idx).cloned() {
if first[0].is_finite() {
pts.push(first);
}
}
} else if let Some(last) = verts.last() {
let p = to_wcs(last.x, last.y);
pts.push(p);
key_verts.push(p);
}
}
BoundaryEdge::Line(ln) => {
let p0 = to_wcs(ln.start.x, ln.start.y);
let p1 = to_wcs(ln.end.x, ln.end.y);
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
pts.push(p0);
pts.push(p1);
key_verts.push(p0);
key_verts.push(p1);
}
BoundaryEdge::CircularArc(arc) => {
let (sa, span) =
arc_signed_span(arc.start_angle, arc.end_angle, arc.counter_clockwise);
let segs = arc_segments(arc.radius, span.abs(), wire_chord_tol(arc.radius));
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
for i in 0..=segs {
let t = sa + span * (i as f64 / segs as f64);
let p = to_wcs(
arc.center.x + arc.radius * t.cos(),
arc.center.y + arc.radius * t.sin(),
);
pts.push(p);
if i == 0 || i == segs {
key_verts.push(p);
}
}
snap_pts.push((
Vec3::from(to_wcs(arc.center.x, arc.center.y)),
SnapHint::Center,
));
}
BoundaryEdge::EllipticArc(ell) => {
let r_maj = (ell.major_axis_endpoint.x * ell.major_axis_endpoint.x
+ ell.major_axis_endpoint.y * ell.major_axis_endpoint.y)
.sqrt();
let r_min = r_maj * ell.minor_axis_ratio;
let rot = ell
.major_axis_endpoint
.y
.atan2(ell.major_axis_endpoint.x);
let (sa, span) =
arc_signed_span(ell.start_angle, ell.end_angle, ell.counter_clockwise);
let segs = arc_segments(r_maj, span.abs(), wire_chord_tol(r_maj));
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
let (cr, sr) = (rot.cos(), rot.sin());
for i in 0..=segs {
let t = sa + span * (i as f64 / segs as f64);
let lx = r_maj * t.cos();
let ly = r_min * t.sin();
let p = to_wcs(
ell.center.x + lx * cr - ly * sr,
ell.center.y + lx * sr + ly * cr,
);
pts.push(p);
if i == 0 || i == segs {
key_verts.push(p);
}
}
snap_pts.push((
Vec3::from(to_wcs(ell.center.x, ell.center.y)),
SnapHint::Center,
));
}
_ => {}
}
}
}
if pts.is_empty() {
pts = vec![[0.0, 0.0, 0.0], [0.0, 0.0, 0.0]];
}
(pts, snap_pts, vec![], key_verts)
}
}
+24
View File
@@ -118,3 +118,27 @@ impl Transformable for Insert {
apply_transform(self, t);
}
}
impl crate::entities::traits::LegacyTess for Insert {
fn legacy_geometry(&self, world_offset: [f64; 3]) -> crate::scene::tess_util::LegacyGeometry {
let [ox, oy, oz] = world_offset;
let ip = Vec3::new(
(self.insert_point.x - ox) as f32,
(self.insert_point.y - oy) as f32,
(self.insert_point.z - oz) as f32,
);
let s = 0.1_f32;
let pts = vec![
[ip.x - s, ip.y, ip.z],
[ip.x + s, ip.y, ip.z],
[ip.x, ip.y - s, ip.z],
[ip.x, ip.y + s, ip.z],
];
(
pts,
vec![(ip, crate::scene::wire_model::SnapHint::Insertion)],
vec![],
vec![],
)
}
}
+164
View File
@@ -480,3 +480,167 @@ impl Transformable for Leader {
apply_transform(self, t);
}
}
/// Per-entity tessellation entry for `Leader`. Lives here so all leader
/// tess code stays alongside the entity definition. Cross-entity dim
/// machinery (arrow shapes, `DimGeom`) lives in `scene::tessellate` and
/// is reused via the dim arrow emitter so the leader matches the active
/// DIMSTYLE.
pub trait LeaderTess {
fn tessellate(
&self,
document: &acadrust::CadDocument,
handle: acadrust::Handle,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
) -> crate::scene::wire_model::WireModel;
}
impl LeaderTess for Leader {
fn tessellate(
&self,
document: &acadrust::CadDocument,
handle: acadrust::Handle,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
) -> crate::scene::wire_model::WireModel {
use crate::scene::tessellate::{append_arrow, arrow_from_block, ArrowKind, DimGeom};
use crate::scene::wire_model::WireModel;
let color = if selected {
WireModel::SELECTED
} else {
entity_color
};
let name = handle.value().to_string();
let [ox, oy, oz] = world_offset;
let p3 = |v: &acadrust::types::Vector3| -> [f32; 3] {
[(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32]
};
let nan = [f32::NAN; 3];
let verts = &self.vertices;
if verts.len() < 2 {
return WireModel {
name,
points: vec![],
color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
};
}
let mut points: Vec<[f32; 3]> = verts.iter().map(|v| p3(v)).collect();
let mut tangents: Vec<TangentGeom> = Vec::new();
let key_vertices: Vec<[f32; 3]> = verts.iter().map(|v| p3(v)).collect();
let mut fill_tris: Vec<[f32; 3]> = Vec::new();
for i in 0..verts.len().saturating_sub(1) {
tangents.push(TangentGeom::Line {
p1: p3(&verts[i]),
p2: p3(&verts[i + 1]),
});
}
if self.arrow_enabled {
// Resolve the active dim style → DIMLDRBLK to pick the arrow shape.
// DIMASZ × DIMSCALE drives the size when available; otherwise fall
// back to the legacy text-height heuristic.
let style = document.dim_styles.iter().find(|s| {
s.name.eq_ignore_ascii_case(&self.dimension_style)
|| (self.dimension_style.trim().is_empty()
&& s.name.eq_ignore_ascii_case("Standard"))
});
let dim_scale = style
.map(|s| {
if s.dimscale > 1e-6 {
s.dimscale
} else {
anno_scale as f64
}
})
.unwrap_or(anno_scale as f64);
let arrow_size = match style {
Some(s) => (s.dimasz * dim_scale) as f32,
None => (self.text_height as f32).max(1.0) * 0.8 * anno_scale,
};
let arrow = match style {
Some(s) => arrow_from_block(document, s.dimldrblk, arrow_size.max(0.001)),
None => ArrowKind::Triangle {
size: arrow_size.max(0.001),
filled: true,
size_mul: 1.0,
},
};
let tip = &verts[0];
let next = &verts[1];
let dx = (next.x - tip.x) as f32;
let dy = (next.y - tip.y) as f32;
let len = (dx * dx + dy * dy).sqrt().max(1e-9);
let dir = Vec3::new(dx / len, dy / len, 0.0);
let tip_f = p3(tip);
let tip_v = Vec3::new(tip_f[0], tip_f[1], tip_f[2]);
// Reuse the dim arrow emitter so the leader shape matches the
// DIMSTYLE in use (Closed Filled by default, Dot, Tick, …).
let mut arrow_pts: Vec<[f32; 3]> = Vec::new();
let mut arrow_geom = DimGeom::new();
append_arrow(&mut arrow_geom, tip_v, dir, &arrow);
if !arrow_geom.dim_lines.is_empty() {
arrow_pts.push(nan);
arrow_pts.extend(arrow_geom.dim_lines);
}
points.extend(arrow_pts);
fill_tris.extend(arrow_geom.arrow_fill);
}
if self.hookline_enabled {
let last = verts.last().unwrap();
let prev = &verts[verts.len() - 2];
let sign = if (last.x - prev.x) >= 0.0 {
1.0_f32
} else {
-1.0_f32
};
let land_len = self.text_height as f32 * 1.5 * anno_scale;
let last_f = p3(last);
points.push(nan);
points.push(last_f);
points.push([last_f[0] + sign * land_len, last_f[1], last_f[2]]);
}
WireModel {
name,
points,
color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: tangents,
key_vertices,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris,
}
}
}
+661
View File
@@ -850,3 +850,664 @@ impl Transformable for MultiLeader {
apply_transform(self, t);
}
}
/// Per-entity tessellation entry for `MultiLeader`. Returns multiple
/// `WireModel`s (leader/dogleg + arrow fill, optional block content,
/// text strokes, frame, background fill) so each piece can carry its
/// own colour.
pub trait MultiLeaderTess {
fn tessellate(
&self,
document: &acadrust::CadDocument,
handle: acadrust::Handle,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
world_per_pixel: Option<f32>,
) -> Vec<crate::scene::wire_model::WireModel>;
}
impl MultiLeaderTess for MultiLeader {
fn tessellate(
&self,
document: &acadrust::CadDocument,
handle: acadrust::Handle,
selected: bool,
entity_color: [f32; 4],
line_weight_px: f32,
world_offset: [f64; 3],
anno_scale: f32,
world_per_pixel: Option<f32>,
) -> Vec<crate::scene::wire_model::WireModel> {
use crate::scene::tessellate::{
append_arrow, arrow_from_block, color_or_inherit, tessellate, ArrowKind, DimGeom,
};
use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel};
use glam::Vec3;
let ml = self;
// line_color falls back to the entity colour when the MultiLeader's own
// colour is ByBlock/ByLayer; otherwise the leader uses its stored hue.
let line_color = if selected {
WireModel::SELECTED
} else {
color_or_inherit(&ml.line_color, entity_color)
};
// ml.line_weight is the leader-line weight override. Negative codes
// (ByLayer/ByBlock/Default) fall through to the entity's already-resolved
// pixel width.
let leader_lw_px = match ml.line_weight {
acadrust::types::LineWeight::Value(v) if v >= 0 => {
(v as f32 / 100.0) * (96.0 / 25.4)
}
_ => line_weight_px,
};
// ml.line_type_handle — resolve via line_types table by handle and apply
// the resulting dash pattern to the leader wire.
let lt_scale = document.header.linetype_scale as f32 * ml.common.linetype_scale as f32;
let (leader_pat_len, leader_pat) = match ml.line_type_handle {
Some(h) if !h.is_null() => {
let name = document
.line_types
.iter()
.find(|lt| lt.handle == h)
.map(|lt| lt.name.clone());
match name {
Some(n) => crate::scene::render::resolve_pattern(&document.line_types, &n, lt_scale),
None => (0.0, [0.0; 8]),
}
}
_ => (0.0, [0.0; 8]),
};
let name = handle.value().to_string();
let nan = [f32::NAN; 3];
let [ox, oy, oz] = world_offset;
let p3 = |v: &acadrust::types::Vector3| -> [f32; 3] {
[(v.x - ox) as f32, (v.y - oy) as f32, (v.z - oz) as f32]
};
// ── Scaling ──────────────────────────────────────────────────────────────
// ml.scale_factor is always applied; anno_scale is only applied when the
// multileader is marked annotative.
let effective_scale = (ml.scale_factor as f32)
* if ml.enable_annotation_scale {
anno_scale
} else {
1.0
};
let arrow_size = ml.arrowhead_size as f32 * effective_scale;
let draw_arrow = arrow_size > 0.0;
let invisible = ml.path_type == MultiLeaderPathType::Invisible;
// arrowhead_handle resolves through the block records to a named arrow
// block (matches DIMLDRBLK on Dimension). Null handle / unknown name →
// ClosedFilled triangle.
let arrow_kind = match ml.arrowhead_handle {
Some(h) if !h.is_null() => arrow_from_block(document, h, arrow_size.max(0.001)),
_ => ArrowKind::Triangle {
size: arrow_size.max(0.001),
filled: true,
size_mul: 1.0,
},
};
// ── Leader / arrow / dogleg points ───────────────────────────────────────
let mut points: Vec<[f32; 3]> = Vec::new();
let mut key_verts: Vec<[f32; 3]> = Vec::new();
let mut snap_pts: Vec<(Vec3, SnapHint)> = Vec::new();
let mut tangents: Vec<TangentGeom> = Vec::new();
let mut arrow_fill: Vec<[f32; 3]> = Vec::new();
let mut first = true;
for root in &ml.context.leader_roots {
let cp = &root.connection_point;
let cp_f = p3(cp);
snap_pts.push((Vec3::from(cp_f), SnapHint::Node));
for line in &root.lines {
if line.points.is_empty() {
continue;
}
if !invisible {
if !first {
points.push(nan);
}
first = false;
let mut ctrl: Vec<[f32; 3]> = line.points.iter().map(|p| p3(p)).collect();
let last_f = *ctrl.last().unwrap_or(&cp_f);
let dist = ((last_f[0] - cp_f[0]).powi(2) + (last_f[1] - cp_f[1]).powi(2)).sqrt();
if dist > 1e-9 {
ctrl.push(cp_f);
}
for &c in &ctrl {
key_verts.push(c);
snap_pts.push((Vec3::from(c), SnapHint::Node));
}
if ml.path_type == MultiLeaderPathType::Spline && ctrl.len() >= 2 {
let ctrl_f64: Vec<[f64; 3]> = ctrl
.iter()
.map(|c| [c[0] as f64, c[1] as f64, c[2] as f64])
.collect();
for pt in catmull_rom_pts(&ctrl_f64, 8) {
points.push([pt[0] as f32, pt[1] as f32, pt[2] as f32]);
}
} else {
for &c in &ctrl {
points.push(c);
}
}
for i in 0..ctrl.len().saturating_sub(1) {
tangents.push(TangentGeom::Line {
p1: ctrl[i],
p2: ctrl[i + 1],
});
}
}
if draw_arrow {
let tip = &line.points[0];
let tip_f = p3(tip);
let next = if line.points.len() >= 2 {
line.points[1]
} else {
*cp
};
let dx = (next.x - tip.x) as f32;
let dy = (next.y - tip.y) as f32;
let dl = (dx * dx + dy * dy).sqrt().max(1e-9);
let dir = Vec3::new(dx / dl, dy / dl, 0.0);
let tip_v = Vec3::new(tip_f[0], tip_f[1], tip_f[2]);
// Reuse the dim/leader arrow emitter so MultiLeader's arrow
// matches the block referenced by arrowhead_handle.
let mut arrow_geom = DimGeom::new();
append_arrow(&mut arrow_geom, tip_v, dir, &arrow_kind);
if !arrow_geom.dim_lines.is_empty() {
points.push(nan);
points.extend(arrow_geom.dim_lines);
}
arrow_fill.extend(arrow_geom.arrow_fill);
}
}
if ml.enable_landing && ml.enable_dogleg && ml.dogleg_length > 0.0 {
let dir = &root.direction;
let dl = (dir.x * dir.x + dir.y * dir.y).sqrt().max(1e-9);
let d = ml.dogleg_length * effective_scale as f64;
let landing_end = [
(cp.x + dir.x / dl * d - ox) as f32,
(cp.y + dir.y / dl * d - oy) as f32,
cp_f[2],
];
points.push(nan);
points.push(cp_f);
points.push(landing_end);
// extend_leader_to_text: continue past the landing to the text
// insertion point along the same direction.
if ml.extend_leader_to_text
&& ml.content_type == LeaderContentType::MText
&& !ml.context.text_string.is_empty()
{
let tx = (ml.context.text_location.x - ox) as f32;
let ty = (ml.context.text_location.y - oy) as f32;
points.push(landing_end);
points.push([tx, ty, cp_f[2]]);
}
}
}
// The leader/arrow/dogleg wire goes out as a single WireModel. Text, frame,
// and background fill (each with their own color) are appended as separate
// WireModels so the renderer respects per-piece coloring.
let mut wires: Vec<WireModel> = Vec::new();
wires.push(WireModel {
name: name.clone(),
points,
color: line_color,
selected,
aci: 0,
pattern_length: leader_pat_len,
pattern: leader_pat,
line_weight_px: leader_lw_px,
snap_pts,
tangent_geoms: tangents,
key_vertices: key_verts,
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: arrow_fill,
});
// ── Block content ───────────────────────────────────────────────────────
// When content_type == Block, the MultiLeader displays a block reference
// at block_content_location with the recorded rotation/scale. Synthesize
// an Insert and explode it through the standard tessellator. The block
// resolves via block_content_handle (handle → block_record name).
if ml.content_type == LeaderContentType::Block && ml.context.has_block_contents {
let block_name = match ml.block_content_handle {
Some(h) if !h.is_null() => document
.block_records
.iter()
.find(|br| br.handle == h)
.map(|br| br.name.clone()),
_ => None,
};
if let Some(block_name) = block_name {
let block_color = if selected {
line_color
} else {
color_or_inherit(&ml.block_content_color, entity_color)
};
let mut synth_ins =
acadrust::entities::Insert::new(block_name, ml.context.block_content_location);
synth_ins.set_x_scale(ml.block_scale.x);
synth_ins.set_y_scale(ml.block_scale.y);
synth_ins.set_z_scale(ml.block_scale.z);
synth_ins.rotation = ml.block_rotation;
synth_ins.common.layer = ml.common.layer.clone();
// block_connection_type (BlockExtents vs BasePoint) chooses the
// anchor when *creating* the multileader; at render time the
// file's stored leader endpoints already encode that choice.
let _ = ml.block_connection_type;
for sub in synth_ins.explode_from_document(document) {
let normalized =
crate::modules::home::modify::explode::normalize_insert_entity(sub);
let mut sub_wires = tessellate(
document,
handle,
&normalized,
selected,
block_color,
leader_pat_len,
leader_pat,
leader_lw_px,
world_offset,
1.0,
);
for w in &mut sub_wires {
w.name = name.clone();
}
wires.extend(sub_wires);
}
// Block attributes attached to the multileader — render each as
// its own attribute entity at WCS location like INSERT does.
for ba in &ml.block_attributes {
let _ = ba; // BlockAttribute carries only the value override
// string; we'd need the AttributeDefinition handle
// to materialise it as ATTRIB geometry. Skipped
// until that wiring exists.
}
}
}
// ── Text strokes / frame / background fill ──────────────────────────────
// Strip inline format codes, split / word-wrap into lines, then place each
// line according to text_attachment_point (horizontal) and
// text_left_attachment (vertical), with text_rotation/text_direction applied.
if ml.content_type == LeaderContentType::MText && !ml.context.text_string.is_empty() {
let ctx = &ml.context;
// `ctx.text_height` (when > 0) is the already-resolved WCS text height
// stored in the per-instance context — style × scale_factor × anno
// scale are all already baked in. Multiplying by `effective_scale`
// again would double-scale (e.g., a context height of 100 in a file
// with scale_factor=20 was rendering at 2000 units — 20× too big).
// Only the fallback path (file omits the context value) needs
// scale_factor + annotation scale applied.
let height = if ctx.text_height > 0.0 {
ctx.text_height as f32
} else {
ml.text_height as f32 * effective_scale
};
let ins = &ctx.text_location;
// Subtract world_offset in f64 before casting to f32: drawings often
// sit at large absolute coordinates and casting first then subtracting
// throws away the precision needed for the rotated sub-glyph offsets.
let local_ins_x = (ins.x - ox) as f32;
let local_ins_y = (ins.y - oy) as f32;
let z = (ins.z - oz) as f32;
// Rotation: prefer text_direction (transforms survive rotations /
// mirrors when acadrust updates it) and fall back to text_rotation.
// ml.text_angle_type then constrains the final angle:
// - Horizontal: always 0
// - ParallelToLastLeaderLine: keep stored direction
// - Optimized: clamp to (-π/2, π/2] (flip upside-down)
// text_direction_negative finally adds π so reading goes the other way.
let td = ctx.text_direction;
let mut rot = if td.x.abs() > 1e-9 || td.y.abs() > 1e-9 {
(td.y as f32).atan2(td.x as f32)
} else {
ctx.text_rotation as f32
};
match ml.text_angle_type {
acadrust::entities::multileader::TextAngleType::Horizontal => rot = 0.0,
acadrust::entities::multileader::TextAngleType::Optimized => {
let pi = std::f32::consts::PI;
if rot > pi / 2.0 {
rot -= pi;
} else if rot <= -pi / 2.0 {
rot += pi;
}
}
acadrust::entities::multileader::TextAngleType::ParallelToLastLeaderLine => {}
}
if ml.text_direction_negative {
rot += std::f32::consts::PI;
}
// Resolve text style via handle when available, falling back to STANDARD.
let style_name = ctx
.text_style_handle
.as_ref()
.and_then(|h| {
document
.text_styles
.iter()
.find(|s| s.handle == *h)
.map(|s| s.name.clone())
})
.unwrap_or_else(|| "STANDARD".to_string());
let style = resolve_text_style(&style_name, document);
let mut rot = rot;
if style.is_upside_down {
rot += std::f32::consts::PI;
}
let (cos_r, sin_r) = (rot.cos(), rot.sin());
// ml.text_alignment overrides the per-context attachment_point when
// not Left (which is the default zero). Otherwise we honour the
// context's stored attachment_point (Left/Center/Right).
use acadrust::entities::multileader::{TextAlignmentType, TextAttachmentDirectionType};
let h_anchor = match (ml.text_alignment, ctx.text_attachment_point) {
(TextAlignmentType::Center, _) => 0.5_f32,
(TextAlignmentType::Right, _) => 1.0,
(TextAlignmentType::Left, TextAttachmentPointType::Center) => 0.5,
(TextAlignmentType::Left, TextAttachmentPointType::Right) => 1.0,
_ => 0.0,
};
// Pick the vertical-anchor attachment based on text_attachment_direction:
// Horizontal — leader attaches left/right; use ml.text_left_attachment
// (matches the file's stored ctx.text_left_attachment).
// Vertical — leader attaches top/bottom; use ml.text_top_attachment
// or ml.text_bottom_attachment depending on which side
// the leader is coming from (chosen via the first
// root.direction.y sign).
let leader_from_top = ml
.context
.leader_roots
.first()
.map(|r| r.direction.y < 0.0)
.unwrap_or(true);
let vertical_attach = match ml.text_attachment_direction {
TextAttachmentDirectionType::Vertical => {
if leader_from_top {
ml.text_top_attachment
} else {
ml.text_bottom_attachment
}
}
TextAttachmentDirectionType::Horizontal => ctx.text_left_attachment,
};
let v_anchor = mleader_v_anchor(vertical_attach);
// Shared MText pipeline — every inline format code (`\f`, `\C`/`\c`,
// `\H`, `\W`, `\Q`, `\T`, `\A`, `\p…`, decorations, stacked
// fractions, …) reaches the stroke output. Stroke origins are
// already in offset-relative space because we pass local_ins_x/y.
let layout = layout_mtext(&MTextRenderOpts {
value: &ctx.text_string,
insertion: [local_ins_x as f64, local_ins_y as f64, z as f64],
height,
rect_w: ctx.text_width as f32,
rotation: rot,
style: &style,
attach_h_anchor: h_anchor,
v_anchor,
line_spacing_factor: ctx.line_spacing_factor as f32,
vertical_text: false,
});
let line_widths = &layout.line_widths;
let max_line_w = line_widths.iter().cloned().fold(0.0_f32, f32::max);
let line_h = layout.line_height;
let v_offset = layout.v_offset;
let n_lines = layout.line_count.max(1) as f32;
// Resolve text color (falls back to entity color for ByLayer / ByBlock).
let text_color = if selected {
line_color
} else {
color_or_inherit(&ctx.text_color, entity_color)
};
// Same LOD ladder used for top-level Text / MText (see scene/mod.rs):
// h_px < 1 → baseline line (skip glyphs)
// 1 ≤ h < 5 → greeked rect in text color (skip glyphs)
// h_px ≥ 5 → full per-glyph stroke tessellation
let lod_h_px = world_per_pixel.map(|wpp| height / wpp);
let lod_mode = match lod_h_px {
Some(h) if h < 1.0 => 0,
Some(h) if h < 5.0 => 1,
_ => 2,
};
// Helper: map a (local_x, local_y) in the text's pre-rotation frame
// (origin at the insertion point) into WCS render space.
let to_wcs = |lx: f32, ly: f32| -> [f32; 3] {
[
local_ins_x + lx * cos_r - ly * sin_r,
local_ins_y + lx * sin_r + ly * cos_r,
z,
]
};
if lod_mode == 0 {
// Baseline of the top line only.
let line_w = line_widths.first().copied().unwrap_or(0.0);
let len_px = world_per_pixel
.map(|wpp| line_w / wpp)
.unwrap_or(f32::INFINITY);
if len_px >= 2.0 {
let line_y_local = v_offset;
let p0 = to_wcs(-line_w * h_anchor, line_y_local);
let p1 = to_wcs(line_w * (1.0 - h_anchor), line_y_local);
wires.push(WireModel {
name: name.clone(),
points: vec![p0, p1],
color: text_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![(
Vec3::new(local_ins_x, local_ins_y, z),
SnapHint::Node,
)],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
} else if lod_mode == 1 {
// One filled rect per line — keeps the visual "text lives here
// per row" hint that multi-line MText carries, in the text's
// own color. Empty `points` opts out of the face3d 0.45 dim so
// the fill renders at full intensity.
let mut greek_tris: Vec<[f32; 3]> = Vec::with_capacity(line_widths.len() * 6);
for (i, &line_w) in line_widths.iter().enumerate() {
let li = i as f32;
let line_y_bottom = -li * line_h + v_offset;
let line_y_top = line_y_bottom + height;
if line_w <= 0.0 {
continue;
}
let left = -line_w * h_anchor;
let right = line_w * (1.0 - h_anchor);
let bl = to_wcs(left, line_y_bottom);
let br = to_wcs(right, line_y_bottom);
let tr = to_wcs(right, line_y_top);
let tl = to_wcs(left, line_y_top);
greek_tris.extend_from_slice(&[bl, br, tr, bl, tr, tl]);
}
if !greek_tris.is_empty() {
wires.push(WireModel {
name: name.clone(),
points: vec![],
color: text_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![(
Vec3::new(local_ins_x, local_ins_y, z),
SnapHint::Node,
)],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: greek_tris,
});
}
} else {
// Pre-tessellated by `layout_mtext`. Each TextStroke is in
// local glyph space with its world origin (already offset-
// relative because we passed local_ins_x/y) stored as f64.
let mut text_points: Vec<[f32; 3]> = Vec::new();
for ts in &layout.strokes {
let ox = ts.origin[0] as f32;
let oy = ts.origin[1] as f32;
for stroke in &ts.strokes {
if stroke.len() < 2 {
continue;
}
text_points.push(nan);
for &[x, y] in stroke {
text_points.push([x + ox, y + oy, z]);
}
}
}
if !text_points.is_empty() {
wires.push(WireModel {
name: name.clone(),
points: text_points,
color: text_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![(
Vec3::new(local_ins_x, local_ins_y, z),
SnapHint::Node,
)],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
}
// Text frame / background-fill rectangle in local frame, then rotated to WCS.
if ml.text_frame || ctx.background_fill_enabled {
// Visual gap so the frame/fill doesn't touch glyph caps.
let pad = height * 0.25;
let block_top = v_offset + height + pad;
let block_bottom = v_offset - (n_lines - 1.0) * line_h - pad;
let block_left = -max_line_w * h_anchor - pad;
let block_right = max_line_w * (1.0 - h_anchor) + pad;
let local_corners: [[f32; 2]; 4] = [
[block_left, block_bottom],
[block_right, block_bottom],
[block_right, block_top],
[block_left, block_top],
];
let wcs_corners: [[f32; 3]; 4] = std::array::from_fn(|i| {
let lx = local_corners[i][0];
let ly = local_corners[i][1];
let wx = local_ins_x + lx * cos_r - ly * sin_r;
let wy = local_ins_y + lx * sin_r + ly * cos_r;
[wx, wy, z]
});
// Background fill — emit two triangles; renders under the text strokes.
if ctx.background_fill_enabled {
let fill_color = if selected {
line_color
} else {
color_or_inherit(&ctx.background_fill_color, entity_color)
};
let fill_tris: Vec<[f32; 3]> = vec![
wcs_corners[0],
wcs_corners[1],
wcs_corners[2],
wcs_corners[0],
wcs_corners[2],
wcs_corners[3],
];
wires.push(WireModel {
name: name.clone(),
points: vec![],
color: fill_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px: 1.0,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris,
});
}
// Text frame — closed rectangle, matches text color.
if ml.text_frame {
let frame_points: Vec<[f32; 3]> = vec![
wcs_corners[0],
wcs_corners[1],
wcs_corners[2],
wcs_corners[3],
wcs_corners[0],
];
wires.push(WireModel {
name,
points: frame_points,
color: text_color,
selected,
aci: 0,
pattern_length: 0.0,
pattern: [0.0; 8],
line_weight_px,
snap_pts: vec![],
tangent_geoms: vec![],
key_vertices: vec![],
aabb: WireModel::UNBOUNDED_AABB,
plinegen: true,
vp_scissor: None,
fill_tris: vec![],
});
}
}
}
wires
}
}
+41
View File
@@ -201,3 +201,44 @@ impl Transformable for Ole2Frame {
apply_transform(self, t);
}
}
impl crate::entities::traits::LegacyTess for Ole2Frame {
fn legacy_geometry(&self, world_offset: [f64; 3]) -> crate::scene::tess_util::LegacyGeometry {
// OLE objects carry a bounding rectangle in model space.
// Render a simple X-through-rectangle placeholder.
let [ox, oy, oz] = world_offset;
let x0 = (self.upper_left_corner.x - ox) as f32;
let y0 = (self.lower_right_corner.y - oy) as f32;
let x1 = (self.lower_right_corner.x - ox) as f32;
let y1 = (self.upper_left_corner.y - oy) as f32;
let z = (self.upper_left_corner.z - oz) as f32;
if (x1 - x0).abs() < 1e-6 && (y1 - y0).abs() < 1e-6 {
// Degenerate / unknown size — show a small cross.
let s = 0.5_f32;
return (
vec![[-s, 0.0, 0.0], [s, 0.0, 0.0]],
vec![],
vec![],
vec![],
);
}
let pts = vec![
// Outer rectangle
[x0, y0, z],
[x1, y0, z],
[x1, y0, z],
[x1, y1, z],
[x1, y1, z],
[x0, y1, z],
[x0, y1, z],
[x0, y0, z],
// Diagonal X
[x0, y0, z],
[x1, y1, z],
[f32::NAN, f32::NAN, f32::NAN],
[x1, y0, z],
[x0, y1, z],
];
(pts, vec![], vec![], vec![[x0, y0, z], [x1, y1, z]])
}
}
+9
View File
@@ -3,11 +3,20 @@ use acadrust::{CadDocument, EntityType};
use crate::command::EntityTransform;
use crate::scene::acad_to_truck::TruckEntity;
use crate::scene::object::{GripApply, GripDef, PropSection};
use crate::scene::tess_util::LegacyGeometry;
pub trait TruckConvertible {
fn to_truck(&self, document: &CadDocument) -> Option<TruckEntity>;
}
/// Legacy fallback geometry for entities not covered by the truck
/// topology pipeline (Viewport, Insert, Hatch outline, Ole2Frame, …).
/// Returns world-offset-relative `f32` points + snap/key vertices the
/// dispatcher wraps into a `WireModel`.
pub trait LegacyTess {
fn legacy_geometry(&self, world_offset: [f64; 3]) -> LegacyGeometry;
}
pub trait Grippable {
fn grips(&self) -> Vec<GripDef>;
fn apply_grip(&mut self, grip_id: usize, apply: GripApply);
+19
View File
@@ -566,3 +566,22 @@ impl Transformable for Viewport {
apply_transform(self, t);
}
}
impl crate::entities::traits::LegacyTess for Viewport {
fn legacy_geometry(&self, world_offset: [f64; 3]) -> crate::scene::tess_util::LegacyGeometry {
let [ox, oy, oz] = world_offset;
let cx = (self.center.x - ox) as f32;
let cy = (self.center.y - oy) as f32;
let cz = (self.center.z - oz) as f32;
let hw = (self.width / 2.0) as f32;
let hh = (self.height / 2.0) as f32;
let pts = vec![
[cx - hw, cy - hh, cz],
[cx + hw, cy - hh, cz],
[cx + hw, cy + hh, cz],
[cx - hw, cy + hh, cz],
[cx - hw, cy - hh, cz],
];
(pts, vec![], vec![], vec![])
}
}
+26 -25
View File
@@ -23,9 +23,10 @@ pub(super) struct EntityIndex {
pub tree: quadtree::QuadTree,
pub unbounded_handles: Vec<Handle>,
}
mod render;
pub(crate) mod render;
mod selection;
pub mod solid3d_tess;
pub mod tess_util;
pub mod tessellate;
pub mod transform;
pub mod truck_tess;
@@ -178,11 +179,11 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
};
let model = match e {
EntityType::Hatch(dxf) => {
let color = tessellate::aci_to_rgba(&dxf.common.color);
let color = tess_util::aci_to_rgba(&dxf.common.color);
Scene::hatch_model_from_dxf(dxf, color, offset)
}
EntityType::Solid(solid) => {
let color = tessellate::aci_to_rgba(&solid.common.color);
let color = tess_util::aci_to_rgba(&solid.common.color);
Some(Scene::solid_hatch_model(solid, color, offset))
}
_ => None,
@@ -211,7 +212,7 @@ pub fn build_derived_caches(doc: &CadDocument) -> DerivedCaches {
.par_iter()
.filter_map(|&handle| {
let e = doc.get_entity(handle)?;
let color = tessellate::aci_to_rgba(&e.common().color);
let color = tess_util::aci_to_rgba(&e.common().color);
let model = match e {
EntityType::Solid3D(s) => solid3d_tess::tessellate_solid3d(s, color, facet_res),
EntityType::Region(r) => solid3d_tess::tessellate_region(r, color, facet_res),
@@ -2445,7 +2446,7 @@ impl Scene {
let color = layer_entry
.map(|l| &l.color)
.unwrap_or(&acadrust::types::Color::WHITE);
let [r, g, b, _] = crate::scene::tessellate::aci_to_rgba(color);
let [r, g, b, _] = crate::scene::tess_util::aci_to_rgba(color);
[r, g, b, 1.0]
}
@@ -2888,10 +2889,10 @@ impl Scene {
if sweep.abs() < 1e-9 {
sweep = if bulge > 0.0 { TAU } else { -TAU };
}
let segs = tessellate::arc_segments(
let segs = tess_util::arc_segments(
r,
sweep.abs(),
tessellate::fill_chord_tol(r),
tess_util::fill_chord_tol(r),
);
for j in 0..segs {
let a = a0 + sweep * (j as f64 / segs as f64);
@@ -2910,15 +2911,15 @@ impl Scene {
boundary.push(to_xy(line.end.x, line.end.y));
}
BoundaryEdge::CircularArc(arc) => {
let (sa, span) = tessellate::arc_signed_span(
let (sa, span) = tess_util::arc_signed_span(
arc.start_angle,
arc.end_angle,
arc.counter_clockwise,
);
let segs = tessellate::arc_segments(
let segs = tess_util::arc_segments(
arc.radius,
span.abs(),
tessellate::fill_chord_tol(arc.radius),
tess_util::fill_chord_tol(arc.radius),
);
for i in 0..=segs {
let t = sa + span * (i as f64 / segs as f64);
@@ -2937,15 +2938,15 @@ impl Scene {
.major_axis_endpoint
.y
.atan2(ell.major_axis_endpoint.x);
let (sa, span) = tessellate::arc_signed_span(
let (sa, span) = tess_util::arc_signed_span(
ell.start_angle,
ell.end_angle,
ell.counter_clockwise,
);
let segs = tessellate::arc_segments(
let segs = tess_util::arc_segments(
r_maj,
span.abs(),
tessellate::fill_chord_tol(r_maj),
tess_util::fill_chord_tol(r_maj),
);
let (cr, sr) = (rot.cos(), rot.sin());
for i in 0..=segs {
@@ -2999,7 +3000,7 @@ impl Scene {
let diag = ((sp_max_x - sp_min_x).powi(2)
+ (sp_max_y - sp_min_y).powi(2))
.sqrt();
let tol = tessellate::fill_chord_tol(diag.max(1.0));
let tol = tess_util::fill_chord_tol(diag.max(1.0));
let mut sampled = false;
if knot_ok {
@@ -3211,11 +3212,11 @@ impl Scene {
};
let model = match &kind {
EntityType::Hatch(dxf) => {
let color = tessellate::aci_to_rgba(&dxf.common.color);
let color = tess_util::aci_to_rgba(&dxf.common.color);
Self::hatch_model_from_dxf(dxf, color, offset)
}
EntityType::Solid(solid) => {
let color = tessellate::aci_to_rgba(&solid.common.color);
let color = tess_util::aci_to_rgba(&solid.common.color);
Some(Self::solid_hatch_model(solid, color, offset))
}
_ => None,
@@ -3241,7 +3242,7 @@ impl Scene {
.entities()
.filter_map(|e| match e {
EntityType::Solid3D(_) | EntityType::Region(_) | EntityType::Body(_) => {
let color = tessellate::aci_to_rgba(&e.common().color);
let color = tess_util::aci_to_rgba(&e.common().color);
Some((e.common().handle, e.clone(), color))
}
_ => None,
@@ -3644,7 +3645,7 @@ impl Scene {
let h = self.document.add_entity(entity).unwrap_or(Handle::NULL);
if !h.is_null() {
let new_model = if let Some(EntityType::Hatch(dxf)) = self.document.get_entity(h) {
let color = tessellate::aci_to_rgba(&dxf.common.color);
let color = tess_util::aci_to_rgba(&dxf.common.color);
Self::hatch_model_from_dxf(dxf, color, hatch_offset)
} else {
None
@@ -3736,7 +3737,7 @@ impl Scene {
};
match self.document.get_entity(handle) {
Some(EntityType::Hatch(dxf)) => {
let color = tessellate::aci_to_rgba(&dxf.common.color);
let color = tess_util::aci_to_rgba(&dxf.common.color);
if let Some(model) = Self::hatch_model_from_dxf(dxf, color, hatch_offset) {
self.hatches.insert(handle, model);
} else {
@@ -3744,7 +3745,7 @@ impl Scene {
}
}
Some(EntityType::Solid(solid)) => {
let color = tessellate::aci_to_rgba(&solid.common.color);
let color = tess_util::aci_to_rgba(&solid.common.color);
self.hatches
.insert(handle, Self::solid_hatch_model(solid, color, hatch_offset));
}
@@ -4691,7 +4692,7 @@ fn clip_polyline_to_rect(
/// so it picks up the standard text LOD ladder (baseline / greek / full),
/// then re-color the returned wires with the dimension's resolved text colour
/// (so DIMCLRT / DIMSTYLE colours win over the synthetic Text's defaults).
pub(super) fn tessellate_entity_dim_text(
pub(crate) fn tessellate_entity_dim_text(
document: &acadrust::CadDocument,
selected: &HashSet<Handle>,
active_viewport: Option<Handle>,
@@ -4899,10 +4900,10 @@ fn tessellate_entity(
if let EntityType::Dimension(dim) = e {
let aabb = entity_aabb(e, world_offset);
let mut wires = tessellate::tessellate_dimension(
use crate::entities::dimension::DimensionTess;
let mut wires = dim.tessellate(
document,
h,
dim,
sel,
entity_color,
line_weight_px,
@@ -4923,10 +4924,10 @@ fn tessellate_entity(
if let EntityType::MultiLeader(ml) = e {
let aabb = entity_aabb(e, world_offset);
let mut wires = tessellate::tessellate_multileader(
use crate::entities::multileader::MultiLeaderTess;
let mut wires = ml.tessellate(
document,
h,
ml,
sel,
entity_color,
line_weight_px,
+3 -3
View File
@@ -13,7 +13,7 @@ use std::sync::Arc;
use super::pipeline::viewcube::{hover_id, VIEWCUBE_PX};
use super::pipeline::Pipeline;
use super::tessellate;
use super::tess_util;
use super::{HatchModel, ImageModel, MeshLodSet, Scene, Uniforms, WireModel};
// ── PaperViewportPipeline / PaperViewportPrimitive ────────────────────────
@@ -242,7 +242,7 @@ pub(super) fn render_style_for(
AcadColor::Index(i) => *i,
_ => 0,
};
let [r, g, b, _] = tessellate::aci_to_rgba(resolved);
let [r, g, b, _] = tess_util::aci_to_rgba(resolved);
let alpha = 1.0 - e.common().transparency.as_percent() as f32;
([r, g, b, alpha], aci)
};
@@ -473,7 +473,7 @@ impl Scene {
// ── Linetype pattern helper ───────────────────────────────────────────────
pub(super) fn resolve_pattern(
pub(crate) fn resolve_pattern(
table: &acadrust::tables::Table<LineType>,
name: &str,
scale: f32,
+90
View File
@@ -0,0 +1,90 @@
// Shared helpers used by per-entity tessellation impls in `crate::entities`
// and the dispatcher in `crate::scene::tessellate`.
//
// Anything cross-entity (colour resolution, arc sampling, chord tolerance)
// lives here. Entity-specific helpers (dim format strings, mleader path
// types, etc.) stay with their entity file.
use acadrust::types::Color as AcadColor;
use glam::Vec3;
use crate::scene::wire_model::{SnapHint, TangentGeom, WireModel};
/// Output of the legacy per-entity geometry path used by entities not
/// (yet) covered by the truck topology pipeline (Viewport, Insert,
/// Hatch outline, Ole2Frame, …). Tuple form preserved to avoid
/// touching every callsite when the dispatcher wraps these into a
/// WireModel.
///
/// Layout: `(points, snap_pts, tangent_geoms, key_vertices)`.
pub type LegacyGeometry = (
Vec<[f32; 3]>,
Vec<(Vec3, SnapHint)>,
Vec<TangentGeom>,
Vec<[f32; 3]>,
);
// ── Arc tessellation helpers ─────────────────────────────────────────────
/// Convert hatch-boundary arc `(start, end, ccw)` into a
/// `(start, signed_span)` ready for the sampling loop. Matches the
/// legacy `(TAU - sa, TAU - ea)` flip used here for years — direction
/// semantics are preserved on real files. (Wrap-through-2π is a known
/// edge case in that convention; do not "fix" it without a wider audit
/// of how upstream writers emit CW boundary arcs.)
pub fn arc_signed_span(start: f64, end: f64, ccw: bool) -> (f64, f64) {
const TAU: f64 = std::f64::consts::TAU;
let (sa, ea) = if ccw { (start, end) } else { (TAU - start, TAU - end) };
(sa, ea - sa)
}
/// Segment count for an arc, targeting `chord_tol_world` chord-height
/// error in world units. Floor 8, cap 512.
///
/// Two production callers:
/// - hatch fill polygon (built at load / on edit) passes a radius-
/// relative tol via [`fill_chord_tol`] — ~0.1% radius, zoom-free so
/// the polygon stays sharp at extreme zoom-in without re-tessellation.
/// - hatch wire outline (re-tessellated every frame inside the render
/// scope) passes a zoom-adaptive tol via [`wire_chord_tol`] — pulls
/// from the per-frame override set by `Scene::wires_for_block` so
/// far-out arcs collapse to a handful of segments.
pub fn arc_segments(radius: f64, span_abs: f64, chord_tol_world: f64) -> u32 {
if span_abs < 1e-9 || radius < 1e-9 {
return 1;
}
let tol = chord_tol_world.max(1e-9).min(radius * 0.99);
// θ where r * (1 - cos(θ/2)) = tol → θ = 2 * acos(1 - tol/r).
let max_step = (2.0 * (1.0 - tol / radius).acos()).max(1e-6);
((span_abs / max_step).ceil() as u32).clamp(8, 512)
}
/// Chord tolerance for the load-time fill polygon: 0.1% of radius,
/// floor 1 µm so degenerate radii still produce a workable count.
pub fn fill_chord_tol(radius: f64) -> f64 {
(radius * 0.001).max(1e-6)
}
/// Chord tolerance for the per-frame wire outline: pulls the active
/// `truck_tess::set_curve_tol_override` value (Scene sets it to
/// `world_per_pixel × 0.5` so curves stay at ~half-pixel chord error at
/// the current zoom). When no override is active (snap / hit-test
/// passes, load-time builds), falls back to [`fill_chord_tol`] so we
/// never under-sample.
pub fn wire_chord_tol(radius: f64) -> f64 {
match crate::scene::truck_tess::active_curve_tol() {
Some(t) => t.min(fill_chord_tol(radius)).max(1e-9),
None => fill_chord_tol(radius),
}
}
// ── Colour helper ──────────────────────────────────────────────────────────
/// Convert an acadrust Color (ACI index or true-color) to a GPU RGBA value.
pub fn aci_to_rgba(color: &AcadColor) -> [f32; 4] {
if let Some((r, g, b)) = color.rgb() {
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0]
} else {
WireModel::WHITE
}
}
+29 -2803
View File
File diff suppressed because it is too large. Load diff