fix(scene): greek text rect renders filled at the text's own color

Greek rects rendered through fill_tris went through the face3d 0.45
dim path; the pre-boost (color × 1/0.45) compensated only until clamp
hit at 1.0, leaving bright user colors washed out. The earlier outline
workaround dodged the dim but lost the filled box visual that AutoCAD
uses for "text lives here".

  - face3d pipeline now skips the 0.45 dim when `wire.points` is
    empty. PolyfaceMesh / PolygonMesh keep their dim (both points and
    fill_tris populated); greek and MultiLeader background-fill emit
    `points: vec![]` so they render at the literal color.
  - All three greek paths (top-level, block-defn, MultiLeader) emit a
    2-triangle filled rect instead of an outline / boosted fill.
  - Greek rects are clamped to single-line height so 5-line MText
    doesn't blow the box up to the full text-block height. MultiLeader
    emits one rect per line — keeps the per-row visual hint.
  - BatchEntry/StyleKey gain an `is_fill_only` discriminator so greek
    batches never collide with regular wire batches; otherwise the
    finalized WireModel could carry both points and fill_tris and
    defeat the pipeline-side empty-points check.

Xref fade still applies to the resolved color via the existing
`fade_toward_bg` path, so xref greek stays faded as expected.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
HakanandClaude Opus 4.7 committed 2026-05-15 16:32:27 +03:00
1 parent 1f1aa80f17
commit ddd0a2de16
4 files changed
+173 -107

No files matched your search

