fix(color): only colour 7 swaps with the background; true colours draw as authored

Every near-white colour turned black on a light background (and near-black
white on a dark one), including true colours. A 255,255,255 solid used as a
white mask became a black block. Now only colour 7 swaps: true colours and
ACI 255 resolve a hair off pure white/black and keep their value on screen
and in plots.

Discussion #1500

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 5.5 committed 2026-09-29 06:16:18 +03:00
1 parent ac2a1e1166
commit 60f35e2b1d
7 files changed
+83 -30

No files matched your search

+7 -2
View File
@@ -1126,9 +1126,14 @@ pub fn line_total_width(
pub fn resolve_inline_color(c: &InlineColor) -> Option<[f32; 3]> {
match c {
InlineColor::Aci(idx) => aci_to_rgb(*idx).map(|(r, g, b)| {
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0]
let rgb = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0];
if *idx == 7 {
rgb
} else {
crate::scene::convert::tess_util::authored_rgb(rgb)
}
}),
InlineColor::True(rgb) => Some(*rgb),
InlineColor::True(rgb) => Some(crate::scene::convert::tess_util::authored_rgb(*rgb)),
}
}
+15 -3
View File
@@ -719,7 +719,11 @@ fn append_pdf_page(
// Near-white and near-yellow (viewport active border) → dark grey for print
// (only when no CTB override was applied).
if !color_overridden {
let is_light = r > 0.80 && g > 0.80 && b > 0.80;
// An authored white (not colour 7) plots as drawn, like on screen.
let is_light = r > 0.80
&& g > 0.80
&& b > 0.80
&& !crate::scene::convert::tess_util::is_authored_white([r, g, b]);
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
if is_light || is_yellow {
@@ -1301,7 +1305,11 @@ fn emit_hatch(
&& hatch.aci != 0
&& !(hatch.aci == 7 && matches!(hatch.pattern, HatchPattern::Solid))
{
let is_light = r > 0.80 && g > 0.80 && b > 0.80;
// An authored white (not colour 7) plots as drawn, like on screen.
let is_light = r > 0.80
&& g > 0.80
&& b > 0.80
&& !crate::scene::convert::tess_util::is_authored_white([r, g, b]);
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
if is_light || is_yellow {
@@ -1482,7 +1490,11 @@ fn glyph_world_xy(v: &crate::scene::pipeline::text_gpu::TextVertex) -> [f64; 2]
/// near-white / near-yellow (colour-7-on-white) → black, near-cyan → dark blue.
#[cfg(not(target_arch = "wasm32"))]
fn adapt_text_color([r, g, b]: [f32; 3]) -> [f32; 3] {
let is_light = r > 0.80 && g > 0.80 && b > 0.80;
// An authored white (not colour 7) plots as drawn, like on screen.
let is_light = r > 0.80
&& g > 0.80
&& b > 0.80
&& !crate::scene::convert::tess_util::is_authored_white([r, g, b]);
let is_yellow = r > 0.80 && g > 0.70 && b < 0.30;
let is_cyan = r < 0.30 && g > 0.70 && b > 0.70;
if is_light || is_yellow {
+15 -12
View File
@@ -3136,19 +3136,22 @@ mod bg_resolution_tests {
assert_eq!(colors(&once), colors(&twice));
}
/// Why the raw colour is stored instead of re-adapting the resolved one.
/// If this ever starts passing, `raw_color` could be dropped — and if it
/// is removed while this still fails, near-white geometry loses its tint
/// on the first layout switch.
/// Only colour 7's exact white / black swap with the background; a colour
/// drawn as authored — near-white, or a true-colour white mask — keeps its
/// value on any background. (#1500)
#[test]
fn adapt_to_bg_cannot_be_reapplied() {
let once = adapt_to_bg(NEAR_WHITE, LIGHT);
assert_eq!(once, [0.0, 0.0, 0.0, 1.0], "near-white snaps to pure black");
assert_ne!(
adapt_to_bg(once, DARK),
adapt_to_bg(NEAR_WHITE, DARK),
"re-adapting the resolved colour must not equal adapting the raw one",
);
fn only_colour_seven_swaps_with_the_background() {
assert_eq!(adapt_to_bg([1.0, 1.0, 1.0, 1.0], LIGHT), [0.0, 0.0, 0.0, 1.0]);
assert_eq!(adapt_to_bg([0.0, 0.0, 0.0, 1.0], DARK), [1.0, 1.0, 1.0, 1.0]);
assert_eq!(adapt_to_bg(NEAR_WHITE, LIGHT), NEAR_WHITE);
let mask = crate::scene::convert::tess_util::aci_to_rgba(&codec::types::Color::Rgb {
r: 255,
g: 255,
b: 255,
});
assert_eq!(adapt_to_bg(mask, LIGHT), mask);
let seven = crate::scene::convert::tess_util::aci_to_rgba(&codec::types::Color::Index(7));
assert_eq!(adapt_to_bg(seven, LIGHT), [0.0, 0.0, 0.0, 1.0]);
}
}
+8 -4
View File
@@ -794,10 +794,14 @@ fn tessellate_entity_inner(
a,
),
ProxyColor::Rgb(r, g, b) => (
view::render::adapt_to_bg(
[r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0, 1.0],
bg_color,
),
{
let [r, g, b] = convert::tess_util::authored_rgb([
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
]);
[r, g, b, 1.0]
},
0,
),
ProxyColor::Inherit => (entity_color, aci),
+29 -1
View File
@@ -31,8 +31,36 @@ pub type FallbackGeometry = (
/// Convert an opencadcodec 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]
let rgb = [r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0];
// Only colour 7 swaps white/black with the background; any other
// colour — a true colour, or ACI 255 — is drawn as authored.
let [r, g, b] = if matches!(color, AcadColor::Index(7)) {
rgb
} else {
authored_rgb(rgb)
};
[r, g, b, 1.0]
} else {
WireModel::WHITE
}
}
/// Pure white and pure black are what the background adaptation swaps, and
/// only colour 7 may be swapped. A colour chosen as-is (255,255,255 used as a
/// white mask on a light sheet, say) is kept a hair off them so it is drawn
/// exactly as authored; the difference is below one display step.
pub fn authored_rgb(rgb: [f32; 3]) -> [f32; 3] {
const OFF: f32 = 1.0 / 1024.0;
if rgb == [1.0; 3] {
[1.0 - OFF; 3]
} else if rgb == [0.0; 3] {
[OFF; 3]
} else {
rgb
}
}
/// True for a colour [`authored_rgb`] moved off pure white.
pub fn is_authored_white(rgb: [f32; 3]) -> bool {
rgb == authored_rgb([1.0; 3])
}
+4 -6
View File
@@ -2868,12 +2868,10 @@ impl DimGeom {
/// `ByLayer` / `ByBlock` (assumes those are already resolved upstream).
pub(crate) fn color_or_inherit(c: &AcadColor, inherited: [f32; 4]) -> [f32; 4] {
match c.rgb() {
Some((r, g, b)) => [
r as f32 / 255.0,
g as f32 / 255.0,
b as f32 / 255.0,
inherited[3],
],
Some(_) => {
let [r, g, b, _] = crate::scene::convert::tess_util::aci_to_rgba(c);
[r, g, b, inherited[3]]
}
None => inherited,
}
}
+5 -2
View File
@@ -3774,8 +3774,11 @@ pub(crate) fn render_style_for_block_sub_viewport(
/// backgrounds become white. All other colors pass through unchanged.
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;
// Only exact white / black swap: that is colour 7 (and interface colours).
// Authored colours reach here nudged off both, so they stay as drawn —
// a true-colour white mask on a light sheet remains white. (#1500)
let is_white = color[..3] == [1.0; 3];
let is_black = color[..3] == [0.0; 3];
if is_white && lum > 0.5 {
[0.0, 0.0, 0.0, color[3]]
} else if is_black && lum <= 0.5 {