From 391a74a61569ce3f7e51b171e7a32bdd20376f9f Mon Sep 17 00:00:00 2001 From: Panos <25664832+mechpanos@users.noreply.github.com> Date: Sat, 5 Sep 2026 15:06:08 +0300 Subject: [PATCH] perf: let one tessellation serve every background MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching to a Layout re-tessellated the whole model — `memo_miss=186455`, 2034 ms — to arrive at geometry identical to what was already in the memo. The only thing that differed was colour. `local_wires_for` already knows this: it stores the raw colour and drops its background argument with `let _ = bg_color;`, noting that `Batches::finalize` applies `adapt_to_bg` last, "so the cache no longer has to rebuild on BACKGROUND / layout-switch". The tessellation memo's guard had not been told, and still mixed the background in, so every entry was discarded on a switch. `finalize` now records what its colour resolution consumed — the raw colour, the raw glyph colours, and the three flags it branches on — as `WireModel::bg_adapt`, and `resolve_colors_for_bg` replays it for another background. The guard drops the background, so a memo entry serves any layout. The resolution recomputes from the recorded raw colour rather than from the resolved one, which makes it idempotent and therefore safe over a set that mixes memo hits with freshly tessellated misses. Re-adapting the resolved colour would not be safe: `adapt_to_bg` snaps a near-white colour to pure black on a light background, and pure black comes back pure *white* on a dark one, losing the tint. A test pins that asymmetry so the raw colour is not optimised away later. The contract is tested against the real `finalize` rather than a copy of its logic: for every background pair, building under one and resolving for the other lands exactly where building under the other would have. `bg_adapt` is `None` for wires built anywhere else, and the resolution skips them, so nothing outside the block cache changes colour. It is also `None` when the resolution would be a no-op — a preserved colour with no canvas fill looks the same under every background — which keeps the common case at no cost. Ordering is unchanged: the pass runs inside the builder, before `append_scene_markers` and `apply_refedit_fade`, both of which have their own separate background dependency. `wires-build` gains `colors=` so the pass has a number of its own. Co-Authored-By: Claude Opus 5 --- src/app/update/viewport.rs | 1 + src/entities/dimension.rs | 7 ++ src/entities/leader.rs | 2 + src/entities/multileader.rs | 5 + src/entities/table.rs | 1 + src/modules/annotate/aligned_dim.rs | 2 + src/modules/annotate/angular_dim.rs | 1 + src/modules/annotate/data_link.rs | 1 + src/modules/annotate/diameter_dim.rs | 1 + src/modules/annotate/dim_baseline.rs | 1 + src/modules/annotate/dimjogline.rs | 1 + src/modules/annotate/dimtedit.rs | 1 + src/modules/annotate/jogged_radius_dim.rs | 1 + src/modules/annotate/leader_cmd.rs | 1 + src/modules/annotate/linear_dim.rs | 1 + src/modules/annotate/mleader_cmd.rs | 1 + src/modules/annotate/mleader_edit.rs | 1 + src/modules/annotate/ordinate_dim.rs | 1 + src/modules/annotate/radius_dim.rs | 1 + src/modules/annotate/table_cmd.rs | 1 + src/modules/annotate/tolerance_cmd.rs | 1 + src/modules/draw/draw/attdef.rs | 1 + src/modules/draw/draw/mline.rs | 1 + src/modules/draw/draw/raster_image.rs | 1 + src/modules/draw/draw/ray.rs | 2 + src/modules/draw/inquiry/area.rs | 1 + src/modules/draw/inquiry/dist.rs | 1 + src/modules/draw/inquiry/measuregeom.rs | 1 + src/modules/draw/modify/trim.rs | 1 + src/modules/model/primitive_cmd.rs | 1 + src/modules/view/plot_window.rs | 1 + src/modules/view/zoom_window.rs | 1 + src/scene/cache/block_cache.rs | 136 ++++++++++++++++++++++ src/scene/convert/tess.rs | 4 + src/scene/convert/tessellate.rs | 19 +++ src/scene/mod.rs | 34 ++++-- src/scene/model/wire_model.rs | 39 +++++++ src/scene/pick/xclip.rs | 1 + src/scene/text/complex_lt.rs | 2 + src/scene/view/render.rs | 37 ++++++ 40 files changed, 308 insertions(+), 9 deletions(-) diff --git a/src/app/update/viewport.rs b/src/app/update/viewport.rs index 3474330de..757a8a276 100644 --- a/src/app/update/viewport.rs +++ b/src/app/update/viewport.rs @@ -2407,6 +2407,7 @@ impl OpenCADStudio { let far_pos = base + dir * far; let far_neg = base - dir * far; let guide = crate::scene::WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/dimension.rs b/src/entities/dimension.rs index 730cb421c..a219b0de3 100644 --- a/src/entities/dimension.rs +++ b/src/entities/dimension.rs @@ -3882,6 +3882,7 @@ fn tessellate_dimension_inner( let (ext1, ext2) = split_ext_lines(&geom.ext_lines); if !ext1.is_empty() { wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -3917,6 +3918,7 @@ fn tessellate_dimension_inner( } if !ext2.is_empty() { wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -3952,6 +3954,7 @@ fn tessellate_dimension_inner( } } else { wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -3988,6 +3991,7 @@ fn tessellate_dimension_inner( } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -4032,6 +4036,7 @@ fn tessellate_dimension_inner( let mut points = Vec::new(); add_polyline(&mut points, &symbol); wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -4080,6 +4085,7 @@ fn tessellate_dimension_inner( aci_to_rgba(&c) }; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -4130,6 +4136,7 @@ fn tessellate_dimension_inner( let p3 = rect[2]; let p4 = rect[5]; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/leader.rs b/src/entities/leader.rs index 91aee670c..901cf6f63 100644 --- a/src/entities/leader.rs +++ b/src/entities/leader.rs @@ -672,6 +672,7 @@ impl LeaderTess for Leader { if verts.len() < 2 { return WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -852,6 +853,7 @@ impl LeaderTess for Leader { crate::scene::convert::tessellate::points_to_ds(fill_tris); WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/multileader.rs b/src/entities/multileader.rs index c1c4bcc65..d020227e4 100644 --- a/src/entities/multileader.rs +++ b/src/entities/multileader.rs @@ -2068,6 +2068,7 @@ impl MultiLeaderTess for MultiLeader { // WireModels so the renderer respects per-piece coloring. let mut wires: Vec = Vec::new(); wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2367,6 +2368,7 @@ impl MultiLeaderTess for MultiLeader { xy = xy.max(p[1] as f64); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2441,6 +2443,7 @@ impl MultiLeaderTess for MultiLeader { pts.push([bx, by, z]); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2530,6 +2533,7 @@ impl MultiLeaderTess for MultiLeader { wcs_corners[3], ]; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -2574,6 +2578,7 @@ impl MultiLeaderTess for MultiLeader { wcs_corners[0], ]; wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/entities/table.rs b/src/entities/table.rs index 3b5174840..cb1c8c9c9 100644 --- a/src/entities/table.rs +++ b/src/entities/table.rs @@ -1990,6 +1990,7 @@ pub fn tessellate_table( let mk = |color: [f32; 4], points: Vec<[f32; 3]>, fill_tris: Vec<[f32; 3]>, lw: f32| -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/aligned_dim.rs b/src/modules/annotate/aligned_dim.rs index e4dd5d475..d6eb177a3 100644 --- a/src/modules/annotate/aligned_dim.rs +++ b/src/modules/annotate/aligned_dim.rs @@ -286,6 +286,7 @@ impl CadCommand for AlignedDimensionCommand { let p1 = p1.as_vec3(); let p2 = p2.as_vec3(); Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -348,6 +349,7 @@ fn preview_aligned(p1: DVec3, p2: DVec3, dim_pt: DVec3) -> WireModel { let half_height = 0.16; let to_point = |point: DVec3| point.as_vec3().to_array(); WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/angular_dim.rs b/src/modules/annotate/angular_dim.rs index 811dc0602..ae736581b 100644 --- a/src/modules/annotate/angular_dim.rs +++ b/src/modules/annotate/angular_dim.rs @@ -1132,6 +1132,7 @@ fn nan() -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/data_link.rs b/src/modules/annotate/data_link.rs index 80032620a..6669a04f9 100644 --- a/src/modules/annotate/data_link.rs +++ b/src/modules/annotate/data_link.rs @@ -63,6 +63,7 @@ impl DataLinkPlaceCommand { ); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/diameter_dim.rs b/src/modules/annotate/diameter_dim.rs index 5077dae15..de60b43b4 100644 --- a/src/modules/annotate/diameter_dim.rs +++ b/src/modules/annotate/diameter_dim.rs @@ -272,6 +272,7 @@ fn dvec(point: Vector3) -> DVec3 { fn preview_line(far_chord: Vec3, chord: Vec3, text: Vec3) -> WireModel { let separator = [f32::NAN; 3]; WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/dim_baseline.rs b/src/modules/annotate/dim_baseline.rs index 834222a34..a5188dacb 100644 --- a/src/modules/annotate/dim_baseline.rs +++ b/src/modules/annotate/dim_baseline.rs @@ -709,6 +709,7 @@ fn preview_for_dimension(dimension: &Dimension) -> WireModel { _ => {} } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/dimjogline.rs b/src/modules/annotate/dimjogline.rs index 168b631da..02fbd90b3 100644 --- a/src/modules/annotate/dimjogline.rs +++ b/src/modules/annotate/dimjogline.rs @@ -86,6 +86,7 @@ impl CadCommand for DimJogLineCommand { } let d = 0.3_f32; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/dimtedit.rs b/src/modules/annotate/dimtedit.rs index 6c5a60b98..97c28168e 100644 --- a/src/modules/annotate/dimtedit.rs +++ b/src/modules/annotate/dimtedit.rs @@ -103,6 +103,7 @@ impl CadCommand for DimTeditCommand { } let d = 0.2_f32; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/jogged_radius_dim.rs b/src/modules/annotate/jogged_radius_dim.rs index 8c3f219cf..711935b49 100644 --- a/src/modules/annotate/jogged_radius_dim.rs +++ b/src/modules/annotate/jogged_radius_dim.rs @@ -442,6 +442,7 @@ fn dvec(point: Vector3) -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/leader_cmd.rs b/src/modules/annotate/leader_cmd.rs index c8dfece71..0c1552b8f 100644 --- a/src/modules/annotate/leader_cmd.rs +++ b/src/modules/annotate/leader_cmd.rs @@ -300,6 +300,7 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { points.push([w2.x, w2.y, w2.z]); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/linear_dim.rs b/src/modules/annotate/linear_dim.rs index 68bb4d133..78a3e6772 100644 --- a/src/modules/annotate/linear_dim.rs +++ b/src/modules/annotate/linear_dim.rs @@ -467,6 +467,7 @@ fn ocs_point(point: [f64; 2], elevation: f64, normal: Vector3) -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/mleader_cmd.rs b/src/modules/annotate/mleader_cmd.rs index c10f4e04a..d897d49d4 100644 --- a/src/modules/annotate/mleader_cmd.rs +++ b/src/modules/annotate/mleader_cmd.rs @@ -764,6 +764,7 @@ fn preview_wire(pts: &[Vec3], arrow_size: f32) -> WireModel { points.push([w2.x, w2.y, w2.z]); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/mleader_edit.rs b/src/modules/annotate/mleader_edit.rs index 07a7feab9..f01134038 100644 --- a/src/modules/annotate/mleader_edit.rs +++ b/src/modules/annotate/mleader_edit.rs @@ -490,6 +490,7 @@ impl CadCommand for MLeaderCollectCommand { fn preview_wire(pts: &[Vec3]) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/ordinate_dim.rs b/src/modules/annotate/ordinate_dim.rs index ea5b94c8c..8b917e33f 100644 --- a/src/modules/annotate/ordinate_dim.rs +++ b/src/modules/annotate/ordinate_dim.rs @@ -270,6 +270,7 @@ fn is_x_type(feature: DVec3, leader: DVec3) -> bool { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/radius_dim.rs b/src/modules/annotate/radius_dim.rs index f68c26f6b..b61db83fb 100644 --- a/src/modules/annotate/radius_dim.rs +++ b/src/modules/annotate/radius_dim.rs @@ -277,6 +277,7 @@ fn dvec(point: Vector3) -> DVec3 { fn preview_wire(points: Vec) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/table_cmd.rs b/src/modules/annotate/table_cmd.rs index 2ffb34878..664532093 100644 --- a/src/modules/annotate/table_cmd.rs +++ b/src/modules/annotate/table_cmd.rs @@ -212,6 +212,7 @@ impl TableCommand { ); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/annotate/tolerance_cmd.rs b/src/modules/annotate/tolerance_cmd.rs index 192835bc8..e20995b65 100644 --- a/src/modules/annotate/tolerance_cmd.rs +++ b/src/modules/annotate/tolerance_cmd.rs @@ -81,6 +81,7 @@ impl CadCommand for ToleranceCommand { })); } Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/attdef.rs b/src/modules/draw/draw/attdef.rs index bba45cb11..a6ee36198 100644 --- a/src/modules/draw/draw/attdef.rs +++ b/src/modules/draw/draw/attdef.rs @@ -199,6 +199,7 @@ impl CadCommand for AttdefCommand { pt + self.plane.y * d, ]; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/mline.rs b/src/modules/draw/draw/mline.rs index c476b2358..79d24ff5f 100644 --- a/src/modules/draw/draw/mline.rs +++ b/src/modules/draw/draw/mline.rs @@ -180,6 +180,7 @@ impl MlineCommand { let (fill_tris, fill_tris_low) = crate::scene::convert::tessellate::points_to_ds(fill_tris); Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/raster_image.rs b/src/modules/draw/draw/raster_image.rs index f2001719b..8f169fb9c 100644 --- a/src/modules/draw/draw/raster_image.rs +++ b/src/modules/draw/draw/raster_image.rs @@ -123,6 +123,7 @@ impl CadCommand for ImageCommand { let [p0, p1, p2, p3] = corners; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/draw/ray.rs b/src/modules/draw/draw/ray.rs index 63d374d00..0b349b42e 100644 --- a/src/modules/draw/draw/ray.rs +++ b/src/modules/draw/draw/ray.rs @@ -85,6 +85,7 @@ impl CadCommand for RayCommand { let dir = (pt - base).normalize_or_zero(); let far = base + dir * DISPLAY_EXTENT; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -189,6 +190,7 @@ impl CadCommand for XLineCommand { let far_pos = base + dir * DISPLAY_EXTENT; let far_neg = base - dir * DISPLAY_EXTENT; Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/inquiry/area.rs b/src/modules/draw/inquiry/area.rs index 2e41e149a..645dc701c 100644 --- a/src/modules/draw/inquiry/area.rs +++ b/src/modules/draw/inquiry/area.rs @@ -365,6 +365,7 @@ impl CadCommand for AreaCommand { points.push(to_render(point)); points.push(to_render(self.points[0])); Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/inquiry/dist.rs b/src/modules/draw/inquiry/dist.rs index ff3729f19..3ee40fb6f 100644 --- a/src/modules/draw/inquiry/dist.rs +++ b/src/modules/draw/inquiry/dist.rs @@ -86,6 +86,7 @@ impl CadCommand for DistCommand { let p1 = self.first?; // The preview wire is purely visual, so f32 vertices are fine here. Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/inquiry/measuregeom.rs b/src/modules/draw/inquiry/measuregeom.rs index 25e47ac68..fd5844d51 100644 --- a/src/modules/draw/inquiry/measuregeom.rs +++ b/src/modules/draw/inquiry/measuregeom.rs @@ -70,6 +70,7 @@ impl MeasureGeomCommand { /// Build a cyan preview wire connecting the picked points and the cursor. fn preview_wire(name: &str, pts: Vec<[f32; 3]>) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/draw/modify/trim.rs b/src/modules/draw/modify/trim.rs index d1341013a..e5f2a5b59 100644 --- a/src/modules/draw/modify/trim.rs +++ b/src/modules/draw/modify/trim.rs @@ -3746,6 +3746,7 @@ fn collect_runs(pts: &[[f64; 2]], take: &dyn Fn([f64; 2]) -> bool, out: &mut Vec /// Build a preview wire from a NaN-break point list. fn preview_wire(points: Vec<[f32; 3]>, color: [f32; 4], name: &str) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/model/primitive_cmd.rs b/src/modules/model/primitive_cmd.rs index 3beecc06f..bc586caca 100644 --- a/src/modules/model/primitive_cmd.rs +++ b/src/modules/model/primitive_cmd.rs @@ -3621,6 +3621,7 @@ inventory::submit!(crate::command::CommandRegistration { fn wire(name: &str, points: Vec<[f32; 3]>) -> WireModel { WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/view/plot_window.rs b/src/modules/view/plot_window.rs index 03341c085..896985e39 100644 --- a/src/modules/view/plot_window.rs +++ b/src/modules/view/plot_window.rs @@ -54,6 +54,7 @@ impl CadCommand for PlotWindowCommand { let p1 = self.p1?.as_vec3(); // Draw the selection rectangle. Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/modules/view/zoom_window.rs b/src/modules/view/zoom_window.rs index a22f030e5..35fefe986 100644 --- a/src/modules/view/zoom_window.rs +++ b/src/modules/view/zoom_window.rs @@ -136,6 +136,7 @@ impl CadCommand for ZoomWindowCommand { let max = p1.max(pt); // Draw a rectangle preview Some(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 5e04a9613..aa8acca81 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1591,6 +1591,25 @@ impl Batches { [b.min_x, b.min_y, b.max_x, b.max_y] }; let contrast_bg = b.contrast_bg.unwrap_or(bg_color); + // Record what this resolution consumed, so switching layout can + // redo it for another background instead of re-running the + // tessellation that produced identical geometry. Skipped when + // the resolution is a no-op — a preserved colour with no canvas + // fill looks the same under every background. + let bg_adapt: crate::scene::model::wire_model::BgAdapt = + (b.canvas_color || !b.preserve_color).then(|| { + Box::new(crate::scene::model::wire_model::BgAdaptInputs { + raw_color: b.color, + text_raw_colors: if b.preserve_color { + Vec::new() + } else { + b.text_verts.iter().map(|v| v.color).collect() + }, + contrast_bg: b.contrast_bg, + canvas_color: b.canvas_color, + preserve_color: b.preserve_color, + }) + }); let color = if b.canvas_color { bg_color } else if b.preserve_color { @@ -1615,6 +1634,7 @@ impl Batches { ); } WireModel { + bg_adapt, point_marker: b.point_marker, taper_widths: Vec::new(), pattern_stations: b.pattern_stations, @@ -2728,3 +2748,119 @@ fn is_unreasonable_extent(e: &EntityType) -> bool { _ => false, } } + +#[cfg(test)] +mod bg_resolution_tests { + use super::*; + use crate::scene::pipeline::text_gpu::TextVertex; + use crate::scene::view::render::{adapt_to_bg, resolve_colors_for_bg}; + + const DARK: [f32; 4] = [0.1, 0.1, 0.1, 1.0]; + const LIGHT: [f32; 4] = [0.95, 0.95, 0.95, 1.0]; + /// Near-white, not pure white: the case that makes `adapt_to_bg` + /// non-re-appliable, so it must survive a round trip through the recorded + /// raw colour. + const NEAR_WHITE: [f32; 4] = [0.97, 0.99, 0.96, 1.0]; + + fn entry( + color: [f32; 4], + contrast_bg: Option<[f32; 4]>, + preserve_color: bool, + canvas_color: bool, + ) -> BatchEntry { + let mut e = BatchEntry::new( + color, + contrast_bg, + preserve_color, + canvas_color, + 0.0, + [0.0; 8], + 1.0, + 0.0, + 0.0, + None, + 1, + false, + false, + false, + false, + true, + false, + ); + e.points = vec![[0.0, 0.0, 0.0], [1.0, 0.0, 0.0]]; + e.text_verts = vec![TextVertex { + pos: [0.0; 3], + pos_low: [0.0; 3], + uv: [0.0; 2], + color, + draw_depth: 0.0, + }]; + e + } + + /// Every combination `finalize` distinguishes, in one set. + fn batches() -> Batches { + Batches { + by_style: HashMap::default(), + closed: vec![ + entry(NEAR_WHITE, None, false, false), + entry([0.0, 0.0, 0.0, 1.0], None, false, false), + entry(NEAR_WHITE, None, true, false), + entry(NEAR_WHITE, Some(LIGHT), false, false), + entry(NEAR_WHITE, None, false, true), + ], + extra_wires: Vec::new(), + nested_prototypes: HashMap::default(), + } + } + + fn colors(wires: &[WireModel]) -> Vec<([f32; 4], Vec<[f32; 4]>)> { + wires + .iter() + .map(|w| (w.color, w.text_verts.iter().map(|v| v.color).collect())) + .collect() + } + + /// The contract the whole background-key removal rests on: resolving a set + /// built under one background for another must land exactly where building + /// it under that background would have. + #[test] + fn resolving_for_a_background_matches_building_under_it() { + for (from, to) in [(DARK, LIGHT), (LIGHT, DARK), (DARK, DARK), (LIGHT, LIGHT)] { + let mut resolved = batches().finalize("b", false, from); + resolve_colors_for_bg(&mut resolved, to); + let direct = batches().finalize("b", false, to); + assert_eq!( + colors(&resolved), + colors(&direct), + "built under {from:?}, resolved for {to:?}", + ); + } + } + + /// Safe to apply twice, and over a set mixing already-resolved wires with + /// freshly built ones — which is exactly what a memo hit plus a miss is. + #[test] + fn resolving_twice_changes_nothing() { + let mut once = batches().finalize("b", false, DARK); + resolve_colors_for_bg(&mut once, LIGHT); + let mut twice = once.clone(); + resolve_colors_for_bg(&mut twice, LIGHT); + 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. + #[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", + ); + } +} diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index b877827dd..a84d3d60d 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -923,6 +923,7 @@ fn tessellate_entity_inner( Vec::new() }; return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1167,6 +1168,7 @@ fn tessellate_entity_inner( (ins.insert_point.z) as f32, ); let marker = WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1510,6 +1512,7 @@ fn lod_stub_wire( // LOD boundary. #19. let stored_color = if selected { WireModel::SELECTED } else { color }; WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1605,6 +1608,7 @@ fn lod_stub_wire_3d( } let stored_color = if selected { WireModel::SELECTED } else { color }; WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/convert/tessellate.rs b/src/scene/convert/tessellate.rs index 6bf477734..5d0044060 100644 --- a/src/scene/convert/tessellate.rs +++ b/src/scene/convert/tessellate.rs @@ -220,6 +220,7 @@ fn point_cloud_wires( }; let (points, points_low) = points_to_ds(body_points); let mut wires = vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -474,6 +475,7 @@ pub fn tessellate( } }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -637,6 +639,7 @@ pub fn tessellate( None }; elem_wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1026,6 +1029,7 @@ pub fn tessellate( } } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1082,6 +1086,7 @@ pub fn tessellate( } } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1135,6 +1140,7 @@ pub fn tessellate( low.push(ll); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1169,6 +1175,7 @@ pub fn tessellate( }); } wires.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1233,6 +1240,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1279,6 +1287,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1322,6 +1331,7 @@ pub fn tessellate( // are empty → the early-return path above). if !sdf_verts.is_empty() { out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1358,6 +1368,7 @@ pub fn tessellate( if out.is_empty() { out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1428,6 +1439,7 @@ pub fn tessellate( .map(|[kx, ky, kz]| [kx, ky, kz]) .collect(); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1556,6 +1568,7 @@ pub fn tessellate( let point_marker = crate::entities::point::relative_marker_spec(entity, document); out.push(WireModel { + bg_adapt: None, point_marker, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1601,6 +1614,7 @@ pub fn tessellate( (Vec::new(), Vec::new(), Vec::new()) }; out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1637,6 +1651,7 @@ pub fn tessellate( if out.is_empty() { out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1695,6 +1710,7 @@ pub fn tessellate( &station_pieces, ); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: station_data, @@ -1741,6 +1757,7 @@ pub fn tessellate( // treatment as the Contour arm, restarting the dash per segment. let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -1791,6 +1808,7 @@ pub fn tessellate( let (pick_tris, pick_tris_low) = points_to_ds(te.pick_tris); let world_width = widths.iter().copied().fold(0.0f32, f32::max); return vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: widths, pattern_stations: Vec::new(), @@ -1899,6 +1917,7 @@ pub fn tessellate( _ => (Vec::new(), Vec::new()), }; vec![WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 9642523f1..91c0e95f7 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -1939,12 +1939,14 @@ pub struct Scene { /// Per-entity memo for the **resident** model wire set (`model_tile_wires_arc`, /// the one the main GPU render holds). Kept separate from `tess_memo` because /// the resident set is camera-INDEPENDENT (no view cull, no zoom LOD), so its - /// guard depends only on anno-scale / background — it survives pan/zoom, and a + /// guard depends only on anno-scale — it survives pan/zoom and layout switches, and a /// single-entity edit re-tessellates just the changed entity instead of the /// whole model. Sharing `tess_memo` would let the camera-dependent culled path /// thrash it on every zoom. (#perf) resident_tess_memo: RefCell>>>, - /// Guard hash for `resident_tess_memo` (anno-scale / bg only). + /// Guard hash for `resident_tess_memo` (anno-scale only). The background + /// is not part of it: it changes colours, not geometry, and + /// `resolve_colors_for_bg` reapplies it to the assembled set. resident_tess_guard: std::cell::Cell, /// Per-mutation delta journal: which handles changed on each `geometry_epoch` /// bump. Bounded ring ([`GEOMETRY_JOURNAL_CAP`]); a derived cache replays the @@ -9013,10 +9015,11 @@ impl Scene { let memo_misses; let t_build = perf.then(iced::time::Instant::now); let mut wires: Vec = if memo_active { - // Guard hash of everything tessellate_entity output depends on + // Guard hash of everything tessellate_entity's GEOMETRY depends on // besides the entity itself. A mismatch (zoom/tol, view, anno, - // offset, bg, entered viewport) means the memo is stale. For the - // resident path wpp/view_aabb are None, so this collapses to anno/bg. + // offset, entered viewport) means the memo is stale. For the + // resident path wpp/view_aabb are None, so this collapses to anno. + // The background is excluded on purpose — see the note below. let guard = { let mut g: u64 = 0xcbf2_9ce4_8422_2325; let mut mix = |x: u64| g = g.rotate_left(13) ^ x; @@ -9029,9 +9032,13 @@ impl Scene { mix(anno.to_bits() as u64); mix(annotation_scale_handle.map(|handle| handle.value()).unwrap_or(0)); mix(all_visible as u64); - for c in bg { - mix(c.to_bits() as u64); - } + // The background is deliberately NOT mixed in. It changes + // colours, never geometry — `local_wires_for` discards it with + // `let _ = bg_color;` and `Batches::finalize` applies it last — + // so a memo entry built under one background serves any other + // once `resolve_colors_for_bg` has run over the assembled set + // below. Keying on it made a layout switch re-tessellate the + // whole model to arrive at identical points. mix(avp.map(|h| h.value()).unwrap_or(0)); // SDF glyph quads bake the atlas UV of each tile, so a growth or // a re-bake (which rescale / rewind every UV) makes memoized text @@ -9133,6 +9140,14 @@ impl Scene { }; let build_ms = crate::perf::elapsed_ms(t_build); + // Resolve display colours for the live background. Memo hits were + // built under whatever background was current then, misses under this + // one; the pass recomputes both from their recorded raw colour, so a + // mixed set lands in the same place either way. + let t_colors = perf.then(iced::time::Instant::now); + crate::scene::view::render::resolve_colors_for_bg(&mut wires, bg); + let colors_ms = crate::perf::elapsed_ms(t_colors); + // Apply draw order via the cached index (O(1) block lookup). let t_sort = perf.then(iced::time::Instant::now); let mut sorted = false; @@ -9157,13 +9172,14 @@ impl Scene { } if perf { crate::perf_record!( - "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} \ + "[perf] wires-build total={:.1}ms sort_cache={:.1} visible={:.1} blk_cache={:.1} colors={:.1} \ build={:.1} [classify={:.1} hits={:.1} tess={:.1} materialize={:.1}] sort={:.1}({}) \ entities={} memo_hit={} memo_miss={} wires={} memo={}", crate::perf::elapsed_ms(t_fn), sort_cache_ms, visible_ms, blk_ms, + colors_ms, build_ms, classify_ms, hits_ms, diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index bc5549f46..a7938bfa3 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -140,6 +140,40 @@ impl PointMarker { /// /// Linetype is encoded as a GPU-side dash pattern so the CPU never needs to /// split wires into per-dash segments. `pattern_length = 0.0` means solid. +/// Everything needed to resolve a wire's display colour against a background, +/// kept so the resolution can be redone for another background without +/// re-tessellating. +/// +/// `None` — the default — means the wire did not record its inputs and a +/// resolution pass must leave it alone. Only `Batches::finalize` fills it in. +pub type BgAdapt = Option>; + +/// The inputs `Batches::finalize` consumes when it resolves a colour. +/// +/// The flags are independent — a wire can paint the background itself while +/// its glyphs still adapt — so this mirrors that code's branches rather than +/// collapsing them into one enum. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct BgAdaptInputs { + /// Colour before adaptation. Resolution always recomputes from here rather + /// than from `WireModel::color`, so it is idempotent and safe over a set + /// mixing memo hits with freshly tessellated wires. `adapt_to_bg` is + /// **not** re-appliable: a near-white colour snaps to pure black on a + /// light background, and pure black comes back pure *white* on a dark one, + /// losing the tint. + pub raw_color: [f32; 4], + /// Glyph colours before adaptation, parallel to `WireModel::text_verts`. + /// Empty when the wire has no glyphs or their colour never adapts. + pub text_raw_colors: Vec<[f32; 4]>, + /// Adapt against this instead of the live background: an MTEXT carrying + /// its own background fill. + pub contrast_bg: Option<[f32; 4]>, + /// The wire paints the background; its colour *is* the live background. + pub canvas_color: bool, + /// Authored colour. The background must not touch it, glyphs included. + pub preserve_color: bool, +} + #[derive(Clone, Debug)] pub struct WireModel { /// Unique identifier — the handle value as a decimal string. @@ -156,6 +190,9 @@ pub struct WireModel { pub point_marker: Option, /// RGBA colour in [0, 1]. pub color: [f32; 4], + /// How `color` and `text_verts` follow the background, or `None` when this + /// wire's colour must not be re-resolved. + pub bg_adapt: BgAdapt, /// Whether this wire is currently selected. #[allow(dead_code)] pub selected: bool, @@ -315,6 +352,7 @@ impl WireModel { /// Create a solid wire (no dash pattern, 1px weight). pub fn solid(name: String, points: Vec<[f32; 3]>, color: [f32; 4], selected: bool) -> Self { Self { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -688,6 +726,7 @@ impl Default for WireModel { points: Vec::new(), points_low: Vec::new(), color: Self::WHITE, + bg_adapt: None, selected: false, pattern_length: 0.0, pattern: [0.0; 8], diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 40786c911..26f0b2982 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -296,6 +296,7 @@ pub fn frame_wire( lo.push([lx, ly, 0.0]); } WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/text/complex_lt.rs b/src/scene/text/complex_lt.rs index 1cb8c7101..0cac9c0f2 100644 --- a/src/scene/text/complex_lt.rs +++ b/src/scene/text/complex_lt.rs @@ -309,6 +309,7 @@ pub fn apply_along( .into_iter() .filter(|s| s.len() >= 2) .map(|pts| WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), @@ -357,6 +358,7 @@ pub fn apply_along( xy = xy.max(y); } out.push(WireModel { + bg_adapt: None, point_marker: None, taper_widths: Vec::new(), pattern_stations: Vec::new(), diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 768f20ce4..6d014f905 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -3145,6 +3145,43 @@ pub(crate) fn adapt_to_bg(color: [f32; 4], bg: [f32; 4]) -> [f32; 4] { } } +/// Re-resolve display colours for `bg`, for wires that recorded what their +/// resolution consumed. +/// +/// This is what lets a set tessellated under one background be shown under +/// another without rebuilding it: the geometry never depended on the +/// background, only the colours did — see the `let _ = bg_color;` in +/// `block_cache::local_wires_for`. +/// +/// Every colour is recomputed from `raw_color`, never from the current +/// `color`, so the pass is idempotent and safe over a set that mixes memoized +/// wires with freshly tessellated ones. Recomputing from `color` would not be: +/// `adapt_to_bg` maps a near-white colour to pure black, and pure black back +/// to pure *white*, so a second application loses the original tint. +/// +/// Wires with `bg_adapt: None` — everything not built by `Batches::finalize` — +/// are left alone. +pub(crate) fn resolve_colors_for_bg(wires: &mut [WireModel], bg: [f32; 4]) { + for wire in wires { + let Some(adapt) = wire.bg_adapt.as_deref() else { + continue; + }; + let contrast = adapt.contrast_bg.unwrap_or(bg); + wire.color = if adapt.canvas_color { + bg + } else if adapt.preserve_color { + adapt.raw_color + } else { + adapt_to_bg(adapt.raw_color, contrast) + }; + if !adapt.preserve_color { + for (vertex, raw) in wire.text_verts.iter_mut().zip(&adapt.text_raw_colors) { + vertex.color = adapt_to_bg(*raw, contrast); + } + } + } +} + // ── Primitive builder helpers (called by ViewportPane's shader::Program impl) ── impl Scene {