+85 -48
View File
@@ -627,11 +627,13 @@ pub fn expand_insert(
Some(batches.finalize(&name, selected))
}
/// Emit a greeked rectangle for a text LocalWire — same color, OBB-sized
/// fill (rotated to match the entity), no per-glyph stroke geometry.
/// Mirrors the top-level text greek in scene/mod.rs (including the 0.45
/// dim pre-boost the face3d pipeline applies to fill_tris). Falls back to
/// the axis-aligned `local_aabb` when no OBB was precomputed.
/// Emit a greeked filled rect (2 triangles) for a text LocalWire. Stays in
/// the text's own color — the face3d pipeline skips its 0.45 dim for wires
/// whose `points` are empty, so the rect lands at full intensity. Clamped
/// to a single line's height so multi-line MText doesn't blow the box up
/// to the full block. Mirrors `text_greek_obb_tris` in scene/mod.rs.
/// Falls back to the axis-aligned `local_aabb` when no OBB was cached;
/// xref fade still applies via the shared color resolution.
fn emit_greeked_text(
lw: &LocalWire,
local_aabb: [f32; 4],
@@ -642,35 +644,57 @@ fn emit_greeked_text(
) {
let [ox, oy, oz] = ctx.world_offset;
let [lo_x, lo_y, lo_z] = defn_lo;
let tris: [[f32; 3]; 6] = if let Some(obb) = lw.text_obb_local {
// Re-add the defn's `local_offset` in f64 before composing with
// `accum_xform` (the OBB corners are stored in offset frame f32 so
// they don't lose precision for distant text). Then subtract host
// `world_offset` so the fill_tris share the batch's f32 space.
let xf = |p: [f32; 3]| -> [f32; 3] {
let w = accum_xform.apply(Vector3::new(
p[0] as f64 + lo_x,
p[1] as f64 + lo_y,
p[2] as f64 + lo_z,
));
[(w.x - ox) as f32, (w.y - oy) as f32, (w.z - oz) as f32]
};
let p00 = xf(obb[0]);
let p10 = xf(obb[1]);
let p11 = xf(obb[2]);
let p01 = xf(obb[3]);
[p00, p10, p11, p00, p11, p01]
// Re-add the defn's `local_offset` in f64 before composing with
// `accum_xform` so the rect corners share the batch's f32 space
// without losing precision for distant text.
let xf = |p: [f32; 3]| -> [f32; 3] {
let w = accum_xform.apply(Vector3::new(
p[0] as f64 + lo_x,
p[1] as f64 + lo_y,
p[2] as f64 + lo_z,
));
[(w.x - ox) as f32, (w.y - oy) as f32, (w.z - oz) as f32]
};
let corners: Option<[[f32; 3]; 4]> = if let (Some(obb), Some(h_local)) =
(lw.text_obb_local, lw.text_height_local)
{
if h_local <= 0.0 {
None
} else {
// OBB[3] - OBB[0] = up direction in local frame, full-block
// height for MText; normalize and rescale to a single line.
let dx = obb[3][0] - obb[0][0];
let dy = obb[3][1] - obb[0][1];
let dz = obb[3][2] - obb[0][2];
let dlen = (dx * dx + dy * dy + dz * dz).sqrt();
if dlen < 1e-9 {
None
} else {
let nx = dx / dlen * h_local;
let ny = dy / dlen * h_local;
let nz = dz / dlen * h_local;
let bl = obb[0];
let br = obb[1];
let tl = [bl[0] + nx, bl[1] + ny, bl[2] + nz];
let tr = [br[0] + nx, br[1] + ny, br[2] + nz];
Some([xf(bl), xf(br), xf(tr), xf(tl)])
}
}
} else {
// OBB missing — fall back to the axis-aligned local AABB. Already
// a tight per-entity rect (no multi-line block to clamp).
let [x0, y0, x1, y1] = local_aabb;
let z = 0.0_f32;
[
[x0, y0, z],
[x1, y0, z],
[x1, y1, z],
[x0, y0, z],
[x1, y1, z],
[x0, y1, z],
]
Some([
xf([x0, y0, z]),
xf([x1, y0, z]),
xf([x1, y1, z]),
xf([x0, y1, z]),
])
};
let Some([bl, br, tr, tl]) = corners else {
return;
};
let final_color = if ctx.selected {
@@ -680,29 +704,19 @@ fn emit_greeked_text(
} else {
lw.color
};
// Apply the same xref fade as `emit_wire` BEFORE the face3d 0.45 boost,
// so the final on-screen color (boost × 0.45 dim) lands at the faded value.
let final_color = if ctx.is_xref && !ctx.selected {
fade_toward_bg(final_color, ctx.bg_color)
} else {
final_color
};
let boost = 1.0 / 0.45_f32;
let [r, g, b, a] = final_color;
let greek_color = [
(r * boost).min(1.0),
(g * boost).min(1.0),
(b * boost).min(1.0),
a,
];
let key = style_key(greek_color, 0.0, [0.0; 8], 1.0, lw.aci, true);
let key = style_key(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, true);
let entry = out
.by_style
.entry(key)
.or_insert_with(|| BatchEntry::new(greek_color, 0.0, [0.0; 8], 1.0, lw.aci, true));
for p in tris {
entry.fill_tris.push(p);
.or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, true));
for p in [bl, br, tr, bl, tr, tl] {
if p[0] < entry.min_x {
entry.min_x = p[0];
}
@@ -715,6 +729,7 @@ fn emit_greeked_text(
if p[1] > entry.max_y {
entry.max_y = p[1];
}
entry.fill_tris.push(p);
}
}
@@ -767,11 +782,11 @@ fn emit_text_baseline(
final_color
};
let key = style_key(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true);
let key = style_key(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, false);
let entry = out
.by_style
.entry(key)
.or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true));
.or_insert_with(|| BatchEntry::new(final_color, 0.0, [0.0; 8], 1.0, lw.aci, true, false));
let needs_sep = !entry.points.is_empty()
&& !entry.points.last().map(|p| p[0].is_nan()).unwrap_or(false);
@@ -844,6 +859,11 @@ struct StyleKey {
line_weight_px: u32,
aci: u8,
plinegen: bool,
/// Marks batches that emit only `fill_tris` with no wire `points`. The
/// face3d pipeline uses `wire.points.is_empty()` as the "skip dim"
/// discriminator, so greek fills must stay in their own batches even
/// when their color/style would otherwise collide with regular wires.
is_fill_only: bool,
}
#[derive(Default, Debug)]
@@ -880,7 +900,20 @@ struct Batches {
}
impl BatchEntry {
fn new(color: [f32; 4], pat_len: f32, pat: [f32; 8], lw_px: f32, aci: u8, plinegen: bool) -> Self {
fn new(
color: [f32; 4],
pat_len: f32,
pat: [f32; 8],
lw_px: f32,
aci: u8,
plinegen: bool,
_is_fill_only: bool,
) -> Self {
// `is_fill_only` is part of the StyleKey hash so greek fills never
// share a batch with regular wires (otherwise the finalized
// WireModel would have both `points` and `fill_tris`, defeating
// the face3d-dim discriminator). It isn't stored on the entry
// itself — the empty `points` field is enough at finalize time.
Self {
color,
pattern_length: pat_len,
@@ -937,6 +970,7 @@ fn style_key(
lw_px: f32,
aci: u8,
plinegen: bool,
is_fill_only: bool,
) -> StyleKey {
StyleKey {
color: [
@@ -959,6 +993,7 @@ fn style_key(
line_weight_px: lw_px.to_bits(),
aci,
plinegen,
is_fill_only,
}
}
@@ -1164,6 +1199,7 @@ fn emit_wire(
final_lw_px,
lw.aci,
lw.plinegen,
false,
);
// If the open batch for this style would exceed wgpu's per-buffer limit
@@ -1183,6 +1219,7 @@ fn emit_wire(
final_lw_px,
lw.aci,
lw.plinegen,
false,
)
});
+34 -18
View File
@@ -4195,22 +4195,13 @@ fn tessellate_entity(
}
if h_px < 5.0 && aabb != WireModel::UNBOUNDED_AABB {
let fill_tris = text_greek_obb_tris(e, anno_scale, world_offset);
// The face3d pipeline dims fill_tris colors to 45% to fake
// ambient occlusion on PolyfaceMesh / 3DFACE solids. Greeked
// text doesn't want that shading — pre-boost the color so it
// emerges close to the original text color after the dim.
let boost = 1.0 / 0.45;
let [r, g, b, a] = entity_color;
let greek_color = [
(r * boost).min(1.0),
(g * boost).min(1.0),
(b * boost).min(1.0),
a,
];
if fill_tris.is_empty() {
return vec![];
}
return vec![WireModel {
name: h.value().to_string(),
points: vec![],
color: greek_color,
color: entity_color,
selected: sel,
aci,
pattern_length: 0.0,
@@ -4401,8 +4392,11 @@ fn text_baseline_points(
vec![cast(corners[0]), cast(corners[1])]
}
/// 6-vertex (2-triangle) fill for a greeked top-level Text / MText, in
/// world-offset-subtracted f32 space.
/// 6-vertex filled rect (2 triangles) for a greeked top-level Text / MText.
/// Clamped to a single line's height — multi-line MText's OBB spans the
/// whole block and using its full height would emit a misleadingly tall
/// box. 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,
@@ -4411,13 +4405,35 @@ fn text_greek_obb_tris(
let Some(corners) = text_obb_corners_native(e, anno_scale) 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,
_ => return vec![],
};
if line_h <= 0.0 {
return vec![];
}
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 [p00, p10, p11, p01] = corners;
let (p00, p10, p11, p01) = (cast(p00), cast(p10), cast(p11), cast(p01));
vec![p00, p10, p11, p00, p11, p01]
let bl = cast(corners[0]);
let br = cast(corners[1]);
let full_tl = cast(corners[3]);
// Up direction = (bl → full_tl) normalized, then scale to a single
// line. Replaces the full-block top corners so MText with N lines
// still emits a single-line rect.
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 tl = [bl[0] + nx * line_h, bl[1] + ny * line_h, bl[2] + nz * line_h];
let tr = [br[0] + nx * line_h, br[1] + ny * line_h, br[2] + nz * line_h];
vec![bl, br, tr, bl, tr, tl]
}
fn entity_aabb(e: &acadrust::EntityType, world_offset: [f64; 3]) -> [f32; 4] {
+11 -2
View File
@@ -85,13 +85,22 @@ impl Face3DGpu {
vertices.push(v(3));
}
// PolyfaceMesh / PolygonMesh pre-triangulated fills.
// PolyfaceMesh / PolygonMesh / unlit fills (text greek, MultiLeader
// background). Wires whose `points` are empty carry pure 2-D fills
// that should render at their literal color — applying the 0.45
// AO-style dim to them would wash out user-picked colors. Wires
// with both fill_tris and points (mesh edges + faces) keep the dim
// so PolyfaceMesh / PolygonMesh still look 3-D-shaded.
for wire in all_wires {
if wire.fill_tris.is_empty() {
continue;
}
let [r, g, b, a] = wire.color;
let fill_color = [r * 0.45, g * 0.45, b * 0.45, a];
let fill_color = if wire.points.is_empty() {
[r, g, b, a]
} else {
[r * 0.45, g * 0.45, b * 0.45, a]
};
for &position in &wire.fill_tris {
vertices.push(Face3DVertex {
position,
+43 -39
View File
@@ -909,45 +909,49 @@ pub fn tessellate_multileader(
});
}
} else if lod_mode == 1 {
// Greeked rect — tight text bbox (no frame padding).
let block_top = v_offset + height;
let block_bottom = v_offset - (n_lines - 1.0) * line_h;
let block_left = -max_line_w * h_anchor;
let block_right = max_line_w * (1.0 - h_anchor);
let c00 = to_wcs(block_left, block_bottom);
let c10 = to_wcs(block_right, block_bottom);
let c11 = to_wcs(block_right, block_top);
let c01 = to_wcs(block_left, block_top);
// Pre-boost the color so the face3d 0.45 fill_tris dim lands at
// the original text color (matches scene/mod.rs greek path).
let boost = 1.0 / 0.45_f32;
let [r, g, b, a] = text_color;
let greek_color = [
(r * boost).min(1.0),
(g * boost).min(1.0),
(b * boost).min(1.0),
a,
];
wires.push(WireModel {
name: name.clone(),
points: vec![],
color: greek_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: vec![c00, c10, c11, c00, c11, c01],
});
// 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(lines.len() * 6);
for (i, _) in lines.iter().enumerate() {
let li = i as f32;
let line_y_bottom = -li * line_h + v_offset;
let line_y_top = line_y_bottom + height;
let line_w = line_widths[i];
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 {
// Build per-line stroke points in WCS.
let mut text_points: Vec<[f32; 3]> = Vec::new();