From fa322991c6a1e67c645d6afed6fdae7d5587915d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 7 Jul 2026 21:32:17 +0300 Subject: [PATCH] fix(text): clip SDF text to xclip boundary; stop debug-assert crash on block fills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for opening drawings with XCLIP'd blocks (crashed / mis-rendered since SDF text became the default). - block_cache emit_wire: the fill_tris/fill_tris_low length debug_assert panicked in debug builds on a legitimate case — a Leader/dimension arrowhead fill nested in a block carries fill_tris with an empty fill_tris_low (the WireModel contract's "all-zero low" form, which the code already handles via get().unwrap_or). Relax the assert to permit the empty case; a *partial* low half is still caught. - xclip clip_wires: also clip the SDF glyph quads. Block-internal text was bleeding past the clip boundary (mixed batch) or vanishing (the tail retain dropped empty-points wires). Now each glyph quad is kept/dropped by its centroid vs the boundary (whole-glyph granularity), text_verts-carrying wires survive the retain, and the pick-box AABB is recomputed from the surviving glyphs. Co-Authored-By: Claude Opus 4.8 --- src/scene/cache/block_cache.rs | 12 +++++++++- src/scene/pick/xclip.rs | 44 ++++++++++++++++++++++++++++++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index acbce744f..31b9bba1a 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -1187,7 +1187,17 @@ fn emit_wire( .tangent_geoms .push(transform_tangent(tg, accum_xform)); } - debug_assert_eq!(lw.fill_tris.len(), lw.fill_tris_low.len()); + // Per the WireModel contract an empty `fill_tris_low` means "all-zero low + // half" (e.g. a Leader / dimension arrowhead fill, which the tessellator + // emits without a low half). Only a *partially* populated low half is a + // real bug — keep the tripwire for that, but permit the empty case so debug + // builds don't panic on legitimate geometry. + debug_assert!( + lw.fill_tris_low.is_empty() || lw.fill_tris.len() == lw.fill_tris_low.len(), + "fill_tris_low must be empty or the same length as fill_tris (got {} vs {})", + lw.fill_tris.len(), + lw.fill_tris_low.len(), + ); for (idx, p) in lw.fill_tris.iter().enumerate() { // Empty/short fill_tris_low means "no low half" (all-zero), per the // WireModel contract — same panic-safe access the other fill consumers diff --git a/src/scene/pick/xclip.rs b/src/scene/pick/xclip.rs index 9817676fa..ed752d845 100644 --- a/src/scene/pick/xclip.rs +++ b/src/scene/pick/xclip.rs @@ -183,13 +183,53 @@ pub fn clip_wires(wires: &mut Vec, poly: &[[f64; 2]]) { w.fill_tris = hi; w.fill_tris_low = lo; } + // Clip SDF text: keep each glyph quad (6 verts = 2 triangles) whose + // centroid is inside the boundary. Whole-glyph granularity so block- + // instance text is bounded by the xclip region instead of bleeding past + // it — the stroke path clips per-segment, but per-glyph reads cleanly. + if !w.text_verts.is_empty() { + let mut kept = Vec::with_capacity(w.text_verts.len()); + for quad in w.text_verts.chunks(6) { + let n = quad.len() as f64; + let (mut cx, mut cy) = (0.0f64, 0.0f64); + for v in quad { + cx += v.pos[0] as f64 + v.pos_low[0] as f64; + cy += v.pos[1] as f64 + v.pos_low[1] as f64; + } + if point_in_poly((cx / n - rx) as f32, (cy / n - ry) as f32, &lpoly) { + kept.extend_from_slice(quad); + } + } + w.text_verts = kept; + } w.key_vertices .retain(|v| point_in_poly((v[0] - rx) as f32, (v[1] - ry) as f32, &lpoly)); w.snap_pts .retain(|(p, _)| point_in_poly((p.x - rx) as f32, (p.y - ry) as f32, &lpoly)); - w.aabb = recompute_aabb(&w.points, &w.fill_tris); + // AABB from surviving points + fills, then fold in the surviving glyph + // quads so a text-only wire keeps a tight (non-degenerate) pick box. + let mut bb = recompute_aabb(&w.points, &w.fill_tris); + if !w.text_verts.is_empty() { + let (mut nx, mut ny, mut xx, mut xy) = if bb == [0.0; 4] { + (f32::MAX, f32::MAX, f32::MIN, f32::MIN) + } else { + (bb[0], bb[1], bb[2], bb[3]) + }; + for v in &w.text_verts { + nx = nx.min(v.pos[0]); + ny = ny.min(v.pos[1]); + xx = xx.max(v.pos[0]); + xy = xy.max(v.pos[1]); + } + if nx <= xx { + bb = [nx, ny, xx, xy]; + } + } + w.aabb = bb; } - wires.retain(|w| !w.points.is_empty() || !w.fill_tris.is_empty()); + wires.retain(|w| { + !w.points.is_empty() || !w.fill_tris.is_empty() || !w.text_verts.is_empty() + }); } /// Double-single split of an f64 into (high, low) f32 — mirrors