refactor(scene/mod.rs): pull entity-specific helpers into entities/

Five chunks moved out of the 5965-line scene/mod.rs to live with the
entities they belong to:

- wide_lwpolyline_fills → entities/lwpolyline.rs::wide_fills
- wide_polyline2d_fills → entities/polyline.rs::wide_fills
- polyline_segment_fill → entities/common.rs (shared by both above)
- text_obb_corners_native + text_baseline_points + text_greek_obb_tris
  → entities/text_support.rs (handles Text/MText/Attribute alike)
- append_insert_attribute_wires → entities/insert.rs

Net: scene/mod.rs 5965 → 5404 lines (~560 lines extracted). Three
helpers in scene/render.rs / scene/block_cache.rs widened from
pub(super) to pub(crate) so the relocated functions can still reach
them. scene::entity_aabb also widened for the same reason.

scene::tessellate_entity_dim_text stays put — it's a thin wrapper
around the private tessellate_entity dispatcher and moving it would
require exposing tessellate_entity's full signature across the crate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Opus 4.7 committed 2026-05-23 23:26:14 +03:00
1 parent 64d8b2117d
commit 40180e9089
8 files changed
+566 -573

No files matched your search

+81
View File
@@ -167,3 +167,84 @@ pub fn ro_prop(label: &'static str, field: &'static str, value: impl Into<String
pub fn parse_f64(value: &str) -> Option<f64> {
value.trim().parse::<f64>().ok()
}
/// Compute the filled boundary polygon for one polyline segment.
/// For straight segments: a rectangle/trapezoid.
/// For arc segments: an arc band (outer arc + reversed inner arc).
/// Returns `None` if the segment is degenerate.
pub(crate) fn polyline_segment_fill(
p0: [f32; 2],
p1: [f32; 2],
hw0: f32,
hw1: f32,
bulge: f32,
) -> Option<Vec<[f32; 2]>> {
if bulge.abs() < 1e-9 {
// Straight segment — rectangle or trapezoid
let dx = p1[0] - p0[0];
let dy = p1[1] - p0[1];
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
return None;
}
let nx = -dy / len;
let ny = dx / len;
Some(vec![
[p0[0] + hw0 * nx, p0[1] + hw0 * ny],
[p1[0] + hw1 * nx, p1[1] + hw1 * ny],
[p1[0] - hw1 * nx, p1[1] - hw1 * ny],
[p0[0] - hw0 * nx, p0[1] - hw0 * ny],
])
} else {
// Arc segment — arc band polygon.
// Center math matches `bulge_to_arc` in modules/home/modify/explode.rs:
// r = chord * (1 + b²) / (4·|b|)
// d = r * (1 - b²) / (1 + b²) (signed: negative ⇒ major arc, center
// flips to the opposite side of chord)
// center = midpoint + sign(b) · d · left_perp(chord)
let b = bulge as f64;
let b2 = b * b;
let dx = (p1[0] - p0[0]) as f64;
let dy = (p1[1] - p0[1]) as f64;
let chord_len = (dx * dx + dy * dy).sqrt();
if chord_len < 1e-9 || b.abs() < 1e-12 {
return None;
}
let r = chord_len * (1.0 + b2) / (4.0 * b.abs());
let d_perp = r * (1.0 - b2) / (1.0 + b2);
let mx = ((p0[0] + p1[0]) * 0.5) as f64;
let my = ((p0[1] + p1[1]) * 0.5) as f64;
let perp_x = -dy / chord_len;
let perp_y = dx / chord_len;
let sign = b.signum();
let cx = (mx + sign * d_perp * perp_x) as f32;
let cy = (my + sign * d_perp * perp_y) as f32;
let a0 = ((p0[1] - cy) as f32).atan2((p0[0] - cx) as f32);
let a1 = ((p1[1] - cy) as f32).atan2((p1[0] - cx) as f32);
let (sa, mut ea) = if bulge > 0.0 { (a0, a1) } else { (a1, a0) };
if ea < sa {
ea += std::f32::consts::TAU;
}
let span = ea - sa;
let segs = ((span.abs() / std::f32::consts::TAU) * 24.0)
.ceil()
.max(4.0) as u32;
let r = r as f32;
let r_outer = |t: f32| r + (hw0 + (hw1 - hw0) * t);
let r_inner = |t: f32| (r - (hw0 + (hw1 - hw0) * t)).max(0.0);
let mut boundary = Vec::with_capacity((segs as usize + 1) * 2);
let inv = 1.0 / segs as f32;
for j in 0..=segs {
let t = j as f32 * inv;
let ang = sa + span * t;
let ro = r_outer(t);
boundary.push([cx + ro * ang.cos(), cy + ro * ang.sin()]);
}
for j in (0..=segs).rev() {
let t = j as f32 * inv;
let ang = sa + span * t;
let ri = r_inner(t);
boundary.push([cx + ri * ang.cos(), cy + ri * ang.sin()]);
}
Some(boundary)
}
}
+75
View File
@@ -1,11 +1,14 @@
use acadrust::entities::Insert;
use acadrust::types::{Transform, Vector3};
use acadrust::{EntityType, Handle};
use glam::Vec3;
use crate::command::EntityTransform;
use crate::entities::common::{edit_prop as edit, parse_f64, ro_prop as ro, square_grip};
use crate::entities::traits::{Grippable, PropertyEditable, Transformable};
use crate::scene::object::{GripApply, GripDef, PropSection};
use crate::scene::{block_cache, render, tessellate};
use crate::scene::wire_model::WireModel;
fn grips(ins: &Insert) -> Vec<GripDef> {
let p = Vec3::new(
@@ -142,3 +145,75 @@ impl crate::entities::traits::FallbackTess for Insert {
)
}
}
pub(crate) fn append_insert_attribute_wires(
wires: &mut Vec<WireModel>,
document: &acadrust::CadDocument,
ins: &acadrust::entities::Insert,
insert_handle: Handle,
sel: bool,
ins_color: [f32; 4],
ins_pat_len: f32,
ins_pat: [f32; 8],
ins_lw_px: f32,
bg_color: [f32; 4],
is_xref: bool,
pslt_factor: f32,
anno_scale: f32,
world_offset: [f64; 3],
) {
if ins.attributes.is_empty() {
return;
}
// ATTMODE (header.attribute_visibility):
// 0 = Off — every attribute hidden
// 1 = Normal — honour per-attribute `invisible` flag (default)
// 2 = On — every attribute forced visible, ignoring its flag
let attmode = document.header.attribute_visibility;
if attmode == 0 {
return;
}
for attr in &ins.attributes {
let per_attr_hidden = attr.common.invisible || attr.flags.invisible;
if attmode == 1 && per_attr_hidden {
continue;
}
let attr_entity = EntityType::AttributeEntity(attr.clone());
let (sub_color, sub_plen, sub_pat, sub_lw_px, sub_aci) =
render::render_style_for_block_sub(
document,
&attr_entity,
ins_color,
ins_pat_len,
ins_pat,
ins_lw_px,
);
let sub_color = render::adapt_to_bg(sub_color, bg_color);
let sub_color = if is_xref && !sel {
block_cache::fade_toward_bg(sub_color, bg_color)
} else {
sub_color
};
let sub_aabb = crate::scene::entity_aabb(&attr_entity, world_offset);
let mut attr_wires = tessellate::tessellate(
document,
insert_handle,
&attr_entity,
sel,
sub_color,
sub_plen * pslt_factor,
sub_pat.map(|v| v * pslt_factor),
sub_lw_px,
world_offset,
anno_scale,
);
// Use the INSERT's handle so selection / picking groups attribute
// text with the parent insert instead of treating it as a stray text.
for w in &mut attr_wires {
w.name = insert_handle.value().to_string();
w.aci = sub_aci;
w.aabb = sub_aabb;
}
wires.extend(attr_wires);
}
}
+33
View File
@@ -516,3 +516,36 @@ impl Transformable for LwPolyline {
apply_transform(self, t);
}
}
pub(crate) fn wide_fills(pl: &acadrust::entities::LwPolyline) -> Vec<Vec<[f32; 2]>> {
let hw_const = (pl.constant_width / 2.0) as f32;
let verts = &pl.vertices;
let n = verts.len();
if n < 2 {
return vec![];
}
let seg_count = if pl.is_closed { n } else { n - 1 };
let mut out = Vec::new();
for i in 0..seg_count {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % n];
let hw0 = if v0.start_width > 1e-9 {
v0.start_width as f32 / 2.0
} else {
hw_const
};
let hw1 = if v0.end_width > 1e-9 {
v0.end_width as f32 / 2.0
} else {
hw_const
};
if hw0 < 1e-6 && hw1 < 1e-6 {
continue;
}
let p0 = [v0.location.x as f32, v0.location.y as f32];
let p1 = [v1.location.x as f32, v1.location.y as f32];
if let Some(poly) = crate::entities::common::polyline_segment_fill(p0, p1, hw0, hw1, v0.bulge as f32) {
out.push(poly);
}
}
out
}
+34
View File
@@ -596,3 +596,37 @@ impl Transformable for Polyline3D {
});
}
}
/// Generate solid-fill boundary polygons for each wide segment of a Polyline2D.
pub(crate) fn wide_fills(pl: &acadrust::entities::Polyline2D) -> Vec<Vec<[f32; 2]>> {
let hw_default = (pl.start_width.max(pl.end_width) / 2.0) as f32;
let verts = &pl.vertices;
let n = verts.len();
if n < 2 {
return vec![];
}
let seg_count = if pl.is_closed() { n } else { n - 1 };
let mut out = Vec::new();
for i in 0..seg_count {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % n];
let hw0 = if v0.start_width > 1e-9 {
v0.start_width as f32 / 2.0
} else {
hw_default
};
let hw1 = if v0.end_width > 1e-9 {
v0.end_width as f32 / 2.0
} else {
hw_default
};
if hw0 < 1e-6 && hw1 < 1e-6 {
continue;
}
let p0 = [v0.location.x as f32, v0.location.y as f32];
let p1 = [v1.location.x as f32, v1.location.y as f32];
if let Some(poly) = crate::entities::common::polyline_segment_fill(p0, p1, hw0, hw1, v0.bulge as f32) {
out.push(poly);
}
}
out
}
+332 -1
View File
@@ -1,5 +1,5 @@
use acadrust::types::aci_table::aci_to_rgb;
use acadrust::CadDocument;
use acadrust::{CadDocument, EntityType};
use crate::scene::acad_to_truck::TextStroke;
use crate::scene::cxf;
@@ -1364,3 +1364,334 @@ pub fn layout_mtext(opts: &MTextRenderOpts) -> MTextLayout {
v_offset,
}
}
pub(crate) fn text_obb_corners_native(
e: &EntityType,
anno_scale: f32,
mtext_lines_override: Option<usize>,
) -> Option<[[f64; 3]; 4]> {
use acadrust::entities::attribute_definition::{
HorizontalAlignment as AttrHA, VerticalAlignment as AttrVA,
};
use acadrust::entities::{AttachmentPoint, TextHorizontalAlignment, TextVerticalAlignment};
let anno = anno_scale as f64;
// Map Attribute alignment enums to the same h/v anchor fractions used by
// Text. Kept local because the Attribute enum is distinct from Text's.
let attr_h_anchor = |ha: AttrHA| -> f64 {
match ha {
AttrHA::Left => 0.0,
AttrHA::Center | AttrHA::Middle | AttrHA::Aligned | AttrHA::Fit => 0.5,
AttrHA::Right => 1.0,
}
};
let attr_v_anchor = |va: AttrVA| -> f64 {
match va {
AttrVA::Baseline | AttrVA::Bottom => 0.0,
AttrVA::Middle => 0.5,
AttrVA::Top => 1.0,
}
};
let attr_use_align_pt = |ha: AttrHA, va: AttrVA| -> bool {
!matches!((ha, va), (AttrHA::Left, AttrVA::Baseline))
};
let (ix, iy, iz, w, h, rot, h_anchor, v_anchor) = match e {
EntityType::Text(t) => {
let h_world = t.height * anno;
let w_factor = if t.width_factor > 0.0 { t.width_factor } else { 1.0 };
let n = t.value.chars().count().max(1) as f64;
// Approximate glyph width: AutoCAD's stroke fonts average ~0.6 em.
let w_world = n * h_world * w_factor * 0.6;
let h_anchor = match t.horizontal_alignment {
TextHorizontalAlignment::Left => 0.0,
TextHorizontalAlignment::Center
| TextHorizontalAlignment::Middle
| TextHorizontalAlignment::Aligned
| TextHorizontalAlignment::Fit => 0.5,
TextHorizontalAlignment::Right => 1.0,
};
let v_anchor = match t.vertical_alignment {
TextVerticalAlignment::Baseline | TextVerticalAlignment::Bottom => 0.0,
TextVerticalAlignment::Middle => 0.5,
TextVerticalAlignment::Top => 1.0,
};
// AutoCAD writes `alignment_point` (DXF 11) whenever the text
// isn't simple Left+Baseline; in that case it — not the
// insertion_point — is the anchor the alignment fractions map to.
let use_alignment_pt = !matches!(
(t.horizontal_alignment, t.vertical_alignment),
(
TextHorizontalAlignment::Left,
TextVerticalAlignment::Baseline,
)
);
let anchor = match (use_alignment_pt, t.alignment_point) {
(true, Some(p)) => p,
_ => t.insertion_point,
};
(
anchor.x,
anchor.y,
anchor.z,
w_world,
h_world,
t.rotation,
h_anchor,
v_anchor,
)
}
EntityType::AttributeDefinition(a) => {
let h_world = a.height * anno;
let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 };
// Render the tag in preview when no default; matches `attribute.rs`.
let display = if a.default_value.is_empty() { &a.tag } else { &a.default_value };
let n = display.chars().count().max(1) as f64;
let w_world = n * h_world * w_factor * 0.6;
let h_anchor = attr_h_anchor(a.horizontal_alignment);
let v_anchor = attr_v_anchor(a.vertical_alignment);
let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) {
a.alignment_point
} else {
a.insertion_point
};
(
anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor,
)
}
EntityType::AttributeEntity(a) => {
let h_world = a.height * anno;
let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 };
let n = a.value.chars().count().max(1) as f64;
let w_world = n * h_world * w_factor * 0.6;
let h_anchor = attr_h_anchor(a.horizontal_alignment);
let v_anchor = attr_v_anchor(a.vertical_alignment);
let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) {
a.alignment_point
} else {
a.insertion_point
};
(
anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor,
)
}
EntityType::Tolerance(t) => {
// Approximate: a feature control frame is roughly 1 line tall;
// width comes from char-count of the (already-stripped) text.
// GD&T symbols all advance one cell, so plain char count is close.
let raw_h = if t.text_height > 0.0 { t.text_height } else { 2.5 };
let h_world = raw_h * anno;
// Each cell ≈ 1.4 × height (matches `tolerance.rs` min_cell_w).
let n = t.text.chars().filter(|c| *c != '\n').count().max(1) as f64;
let w_world = h_world * 1.4 * n * 0.5; // rough — fine for LOD greek
// direction encodes rotation as a vector.
let rot = (t.direction.y).atan2(t.direction.x);
(
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
w_world,
h_world * 1.5, // frame is taller than glyph cap by ~0.5 h
rot,
0.0, // anchored at insertion point (bottom-left)
0.0,
)
}
EntityType::MText(m) => {
let h_world = m.height * anno;
let raw_lines = (m.value.matches('\n').count() + 1) as f64;
let effective_lines = match mtext_lines_override {
Some(n) => n.max(1) as f64,
None => raw_lines,
};
let w_world = if m.rectangle_width > 0.0 {
m.rectangle_width
} else {
h_world * 8.0 * effective_lines.max(1.0)
};
// Wrap-aware override beats `rectangle_height` — the stored
// height can be stale on DWGs that were re-saved without
// updating the bounds.
let total_h = if mtext_lines_override.is_some() {
h_world * effective_lines.max(1.0) * m.line_spacing_factor.max(0.5)
} else {
m.rectangle_height.unwrap_or(
h_world * raw_lines.max(1.0) * m.line_spacing_factor.max(0.5),
)
};
// MText `attachment_point` puts `insertion_point` at one of the
// 9 corners/midpoints of the text bbox. h_anchor / v_anchor are
// fractions from (left, bottom) of the bbox.
let (h_anchor, v_anchor) = match m.attachment_point {
AttachmentPoint::TopLeft => (0.0, 1.0),
AttachmentPoint::TopCenter => (0.5, 1.0),
AttachmentPoint::TopRight => (1.0, 1.0),
AttachmentPoint::MiddleLeft => (0.0, 0.5),
AttachmentPoint::MiddleCenter => (0.5, 0.5),
AttachmentPoint::MiddleRight => (1.0, 0.5),
AttachmentPoint::BottomLeft => (0.0, 0.0),
AttachmentPoint::BottomCenter => (0.5, 0.0),
AttachmentPoint::BottomRight => (1.0, 0.0),
};
(
m.insertion_point.x,
m.insertion_point.y,
m.insertion_point.z,
w_world,
total_h,
m.rotation,
h_anchor,
v_anchor,
)
}
_ => return None,
};
let x0 = -h_anchor * w;
let x1 = (1.0 - h_anchor) * w;
let y0 = -v_anchor * h;
let y1 = (1.0 - v_anchor) * h;
let (s, c) = (rot.sin(), rot.cos());
let rot_pt = |lx: f64, ly: f64| -> [f64; 3] {
let rx = lx * c - ly * s;
let ry = lx * s + ly * c;
[ix + rx, iy + ry, iz]
};
Some([
rot_pt(x0, y0),
rot_pt(x1, y0),
rot_pt(x1, y1),
rot_pt(x0, y1),
])
}
pub(crate) fn text_baseline_points(
e: &EntityType,
anno_scale: f32,
world_offset: [f64; 3],
n_lines: usize,
) -> Vec<[f32; 3]> {
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
return vec![];
};
let line_h = match e {
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32,
EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32,
EntityType::Tolerance(t) => {
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
(raw * anno_scale as f64) as f32
}
_ => return vec![],
};
if line_h <= 0.0 {
return vec![];
}
let n_lines = n_lines.max(1);
let [ox, oy, oz] = world_offset;
let cast = |p: [f64; 3]| -> [f32; 3] {
[(p[0] - ox) as f32, (p[1] - oy) as f32, (p[2] - oz) as f32]
};
let bl = cast(corners[0]);
let br = cast(corners[1]);
let full_tl = cast(corners[3]);
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
if ulen < 1e-9 {
return vec![bl, br];
}
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
let mut pts = Vec::with_capacity(n_lines * 3);
for i in 0..n_lines {
// i = 0 is the topmost line — its bottom sits one line_h below
// the OBB top (≈ `ulen`). For n_lines > ulen/line_h the deepest
// baselines clamp to the OBB bottom.
let bot_off = ((i + 1) as f32) * line_h;
let along = (ulen - bot_off).max(0.0);
let p0 = [bl[0] + nx * along, bl[1] + ny * along, bl[2] + nz * along];
let p1 = [br[0] + nx * along, br[1] + ny * along, br[2] + nz * along];
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
pts.extend_from_slice(&[p0, p1]);
}
pts
}
/// Filled tris for a greeked top-level Text / MText. One 2-triangle rect
/// per visible line — `n_lines` is the actual rendered line count from
/// `mtext_line_count` (1 for Text). Stacked top → bottom along the OBB's
/// up direction. The face3d pipeline skips its 0.45 dim for wires with
/// empty `points`, so these tris render at the literal text color.
pub(crate) fn text_greek_obb_tris(
e: &EntityType,
anno_scale: f32,
world_offset: [f64; 3],
n_lines: usize,
) -> Vec<[f32; 3]> {
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
return vec![];
};
let line_h = match e {
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32,
EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32,
EntityType::Tolerance(t) => {
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
(raw * anno_scale as f64) as f32
}
_ => return vec![],
};
if line_h <= 0.0 {
return vec![];
}
let n_lines = n_lines.max(1);
let [ox, oy, oz] = world_offset;
let cast = |p: [f64; 3]| -> [f32; 3] {
[(p[0] - ox) as f32, (p[1] - oy) as f32, (p[2] - oz) as f32]
};
let bl = cast(corners[0]);
let br = cast(corners[1]);
let full_tl = cast(corners[3]);
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
if ulen < 1e-9 {
return vec![];
}
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
let mut tris = Vec::with_capacity(n_lines * 6);
for i in 0..n_lines {
let top_along = (ulen - (i as f32) * line_h).max(0.0);
let bot_along = (ulen - ((i + 1) as f32) * line_h).max(0.0);
let tl = [
bl[0] + nx * top_along,
bl[1] + ny * top_along,
bl[2] + nz * top_along,
];
let tr = [
br[0] + nx * top_along,
br[1] + ny * top_along,
br[2] + nz * top_along,
];
let lbl = [
bl[0] + nx * bot_along,
bl[1] + ny * bot_along,
bl[2] + nz * bot_along,
];
let lbr = [
br[0] + nx * bot_along,
br[1] + ny * bot_along,
br[2] + nz * bot_along,
];
tris.extend_from_slice(&[lbl, lbr, tr, lbl, tr, tl]);
}
tris
}
+2 -2
View File
@@ -456,7 +456,7 @@ fn tessellate_sub_local(
_ => None,
};
let text_obb_local: Option<[[f32; 3]; 4]> =
crate::scene::text_obb_corners_native(sub, anno_scale, mtext_lines).map(|c| {
crate::entities::text_support::text_obb_corners_native(sub, anno_scale, mtext_lines).map(|c| {
[
[(c[0][0] - lo_x) as f32, (c[0][1] - lo_y) as f32, (c[0][2] - lo_z) as f32],
[(c[1][0] - lo_x) as f32, (c[1][1] - lo_y) as f32, (c[1][2] - lo_z) as f32],
@@ -941,7 +941,7 @@ struct ExpandCtx<'a> {
/// Fade `color` toward `bg` by 50%, preserving alpha. Used to mark xref
/// geometry — the hue stays recognizable but the contrast against the
/// background drops, reading as "washed out".
pub(super) fn fade_toward_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
pub(crate) fn fade_toward_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
const T: f32 = 0.5;
[
color[0] * (1.0 - T) + bg[0] * T,
+7 -568
View File
@@ -2637,8 +2637,8 @@ impl Scene {
let oy = oy as f32;
for entity in self.document.entities() {
let (common, fills) = match entity {
EntityType::LwPolyline(pl) => (&pl.common, wide_lwpolyline_fills(pl)),
EntityType::Polyline2D(pl) => (&pl.common, wide_polyline2d_fills(pl)),
EntityType::LwPolyline(pl) => (&pl.common, crate::entities::lwpolyline::wide_fills(pl)),
EntityType::Polyline2D(pl) => (&pl.common, crate::entities::polyline::wide_fills(pl)),
_ => continue,
};
if fills.is_empty() {
@@ -5047,7 +5047,7 @@ fn tessellate_entity(
// the AttributeEntity instances live on the Insert itself in
// WCS and need their own tessellation so the user sees the
// values they actually filled in. See #20.
append_insert_attribute_wires(
crate::entities::insert::append_insert_attribute_wires(
&mut wires,
document,
ins,
@@ -5119,7 +5119,7 @@ fn tessellate_entity(
wires
})
.collect();
append_insert_attribute_wires(
crate::entities::insert::append_insert_attribute_wires(
&mut wires,
document,
ins,
@@ -5176,7 +5176,7 @@ fn tessellate_entity(
_ => 1,
};
if h_px < 1.0 {
let pts = text_baseline_points(e, anno_scale, world_offset, n_lines);
let pts = crate::entities::text_support::text_baseline_points(e, anno_scale, world_offset, n_lines);
if pts.len() < 2 {
return vec![];
}
@@ -5218,7 +5218,7 @@ fn tessellate_entity(
}];
}
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
let fill_tris = text_greek_obb_tris(e, anno_scale, world_offset, n_lines);
let fill_tris = crate::entities::text_support::text_greek_obb_tris(e, anno_scale, world_offset, n_lines);
if fill_tris.is_empty() {
return vec![lod_stub_wire(
h.value().to_string(),
@@ -5309,208 +5309,6 @@ fn tessellate_entity(
/// (from `text_support::mtext_line_count`). Without it, MText's OBB
/// height collapses to a single line when the file omits `rectangle_height`,
/// which makes downstream per-line LOD math degenerate.
pub(crate) fn text_obb_corners_native(
e: &EntityType,
anno_scale: f32,
mtext_lines_override: Option<usize>,
) -> Option<[[f64; 3]; 4]> {
use acadrust::entities::attribute_definition::{
HorizontalAlignment as AttrHA, VerticalAlignment as AttrVA,
};
use acadrust::entities::{AttachmentPoint, TextHorizontalAlignment, TextVerticalAlignment};
let anno = anno_scale as f64;
// Map Attribute alignment enums to the same h/v anchor fractions used by
// Text. Kept local because the Attribute enum is distinct from Text's.
let attr_h_anchor = |ha: AttrHA| -> f64 {
match ha {
AttrHA::Left => 0.0,
AttrHA::Center | AttrHA::Middle | AttrHA::Aligned | AttrHA::Fit => 0.5,
AttrHA::Right => 1.0,
}
};
let attr_v_anchor = |va: AttrVA| -> f64 {
match va {
AttrVA::Baseline | AttrVA::Bottom => 0.0,
AttrVA::Middle => 0.5,
AttrVA::Top => 1.0,
}
};
let attr_use_align_pt = |ha: AttrHA, va: AttrVA| -> bool {
!matches!((ha, va), (AttrHA::Left, AttrVA::Baseline))
};
let (ix, iy, iz, w, h, rot, h_anchor, v_anchor) = match e {
EntityType::Text(t) => {
let h_world = t.height * anno;
let w_factor = if t.width_factor > 0.0 { t.width_factor } else { 1.0 };
let n = t.value.chars().count().max(1) as f64;
// Approximate glyph width: AutoCAD's stroke fonts average ~0.6 em.
let w_world = n * h_world * w_factor * 0.6;
let h_anchor = match t.horizontal_alignment {
TextHorizontalAlignment::Left => 0.0,
TextHorizontalAlignment::Center
| TextHorizontalAlignment::Middle
| TextHorizontalAlignment::Aligned
| TextHorizontalAlignment::Fit => 0.5,
TextHorizontalAlignment::Right => 1.0,
};
let v_anchor = match t.vertical_alignment {
TextVerticalAlignment::Baseline | TextVerticalAlignment::Bottom => 0.0,
TextVerticalAlignment::Middle => 0.5,
TextVerticalAlignment::Top => 1.0,
};
// AutoCAD writes `alignment_point` (DXF 11) whenever the text
// isn't simple Left+Baseline; in that case it — not the
// insertion_point — is the anchor the alignment fractions map to.
let use_alignment_pt = !matches!(
(t.horizontal_alignment, t.vertical_alignment),
(
TextHorizontalAlignment::Left,
TextVerticalAlignment::Baseline,
)
);
let anchor = match (use_alignment_pt, t.alignment_point) {
(true, Some(p)) => p,
_ => t.insertion_point,
};
(
anchor.x,
anchor.y,
anchor.z,
w_world,
h_world,
t.rotation,
h_anchor,
v_anchor,
)
}
EntityType::AttributeDefinition(a) => {
let h_world = a.height * anno;
let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 };
// Render the tag in preview when no default; matches `attribute.rs`.
let display = if a.default_value.is_empty() { &a.tag } else { &a.default_value };
let n = display.chars().count().max(1) as f64;
let w_world = n * h_world * w_factor * 0.6;
let h_anchor = attr_h_anchor(a.horizontal_alignment);
let v_anchor = attr_v_anchor(a.vertical_alignment);
let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) {
a.alignment_point
} else {
a.insertion_point
};
(
anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor,
)
}
EntityType::AttributeEntity(a) => {
let h_world = a.height * anno;
let w_factor = if a.width_factor > 0.0 { a.width_factor } else { 1.0 };
let n = a.value.chars().count().max(1) as f64;
let w_world = n * h_world * w_factor * 0.6;
let h_anchor = attr_h_anchor(a.horizontal_alignment);
let v_anchor = attr_v_anchor(a.vertical_alignment);
let anchor = if attr_use_align_pt(a.horizontal_alignment, a.vertical_alignment) {
a.alignment_point
} else {
a.insertion_point
};
(
anchor.x, anchor.y, anchor.z, w_world, h_world, a.rotation, h_anchor, v_anchor,
)
}
EntityType::Tolerance(t) => {
// Approximate: a feature control frame is roughly 1 line tall;
// width comes from char-count of the (already-stripped) text.
// GD&T symbols all advance one cell, so plain char count is close.
let raw_h = if t.text_height > 0.0 { t.text_height } else { 2.5 };
let h_world = raw_h * anno;
// Each cell ≈ 1.4 × height (matches `tolerance.rs` min_cell_w).
let n = t.text.chars().filter(|c| *c != '\n').count().max(1) as f64;
let w_world = h_world * 1.4 * n * 0.5; // rough — fine for LOD greek
// direction encodes rotation as a vector.
let rot = (t.direction.y).atan2(t.direction.x);
(
t.insertion_point.x,
t.insertion_point.y,
t.insertion_point.z,
w_world,
h_world * 1.5, // frame is taller than glyph cap by ~0.5 h
rot,
0.0, // anchored at insertion point (bottom-left)
0.0,
)
}
EntityType::MText(m) => {
let h_world = m.height * anno;
let raw_lines = (m.value.matches('\n').count() + 1) as f64;
let effective_lines = match mtext_lines_override {
Some(n) => n.max(1) as f64,
None => raw_lines,
};
let w_world = if m.rectangle_width > 0.0 {
m.rectangle_width
} else {
h_world * 8.0 * effective_lines.max(1.0)
};
// Wrap-aware override beats `rectangle_height` — the stored
// height can be stale on DWGs that were re-saved without
// updating the bounds.
let total_h = if mtext_lines_override.is_some() {
h_world * effective_lines.max(1.0) * m.line_spacing_factor.max(0.5)
} else {
m.rectangle_height.unwrap_or(
h_world * raw_lines.max(1.0) * m.line_spacing_factor.max(0.5),
)
};
// MText `attachment_point` puts `insertion_point` at one of the
// 9 corners/midpoints of the text bbox. h_anchor / v_anchor are
// fractions from (left, bottom) of the bbox.
let (h_anchor, v_anchor) = match m.attachment_point {
AttachmentPoint::TopLeft => (0.0, 1.0),
AttachmentPoint::TopCenter => (0.5, 1.0),
AttachmentPoint::TopRight => (1.0, 1.0),
AttachmentPoint::MiddleLeft => (0.0, 0.5),
AttachmentPoint::MiddleCenter => (0.5, 0.5),
AttachmentPoint::MiddleRight => (1.0, 0.5),
AttachmentPoint::BottomLeft => (0.0, 0.0),
AttachmentPoint::BottomCenter => (0.5, 0.0),
AttachmentPoint::BottomRight => (1.0, 0.0),
};
(
m.insertion_point.x,
m.insertion_point.y,
m.insertion_point.z,
w_world,
total_h,
m.rotation,
h_anchor,
v_anchor,
)
}
_ => return None,
};
let x0 = -h_anchor * w;
let x1 = (1.0 - h_anchor) * w;
let y0 = -v_anchor * h;
let y1 = (1.0 - v_anchor) * h;
let (s, c) = (rot.sin(), rot.cos());
let rot_pt = |lx: f64, ly: f64| -> [f64; 3] {
let rx = lx * c - ly * s;
let ry = lx * s + ly * c;
[ix + rx, iy + ry, iz]
};
Some([
rot_pt(x0, y0),
rot_pt(x1, y0),
rot_pt(x1, y1),
rot_pt(x0, y1),
])
}
/// Build a "low-LOD stub" wire for an entity that would otherwise be culled
/// to nothing — the entity's AABB diagonal as a 2-point segment, plus the
@@ -5560,213 +5358,7 @@ fn lod_stub_wire(
/// INSERT only stamps the geometry once, attribute text sits at the world
/// position recorded on each ATTRIB. See #20.
#[allow(clippy::too_many_arguments)]
fn append_insert_attribute_wires(
wires: &mut Vec<WireModel>,
document: &acadrust::CadDocument,
ins: &acadrust::entities::Insert,
insert_handle: Handle,
sel: bool,
ins_color: [f32; 4],
ins_pat_len: f32,
ins_pat: [f32; 8],
ins_lw_px: f32,
bg_color: [f32; 4],
is_xref: bool,
pslt_factor: f32,
anno_scale: f32,
world_offset: [f64; 3],
) {
if ins.attributes.is_empty() {
return;
}
// ATTMODE (header.attribute_visibility):
// 0 = Off — every attribute hidden
// 1 = Normal — honour per-attribute `invisible` flag (default)
// 2 = On — every attribute forced visible, ignoring its flag
let attmode = document.header.attribute_visibility;
if attmode == 0 {
return;
}
for attr in &ins.attributes {
let per_attr_hidden = attr.common.invisible || attr.flags.invisible;
if attmode == 1 && per_attr_hidden {
continue;
}
let attr_entity = EntityType::AttributeEntity(attr.clone());
let (sub_color, sub_plen, sub_pat, sub_lw_px, sub_aci) =
render::render_style_for_block_sub(
document,
&attr_entity,
ins_color,
ins_pat_len,
ins_pat,
ins_lw_px,
);
let sub_color = render::adapt_to_bg(sub_color, bg_color);
let sub_color = if is_xref && !sel {
block_cache::fade_toward_bg(sub_color, bg_color)
} else {
sub_color
};
let sub_aabb = entity_aabb(&attr_entity, world_offset);
let mut attr_wires = tessellate::tessellate(
document,
insert_handle,
&attr_entity,
sel,
sub_color,
sub_plen * pslt_factor,
sub_pat.map(|v| v * pslt_factor),
sub_lw_px,
world_offset,
anno_scale,
);
// Use the INSERT's handle so selection / picking groups attribute
// text with the parent insert instead of treating it as a stray text.
for w in &mut attr_wires {
w.name = insert_handle.value().to_string();
w.aci = sub_aci;
w.aabb = sub_aabb;
}
wires.extend(attr_wires);
}
}
/// NaN-separated baseline segments — one per visible wrap line — for a
/// sub-pixel Text / MText, in world-offset-subtracted f32 space. `n_lines`
/// must be the actual rendered line count (1 for Text, the wrap-expanded
/// count for MText). Lines are emitted top → bottom along the OBB's up
/// direction, each at the bottom edge of its slab.
fn text_baseline_points(
e: &EntityType,
anno_scale: f32,
world_offset: [f64; 3],
n_lines: usize,
) -> Vec<[f32; 3]> {
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
return vec![];
};
let line_h = match e {
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32,
EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32,
EntityType::Tolerance(t) => {
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
(raw * anno_scale as f64) as f32
}
_ => return vec![],
};
if line_h <= 0.0 {
return vec![];
}
let n_lines = n_lines.max(1);
let [ox, oy, oz] = world_offset;
let cast = |p: [f64; 3]| -> [f32; 3] {
[(p[0] - ox) as f32, (p[1] - oy) as f32, (p[2] - oz) as f32]
};
let bl = cast(corners[0]);
let br = cast(corners[1]);
let full_tl = cast(corners[3]);
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
if ulen < 1e-9 {
return vec![bl, br];
}
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
let mut pts = Vec::with_capacity(n_lines * 3);
for i in 0..n_lines {
// i = 0 is the topmost line — its bottom sits one line_h below
// the OBB top (≈ `ulen`). For n_lines > ulen/line_h the deepest
// baselines clamp to the OBB bottom.
let bot_off = ((i + 1) as f32) * line_h;
let along = (ulen - bot_off).max(0.0);
let p0 = [bl[0] + nx * along, bl[1] + ny * along, bl[2] + nz * along];
let p1 = [br[0] + nx * along, br[1] + ny * along, br[2] + nz * along];
if !pts.is_empty() {
pts.push([f32::NAN; 3]);
}
pts.extend_from_slice(&[p0, p1]);
}
pts
}
/// Filled tris for a greeked top-level Text / MText. One 2-triangle rect
/// per visible line — `n_lines` is the actual rendered line count from
/// `mtext_line_count` (1 for Text). Stacked top → bottom along the OBB's
/// up direction. The face3d pipeline skips its 0.45 dim for wires with
/// empty `points`, so these tris render at the literal text color.
fn text_greek_obb_tris(
e: &EntityType,
anno_scale: f32,
world_offset: [f64; 3],
n_lines: usize,
) -> Vec<[f32; 3]> {
let Some(corners) = text_obb_corners_native(e, anno_scale, Some(n_lines)) else {
return vec![];
};
let line_h = match e {
EntityType::Text(t) => (t.height * anno_scale as f64) as f32,
EntityType::MText(m) => (m.height * anno_scale as f64) as f32,
EntityType::AttributeDefinition(a) => (a.height * anno_scale as f64) as f32,
EntityType::AttributeEntity(a) => (a.height * anno_scale as f64) as f32,
EntityType::Tolerance(t) => {
let raw = if t.text_height > 0.0 { t.text_height } else { 2.5 };
(raw * anno_scale as f64) as f32
}
_ => return vec![],
};
if line_h <= 0.0 {
return vec![];
}
let n_lines = n_lines.max(1);
let [ox, oy, oz] = world_offset;
let cast = |p: [f64; 3]| -> [f32; 3] {
[(p[0] - ox) as f32, (p[1] - oy) as f32, (p[2] - oz) as f32]
};
let bl = cast(corners[0]);
let br = cast(corners[1]);
let full_tl = cast(corners[3]);
let (ux, uy, uz) = (full_tl[0] - bl[0], full_tl[1] - bl[1], full_tl[2] - bl[2]);
let ulen = (ux * ux + uy * uy + uz * uz).sqrt();
if ulen < 1e-9 {
return vec![];
}
let (nx, ny, nz) = (ux / ulen, uy / ulen, uz / ulen);
let mut tris = Vec::with_capacity(n_lines * 6);
for i in 0..n_lines {
let top_along = (ulen - (i as f32) * line_h).max(0.0);
let bot_along = (ulen - ((i + 1) as f32) * line_h).max(0.0);
let tl = [
bl[0] + nx * top_along,
bl[1] + ny * top_along,
bl[2] + nz * top_along,
];
let tr = [
br[0] + nx * top_along,
br[1] + ny * top_along,
br[2] + nz * top_along,
];
let lbl = [
bl[0] + nx * bot_along,
bl[1] + ny * bot_along,
bl[2] + nz * bot_along,
];
let lbr = [
br[0] + nx * bot_along,
br[1] + ny * bot_along,
br[2] + nz * bot_along,
];
tris.extend_from_slice(&[lbl, lbr, tr, lbl, tr, tl]);
}
tris
}
fn entity_aabb(e: &acadrust::EntityType, world_offset: [f64; 3]) -> [f32; 4] {
pub(crate) fn entity_aabb(e: &acadrust::EntityType, world_offset: [f64; 3]) -> [f32; 4] {
let bbox = e.as_entity().bounding_box();
let [ox, oy, _] = world_offset;
let min_x = (bbox.min.x - ox) as f32;
@@ -5810,156 +5402,3 @@ fn is_unindexable_entity(e: &acadrust::EntityType) -> bool {
)
}
/// Generate solid-fill boundary polygons for each wide segment of an LWPolyline.
/// Returns one polygon per segment that has non-zero width; empty if the polyline
/// has zero `constant_width` and all vertex widths are zero.
fn wide_lwpolyline_fills(pl: &acadrust::entities::LwPolyline) -> Vec<Vec<[f32; 2]>> {
let hw_const = (pl.constant_width / 2.0) as f32;
let verts = &pl.vertices;
let n = verts.len();
if n < 2 {
return vec![];
}
let seg_count = if pl.is_closed { n } else { n - 1 };
let mut out = Vec::new();
for i in 0..seg_count {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % n];
let hw0 = if v0.start_width > 1e-9 {
v0.start_width as f32 / 2.0
} else {
hw_const
};
let hw1 = if v0.end_width > 1e-9 {
v0.end_width as f32 / 2.0
} else {
hw_const
};
if hw0 < 1e-6 && hw1 < 1e-6 {
continue;
}
let p0 = [v0.location.x as f32, v0.location.y as f32];
let p1 = [v1.location.x as f32, v1.location.y as f32];
if let Some(poly) = polyline_segment_fill(p0, p1, hw0, hw1, v0.bulge as f32) {
out.push(poly);
}
}
out
}
/// Generate solid-fill boundary polygons for each wide segment of a Polyline2D.
fn wide_polyline2d_fills(pl: &acadrust::entities::Polyline2D) -> Vec<Vec<[f32; 2]>> {
let hw_default = (pl.start_width.max(pl.end_width) / 2.0) as f32;
let verts = &pl.vertices;
let n = verts.len();
if n < 2 {
return vec![];
}
let seg_count = if pl.is_closed() { n } else { n - 1 };
let mut out = Vec::new();
for i in 0..seg_count {
let v0 = &verts[i];
let v1 = &verts[(i + 1) % n];
let hw0 = if v0.start_width > 1e-9 {
v0.start_width as f32 / 2.0
} else {
hw_default
};
let hw1 = if v0.end_width > 1e-9 {
v0.end_width as f32 / 2.0
} else {
hw_default
};
if hw0 < 1e-6 && hw1 < 1e-6 {
continue;
}
let p0 = [v0.location.x as f32, v0.location.y as f32];
let p1 = [v1.location.x as f32, v1.location.y as f32];
if let Some(poly) = polyline_segment_fill(p0, p1, hw0, hw1, v0.bulge as f32) {
out.push(poly);
}
}
out
}
/// Compute the filled boundary polygon for one polyline segment.
/// For straight segments: a rectangle/trapezoid.
/// For arc segments: an arc band (outer arc + reversed inner arc).
/// Returns `None` if the segment is degenerate.
fn polyline_segment_fill(
p0: [f32; 2],
p1: [f32; 2],
hw0: f32,
hw1: f32,
bulge: f32,
) -> Option<Vec<[f32; 2]>> {
if bulge.abs() < 1e-9 {
// Straight segment — rectangle or trapezoid
let dx = p1[0] - p0[0];
let dy = p1[1] - p0[1];
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-9 {
return None;
}
let nx = -dy / len;
let ny = dx / len;
Some(vec![
[p0[0] + hw0 * nx, p0[1] + hw0 * ny],
[p1[0] + hw1 * nx, p1[1] + hw1 * ny],
[p1[0] - hw1 * nx, p1[1] - hw1 * ny],
[p0[0] - hw0 * nx, p0[1] - hw0 * ny],
])
} else {
// Arc segment — arc band polygon.
// Center math matches `bulge_to_arc` in modules/home/modify/explode.rs:
// r = chord * (1 + b²) / (4·|b|)
// d = r * (1 - b²) / (1 + b²) (signed: negative ⇒ major arc, center
// flips to the opposite side of chord)
// center = midpoint + sign(b) · d · left_perp(chord)
let b = bulge as f64;
let b2 = b * b;
let dx = (p1[0] - p0[0]) as f64;
let dy = (p1[1] - p0[1]) as f64;
let chord_len = (dx * dx + dy * dy).sqrt();
if chord_len < 1e-9 || b.abs() < 1e-12 {
return None;
}
let r = chord_len * (1.0 + b2) / (4.0 * b.abs());
let d_perp = r * (1.0 - b2) / (1.0 + b2);
let mx = ((p0[0] + p1[0]) * 0.5) as f64;
let my = ((p0[1] + p1[1]) * 0.5) as f64;
let perp_x = -dy / chord_len;
let perp_y = dx / chord_len;
let sign = b.signum();
let cx = (mx + sign * d_perp * perp_x) as f32;
let cy = (my + sign * d_perp * perp_y) as f32;
let a0 = ((p0[1] - cy) as f32).atan2((p0[0] - cx) as f32);
let a1 = ((p1[1] - cy) as f32).atan2((p1[0] - cx) as f32);
let (sa, mut ea) = if bulge > 0.0 { (a0, a1) } else { (a1, a0) };
if ea < sa {
ea += std::f32::consts::TAU;
}
let span = ea - sa;
let segs = ((span.abs() / std::f32::consts::TAU) * 24.0)
.ceil()
.max(4.0) as u32;
let r = r as f32;
let r_outer = |t: f32| r + (hw0 + (hw1 - hw0) * t);
let r_inner = |t: f32| (r - (hw0 + (hw1 - hw0) * t)).max(0.0);
let mut boundary = Vec::with_capacity((segs as usize + 1) * 2);
let inv = 1.0 / segs as f32;
for j in 0..=segs {
let t = j as f32 * inv;
let ang = sa + span * t;
let ro = r_outer(t);
boundary.push([cx + ro * ang.cos(), cy + ro * ang.sin()]);
}
for j in (0..=segs).rev() {
let t = j as f32 * inv;
let ang = sa + span * t;
let ri = r_inner(t);
boundary.push([cx + ri * ang.cos(), cy + ri * ang.sin()]);
}
Some(boundary)
}
}
+2 -2
View File
@@ -279,7 +279,7 @@ pub(super) fn render_style_for(
/// Like `render_style_for` but resolves ByBlock properties by inheriting from
/// the INSERT entity's already-resolved style. Call this for exploded block
/// sub-entities so that ByBlock color/linetype/lineweight propagate correctly.
pub(super) fn render_style_for_block_sub(
pub(crate) fn render_style_for_block_sub(
document: &CadDocument,
e: &EntityType,
insert_color: [f32; 4],
@@ -314,7 +314,7 @@ pub(super) fn render_style_for_block_sub(
/// Adapt white→black or black→white based on background luminance.
/// White entities on light backgrounds become black, black entities on dark
/// backgrounds become white. All other colors pass through unchanged.
pub(super) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] {
let lum = 0.299 * bg[0] + 0.587 * bg[1] + 0.114 * bg[2];
let is_white = color[0] > 0.95 && color[1] > 0.95 && color[2] > 0.95;
let is_black = color[0] < 0.05 && color[1] < 0.05 && color[2] < 0.05;