From a35fc949f35e107f91a285a0b69a31d11992f445 Mon Sep 17 00:00:00 2001 From: yztink Date: Mon, 28 Sep 2026 16:09:41 -0700 Subject: [PATCH 01/17] fix(ellipse): accurately transform ellipse and elliptical arcs in blocks and standard transforms - Add transform_ellipse_geometry helper in scene::view::transform to compute exact principal semi-axes, normal, ratio, and shifted arc parameters under any affine transform (negative scales, reflections, negative rotations, non-uniform scaling, and translations). - Use transform_ellipse_geometry in block_cache::transform_tangent for TangentGeom::PlanarEllipse so GPU analytical instances and curve endpoints match true WCS positions. - Update entities::ellipse::apply_transform to use transform_ellipse_geometry for complete scaling, rotation, mirroring, and affine transformations. - Clean up tangent parameters in to_render for Ellipse so start_param and end_param are consistently unwrapped. - Add comprehensive unit tests in transform.rs, block_cache.rs, and ellipse.rs verifying negative rotations, negative scales, and distant origins. --- src/entities/ellipse.rs | 131 +++++++++++++++--- src/scene/cache/block_cache.rs | 79 +++++++++-- src/scene/view/transform.rs | 238 +++++++++++++++++++++++++++++++++ 3 files changed, 421 insertions(+), 27 deletions(-) diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index be0cbea77..0cf40a5b2 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -30,13 +30,28 @@ fn to_render(ell: &Ellipse) -> RenderEntity { .map(crate::entities::curve::snap_from) .unwrap_or_default(); + let is_full = ell.is_full() + || (ell.end_parameter - ell.start_parameter).abs() >= std::f64::consts::TAU - 1e-9; + let (start_param, end_param) = if is_full { + (0.0, std::f64::consts::TAU) + } else { + let s = ell.start_parameter.rem_euclid(std::f64::consts::TAU); + let raw = ell.end_parameter - ell.start_parameter; + let sweep = if raw <= 0.0 { + raw.rem_euclid(std::f64::consts::TAU) + } else { + raw + }; + (s, s + sweep) + }; + let tangent = TangentGeom::PlanarEllipse { center: [ell.center.x, ell.center.y, ell.center.z], major_axis: [ell.major_axis.x, ell.major_axis.y, ell.major_axis.z], normal: [ell.normal.x, ell.normal.y, ell.normal.z], minor_axis_ratio: ell.minor_axis_ratio, - start_param: ell.start_parameter, - end_param: ell.end_parameter, + start_param, + end_param, }; // The points come from the entity's own kernel curve and angular policy. @@ -339,20 +354,58 @@ fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) { } fn apply_transform(ell: &mut Ellipse, t: &EntityTransform) { - crate::scene::view::transform::apply_standard_entity_transform(ell, t, |entity, p1, p2| { - crate::scene::view::transform::reflect_xy_point( - &mut entity.center.x, - &mut entity.center.y, - p1, - p2, - ); - crate::scene::view::transform::reflect_xy_point( - &mut entity.major_axis.x, - &mut entity.major_axis.y, - p1, - p2, - ); - }); + use codec::types::{Transform, Vector3}; + let transform = match t { + EntityTransform::Translate(d) => { + ell.center = ell.center + Vector3::new(d.x, d.y, d.z); + return; + } + EntityTransform::Rotate { center, axis, angle_rad } => { + let axis_norm = Vector3::new(axis.x, axis.y, axis.z); + let axis_len = axis_norm.length(); + let axis_v = if axis_len > 1e-12 { axis_norm / axis_len } else { Vector3::UNIT_Z }; + Transform::from_translation(Vector3::new(-center.x, -center.y, -center.z)) + .then(&Transform::from_rotation(axis_v, *angle_rad)) + .then(&Transform::from_translation(Vector3::new(center.x, center.y, center.z))) + } + EntityTransform::Scale { center, factor } => { + let s = *factor; + Transform::from_scaling_with_origin( + Vector3::new(s, s, s), + Vector3::new(center.x, center.y, center.z), + ) + } + EntityTransform::Mirror { p1, p2, working_normal } => { + crate::scene::view::transform::reflection_about_working_line( + *p1, + *p2, + *working_normal, + ) + } + EntityTransform::Affine(tr) => tr.clone(), + }; + + if let Some((c, m, n, ratio, s, e)) = crate::scene::view::transform::transform_ellipse_geometry( + ell.center, + ell.major_axis, + ell.normal, + ell.minor_axis_ratio, + ell.start_parameter, + ell.end_parameter, + &transform, + ) { + ell.center = c; + ell.major_axis = m; + ell.normal = n; + ell.minor_axis_ratio = ratio; + ell.start_parameter = s; + let is_full = (e - s - std::f64::consts::TAU).abs() < 1e-9; + ell.end_parameter = if is_full { + std::f64::consts::TAU + } else { + e.rem_euclid(std::f64::consts::TAU) + }; + } } impl RenderConvertible for Ellipse { @@ -487,4 +540,50 @@ mod grip_tests { "minor still 10 — no ballooning" ); } + + #[test] + fn test_apply_transform_negative_scale_and_rotation() { + let mut e = ell(10.0, 0.5); + e.start_parameter = 0.0; + e.end_parameter = std::f64::consts::FRAC_PI_2; + + let rot = EntityTransform::Rotate { + center: DVec3::ZERO, + axis: DVec3::Z, + angle_rad: -std::f64::consts::FRAC_PI_4, + }; + apply_transform(&mut e, &rot); + + let scale = EntityTransform::Scale { + center: DVec3::ZERO, + factor: -1.0, + }; + apply_transform(&mut e, &scale); + + assert!((xy_len(&e) - 10.0).abs() < 1e-9); + assert!((e.minor_axis_ratio - 0.5).abs() < 1e-9); + assert!((e.normal.z - 1.0).abs() < 1e-9); + // Start parameter was 0, rotated by -45 then scaled by -1 (180 rotation) -> net effect + assert!(e.start_parameter >= 0.0 && e.start_parameter < std::f64::consts::TAU); + } + + #[test] + fn test_apply_transform_mirror() { + let mut e = ell(10.0, 0.5); + e.start_parameter = 0.1; + e.end_parameter = 1.5; + + // Mirror across Y axis (p1=(0,0,0), p2=(0,1,0)) + let mirror = EntityTransform::Mirror { + p1: DVec3::ZERO, + p2: DVec3::Y, + working_normal: DVec3::Z, + }; + apply_transform(&mut e, &mirror); + + // Major axis should be reflected to (-10, 0, 0) + assert!((e.major_axis.x - (-10.0)).abs() < 1e-9); + // Normal should flip to -Z to preserve counter-clockwise traversal + assert!(e.normal.z < 0.0); + } } diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index 5af7d3cd5..a02f7b5a7 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -2997,21 +2997,22 @@ fn transform_tangent( start_param, end_param, } => { - let c = t.apply(Vector3::new(center[0], center[1], center[2])); - let m = t.apply_rotation(Vector3::new(major_axis[0], major_axis[1], major_axis[2])); - let n = t.apply_rotation(Vector3::new(normal[0], normal[1], normal[2])); - let n_len = n.length(); - if n_len <= 1.0e-12 { - return None; - } - let n = n / n_len; + let (c, m, n, ratio, s, e) = crate::scene::view::transform::transform_ellipse_geometry( + Vector3::new(center[0], center[1], center[2]), + Vector3::new(major_axis[0], major_axis[1], major_axis[2]), + Vector3::new(normal[0], normal[1], normal[2]), + *minor_axis_ratio, + *start_param, + *end_param, + t, + )?; Some(TangentGeom::PlanarEllipse { center: [c.x, c.y, c.z], major_axis: [m.x, m.y, m.z], normal: [n.x, n.y, n.z], - minor_axis_ratio: *minor_axis_ratio, - start_param: *start_param, - end_param: *end_param, + minor_axis_ratio: ratio, + start_param: s, + end_param: e, }) } } @@ -3263,4 +3264,60 @@ mod compact_nested_tests { assert_eq!(wires.len(), 1); assert!(wires[0].render_instance.is_none()); } + + #[test] + fn test_transform_tangent_ellipse_arc_negative_scale_and_rotation() { + let ellipse_geom = TangentGeom::PlanarEllipse { + center: [10.0, 20.0, 0.0], + major_axis: [100.0, 0.0, 0.0], + normal: [0.0, 0.0, 1.0], + minor_axis_ratio: 0.5, + start_param: std::f64::consts::FRAC_PI_4, + end_param: 3.0 * std::f64::consts::FRAC_PI_4, + }; + + // Negative scale (-1, -1, 1), negative rotation -45 deg, distant translation (7000, 5000, 0) + let t = Transform::from_scaling(Vector3::new(-1.0, -1.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, -std::f64::consts::FRAC_PI_4)) + .then(&Transform::from_translation(Vector3::new(7000.0, 5000.0, 0.0))); + + let transformed = transform_tangent(&ellipse_geom, &t).expect("transform_tangent should succeed"); + let TangentGeom::PlanarEllipse { + center: c, + major_axis: m, + normal: n, + minor_axis_ratio: ratio, + start_param: s, + end_param: _e, + } = transformed else { + panic!("Expected PlanarEllipse"); + }; + + // Center must be transformed + let expected_c = t.apply(Vector3::new(10.0, 20.0, 0.0)); + assert!((c[0] - expected_c.x).abs() < 1e-9); + assert!((c[1] - expected_c.y).abs() < 1e-9); + + // Major axis length must be 100, ratio 0.5 + let m_vec = Vector3::new(m[0], m[1], m[2]); + assert!((m_vec.length() - 100.0).abs() < 1e-9); + assert!((ratio - 0.5).abs() < 1e-9); + assert!((n[2] - 1.0).abs() < 1e-9); + + // Start and end points on the curve must match mathematical transformation of original start/end + let orig_start_pt = Vector3::new(10.0, 20.0, 0.0) + + Vector3::new(100.0, 0.0, 0.0) * (std::f64::consts::FRAC_PI_4).cos() + + Vector3::new(0.0, 50.0, 0.0) * (std::f64::consts::FRAC_PI_4).sin(); + let expected_start_pt = t.apply(orig_start_pt); + + let new_v = Vector3::new(n[0], n[1], n[2]).cross(&m_vec) * ratio; + let actual_start_pt = Vector3::new(c[0], c[1], c[2]) + + m_vec * s.cos() + + new_v * s.sin(); + + assert!( + (actual_start_pt - expected_start_pt).length() < 1e-9, + "Start point mismatch: expected {expected_start_pt:?}, got {actual_start_pt:?}" + ); + } } diff --git a/src/scene/view/transform.rs b/src/scene/view/transform.rs index d9ed5fc90..151995ba9 100644 --- a/src/scene/view/transform.rs +++ b/src/scene/view/transform.rs @@ -120,6 +120,93 @@ pub fn reflect_xy_point(x: &mut f64, y: &mut f64, p1: DVec3, p2: DVec3) { *y = p1.y as f64 + my; } +/// Fully transforms ellipse / elliptical arc geometry under any 3D affine transform +/// (including negative scales, reflections, negative rotations, non-uniform scales, and translation). +/// +/// Returns `(new_center, new_major_axis, new_normal, new_minor_axis_ratio, new_start_param, new_end_param)` +/// where `new_major_axis` is guaranteed to be orthogonal to `new_normal`, its length is >= semi-minor axis length, +/// and `new_minor_axis_ratio <= 1.0`. +pub fn transform_ellipse_geometry( + center: Vector3, + major_axis: Vector3, + normal: Vector3, + minor_axis_ratio: f64, + start_param: f64, + end_param: f64, + transform: &Transform, +) -> Option<(Vector3, Vector3, Vector3, f64, f64, f64)> { + let norm_len = normal.length(); + if norm_len <= 1e-12 || !norm_len.is_finite() { + return None; + } + let norm = normal / norm_len; + let m = major_axis; + let m_len = m.length(); + if m_len <= 1e-12 || !m_len.is_finite() { + return None; + } + let ratio = minor_axis_ratio.clamp(1e-12, 1.0); + // V = (N x M) * ratio + let v = norm.cross(&m) * ratio; + + let c_prime = transform.apply(center); + let m0 = transform.apply_rotation(m); + let v0 = transform.apply_rotation(v); + + let m0_len_sq = m0.length_squared(); + let v0_len_sq = v0.length_squared(); + if m0_len_sq <= 1e-24 || v0_len_sq <= 1e-24 || !m0_len_sq.is_finite() || !v0_len_sq.is_finite() { + return None; + } + + // Extremum angle theta to find the principal axes of the transformed ellipse + let dot = m0.dot(&v0); + let diff = m0_len_sq - v0_len_sq; + let theta = 0.5 * (2.0 * dot).atan2(diff); + + let (sin_t, cos_t) = theta.sin_cos(); + let m_new = m0 * cos_t + v0 * sin_t; + let v_new = m0 * (-sin_t) + v0 * cos_t; + + let a = m_new.length(); + let b = v_new.length(); + if a <= 1e-12 || !a.is_finite() { + return None; + } + + let cross = m_new.cross(&v_new); + let cross_len = cross.length(); + if cross_len <= 1e-12 || !cross_len.is_finite() { + return None; + } + let n_new = cross / cross_len; + let new_ratio = (b / a).clamp(1e-12, 1.0); + + // Sweep and parameters + use std::f64::consts::TAU; + let raw = end_param - start_param; + let is_full = (raw - TAU).abs() < 1e-9 + || (raw.abs() >= TAU - 1e-9) + || (raw.rem_euclid(TAU).abs() < 1e-9 && raw.abs() > 1e-9); + + let (new_start, new_end) = if is_full { + (0.0, TAU) + } else { + let sweep = if raw <= 0.0 { + let s = raw.rem_euclid(TAU); + if s <= 1e-9 { TAU } else { s } + } else if raw > TAU { + TAU + } else { + raw + }; + let s = (start_param - theta).rem_euclid(TAU); + (s, s + sweep) + }; + + Some((c_prime, m_new, n_new, new_ratio, new_start, new_end)) +} + /// DXF arbitrary-axis algorithm — returns the OCS X and Y basis vectors in WCS /// for a given entity normal vector. /// @@ -271,3 +358,154 @@ mod ocs_axes_142 { assert!(p.0.abs()+p.1.abs()+p.2.abs() > 1.0, "point collapsed: {:?}", p); } } + +#[cfg(test)] +mod ellipse_transform_tests { + use super::*; + use std::f64::consts::{FRAC_PI_2, FRAC_PI_4}; + + fn sample_point(center: Vector3, major: Vector3, normal: Vector3, ratio: f64, t: f64) -> Vector3 { + let u = major; + let v = normal.normalize().cross(&major) * ratio; + center + u * t.cos() + v * t.sin() + } + + #[test] + fn negative_rotation_negative_scale_distant_origin() { + // Original ellipse arc at distant center (7000, 5000, 0) + let center = Vector3::new(7000.0, 5000.0, 0.0); + let major = Vector3::new(10.0, 0.0, 0.0); + let normal = Vector3::new(0.0, 0.0, 1.0); + let ratio = 0.5; + let start_param = FRAC_PI_4; + let end_param = 3.0 * FRAC_PI_4; + + // Transform: non-uniform negative scale (-1.0, -1.0, 1.0), rotation -45 deg, translation (100, 200, 0) + let rot_rad = -FRAC_PI_4; + let transform = Transform::from_scaling(Vector3::new(-1.0, -1.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, rot_rad)) + .then(&Transform::from_translation(Vector3::new(100.0, 200.0, 0.0))); + + let (c, m, n, r, s, e) = transform_ellipse_geometry( + center, + major, + normal, + ratio, + start_param, + end_param, + &transform, + ) + .expect("transformation should succeed"); + + assert!(r <= 1.0 && r > 0.0, "ratio must be in (0, 1], got {r}"); + assert!(m.length() >= m.length() * r); + + // Verify that start, end, and intermediate points match exactly + for i in 0..=10 { + let frac = i as f64 / 10.0; + let orig_t = start_param + frac * (end_param - start_param); + let orig_pt = sample_point(center, major, normal, ratio, orig_t); + let expected_pt = transform.apply(orig_pt); + + let new_t = s + frac * (e - s); + let actual_pt = sample_point(c, m, n, r, new_t); + + let diff = (expected_pt - actual_pt).length(); + assert!( + diff < 1e-9, + "Point at frac {frac} differs by {diff}: expected {expected_pt:?}, got {actual_pt:?}" + ); + } + } + + #[test] + fn axis_swapping_under_non_uniform_negative_scale() { + // When scale along Y is much larger, the minor axis becomes the major axis + let center = Vector3::new(0.0, 0.0, 0.0); + let major = Vector3::new(10.0, 0.0, 0.0); + let normal = Vector3::new(0.0, 0.0, 1.0); + let ratio = 0.5; // initial minor length is 5.0 + let start_param = 0.1; + let end_param = 2.0; + + // Scale X by -1, Scale Y by -3: transformed major length is 10, transformed minor length is 15. + // So axes must swap! + let transform = Transform::from_scaling(Vector3::new(-1.0, -3.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, -FRAC_PI_2)); + + let (c, m, n, r, s, e) = transform_ellipse_geometry( + center, + major, + normal, + ratio, + start_param, + end_param, + &transform, + ) + .expect("transformation should succeed"); + + assert!(r <= 1.0, "ratio must be <= 1.0 after axis swap, got {r}"); + assert!((r - (10.0 / 15.0)).abs() < 1e-9); + + // Verify points match + for i in 0..=10 { + let frac = i as f64 / 10.0; + let orig_t = start_param + frac * (end_param - start_param); + let orig_pt = sample_point(center, major, normal, ratio, orig_t); + let expected_pt = transform.apply(orig_pt); + + let new_t = s + frac * (e - s); + let actual_pt = sample_point(c, m, n, r, new_t); + + let diff = (expected_pt - actual_pt).length(); + assert!( + diff < 1e-9, + "Point at frac {frac} differs by {diff}: expected {expected_pt:?}, got {actual_pt:?}" + ); + } + } + + #[test] + fn reflection_handedness_flip_arc_preservation() { + // Reflection across Y axis (scale X by -1, Y by 1) + let center = Vector3::new(50.0, 60.0, 0.0); + let major = Vector3::new(8.0, 0.0, 0.0); + let normal = Vector3::new(0.0, 0.0, 1.0); + let ratio = 0.75; + let start_param = 0.2; + let end_param = 1.8; + + let transform = Transform::from_scaling(Vector3::new(-1.0, 1.0, 1.0)) + .then(&Transform::from_rotation(Vector3::UNIT_Z, -0.6)); + + let (c, m, n, r, s, e) = transform_ellipse_geometry( + center, + major, + normal, + ratio, + start_param, + end_param, + &transform, + ) + .expect("transformation should succeed"); + + // Normal must be inverted in Z due to reflection + assert!(n.z < 0.0, "normal Z should be flipped, got {}", n.z); + + for i in 0..=10 { + let frac = i as f64 / 10.0; + let orig_t = start_param + frac * (end_param - start_param); + let orig_pt = sample_point(center, major, normal, ratio, orig_t); + let expected_pt = transform.apply(orig_pt); + + let new_t = s + frac * (e - s); + let actual_pt = sample_point(c, m, n, r, new_t); + + let diff = (expected_pt - actual_pt).length(); + assert!( + diff < 1e-9, + "Point at frac {frac} differs by {diff}: expected {expected_pt:?}, got {actual_pt:?}" + ); + } + } +} From 596558f80ade26e157b4e02b9797f614a33b6aac Mon Sep 17 00:00:00 2001 From: yztink Date: Mon, 28 Sep 2026 22:52:21 -0700 Subject: [PATCH 02/17] fix: resolve tangent snap when continuing an arc --- src/app/commands/draw.rs | 1 + src/modules/draw/draw/arc.rs | 348 ++++++++++++++++++++++++++++++++++- 2 files changed, 344 insertions(+), 5 deletions(-) diff --git a/src/app/commands/draw.rs b/src/app/commands/draw.rs index a23c1e810..ba5fee7f4 100644 --- a/src/app/commands/draw.rs +++ b/src/app/commands/draw.rs @@ -391,6 +391,7 @@ impl OpenCADStudio { }); match seed { Some((s, tangent)) => { + self.last_point = Some(s); let new_cmd = ArcContCommand::new(s, tangent); self.command_line.push_info(&new_cmd.prompt()); self.tabs[i].active_cmd = Some(Box::new(new_cmd)); diff --git a/src/modules/draw/draw/arc.rs b/src/modules/draw/draw/arc.rs index 5f835fef2..29a1ed58b 100644 --- a/src/modules/draw/draw/arc.rs +++ b/src/modules/draw/draw/arc.rs @@ -2724,6 +2724,224 @@ impl CadCommand for ArcCSLCommand { } +/// Solves an arc continuing from start point `s` with initial tangent `tangent` +/// that touches `target` tangentially. +/// +/// Returns `(center, radius, start_angle, end_angle, tangent_pt_on_target)`. +pub fn solve_tangent_continue( + s: DVec3, + tangent: DVec3, + target: TangentObject, + hit: DVec3, + flip: bool, + plane: WorkingPlane, +) -> Option<(DVec3, f64, f64, f64, DVec3)> { + let s_loc = plane.to_local(s); + let t_loc = plane.vector_to_local(tangent); + let hit_loc = plane.to_local(hit); + + let s2 = DVec2::new(s_loc.x, s_loc.y); + let t2 = DVec2::new(t_loc.x, t_loc.y).normalize_or_zero(); + let hit2 = DVec2::new(hit_loc.x, hit_loc.y); + + if t2.length_squared() < 1e-12 { + return None; + } + let n2 = DVec2::new(-t2.y, t2.x); + + match target { + TangentObject::Circle { center, radius } => { + let c_loc = plane.to_local(center); + let c2 = DVec2::new(c_loc.x, c_loc.y); + let r2 = radius.abs(); + if r2 < 1e-6 { + return None; + } + let d = s2 - c2; + let d_len_sq = d.length_squared(); + let d_dot_n = d.dot(n2); + + let mut candidates = Vec::new(); + for sigma in [1.0, -1.0] { + let denom = 2.0 * (d_dot_n - sigma * r2); + if denom.abs() > 1e-9 { + let r = (r2 * r2 - d_len_sq) / denom; + if r.abs() > 1e-4 && r.abs() < 1e8 { + let center2 = s2 + n2 * r; + let v = center2 - c2; + let dist = v.length(); + if dist > 1e-9 { + let u = v / dist; + let p1 = c2 + u * r2; + let p2 = c2 - u * r2; + let (best_p, err) = if ((p1 - center2).length() - r.abs()).abs() + <= ((p2 - center2).length() - r.abs()) + { + (p1, ((p1 - center2).length() - r.abs()).abs()) + } else { + (p2, ((p2 - center2).length() - r.abs()).abs()) + }; + if err < 1e-3 { + candidates.push(best_p); + } + } + } + } + } + + candidates + .into_iter() + .filter_map(|best_p| { + let tgt_world = plane.to_world(DVec3::new(best_p.x, best_p.y, s_loc.z)); + let res = arc_continue(s, tangent, tgt_world, flip, plane)?; + let score = (best_p - hit2).length_squared(); + Some((score, res, tgt_world)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, (c, r, sa, ea), tgt_world)| (c, r, sa, ea, tgt_world)) + } + + TangentObject::Line { p1, p2 } => { + let p1_loc = plane.to_local(p1); + let p2_loc = plane.to_local(p2); + let p1_2 = DVec2::new(p1_loc.x, p1_loc.y); + let p2_2 = DVec2::new(p2_loc.x, p2_loc.y); + let line_vec = p2_2 - p1_2; + let line_len = line_vec.length(); + if line_len < 1e-9 { + return None; + } + let v_line = line_vec / line_len; + let n_line = DVec2::new(-v_line.y, v_line.x); + let d0 = (s2 - p1_2).dot(n_line); + let k = n2.dot(n_line); + + let mut r_candidates = Vec::new(); + if (1.0 - k).abs() > 1e-9 { + r_candidates.push(d0 / (1.0 - k)); + } + if (1.0 + k).abs() > 1e-9 { + r_candidates.push(-d0 / (1.0 + k)); + } + if (1.0 - k * k).abs() <= 1e-9 && k.abs() > 1e-9 { + r_candidates.push(-d0 / (2.0 * k)); + } + + let mut candidates = Vec::new(); + for r in r_candidates { + if r.abs() > 1e-4 && r.abs() < 1e8 { + let center2 = s2 + n2 * r; + let p_tgt2 = center2 - n_line * ((center2 - p1_2).dot(n_line)); + let err = ((p_tgt2 - center2).length() - r.abs()).abs(); + if err < 1e-3 { + candidates.push(p_tgt2); + } + } + } + + candidates + .into_iter() + .filter_map(|best_p| { + let tgt_world = plane.to_world(DVec3::new(best_p.x, best_p.y, s_loc.z)); + let res = arc_continue(s, tangent, tgt_world, flip, plane)?; + let seg_t = (best_p - p1_2).dot(v_line); + let outside_dist = if seg_t < 0.0 { + -seg_t + } else if seg_t > line_len { + seg_t - line_len + } else { + 0.0 + }; + let score = + (best_p - hit2).length_squared() + outside_dist * outside_dist * 100.0; + Some((score, res, tgt_world)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, (c, r, sa, ea), tgt_world)| (c, r, sa, ea, tgt_world)) + } + + TangentObject::Ellipse { .. } => { + let ell = target_to_ellipse2d(target, plane)?; + + let eval_t = |t: f64| -> Option<(f64, DVec2, f64, DVec2)> { + let pt = ell.point_at(t); + let n_hat = ell.normal_at(t); + let delta = pt - s2; + let det = n2.y * n_hat.x - n2.x * n_hat.y; + if det.abs() < 1e-6 { + return None; + } + let r = (-delta.x * n_hat.y + delta.y * n_hat.x) / det; + let lambda = (n2.x * delta.y - n2.y * delta.x) / det; + let center = s2 + n2 * r; + let res = r.abs() - lambda.abs(); + Some((res, pt, r.abs(), center)) + }; + + let n_steps = 144; + let dt = std::f64::consts::TAU / (n_steps as f64); + let mut candidates = Vec::new(); + + for i in 0..n_steps { + let t_a = i as f64 * dt; + let t_b = (i + 1) as f64 * dt; + + let Some((res_a, _, _, _)) = eval_t(t_a) else { + continue; + }; + let Some((res_b, _, _, _)) = eval_t(t_b) else { + continue; + }; + + let n_hat_a = ell.normal_at(t_a); + let det_a = n2.y * n_hat_a.x - n2.x * n_hat_a.y; + let n_hat_b = ell.normal_at(t_b); + let det_b = n2.y * n_hat_b.x - n2.x * n_hat_b.y; + if det_a.signum() != det_b.signum() { + continue; + } + + if res_a.signum() != res_b.signum() { + let mut lo = t_a; + let mut hi = t_b; + let mut best_t = (lo + hi) * 0.5; + for _ in 0..16 { + let mid = (lo + hi) * 0.5; + let Some((res_m, _, _, _)) = eval_t(mid) else { + break; + }; + best_t = mid; + if res_m.abs() < 1e-8 { + break; + } + if res_m.signum() == res_a.signum() { + lo = mid; + } else { + hi = mid; + } + } + if let Some((res, pt, r, _center)) = eval_t(best_t) { + if res.abs() < 1e-3 && r.is_finite() && r > 1e-4 && r < 1e8 { + candidates.push(pt); + } + } + } + } + + candidates + .into_iter() + .filter_map(|best_p| { + let tgt_world = plane.to_world(DVec3::new(best_p.x, best_p.y, s_loc.z)); + let res = arc_continue(s, tangent, tgt_world, flip, plane)?; + let score = (best_p - hit2).length_squared(); + Some((score, res, tgt_world)) + }) + .min_by(|a, b| a.0.total_cmp(&b.0)) + .map(|(_, (c, r, sa, ea), tgt_world)| (c, r, sa, ea, tgt_world)) + } + } +} + // ── Command 11: Continue (ARC_CONT) ────────────────────────────────────── pub struct ArcContCommand { s: DVec3, @@ -2731,6 +2949,7 @@ pub struct ArcContCommand { /// Live Ctrl state (set via `set_ctrl`): flips the arc to the other way. ctrl: bool, plane: WorkingPlane, + resolved_end: Option, } impl ArcContCommand { @@ -2740,6 +2959,7 @@ impl ArcContCommand { tangent, ctrl: false, plane: WorkingPlane::default(), + resolved_end: None, } } } @@ -2759,11 +2979,27 @@ impl CadCommand for ArcContCommand { self.ctrl = ctrl; } fn on_point(&mut self, pt: DVec3) -> CmdResult { + self.on_point_with_tangent(pt, None).unwrap_or(CmdResult::NeedPoint) + } + fn on_point_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Option { + if let Some(target) = tangent { + if let Some((center, radius, sa, ea, tgt_pt)) = + solve_tangent_continue(self.s, self.tangent, target, pt, self.ctrl, self.plane) + { + self.resolved_end = Some(tgt_pt); + return Some(arc_result(center, radius, sa, ea, self.plane)); + } + } match arc_continue(self.s, self.tangent, pt, self.ctrl, self.plane) { Some((center, radius, sa, ea)) => { - arc_result(center, radius, sa, ea, self.plane) + self.resolved_end = Some(pt); + Some(arc_result(center, radius, sa, ea, self.plane)) } - None => CmdResult::NeedPoint, + None => Some(CmdResult::NeedPoint), } } fn on_enter(&mut self) -> CmdResult { @@ -2773,10 +3009,29 @@ impl CadCommand for ArcContCommand { CmdResult::Cancel } fn on_mouse_move(&mut self, pt: DVec3) -> Option { - match arc_continue(self.s, self.tangent, pt, self.ctrl, self.plane) { - Some((center, radius, sa, ea)) => arc_preview(center, radius, sa, ea, self.plane), - None => Some(line_wire(self.s, pt)), + self.on_preview_wires_with_tangent(pt, None).into_iter().next() + } + fn on_preview_wires_with_tangent( + &mut self, + pt: DVec3, + tangent: Option, + ) -> Vec { + if let Some(target) = tangent { + if let Some((center, radius, sa, ea, _tgt_pt)) = + solve_tangent_continue(self.s, self.tangent, target, pt, self.ctrl, self.plane) + { + return arc_preview(center, radius, sa, ea, self.plane).into_iter().collect(); + } } + match arc_continue(self.s, self.tangent, pt, self.ctrl, self.plane) { + Some((center, radius, sa, ea)) => { + arc_preview(center, radius, sa, ea, self.plane).into_iter().collect() + } + None => vec![line_wire(self.s, pt)], + } + } + fn resolved_anchor(&self) -> Option { + self.resolved_end } } @@ -3213,5 +3468,88 @@ mod acad_flow_tests { "Both endpoints must terminate tangentially on the respective ellipses! start: {arc_start:?}, end: {arc_end:?}" ); } + + #[test] + fn arc_cont_tangent_to_circle() { + let s = DVec3::new(0.0, 0.0, 0.0); + let tangent = DVec3::new(1.0, 0.0, 0.0); + let mut cmd = ArcContCommand::new(s, tangent); + + let circle = TangentObject::Circle { + center: DVec3::new(10.0, 10.0, 0.0), + radius: 4.0, + }; + + let hit = DVec3::new(10.0, 6.0, 0.0); + let result = cmd.on_point_with_tangent(hit, Some(circle)); + let arc = committed_arc(result.expect("arc cont command completed with tangent circle")); + + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + let start_pt = DVec3::new( + arc.center.x + arc.radius * arc.start_angle.cos(), + arc.center.y + arc.radius * arc.start_angle.sin(), + 0.0, + ); + let end_pt = DVec3::new( + arc.center.x + arc.radius * arc.end_angle.cos(), + arc.center.y + arc.radius * arc.end_angle.sin(), + 0.0, + ); + assert!(start_pt.distance(s) < 1e-3 || end_pt.distance(s) < 1e-3); + + let resolved = cmd.resolved_anchor().expect("resolved anchor must exist"); + let circle_center = DVec3::new(10.0, 10.0, 0.0); + assert!((resolved.distance(circle_center) - 4.0).abs() < 1e-3, "resolved point must be on circle circumference"); + + let center_dist = center.distance(circle_center); + let is_ext_tangent = (center_dist - (arc.radius + 4.0)).abs() < 1e-3; + let is_int_tangent = (center_dist - (arc.radius - 4.0).abs()).abs() < 1e-3; + assert!(is_ext_tangent || is_int_tangent, "Arc must be tangent to circle: center_dist={center_dist}, arc.radius={}", arc.radius); + } + + #[test] + fn arc_cont_tangent_to_line() { + let s = DVec3::new(0.0, 0.0, 0.0); + let tangent = DVec3::new(1.0, 0.0, 0.0); + let mut cmd = ArcContCommand::new(s, tangent); + + let line = TangentObject::Line { + p1: DVec3::new(5.0, 10.0, 0.0), + p2: DVec3::new(15.0, 10.0, 0.0), + }; + + let hit = DVec3::new(10.0, 10.0, 0.0); + let result = cmd.on_point_with_tangent(hit, Some(line)); + let arc = committed_arc(result.expect("arc cont completed with tangent line")); + + let center = DVec3::new(arc.center.x, arc.center.y, arc.center.z); + assert!(((center.y - 10.0).abs() - arc.radius).abs() < 1e-3); + + let resolved = cmd.resolved_anchor().expect("resolved anchor must exist"); + assert!((resolved.y - 10.0).abs() < 1e-3, "resolved point must lie on the line"); + } + + #[test] + fn arc_cont_tangent_to_ellipse() { + let s = DVec3::new(0.0, 0.0, 0.0); + let tangent = DVec3::new(1.0, 0.0, 0.0); + let mut cmd = ArcContCommand::new(s, tangent); + + let ell = TangentObject::Ellipse { + center: DVec3::new(20.0, 10.0, 0.0), + major_axis: DVec3::new(10.0, 0.0, 0.0), + normal: DVec3::Z, + minor_axis_ratio: 0.5, + }; + + let hit = DVec3::new(15.0, 10.0, 0.0); + let result = cmd.on_point_with_tangent(hit, Some(ell)); + let _arc = committed_arc(result.expect("arc cont completed with tangent ellipse")); + + let resolved = cmd.resolved_anchor().expect("resolved anchor must exist"); + let dx = (resolved.x - 20.0) / 10.0; + let dy = (resolved.y - 10.0) / 5.0; + assert!((dx * dx + dy * dy - 1.0).abs() < 1e-3, "resolved point must lie on ellipse"); + } } From f7106c7d3009703f5ef462e9833d0a2b354c4736 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:58:47 +0300 Subject: [PATCH 03/17] DGN underlays: global origin, stored extents, colour table display, 3D cells - V8 models are placed relative to their global origin (model header). - A V8 model's extent is the union of its elements' stored ranges; a V7 model's text box is one text height tall. - Files without a colour table use the standard DGN default table; colour 255 is read from the table's background entry. - Colours from a file's colour table follow the background: near-white colours swap with it, and over non-black backgrounds some shades drop a step. - DGN contrast pulls towards a pivot taken from the model's own colours, after the fade, which is blended into the background first. - Shared cells are read through their full 3D placement, so tilted instances and 3D definitions are drawn in plan. - Deleted V8 elements are skipped; lines along the model's extent stay in the raster. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/image_model.rs | 46 +++-- src/scene/model/pdf_raster.rs | 16 +- src/scene/model/underlay_vector/dgn7.rs | 43 ++--- src/scene/model/underlay_vector/dgn8.rs | 236 ++++++++++++++--------- src/scene/model/underlay_vector/dwf.rs | 2 +- src/scene/model/underlay_vector/mod.rs | 113 +++++++++-- src/scene/model/underlay_vector/model.rs | 128 +++++++++++- src/ui/window/pdf_dialogs.rs | 3 +- 8 files changed, 433 insertions(+), 154 deletions(-) diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index dfd6d1ea9..96b994f8e 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -231,19 +231,23 @@ impl ImageModel { } kind => { let hidden = super::pdf_layers::hidden_layers(u); + let backdrop = super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]); // The adjusted-pixel memo keys on the source: one per set of - // hidden layers. - let source = if hidden.is_empty() { - def.file_path.clone() - } else { - format!("{}#{}", def.file_path, hidden.join("|")) - }; + // hidden layers and backdrop. + let source = format!( + "{}#{}~{}{}", + def.file_path, + hidden.join("|"), + backdrop.max_channel, + backdrop.light + ); let raster = super::underlay_vector::display_raster( kind, &def.file_path, page, &hidden, screen_side.unwrap_or(super::underlay_vector::RASTER_SIDE), + backdrop, )?; (source, raster) } @@ -253,6 +257,11 @@ impl ImageModel { let bg_max = background[0].max(background[1]).max(background[2]); let bg_min = background[0].min(background[1]).min(background[2]); let bg_lum = (bg_max + bg_min) / 2.0; + let dgn = def.underlay_type == UnderlayType::Dgn; + // A DGN model is faded into the background before its contrast is + // applied (the reference's order), so it is drawn opaque. + let bg_rgb = [0, 1, 2].map(|i| (background[i].clamp(0.0, 1.0) * 255.0).round() as u8); + let fade_before_contrast = (dgn && u.fade > 0).then_some((bg_rgb, u.fade.min(100))); let pixels = pdf_raster::adjusted_pixels( &source, page, @@ -260,7 +269,8 @@ impl ImageModel { PageAdjust { contrast: u.contrast.min(100), // Measured: PDF 0.5; DWF 0.243, or 0.73 once its colours are - // turned over for a dark background; DGN 0.65. + // turned over for a dark background; a DGN model's from its + // own colours, over any background. contrast_pivot: match def.underlay_type { UnderlayType::Pdf => 500, UnderlayType::Dwf @@ -269,14 +279,22 @@ impl ImageModel { 730 } UnderlayType::Dwf => 243, - UnderlayType::Dgn => 650, + UnderlayType::Dgn => super::underlay_vector::dgn_contrast_pivot( + &def.file_path, + page, + &super::pdf_layers::hidden_layers(u), + super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]), + fade_before_contrast, + ) + .unwrap_or(404), }, monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME), - adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND), - // A DGN model is drawn for a black background, so its - // colours turn over on a light one instead (white text stays - // light on a dark background, as the reference shows it). - dark_background: (bg_lum < 0.5) != (def.underlay_type == UnderlayType::Dgn), + // A DGN model's colours follow the background through its + // colour table (the raster above), whatever this setting. + adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) + && !dgn, + dark_background: bg_lum < 0.5, + fade_before_contrast, }, ); @@ -329,7 +347,7 @@ impl ImageModel { pixels, width: raster.width, height: raster.height, - opacity: 1.0 - u.fade.min(100) as f32 / 100.0, + opacity: if dgn { 1.0 } else { 1.0 - u.fade.min(100) as f32 / 100.0 }, corners, corners_low, draw_depth: 0.0, diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index eab0bdd63..d5f1ccf63 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -217,13 +217,18 @@ pub struct PageAdjust { /// towards `contrast_pivot` (hue and saturation kept). pub contrast: u8, /// The lightness contrast 0 leaves every colour at, in thousandths: - /// measured 500 for PDF, 243 for DWF and 650 for DGN underlays. + /// measured 500 for PDF and 243 for DWF; a DGN model's follows its + /// colours. pub contrast_pivot: u16, pub monochrome: bool, /// Lighten dark, unsaturated content on a dark background (black lines /// and text become white), keeping coloured content. pub adjust_for_background: bool, pub dark_background: bool, + /// A fade blended into the colours before the contrast (a DGN + /// model's): the background and the share, 0..=100, of the way each + /// colour is taken to it. + pub fade_before_contrast: Option<([u8; 3], u8)>, } type AdjustKey = (String, String, PageAdjust); @@ -236,7 +241,7 @@ fn adjusted_cache() -> &'static Mutex>>> { /// The display raster with the underlay's contrast / monochrome / background /// adjustments applied, memoised per source, page and adjustment. pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdjust) -> Arc> { - let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background; + let identity = adjust.contrast >= 100 && !adjust.monochrome && !adjust.adjust_for_background && adjust.fade_before_contrast.is_none(); if identity { return raster.pixels.clone(); } @@ -268,6 +273,12 @@ pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdj let (h, s, l) = rgb_to_hsl(rgb); rgb = hsl_to_rgb(h, s, (1.0 - l + 20.0 / 255.0).min(1.0)); } + if let Some((bg, fade)) = adjust.fade_before_contrast { + let f = fade.min(100) as f32 / 100.0; + for (c, b) in rgb.iter_mut().zip(bg) { + *c += (b as f32 / 255.0 - *c) * f; + } + } if adjust.contrast < 100 { // Lightness is pulled towards the pivot, hue and saturation kept: // contrast 0 leaves every colour at the pivot's lightness. @@ -276,6 +287,7 @@ pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdj let pivot = adjust.contrast_pivot as f32 / 1000.0; rgb = hsl_to_rgb(h, s, pivot + (l - pivot) * k); } + for (dst, c) in px[..3].iter_mut().zip(rgb) { *dst = (c.clamp(0.0, 1.0) * 255.0).round() as u8; } diff --git a/src/scene/model/underlay_vector/dgn7.rs b/src/scene/model/underlay_vector/dgn7.rs index 8664c5eca..86e0f9fb7 100644 --- a/src/scene/model/underlay_vector/dgn7.rs +++ b/src/scene/model/underlay_vector/dgn7.rs @@ -5,7 +5,7 @@ use std::collections::HashMap; -use super::model::{arc_cubics, bspline_points, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text}; +use super::model::{arc_cubics, bspline_points, dgn_color_table, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text, DGN_DEFAULT_COLORS}; /// A 32-bit integer stored as two little-endian words, high word first. fn int32(b: &[u8], at: usize) -> Option { @@ -76,36 +76,13 @@ fn elements(b: &[u8]) -> Vec<&[u8]> { out } -/// The file's colour table (element type 5 on level 1): the colour of -/// index i is at 41 + 3i. +/// The file's colour table (element type 5 on level 1): index i < 255 at +/// 41 + 3i, the background (255) at 38. fn color_table(elements: &[&[u8]]) -> Option> { - elements.iter().find_map(|e| { - (e[1] & 0x7f == 5 && e[0] & 0x3f == 1 && e.len() >= 41 + 765).then(|| { - (0..256) - .map(|i| { - let at = 41 + i * 3; - e.get(at..at + 3).map(|c| [c[0], c[1], c[2]]).unwrap_or([255, 255, 255]) - }) - .collect() - }) - }) -} - -/// A usable default: the first eight DGN colours, then greys. -// ponytail: The full DGN default table is not reproduced; the files -// the reference writes carry their own colour table. -fn default_colors() -> Vec<[u8; 3]> { - let base: [[u8; 3]; 8] = [ - [255, 255, 255], - [0, 0, 255], - [0, 255, 0], - [255, 0, 0], - [255, 255, 0], - [255, 0, 255], - [255, 127, 0], - [0, 255, 255], - ]; - (0..256).map(|i| if i < 8 { base[i] } else { [i as u8; 3] }).collect() + elements + .iter() + .find(|e| e[1] == 5 && e[0] & 0x3f == 1) + .and_then(|e| dgn_color_table(e, 41)) } /// Model names of a V7 file (it has one). @@ -432,7 +409,9 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option { } let u = units(bytes)?; let all = elements(bytes); - let colors = color_table(&all).unwrap_or_else(default_colors); + let table = color_table(&all); + let table_colors = table.is_some(); + let colors = table.unwrap_or_else(|| DGN_DEFAULT_COLORS.to_vec()); let names = level_names(&all); let off = |e: &[u8]| { let l = level(e); @@ -463,7 +442,7 @@ pub fn model(bytes: &[u8], hidden: &[String]) -> Option { let raw = read_run(&top, &colors); let per_unit = u.uor_per_sub * u.sub_per_master; let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, -u.origin[0] / per_unit, -u.origin[1] / per_unit]; - let mut sheet = Sheet { sub_per_master: u.sub_per_master, ..Default::default() }; + let mut sheet = Sheet { sub_per_master: u.sub_per_master, table_colors, ..Default::default() }; place(&raw, &m, &defs, 0, &mut sheet); sheet.rect = paths_bounds(&sheet.paths).or_else(|| { let t = sheet.texts.first()?; diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index e1cc34d91..96e81e286 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -7,7 +7,7 @@ use std::collections::HashMap; use std::io::Read; -use super::model::{arc_cubics, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text}; +use super::model::{arc_cubics, dgn_color_table, paths_bounds, Path, PathBuilder, Segment, Sheet, SubPath, Text, DGN_DEFAULT_COLORS}; // ── Compound file ─────────────────────────────────────────────────────────── @@ -188,7 +188,9 @@ fn elements(d: &[u8]) -> Vec<&[u8]> { // The stream can end inside its last element (its trailing words are // not stored); what is there is still read. let e = &d[at..(at + len).min(d.len())]; - out.push(e); + if !is_deleted(e) { + out.push(e); + } at += len; } out @@ -202,6 +204,12 @@ fn flags(e: &[u8]) -> u16 { u16::from_le_bytes([e[2], e[3]]) } +/// A deleted element (flag 0x80 of its second byte), kept in the stream but +/// no longer part of the file. +fn is_deleted(e: &[u8]) -> bool { + e[1] & 0x80 != 0 +} + /// A component of a complex element, cell or shared cell definition. fn is_component(e: &[u8]) -> bool { flags(e) & 0x4000 != 0 @@ -290,7 +298,7 @@ impl Styles { } } -/// Geometry in raw UORs, placed through a transform. +/// Plan geometry in UORs, and the shared cells it places. #[derive(Default, Clone)] struct Raw { paths: Vec, @@ -301,8 +309,31 @@ struct Raw { #[derive(Clone)] struct Instance { name: String, - m: [f64; 4], - origin: [f64; 2], + /// The definition's placement, with every enclosing one applied. + xf: Xf, +} + +/// A placement in UORs: x' = r[0]·(x, y, z, 1), y' = r[1]·…, z' = r[2]·…. +type Xf = [[f64; 4]; 3]; + +const IDENTITY: Xf = [[1.0, 0.0, 0.0, 0.0], [0.0, 1.0, 0.0, 0.0], [0.0, 0.0, 1.0, 0.0]]; + +fn xf_vector(m: &Xf, v: [f64; 3]) -> [f64; 3] { + m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2]) +} + +fn xf_point(m: &Xf, p: [f64; 3]) -> [f64; 3] { + m.map(|r| r[0] * p[0] + r[1] * p[1] + r[2] * p[2] + r[3]) +} + +fn xf_compose(outer: &Xf, inner: &Xf) -> Xf { + let mut out = [[0.0; 4]; 3]; + for (i, row) in out.iter_mut().enumerate() { + for (j, v) in row.iter_mut().enumerate() { + *v = (0..3).map(|k| outer[i][k] * inner[k][j]).sum::() + if j == 3 { outer[i][3] } else { 0.0 }; + } + } + out } /// A 3D element (flag 0x0800 of its properties word): three coordinates a @@ -311,17 +342,22 @@ fn is_3d(e: &[u8]) -> bool { u32_at(e, 40).is_some_and(|v| v & 0x0800 != 0) } -/// The in-plane x and y axes (projected onto the plan) of a 3D element's -/// orientation quaternion w, x, y, z stored at `at` (the rows of its -/// rotation matrix). -fn quat_axes(e: &[u8], at: usize) -> Option<([f64; 2], [f64; 2])> { +/// The in-plane x and y axes of a 3D element's orientation quaternion w, x, +/// y, z stored at `at` (the rows of its rotation matrix). +fn quat_axes(e: &[u8], at: usize) -> Option<([f64; 3], [f64; 3])> { let (w, x, y, z) = (f64_at(e, at)?, f64_at(e, at + 8)?, f64_at(e, at + 16)?, f64_at(e, at + 24)?); Some(( - [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z)], - [2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z)], + [1.0 - 2.0 * (y * y + z * z), 2.0 * (x * y - w * z), 2.0 * (x * z + w * y)], + [2.0 * (x * y + w * z), 1.0 - 2.0 * (x * x + z * z), 2.0 * (y * z - w * x)], )) } +/// The in-plane axes of a 2D element turned by `rotation` (radians). +fn turned_axes(rotation: f64) -> ([f64; 3], [f64; 3]) { + let (c, s) = (rotation.cos(), rotation.sin()); + ([c, s, 0.0], [-s, c, 0.0]) +} + /// An elliptical arc on axes `u` and `v` (plan projections) as cubics. fn projected_arc(c: [f64; 2], a: f64, b: f64, u: [f64; 2], v: [f64; 2], start: f64, sweep: f64) -> Vec { let map = |p: [f64; 2]| [c[0] + a * p[0] * u[0] + b * p[1] * v[0], c[1] + a * p[0] * u[1] + b * p[1] * v[1]]; @@ -331,7 +367,8 @@ fn projected_arc(c: [f64; 2], a: f64, b: f64, u: [f64; 2], v: [f64; 2], start: f .collect() } -fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { +/// Reads a run of elements placed through `xf` into plan geometry. +fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { let mut raw = Raw::default(); // A complex shape or chain collects its components into one path. let mut complex: Option<(usize, PathBuilder, Option<([u8; 3], f64)>, Option<[u8; 3]>, bool)> = None; @@ -363,14 +400,25 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { raw.paths.push(path); } } + let plan = |p: [f64; 3]| { + let q = xf_point(xf, p); + [q[0], q[1]] + }; + let axis = |v: [f64; 3]| { + let q = xf_vector(xf, v); + [q[0], q[1]] + }; for e in run { if e.len() < 104 || styles.hidden.contains(&u32_at(e, 12).unwrap_or(0)) { continue; } let color = styles.color(e); let stroke = Some((color, -1.0)); - let p2 = |at: usize| -> Option<[f64; 2]> { Some([f64_at(e, at)?, f64_at(e, at + 8)?]) }; let d3 = is_3d(e); + // A point: x, y (and z in a 3D element). + let p3 = |at: usize| -> Option<[f64; 3]> { + Some([f64_at(e, at)?, f64_at(e, at + 8)?, if d3 { f64_at(e, at + 16)? } else { 0.0 }]) + }; match kind(e) { 12 | 14 => { let n = u32_at(e, 104).unwrap_or(0) as usize; @@ -380,14 +428,14 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { } } 3 => { - if let (Some(a), Some(b)) = (p2(104), p2(if d3 { 128 } else { 120 })) { - push(&mut raw, &mut complex, Path { subpaths: vec![SubPath { segments: vec![Segment::Line(a, b)], closed: false }], stroke, fill: None }); + if let (Some(a), Some(b)) = (p3(104), p3(if d3 { 128 } else { 120 })) { + push(&mut raw, &mut complex, Path { subpaths: vec![SubPath { segments: vec![Segment::Line(plan(a), plan(b))], closed: false }], stroke, fill: None }); } } 4 | 6 | 11 => { let n = u32_at(e, 104).unwrap_or(0) as usize; let stride = if d3 { 24 } else { 16 }; - let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| p2(112 + k * stride)).collect(); + let pts: Vec<[f64; 2]> = (0..n).filter_map(|k| p3(112 + k * stride)).map(plan).collect(); if pts.len() >= 2 { let mut pb = PathBuilder::default(); pb.move_to(pts[0]); @@ -400,14 +448,16 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { push(&mut raw, &mut complex, Path { subpaths: pb.finish(), stroke, fill: None }); } } - 15 | 16 if d3 => { - // Axes, orientation and centre: ellipse a 104, b 112, + 15 | 16 => { + // Radii, orientation and centre. 3D: ellipse a 104, b 112, // quaternion 120, centre 152; arc start 104, sweep 112, a 120, - // b 128, quaternion 136, centre 168. - let arc = if kind(e) == 15 { - (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, quat_axes(e, 120)?, p2(152)?, 0.0, std::f64::consts::TAU)))() - } else { - (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, quat_axes(e, 136)?, p2(168)?, f64_at(e, 104)?, f64_at(e, 112)?)))() + // b 128, quaternion 136, centre 168. 2D: the orientation is an + // angle, ellipse centre 128, arc centre 144. + let arc = match (kind(e), d3) { + (15, true) => (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, quat_axes(e, 120)?, p3(152)?, 0.0, 0.0)))(), + (_, true) => (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, quat_axes(e, 136)?, p3(168)?, f64_at(e, 104)?, f64_at(e, 112)?)))(), + (15, false) => (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, turned_axes(f64_at(e, 120)?), p3(128)?, 0.0, 0.0)))(), + _ => (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, turned_axes(f64_at(e, 136)?), p3(144)?, f64_at(e, 104)?, f64_at(e, 112)?)))(), }; let Some((a, b, (u, v), c, start, sweep)) = arc else { continue }; let sweep = if sweep == 0.0 { std::f64::consts::TAU } else { sweep }; @@ -415,60 +465,52 @@ fn read_run(run: &[&[u8]], styles: &Styles) -> Raw { push( &mut raw, &mut complex, - Path { subpaths: vec![SubPath { segments: projected_arc(c, a, b, u, v, start, sweep), closed }], stroke, fill: None }, + Path { subpaths: vec![SubPath { segments: projected_arc(plan(c), a, b, axis(u), axis(v), start, sweep), closed }], stroke, fill: None }, ); } - 15 | 16 => { - let arc = if kind(e) == 15 { - (|| Some((f64_at(e, 104)?, f64_at(e, 112)?, f64_at(e, 120)?, p2(128)?, 0.0, std::f64::consts::TAU)))() + 17 => { + // 3D: width 112, height 120, quaternion 144, origin 176, the + // characters after the string marker. 2D: angle 144, origin + // 152, a byte count (with its 4-byte marker) at 110 and the + // characters from 174. + let placed = if d3 { + (|| Some((f64_at(e, 112)?, f64_at(e, 120)?, quat_axes(e, 144)?, p3(176)?, marked_string(e, 200)?)))() } else { - (|| Some((f64_at(e, 120)?, f64_at(e, 128)?, f64_at(e, 136)?, p2(144)?, f64_at(e, 104)?, f64_at(e, 112)?)))() + (|| { + let count = u16::from_le_bytes([e[110], e[111]]) as usize; + let chars = e.get(174..170 + count.max(4))?; + let text = String::from_utf8_lossy(chars).trim_end_matches(char::from(0)).to_string(); + Some((f64_at(e, 112)?, f64_at(e, 120)?, turned_axes(f64_at(e, 144)?), p3(152)?, text)) + })() }; - let Some((a, b, rot, c, start, sweep)) = arc else { continue }; - let pieces = arc_cubics(c, a, b, rot, start, if sweep == 0.0 { std::f64::consts::TAU } else { sweep }); - let closed = sweep.abs() >= std::f64::consts::TAU - 1e-9 || sweep == 0.0; - push( - &mut raw, - &mut complex, - Path { subpaths: vec![SubPath { segments: pieces.into_iter().map(|[a, b, c, d]| Segment::Cubic(a, b, c, d)).collect(), closed }], stroke, fill: None }, - ); - } - 17 if d3 => { - // Width 112, height 120, quaternion 144, origin 176; the - // characters follow the string marker. - let (Some(width), Some(height), Some((u, _)), Some(origin)) = (f64_at(e, 112), f64_at(e, 120), quat_axes(e, 144), p2(176)) else { + let Some((width, height, (u, v), origin, text)) = placed else { continue }; + // The text's axes as the plan shows them: its baseline sets the + // angle, and each axis's plan length scales its size. + let (pu, pv) = (axis(u), axis(v)); + let (su, sv) = (pu[0].hypot(pu[1]), pv[0].hypot(pv[1])); + if su < 1e-9 || sv < 1e-9 { continue; - }; - let Some(text) = marked_string(e, 200) else { continue }; - let (height, width) = (height * 6.0 / 1000.0, width * 6.0 / 1000.0); + } + let (height, width) = (height * 6.0 / 1000.0 * sv, width * 6.0 / 1000.0 * su); raw.texts.push(Text { text, - origin, + origin: plan(origin), height, width_factor: if height > 0.0 { width / height } else { 1.0 }, - rotation: u[1].atan2(u[0]), + rotation: pu[1].atan2(pu[0]), color, font: "txt".to_string(), }); } - 17 => { - let (Some(width), Some(height), Some(rotation), Some(origin)) = (f64_at(e, 112), f64_at(e, 120), f64_at(e, 144), p2(152)) else { - continue; - }; - // The text: a byte count (with its 4-byte marker) and the - // characters after the marker. - let count = u16::from_le_bytes([e[110], e[111]]) as usize; - let Some(chars) = e.get(174..170 + count.max(4)) else { continue }; - let text = String::from_utf8_lossy(chars).trim_end_matches(char::from(0)).to_string(); - let (height, width) = (height * 6.0 / 1000.0, width * 6.0 / 1000.0); - raw.texts.push(Text { text, origin, height, width_factor: if height > 0.0 { width / height } else { 1.0 }, rotation, color, font: "txt".to_string() }); - } 35 => { - let (Some(m0), Some(m1), Some(m3), Some(m4), Some(origin)) = (f64_at(e, 160), f64_at(e, 168), f64_at(e, 184), f64_at(e, 192), p2(232)) else { - continue; - }; + // Shared cell instance: a 3×3 matrix by rows from 160, the + // origin at 232 (z at 248 in 3D), the definition's name after + // the string marker. + let m: Option> = (0..9).map(|k| f64_at(e, 160 + k * 8)).collect(); + let (Some(m), Some(o)) = (m, p3(232)) else { continue }; let Some(name) = marked_string(e, 248) else { continue }; - raw.instances.push(Instance { name: name.to_ascii_uppercase(), m: [m0, m1, m3, m4], origin }); + let local: Xf = [[m[0], m[1], m[2], o[0]], [m[3], m[4], m[5], o[1]], [m[6], m[7], m[8], o[2]]]; + raw.instances.push(Instance { name: name.to_ascii_uppercase(), xf: xf_compose(xf, &local) }); } _ => {} } @@ -489,18 +531,9 @@ fn apply(m: &Affine, p: [f64; 2]) -> [f64; 2] { [m[0] * p[0] + m[1] * p[1] + m[4], m[2] * p[0] + m[3] * p[1] + m[5]] } -fn compose(o: &Affine, i: &Affine) -> Affine { - [ - o[0] * i[0] + o[1] * i[2], - o[0] * i[1] + o[1] * i[3], - o[2] * i[0] + o[3] * i[2], - o[2] * i[1] + o[3] * i[3], - o[0] * i[4] + o[1] * i[5] + o[4], - o[2] * i[4] + o[3] * i[5] + o[5], - ] -} - -fn place(raw: &Raw, m: &Affine, defs: &HashMap, depth: usize, out: &mut Sheet) { +/// Places plan geometry through the sheet mapping `m`, and the shared cells +/// it instances (each definition read through its own placement). +fn place(raw: &Raw, m: &Affine, defs: &HashMap>, styles: &Styles, depth: usize, out: &mut Sheet) { let map = |p: [f64; 2]| apply(m, p); for path in &raw.paths { out.paths.push(Path { @@ -533,8 +566,7 @@ fn place(raw: &Raw, m: &Affine, defs: &HashMap, depth: usize, out: } for inst in &raw.instances { let Some(def) = defs.get(&inst.name) else { continue }; - let local: Affine = [inst.m[0], inst.m[1], inst.m[2], inst.m[3], inst.origin[0], inst.origin[1]]; - place(def, &compose(m, &local), defs, depth + 1, out); + place(&read_run(def, styles, &inst.xf), m, defs, styles, depth + 1, out); } } @@ -627,11 +659,10 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option { let (_, graphics, header) = all.iter().find(|m| m.0.eq_ignore_ascii_case(name)).or_else(|| all.first())?; let non_model = non_model(&cfb); let nm = elements(&non_model); - let colors = nm - .iter() - .find(|e| kind(e) == 5 && e.len() >= 37 + 768) - .map(|e| (0..256).map(|i| [e[37 + i * 3], e[38 + i * 3], e[39 + i * 3]]).collect()) - .unwrap_or_else(|| vec![[255, 255, 255]; 256]); + // Colours 0-254 from byte 37, the background (255) opening the table. + let color_table = nm.iter().find(|e| kind(e) == 5).and_then(|e| dgn_color_table(e, 37)); + let table_colors = color_table.is_some(); + let colors = color_table.unwrap_or_else(|| DGN_DEFAULT_COLORS.to_vec()); let table = level_table(&nm); let levels = table.iter().map(|l| (l.0, l.2)).collect(); let hidden = table @@ -641,7 +672,7 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option { .collect(); let styles = Styles { colors, levels, hidden, extended: extended_colors(&cfb) }; // Shared cell definitions: a type-34 element and its components. - let mut defs = HashMap::new(); + let mut defs: HashMap> = HashMap::new(); let mut i = 0; while i < nm.len() { if kind(nm[i]) == 34 && !is_component(nm[i]) { @@ -650,17 +681,46 @@ pub fn model(bytes: &[u8], name: &str, hidden: &[String]) -> Option { while j < nm.len() && is_component(nm[j]) { j += 1; } - defs.insert(name, read_run(&nm[i + 1..j], &styles)); + defs.insert(name, nm[i + 1..j].to_vec()); i = j; } else { i += 1; } } - let raw = read_run(&elements(graphics), &styles); + let graphics = elements(graphics); + let raw = read_run(&graphics, &styles, &IDENTITY); let (per_unit, sub_uor) = units(header); - let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, 0.0, 0.0]; - let mut sheet = Sheet { sub_per_master: per_unit / sub_uor, ..Default::default() }; - place(&raw, &m, &defs, 0, &mut sheet); - sheet.rect = paths_bounds(&sheet.paths)?; + let origin = global_origin(header); + let m: Affine = [1.0 / per_unit, 0.0, 0.0, 1.0 / per_unit, -origin[0] / per_unit, -origin[1] / per_unit]; + let mut sheet = Sheet { sub_per_master: per_unit / sub_uor, table_colors, ..Default::default() }; + place(&raw, &m, &defs, &styles, 0, &mut sheet); + // The model's extent is its elements' stored ranges (text by its full + // box), as the reference sizes a model. + sheet.rect = match stored_range(&graphics) { + Some([x0, y0, x1, y1]) => { + let (lo, hi) = (apply(&m, [x0, y0]), apply(&m, [x1, y1])); + [lo[0], lo[1], hi[0], hi[1]] + } + None => paths_bounds(&sheet.paths)?, + }; Some(sheet) } + +/// The model's global origin (UORs; the design plane point drawn at the +/// model's 0,0). +fn global_origin(header: &[u8]) -> [f64; 2] { + let get = |at: usize| f64_at(header, at).filter(|v| v.is_finite()).unwrap_or(0.0); + [get(4212), get(4220)] +} + +/// The union of the top-level elements' ranges (UORs): each graphic element +/// stores its low corner at 56 and its size at 80, as 64-bit integers. +fn stored_range(graphics: &[&[u8]]) -> Option<[f64; 4]> { + let i64_at = |e: &[u8], at: usize| Some(i64::from_le_bytes(e.get(at..at + 8)?.try_into().ok()?) as f64); + let mut r = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; + for e in graphics.iter().filter(|e| e.len() >= 104 && !is_component(e)) { + let (Some(x), Some(y), Some(w), Some(h)) = (i64_at(e, 56), i64_at(e, 64), i64_at(e, 80), i64_at(e, 88)) else { continue }; + r = [r[0].min(x), r[1].min(y), r[2].max(x + w), r[3].max(y + h)]; + } + (r[0] <= r[2]).then_some(r) +} diff --git a/src/scene/model/underlay_vector/dwf.rs b/src/scene/model/underlay_vector/dwf.rs index 52e26b16d..e654c0721 100644 --- a/src/scene/model/underlay_vector/dwf.rs +++ b/src/scene/model/underlay_vector/dwf.rs @@ -180,7 +180,7 @@ pub fn sheet(bytes: &[u8], name: &str) -> Option { xps::read(data, w2x.map(|v| v.as_slice()), entry, &family)? }; let rect = view.or_else(|| paths_bounds(&paths))?; - Some(Sheet { rect, paths, texts, sub_per_master: 1.0 }) + Some(Sheet { rect, paths, texts, sub_per_master: 1.0, table_colors: false, element_colors: Vec::new() }) } /// An XPS obfuscated font (.odttf): its first 32 bytes are XORed with the diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs index 09809956f..ceda1d088 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -93,10 +93,11 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin UnderlayType::Dgn => dgn8::model(&bytes, item, hidden).unwrap_or_default(), UnderlayType::Pdf => return None, }; - // A DGN model's extent includes its text: each text's box runs its - // length along the baseline and 1.5 × its height above it (as the - // reference measures a model). A DWF sheet keeps its plotted view. - if kind == UnderlayType::Dgn { + // A V7 model's extent includes its text: each text's box runs its + // length along the baseline and its height above it (as the + // reference measures a model). A V8 model has its stored element + // ranges; a DWF sheet keeps its plotted view. + if kind == UnderlayType::Dgn && dgn7::is_v7(&bytes) { let mut r = sheet.rect; for t in &sheet.texts { let (strokes, _) = crate::scene::text::lff::tessellate_text_ex( @@ -110,13 +111,14 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin ); let length = strokes.iter().flatten().map(|p| p[0] as f64).fold(0.0, f64::max); let (c, s) = (t.rotation.cos(), t.rotation.sin()); - for [x, y] in [[0.0, 0.0], [length, 0.0], [length, 1.5 * t.height], [0.0, 1.5 * t.height]] { + for [x, y] in [[0.0, 0.0], [length, 0.0], [length, t.height], [0.0, t.height]] { let p = [t.origin[0] + x * c - y * s, t.origin[1] + x * s + y * c]; r = [r[0].min(p[0]), r[1].min(p[1]), r[2].max(p[0]), r[3].max(p[1])]; } } sheet.rect = r; } + sheet.element_colors = element_colors(&sheet); outline_texts(&mut sheet); if !hidden.is_empty() { sheet.rect = sheet_without(kind, path, item, &[])?.rect; @@ -128,6 +130,45 @@ pub fn sheet_without(kind: UnderlayType, path: &str, item: &str, hidden: &[Strin value } +/// The colour of each path and text of the sheet (a path's fill before +/// its stroke). +fn element_colors(sheet: &Sheet) -> Vec<[u8; 3]> { + sheet + .paths + .iter() + .filter_map(|p| p.fill.or(p.stroke.map(|s| s.0))) + .chain(sheet.texts.iter().map(|t| t.color)) + .collect() +} + +/// The lightness a DGN model's contrast pulls its colours towards, in +/// thousandths. The reference takes it from the model's own colours as +/// shown (over `backdrop`, faded by `fade` into the background): about +/// 0.788 × their mean lightness, each element counted once (0.788 for an +/// all-white model, 0.416 for mid grey, 0.09 for dark red). +pub fn dgn_contrast_pivot( + path: &str, + item: &str, + hidden: &[String], + backdrop: Backdrop, + fade: Option<([u8; 3], u8)>, +) -> Option { + let sheet = sheet_without(UnderlayType::Dgn, path, item, hidden)?; + if sheet.element_colors.is_empty() { + return None; + } + let lightness = |c: [u8; 3]| { + let c = if sheet.table_colors { model::dgn_table_color(c, backdrop.max_channel, backdrop.light) } else { c }; + let c = match fade { + Some((bg, f)) => [0, 1, 2].map(|i| c[i] as f64 + (bg[i] as f64 - c[i] as f64) * f.min(100) as f64 / 100.0), + None => c.map(|v| v as f64), + }; + (c[0].max(c[1]).max(c[2]) + c[0].min(c[1]).min(c[2])) / 510.0 + }; + let mean = sheet.element_colors.iter().map(|&c| lightness(c)).sum::() / sheet.element_colors.len() as f64; + Some((0.788 * mean * 1000.0).round() as u16) +} + /// Text as strokes (and fills for outline fonts), in the text's colour. fn outline_texts(sheet: &mut Sheet) { for t in std::mem::take(&mut sheet.texts) { @@ -165,21 +206,64 @@ fn outline_texts(sheet: &mut Sheet) { /// Longest side of a sheet's full-size display raster, pixels. pub const RASTER_SIDE: f64 = 3072.0; +/// The background a sheet is shown over, as far as its colours depend on +/// it: the largest channel (in the steps a DGN colour table changes at) and +/// whether the background is light. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct Backdrop { + pub max_channel: u8, + pub light: bool, +} + +impl Backdrop { + pub fn of(rgb: [f32; 3]) -> Self { + let max = rgb[0].max(rgb[1]).max(rgb[2]); + let min = rgb[0].min(rgb[1]).min(rgb[2]); + let top = (max.clamp(0.0, 1.0) * 255.0).round() as u8; + Backdrop { + max_channel: match top { + 0..=29 => 0, + 30..=31 => 30, + _ => 32, + }, + // Light means an HSL lightness of one half or more. + light: (max + min) / 2.0 >= 0.5, + } + } +} + /// The sheet drawn on a transparent background with `side` pixels on its /// longest side (at most [`RASTER_SIDE`]), straight alpha, rows from the -/// top, memoised per size. -pub fn display_raster(kind: UnderlayType, path: &str, item: &str, hidden: &[String], side: f64) -> Option> { +/// top, memoised per size and backdrop. +pub fn display_raster( + kind: UnderlayType, + path: &str, + item: &str, + hidden: &[String], + side: f64, + backdrop: Backdrop, +) -> Option> { let side = side.clamp(16.0, RASTER_SIDE).round(); - let key = (path.to_string(), format!("{}@{side}", layered_item(item, hidden))); + let key = ( + path.to_string(), + format!("{}@{side}~{}{}", layered_item(item, hidden), backdrop.max_channel, backdrop.light), + ); if let Some(hit) = raster_cache().lock().unwrap_or_else(|e| e.into_inner()).get(&key) { return hit.clone(); } - let value = sheet_without(kind, path, item, hidden).and_then(|s| rasterize(&s, side)).map(Arc::new); + let value = sheet_without(kind, path, item, hidden).and_then(|s| rasterize(&s, side, backdrop)).map(Arc::new); raster_cache().lock().unwrap_or_else(|e| e.into_inner()).insert(key, value.clone()); value } -fn rasterize(sheet: &Sheet, side: f64) -> Option { +fn rasterize(sheet: &Sheet, side: f64, backdrop: Backdrop) -> Option { + let shown = |c: [u8; 3]| { + if sheet.table_colors { + model::dgn_table_color(c, backdrop.max_channel, backdrop.light) + } else { + c + } + }; let [x0, y0, x1, y1] = sheet.rect; let (w, h) = (x1 - x0, y1 - y0); if !(w > 0.0 && h > 0.0) { @@ -188,7 +272,10 @@ fn rasterize(sheet: &Sheet, side: f64) -> Option { let scale = side / w.max(h); let (pw, ph) = ((w * scale).ceil().max(1.0) as u32, (h * scale).ceil().max(1.0) as u32); let mut pixmap = tiny_skia::Pixmap::new(pw, ph)?; - let to_px = |p: [f64; 2]| (((p[0] - x0) * scale) as f32, ((y1 - p[1]) * scale) as f32); + // The rectangle's edges fall on the centres of the outer pixels, so a + // line along the edge of the extent (every extent has some) is drawn. + let (sx, sy) = ((pw.max(2) - 1) as f64 / w, (ph.max(2) - 1) as f64 / h); + let to_px = |p: [f64; 2]| (((p[0] - x0) * sx + 0.5) as f32, ((y1 - p[1]) * sy + 0.5) as f32); for path in &sheet.paths { let mut pb = tiny_skia::PathBuilder::new(); for sp in &path.subpaths { @@ -212,14 +299,14 @@ fn rasterize(sheet: &Sheet, side: f64) -> Option { } } let Some(p) = pb.finish() else { continue }; - if let Some(c) = path.fill { + if let Some(c) = path.fill.map(shown) { let mut paint = tiny_skia::Paint::default(); paint.set_color_rgba8(c[0], c[1], c[2], 255); // Fills that share an edge (triangle strips) would show a seam. paint.anti_alias = false; pixmap.fill_path(&p, &paint, tiny_skia::FillRule::Winding, tiny_skia::Transform::identity(), None); } - if let Some((c, width)) = path.stroke { + if let Some((c, width)) = path.stroke.map(|(c, w)| (shown(c), w)) { let mut paint = tiny_skia::Paint::default(); paint.set_color_rgba8(c[0], c[1], c[2], 255); // A negative width is in pixels (DGN line weights). diff --git a/src/scene/model/underlay_vector/model.rs b/src/scene/model/underlay_vector/model.rs index 90e7dd28a..a4bcdae1a 100644 --- a/src/scene/model/underlay_vector/model.rs +++ b/src/scene/model/underlay_vector/model.rs @@ -39,6 +39,47 @@ pub struct Sheet { pub texts: Vec, /// DGN: sub units per master unit (the scale a Sub conversion offers). pub sub_per_master: f64, + /// DGN: the colours come from the file's own colour table, which the + /// reference shows adapted to the background ([`dgn_table_color`]). + pub table_colors: bool, + /// The colour of each path and text the sheet draws, one per element. + pub element_colors: Vec<[u8; 3]>, +} + +/// How a colour from a DGN file's colour table shows over a background +/// (its largest channel and whether it is light), as the reference draws +/// it: a near-white colour (61 or less from white, channels summed) is white, +/// or black on a light background; over a background with a channel of 32 or +/// more, colours whose largest channel is 76, 127, 153 or 204 drop a shade +/// (to 38, 76, 127 or 165, hue kept) and the greys 91, 132, 173 and 214 drop +/// to 45, 91, 137 and 183 — grey 51 to black from a channel of 30. +pub fn dgn_table_color(rgb: [u8; 3], bg_max: u8, light: bool) -> [u8; 3] { + if rgb.iter().map(|&c| 255 - c as u32).sum::() <= 61 { + return if light { [0; 3] } else { [255; 3] }; + } + if rgb[0] == rgb[1] && rgb[1] == rgb[2] { + let v = match rgb[0] { + 51 if bg_max >= 30 => 0, + 91 if bg_max >= 32 => 45, + 132 if bg_max >= 32 => 91, + 173 if bg_max >= 32 => 137, + 214 if bg_max >= 32 => 183, + v => v, + }; + return [v; 3]; + } + let top = rgb.into_iter().max().unwrap_or(0); + let to = match top { + 76 => 38, + 127 => 76, + 153 => 127, + 204 => 165, + _ => return rgb, + }; + if bg_max < 32 { + return rgb; + } + rgb.map(|c| ((c as u32 * to + top as u32 / 2) / top as u32) as u8) } /// Builds paths from pen moves. @@ -131,7 +172,8 @@ pub fn arc_cubics( out } -/// Bounds of the paths, when there are any. +/// Tight bounds of the paths (curves by their extreme points, not their +/// control points), when there are any. pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { let mut b = [f64::INFINITY, f64::INFINITY, f64::NEG_INFINITY, f64::NEG_INFINITY]; let mut add = |p: [f64; 2]| { @@ -147,9 +189,10 @@ pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { } Segment::Cubic(a, c1, c2, c) => { add(*a); - add(*c1); - add(*c2); add(*c); + for t in cubic_extrema(*a, *c1, *c2, *c) { + add(cubic_at(*a, *c1, *c2, *c, t)); + } } } } @@ -158,6 +201,85 @@ pub fn paths_bounds(paths: &[Path]) -> Option<[f64; 4]> { (b[0] <= b[2]).then_some(b) } +fn cubic_at(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2], t: f64) -> [f64; 2] { + let u = 1.0 - t; + let (a, b, c, d) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + [a * p0[0] + b * p1[0] + c * p2[0] + d * p3[0], a * p0[1] + b * p1[1] + c * p2[1] + d * p3[1]] +} + +/// Parameters in (0, 1) where a cubic turns in x or in y. +fn cubic_extrema(p0: [f64; 2], p1: [f64; 2], p2: [f64; 2], p3: [f64; 2]) -> Vec { + let mut out = Vec::new(); + for i in 0..2 { + // The derivative's quadratic a·t² + b·t + c. + let a = 3.0 * (-p0[i] + 3.0 * p1[i] - 3.0 * p2[i] + p3[i]); + let b = 6.0 * (p0[i] - 2.0 * p1[i] + p2[i]); + let c = 3.0 * (p1[i] - p0[i]); + if a.abs() < 1e-12 { + if b.abs() > 1e-12 { + out.push(-c / b); + } + continue; + } + let disc = b * b - 4.0 * a * c; + if disc >= 0.0 { + let r = disc.sqrt(); + out.push((-b + r) / (2.0 * a)); + out.push((-b - r) / (2.0 * a)); + } + } + out.retain(|t| *t > 0.0 && *t < 1.0); + out +} + +/// The colour table a DGN file without one is drawn with (index 255, the +/// background entry, black). +pub const DGN_DEFAULT_COLORS: [[u8; 3]; 256] = [ + [255, 255, 255], [0, 0, 255], [0, 255, 0], [255, 0, 0], [255, 255, 0], [255, 0, 255], [255, 127, 0], [0, 255, 255], + [64, 64, 64], [192, 192, 192], [254, 0, 96], [160, 224, 0], [0, 254, 160], [128, 0, 160], [176, 176, 176], [0, 240, 240], + [240, 240, 240], [0, 0, 240], [0, 240, 0], [240, 0, 0], [240, 240, 0], [240, 0, 240], [240, 122, 0], [0, 240, 240], + [240, 240, 240], [0, 0, 240], [0, 240, 0], [240, 0, 0], [240, 240, 0], [240, 0, 240], [240, 122, 0], [0, 225, 225], + [225, 225, 225], [0, 0, 225], [0, 225, 0], [225, 0, 0], [225, 225, 0], [225, 0, 225], [225, 117, 0], [0, 225, 225], + [225, 225, 225], [0, 0, 225], [0, 225, 0], [225, 0, 0], [225, 225, 0], [225, 0, 225], [225, 117, 0], [0, 210, 210], + [210, 210, 210], [0, 0, 210], [0, 210, 0], [210, 0, 0], [210, 210, 0], [210, 0, 210], [210, 112, 0], [0, 210, 210], + [210, 210, 210], [0, 0, 210], [0, 210, 0], [210, 0, 0], [210, 210, 0], [210, 0, 210], [210, 112, 0], [0, 195, 195], + [195, 195, 195], [0, 0, 195], [0, 195, 0], [195, 0, 0], [195, 195, 0], [195, 0, 195], [195, 107, 0], [0, 195, 195], + [195, 195, 195], [0, 0, 195], [0, 195, 0], [195, 0, 0], [195, 195, 0], [195, 0, 195], [195, 107, 0], [0, 180, 180], + [180, 180, 180], [0, 0, 180], [0, 180, 0], [180, 0, 0], [180, 180, 0], [180, 0, 180], [180, 102, 0], [0, 180, 180], + [180, 180, 180], [0, 0, 180], [0, 180, 0], [180, 0, 0], [180, 180, 0], [180, 0, 180], [180, 102, 0], [0, 165, 165], + [165, 165, 165], [0, 0, 165], [0, 165, 0], [165, 0, 0], [165, 165, 0], [165, 0, 165], [165, 97, 0], [0, 165, 165], + [165, 165, 165], [0, 0, 165], [0, 165, 0], [165, 0, 0], [165, 165, 0], [165, 0, 165], [165, 97, 0], [0, 150, 150], + [150, 150, 150], [0, 0, 150], [0, 150, 0], [150, 0, 0], [150, 150, 0], [150, 0, 150], [150, 92, 0], [0, 150, 150], + [150, 150, 150], [0, 0, 150], [0, 150, 0], [150, 0, 0], [150, 150, 0], [150, 0, 150], [150, 92, 0], [0, 135, 135], + [135, 135, 135], [0, 0, 135], [0, 135, 0], [135, 0, 0], [135, 135, 0], [135, 0, 135], [135, 87, 0], [0, 135, 135], + [135, 135, 135], [0, 0, 135], [0, 135, 0], [135, 0, 0], [135, 135, 0], [135, 0, 135], [135, 87, 0], [0, 120, 120], + [120, 120, 120], [0, 0, 120], [0, 120, 0], [120, 0, 0], [120, 120, 0], [120, 0, 120], [120, 82, 0], [0, 120, 120], + [120, 120, 120], [0, 0, 120], [0, 120, 0], [120, 0, 0], [120, 120, 0], [120, 0, 120], [120, 82, 0], [0, 105, 105], + [105, 105, 105], [0, 0, 105], [0, 105, 0], [105, 0, 0], [105, 105, 0], [105, 0, 105], [105, 77, 0], [0, 105, 105], + [105, 105, 105], [0, 0, 105], [0, 105, 0], [105, 0, 0], [105, 105, 0], [105, 0, 105], [105, 77, 0], [0, 90, 90], + [90, 90, 90], [0, 0, 90], [0, 90, 0], [90, 0, 0], [90, 90, 0], [90, 0, 90], [90, 72, 0], [0, 90, 90], + [90, 90, 90], [0, 0, 90], [0, 90, 0], [90, 0, 0], [90, 90, 0], [90, 0, 90], [90, 72, 0], [0, 75, 75], + [75, 75, 75], [0, 0, 75], [0, 75, 0], [75, 0, 0], [75, 75, 0], [75, 0, 75], [75, 67, 0], [0, 75, 75], + [75, 75, 75], [0, 0, 75], [0, 75, 0], [75, 0, 0], [75, 75, 0], [75, 0, 75], [75, 67, 0], [0, 60, 60], + [60, 60, 60], [0, 0, 60], [0, 60, 0], [60, 0, 0], [60, 60, 0], [60, 0, 60], [60, 62, 0], [0, 60, 60], + [60, 60, 60], [0, 0, 60], [0, 60, 0], [60, 0, 0], [60, 60, 0], [60, 0, 60], [60, 62, 0], [0, 45, 45], + [45, 45, 45], [0, 0, 45], [0, 45, 0], [45, 0, 0], [45, 45, 0], [45, 0, 45], [45, 57, 0], [0, 45, 45], + [45, 45, 45], [0, 0, 45], [0, 45, 0], [45, 0, 0], [45, 45, 0], [45, 0, 45], [45, 57, 0], [0, 30, 30], + [30, 30, 30], [0, 0, 30], [0, 30, 0], [30, 0, 0], [30, 30, 0], [30, 0, 30], [30, 52, 0], [0, 30, 30], + [30, 30, 30], [0, 0, 30], [0, 30, 0], [30, 0, 0], [30, 30, 0], [30, 0, 30], [192, 192, 192], [0, 0, 0], +]; + +/// A colour table element's 256 colours: entries 0-254 from `first`, and +/// entry 255 (the background) from the three bytes that open the table at +/// `first - 3`. +pub fn dgn_color_table(e: &[u8], first: usize) -> Option> { + let bg = e.get(first - 3..first)?; + let body = e.get(first..first + 255 * 3)?; + let mut out: Vec<[u8; 3]> = body.chunks_exact(3).map(|c| [c[0], c[1], c[2]]).collect(); + out.push([bg[0], bg[1], bg[2]]); + Some(out) +} + /// A line of text the host draws with its own font. #[derive(Clone, Debug, PartialEq)] pub struct Text { diff --git a/src/ui/window/pdf_dialogs.rs b/src/ui/window/pdf_dialogs.rs index cee3e71f4..8946be613 100644 --- a/src/ui/window/pdf_dialogs.rs +++ b/src/ui/window/pdf_dialogs.rs @@ -146,7 +146,8 @@ pub fn item_thumbs(kind: codec::entities::UnderlayType, path: &str) -> Vec Date: Tue, 29 Sep 2026 12:18:48 +0300 Subject: [PATCH 04/17] fix(gpu): a crash the device caused still costs the backend, however late MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1519 stopped the sentinel blaming a backend for a run that had already drawn for SENTINEL_PROOF_DELAY, on the reasoning that a backend which kept the application alive that long had proved itself. That reasoning does not hold for the failure this repository sees most on integrated hardware. Measured here on an Intel Iris Xe: with `--backend dx12` a large drawing aborts the process 110 s in, long past the proof delay, so the sentinel carried `survived=1` and the next launch went straight back to dx12 and crashed again. The same binary on `--backend vulkan` opens the same drawings with no out-of-memory report and no uncaptured errors at all, so the backend really is the difference and moving off it really is the cure. D3D12 is fine while the interface draws and only fails once the whole scene uploads, which is why the clock is the wrong question. The crash report from #1521 answers the right one. It records the backend that was live and what the panic was, and it is named for the process that wrote it, so it joins exactly to the `pid` the sentinel already stores — no guessing from whichever report is newest, and no new state to keep. A sentinel marked survived is now excused only when that run left no report, or left one that has nothing to do with the graphics device. The test that matters is the pair, and both were run end to end: killed from Task Manager after 40 s, no report -> dx12 kept device crash after 110 s, report names wgpu -> dx12 skipped, Vulkan `is_device_failure` is deliberately narrow: an indexing bug in drawing code that happens to run on a frame must not cost the user a working backend. Co-Authored-By: Claude Opus 5 --- src/gpu_backend.rs | 108 +++++++++++++++++++++++++++++++++++++++++---- src/sys.rs | 96 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 9 deletions(-) diff --git a/src/gpu_backend.rs b/src/gpu_backend.rs index a11f211e8..ad16e13ab 100644 --- a/src/gpu_backend.rs +++ b/src/gpu_backend.rs @@ -319,17 +319,46 @@ pub fn crashed_backends() -> Vec { return Vec::new(); }; let text = std::fs::read_to_string(p).unwrap_or_default(); + let named = || -> Vec { + text.lines() + .find_map(|l| l.strip_prefix("backend=")) + .unwrap_or("") + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_string) + .collect() + }; if text.lines().any(|l| l.trim() == SURVIVED_MARKER) { - return Vec::new(); + // Surviving the proof delay is good evidence, not proof. A backend + // can carry the interface for minutes and still be unable to hold a + // large drawing: D3D12 on an integrated GPU runs out of room only + // once the whole scene uploads, well past the mark, and a run killed + // then used to be excused. The crash report says what actually + // happened, so ask it before excusing anything. + return match crash_report_blames_the_device(&text) { + true => named(), + false => Vec::new(), + }; } - text.lines() - .find_map(|l| l.strip_prefix("backend=")) - .unwrap_or("") - .split(',') - .map(str::trim) - .filter(|s| !s.is_empty()) - .map(str::to_string) - .collect() + named() +} + +/// Whether the run this sentinel belongs to died inside the graphics device. +/// +/// The sentinel records the process id it armed for, and crash reports are +/// named for the process that wrote them, so the two join exactly: this asks +/// about that run and not whichever report happens to be newest. +fn crash_report_blames_the_device(sentinel: &str) -> bool { + let Some(pid) = sentinel + .lines() + .find_map(|line| line.strip_prefix("pid=")) + .and_then(|value| value.trim().parse::().ok()) + else { + return false; + }; + crate::sys::crash_log::report_for_pid(pid) + .is_some_and(|report| crate::sys::crash_log::is_device_failure(&report)) } /// Record that the armed backend has kept the app alive long enough to be @@ -1086,6 +1115,67 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Surviving the proof delay excuses a kill, but not a crash the + /// graphics device itself caused. D3D12 on an integrated GPU carries the + /// interface for minutes and only fails once a large drawing uploads, so + /// a run that died then has to keep costing the backend. + #[test] + fn a_device_crash_still_blames_the_backend_however_late_it_came() { + let _guard = SENTINEL_TESTS.lock().unwrap_or_else(|e| e.into_inner()); + let dir = std::env::temp_dir().join(format!("ocs-gpu-late-{}", std::process::id())); + let _ = std::fs::create_dir_all(&dir); + // SAFETY: single-threaded test mutating a process-local test hook. + unsafe { std::env::set_var(SENTINEL_DIR_ENV, &dir) }; + + let reports = crate::sys::crash_log::directory().expect("a config directory"); + let _ = std::fs::create_dir_all(&reports); + let report = reports.join(format!("crash-1-{}.log", std::process::id())); + + arm_sentinel("dx12"); + mark_sentinel_survived(); + let _ = std::fs::remove_file(&report); + assert!( + crashed_backends().is_empty(), + "with no crash report, a survived run blames nobody" + ); + + // The run left a report, and it is the device's own failure. + std::fs::write( + &report, + crate::sys::crash_log::report_from( + "Error in Buffer::get_mapped_range: Validation Error", + "wgpu-29.0.4/src/backend/wgpu_core.rs:2253:18", + 1, + ), + ) + .unwrap(); + assert_eq!( + crashed_backends(), + vec!["dx12".to_string()], + "a device failure must cost the backend even past the proof delay" + ); + + // A crash that has nothing to do with graphics still costs nothing. + std::fs::write( + &report, + crate::sys::crash_log::report_from( + "index out of bounds: the len is 3 but the index is 7", + "src/app/commands/draw.rs:120:5", + 1, + ), + ) + .unwrap(); + assert!( + crashed_backends().is_empty(), + "an unrelated panic must not cost the user a working backend" + ); + + let _ = std::fs::remove_file(&report); + disarm_sentinel(); + unsafe { std::env::remove_var(SENTINEL_DIR_ENV) }; + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn force_hooks_drive_probes_without_spawning() { // SAFETY: single-threaded test mutating process-local test hooks. diff --git a/src/sys.rs b/src/sys.rs index ff3b2e40f..52d27561e 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -429,6 +429,62 @@ pub mod crash_log { crate::config::config_dir().map(|path| path.join("crash_logs")) } + /// The report a given process left behind, if it left one. + /// + /// Reports are named for the process that wrote them, so a caller holding + /// a process id from somewhere else — the GPU crash sentinel does — can + /// ask what killed that exact run instead of guessing from the newest + /// file, which on a busy machine may belong to a different one. + pub fn report_for_pid(pid: u32) -> Option { + let directory = directory()?; + let suffix = format!("-{pid}.log"); + let entry = std::fs::read_dir(directory) + .ok()? + .flatten() + .find(|entry| { + entry + .file_name() + .to_string_lossy() + .ends_with(suffix.as_str()) + })?; + std::fs::read_to_string(entry.path()).ok() + } + + /// Whether a report describes the graphics device giving up. + /// + /// Deliberately narrow. Anything that reaches wgpu — an exhausted device, + /// a lost one, a buffer that was never created — is the backend's + /// business; a panic in the drawing code that happens to run on a frame + /// is not, and must not cost the user a working backend. + pub fn is_device_failure(report: &str) -> bool { + let mut panic_line = ""; + let mut at_line = ""; + for line in report.lines() { + if let Some(rest) = line.strip_prefix("panic: ") { + panic_line = rest; + } else if let Some(rest) = line.strip_prefix("at: ") { + at_line = rest; + } + } + // The message wgpu panics with names the call, and the location names + // wgpu itself; either alone is enough, since a stripped build can + // leave the message terse. + let haystack = format!("{panic_line} {at_line}").to_ascii_lowercase(); + ["wgpu", "out of memory", "buffer", "surface", "device lost"] + .iter() + .any(|needle| haystack.contains(needle)) + } + + /// The backend a report says was live, as written by [`report_from`]. + pub fn backend_in_report(report: &str) -> Option { + report + .lines() + .find_map(|line| line.strip_prefix("gpu backend: ")) + .map(str::trim) + .filter(|backend| !backend.is_empty() && *backend != "(default)") + .map(str::to_string) + } + /// Chain a report writer onto the current panic hook. /// /// Call once, early: a panic before this runs is still silent. @@ -562,6 +618,46 @@ pub mod crash_log { ); } + /// Only the device's own failures may cost a backend. A panic in + /// drawing code that merely happened to run on a frame must not. + #[test] + fn a_device_failure_is_told_apart_from_an_ordinary_panic() { + let device = super::report_from( + "Error in Buffer::get_mapped_range: Validation Error", + "wgpu-29.0.4/src/backend/wgpu_core.rs:2253:18", + 1, + ); + assert!(super::is_device_failure(&device), "{device}"); + + let oom = super::report_from("Out of Memory", "src/scene/pipeline/mod.rs:10:1", 1); + assert!(super::is_device_failure(&oom), "{oom}"); + + let ordinary = super::report_from( + "index out of bounds: the len is 3 but the index is 7", + "src/app/commands/draw.rs:120:5", + 1, + ); + assert!( + !super::is_device_failure(&ordinary), + "an indexing bug is not the graphics device's fault: +{ordinary}" + ); + } + + /// The backend line is what the sentinel joins on, so it has to come + /// back out — and "(default)" names nothing to blame. + #[test] + fn the_backend_is_read_back_out_of_a_report() { + // SAFETY: single-threaded test mutating a process-local variable. + unsafe { std::env::set_var("WGPU_BACKEND", "dx12") }; + let named = super::report_from("boom", "x.rs:1:1", 0); + assert_eq!(super::backend_in_report(&named).as_deref(), Some("dx12")); + + unsafe { std::env::remove_var("WGPU_BACKEND") }; + let unnamed = super::report_from("boom", "x.rs:1:1", 0); + assert_eq!(super::backend_in_report(&unnamed), None); + } + /// A panic with no message still has to produce a filed report rather /// than nothing at all. #[test] From be023142def1d34b447f62cd82d398991c40bfbe Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:20:27 +0300 Subject: [PATCH 05/17] DWF/DGN underlays: anti-alias slanted and curved hairlines One-pixel hairlines keep horizontal and vertical pieces solid, but slanted pieces and curves are anti-aliased: their pixel steps broke up into dashes once the raster was scaled to the screen. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/underlay_vector/mod.rs | 74 ++++++++++++++++++++++++-- 1 file changed, 70 insertions(+), 4 deletions(-) diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs index ceda1d088..7e68a881d 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -311,16 +311,31 @@ fn rasterize(sheet: &Sheet, side: f64, backdrop: Backdrop) -> Option { paint.set_color_rgba8(c[0], c[1], c[2], 255); // A negative width is in pixels (DGN line weights). let px = if width < 0.0 { (-width) as f32 } else { ((width * scale) as f32).max(1.0) }; - // Hairlines are drawn solid, one pixel wide, as the reference - // draws them; anti-aliasing would spread them to half intensity. - paint.anti_alias = px > 1.5; let stroke = tiny_skia::Stroke { width: px, line_cap: tiny_skia::LineCap::Round, line_join: tiny_skia::LineJoin::Round, ..Default::default() }; - pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + if px > 1.5 { + paint.anti_alias = true; + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } else { + // Hairlines: horizontal and vertical pieces solid, one pixel + // wide, as the reference draws them (anti-aliasing would + // spread them to half intensity); slanted pieces and curves + // anti-aliased, since their pixel steps break up once the + // raster is scaled to the screen. + let (straight, curved) = hairline_parts(path, &to_px); + paint.anti_alias = false; + if let Some(p) = straight { + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } + paint.anti_alias = true; + if let Some(p) = curved { + pixmap.stroke_path(&p, &paint, &stroke, tiny_skia::Transform::identity(), None); + } + } } } let mut pixels = pixmap.take(); @@ -335,6 +350,57 @@ fn rasterize(sheet: &Sheet, side: f64, backdrop: Backdrop) -> Option { Some(PdfPage { pixels: Arc::new(pixels), width: pw, height: ph, dpi: 0.0 }) } +/// A path's horizontal and vertical segments, and its slanted and curved +/// ones, each as a pixel path. +fn hairline_parts( + path: &Path, + to_px: &dyn Fn([f64; 2]) -> (f32, f32), +) -> (Option, Option) { + let (mut straight, mut curved) = (tiny_skia::PathBuilder::new(), tiny_skia::PathBuilder::new()); + // Consecutive segments of one kind stay one run, so their joins are + // drawn once. + let (mut straight_at, mut curved_at) = (None, None); + for sp in &path.subpaths { + for seg in &sp.segments { + let a = to_px(seg.start()); + match seg { + Segment::Line(_, b) if along_axis(a, to_px(*b)) => { + let b = to_px(*b); + if straight_at != Some(a) { + straight.move_to(a.0, a.1); + } + straight.line_to(b.0, b.1); + straight_at = Some(b); + } + Segment::Line(_, b) => { + let b = to_px(*b); + if curved_at != Some(a) { + curved.move_to(a.0, a.1); + } + curved.line_to(b.0, b.1); + curved_at = Some(b); + } + Segment::Cubic(_, c1, c2, b) => { + let (c1, c2, b) = (to_px(*c1), to_px(*c2), to_px(*b)); + if curved_at != Some(a) { + curved.move_to(a.0, a.1); + } + curved.cubic_to(c1.0, c1.1, c2.0, c2.1, b.0, b.1); + curved_at = Some(b); + } + } + } + straight_at = None; + curved_at = None; + } + (straight.finish(), curved.finish()) +} + +/// A pixel segment that runs horizontally or vertically. +fn along_axis(a: (f32, f32), b: (f32, f32)) -> bool { + (b.0 - a.0).abs() < 0.01 || (b.1 - a.1).abs() < 0.01 +} + fn vector_cache() -> &'static Mutex>>> { static CACHE: OnceLock>>>> = OnceLock::new(); CACHE.get_or_init(Default::default) From 507a6ab43c11d46fca4486b65da03a846de8e5c6 Mon Sep 17 00:00:00 2001 From: ramox81 <184937705+ramox81@users.noreply.github.com> Date: Tue, 29 Sep 2026 12:43:20 +0300 Subject: [PATCH 06/17] DWF/DGN underlays: draw content as vector wires A DWF sheet or DGN model is drawn as world-space wires (one line wire per colour and width, one fill wire per colour) instead of a raster rescaled to the view, so it stays sharp while zooming instead of showing pixels until the raster is made again. Colours go through the colour table mapping, contrast, fade and monochrome; clip boundaries clip the wires. The wires are drawn and plotted but neither picked nor snapped; the underlay's own snap geometry still serves object snaps. Co-Authored-By: Claude Opus 5.5 --- src/scene/convert/tess.rs | 4 + src/scene/model/image_model.rs | 128 +++++++-------- src/scene/model/pdf_raster.rs | 62 +++---- src/scene/model/underlay_vector/mod.rs | 3 + src/scene/model/underlay_vector/wires.rs | 197 +++++++++++++++++++++++ src/scene/model/wire_model.rs | 10 +- src/scene/pick/interaction_index.rs | 4 + src/snap.rs | 2 +- 8 files changed, 310 insertions(+), 100 deletions(-) create mode 100644 src/scene/model/underlay_vector/wires.rs diff --git a/src/scene/convert/tess.rs b/src/scene/convert/tess.rs index 2247fe1c7..7bed040dc 100644 --- a/src/scene/convert/tess.rs +++ b/src/scene/convert/tess.rs @@ -1321,6 +1321,10 @@ fn tessellate_entity_inner( set_wire_aabb(&mut wire, WireModel::UNBOUNDED_AABB); bases.push(wire); } + // DWF / DGN content: vector wires, sharp at every zoom. + if let Some(def) = crate::entities::underlay::definition(underlay, document) { + bases.extend(crate::scene::model::underlay_vector::display_wires(underlay, def, &h.value().to_string(), bg_color)); + } } // A hidden mask frame remains selectable and appears while selected, but diff --git a/src/scene/model/image_model.rs b/src/scene/model/image_model.rs index 96b994f8e..d486f146a 100644 --- a/src/scene/model/image_model.rs +++ b/src/scene/model/image_model.rs @@ -99,6 +99,59 @@ pub struct ImageModel { pub use_alpha: bool, } +/// The colour adjustments an underlay's content is drawn with over +/// the background: contrast, monochrome, the background adjustment and (DGN) +/// the fade. +pub(crate) fn underlay_adjust( + u: &codec::entities::Underlay, + def: &codec::entities::UnderlayDefinition, + background: [f32; 4], +) -> super::pdf_raster::PageAdjust { + use codec::entities::{UnderlayDisplayFlags, UnderlayType}; + use super::pdf_raster::PageAdjust; + let page = crate::entities::underlay::page_of(def); + // Dark means an HSL lightness under one half: pure blue counts as + // light, (0, 128, 0) as dark. + let bg_max = background[0].max(background[1]).max(background[2]); + let bg_min = background[0].min(background[1]).min(background[2]); + let bg_lum = (bg_max + bg_min) / 2.0; + let dgn = def.underlay_type == UnderlayType::Dgn; + // A DGN model is faded into the background before its contrast is + // applied (the reference's order), so it is drawn opaque. + let bg_rgb = [0, 1, 2].map(|i| (background[i].clamp(0.0, 1.0) * 255.0).round() as u8); + let fade_before_contrast = (dgn && u.fade > 0).then_some((bg_rgb, u.fade.min(100))); + PageAdjust { + contrast: u.contrast.min(100), + // Measured: PDF 0.5; DWF 0.243, or 0.73 once its colours are + // turned over for a dark background; a DGN model's from its + // own colours, over any background. + contrast_pivot: match def.underlay_type { + UnderlayType::Pdf => 500, + UnderlayType::Dwf + if u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && bg_lum < 0.5 => + { + 730 + } + UnderlayType::Dwf => 243, + UnderlayType::Dgn => super::underlay_vector::dgn_contrast_pivot( + &def.file_path, + page, + &super::pdf_layers::hidden_layers(u), + super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]), + fade_before_contrast, + ) + .unwrap_or(404), + }, + monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME), + // A DGN model's colours follow the background through its + // colour table, whatever this setting. + adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) + && !dgn, + dark_background: bg_lum < 0.5, + fade_before_contrast, + } +} + impl ImageModel { /// Build an ImageModel from a DXF RasterImage entity. /// Returns `None` if the image file cannot be opened or decoded. @@ -197,7 +250,7 @@ impl ImageModel { world_per_pixel: Option, ) -> Option { use codec::entities::{UnderlayDisplayFlags, UnderlayType}; - use super::pdf_raster::{self, PageAdjust}; + use super::pdf_raster; if !u.flags.contains(UnderlayDisplayFlags::ON) { return None; } @@ -229,74 +282,11 @@ impl ImageModel { }; (source, raster) } - kind => { - let hidden = super::pdf_layers::hidden_layers(u); - let backdrop = super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]); - // The adjusted-pixel memo keys on the source: one per set of - // hidden layers and backdrop. - let source = format!( - "{}#{}~{}{}", - def.file_path, - hidden.join("|"), - backdrop.max_channel, - backdrop.light - ); - let raster = super::underlay_vector::display_raster( - kind, - &def.file_path, - page, - &hidden, - screen_side.unwrap_or(super::underlay_vector::RASTER_SIDE), - backdrop, - )?; - (source, raster) - } + // DWF sheets and DGN models are drawn as vector wires + // (`underlay_vector::display_wires`). + _ => return None, }; - // Dark means an HSL lightness under one half: pure blue counts as - // light, (0, 128, 0) as dark. - let bg_max = background[0].max(background[1]).max(background[2]); - let bg_min = background[0].min(background[1]).min(background[2]); - let bg_lum = (bg_max + bg_min) / 2.0; - let dgn = def.underlay_type == UnderlayType::Dgn; - // A DGN model is faded into the background before its contrast is - // applied (the reference's order), so it is drawn opaque. - let bg_rgb = [0, 1, 2].map(|i| (background[i].clamp(0.0, 1.0) * 255.0).round() as u8); - let fade_before_contrast = (dgn && u.fade > 0).then_some((bg_rgb, u.fade.min(100))); - let pixels = pdf_raster::adjusted_pixels( - &source, - page, - &raster, - PageAdjust { - contrast: u.contrast.min(100), - // Measured: PDF 0.5; DWF 0.243, or 0.73 once its colours are - // turned over for a dark background; a DGN model's from its - // own colours, over any background. - contrast_pivot: match def.underlay_type { - UnderlayType::Pdf => 500, - UnderlayType::Dwf - if u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) && bg_lum < 0.5 => - { - 730 - } - UnderlayType::Dwf => 243, - UnderlayType::Dgn => super::underlay_vector::dgn_contrast_pivot( - &def.file_path, - page, - &super::pdf_layers::hidden_layers(u), - super::underlay_vector::Backdrop::of([background[0], background[1], background[2]]), - fade_before_contrast, - ) - .unwrap_or(404), - }, - monochrome: u.flags.contains(UnderlayDisplayFlags::MONOCHROME), - // A DGN model's colours follow the background through its - // colour table (the raster above), whatever this setting. - adjust_for_background: u.flags.contains(UnderlayDisplayFlags::ADJUST_FOR_BACKGROUND) - && !dgn, - dark_background: bg_lum < 0.5, - fade_before_contrast, - }, - ); + let pixels = pdf_raster::adjusted_pixels(&source, page, &raster, underlay_adjust(u, def, background)); // The page rectangle in drawing units (1 unit per PDF inch, or per // DWF/DGN sheet unit), entity scale applied, from its lower-left @@ -347,7 +337,7 @@ impl ImageModel { pixels, width: raster.width, height: raster.height, - opacity: if dgn { 1.0 } else { 1.0 - u.fade.min(100) as f32 / 100.0 }, + opacity: 1.0 - u.fade.min(100) as f32 / 100.0, corners, corners_low, draw_depth: 0.0, diff --git a/src/scene/model/pdf_raster.rs b/src/scene/model/pdf_raster.rs index d5f1ccf63..b613984d0 100644 --- a/src/scene/model/pdf_raster.rs +++ b/src/scene/model/pdf_raster.rs @@ -238,6 +238,39 @@ fn adjusted_cache() -> &'static Mutex>>> { CACHE.get_or_init(|| Mutex::new(HashMap::new())) } +/// One colour (0..=1 channels) with the underlay's contrast / monochrome / +/// background adjustments applied. +pub fn adjust_rgb(rgb: [f32; 3], adjust: &PageAdjust) -> [f32; 3] { + let mut rgb = rgb; + if adjust.monochrome { + let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; + rgb = [l; 3]; + } + if adjust.adjust_for_background && adjust.dark_background { + // On a dark background every colour's lightness is turned over + // (plus 20/255, capped at full), hue and saturation kept: black + // becomes white, dark red light pink. On a light background the + // colours stay as drawn. + let (h, s, l) = rgb_to_hsl(rgb); + rgb = hsl_to_rgb(h, s, (1.0 - l + 20.0 / 255.0).min(1.0)); + } + if let Some((bg, fade)) = adjust.fade_before_contrast { + let f = fade.min(100) as f32 / 100.0; + for (c, b) in rgb.iter_mut().zip(bg) { + *c += (b as f32 / 255.0 - *c) * f; + } + } + if adjust.contrast < 100 { + // Lightness is pulled towards the pivot, hue and saturation kept: + // contrast 0 leaves every colour at the pivot's lightness. + let (h, s, l) = rgb_to_hsl(rgb); + let k = adjust.contrast as f32 / 100.0; + let pivot = adjust.contrast_pivot as f32 / 1000.0; + rgb = hsl_to_rgb(h, s, pivot + (l - pivot) * k); + } + rgb +} + /// The display raster with the underlay's contrast / monochrome / background /// adjustments applied, memoised per source, page and adjustment. pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdjust) -> Arc> { @@ -260,34 +293,7 @@ pub fn adjusted_pixels(path: &str, page: &str, raster: &PdfPage, adjust: PageAdj if px[3] == 0 { continue; } - let mut rgb = [px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0]; - if adjust.monochrome { - let l = 0.299 * rgb[0] + 0.587 * rgb[1] + 0.114 * rgb[2]; - rgb = [l; 3]; - } - if adjust.adjust_for_background && adjust.dark_background { - // On a dark background every colour's lightness is turned over - // (plus 20/255, capped at full), hue and saturation kept: black - // becomes white, dark red light pink. On a light background the - // colours stay as drawn. - let (h, s, l) = rgb_to_hsl(rgb); - rgb = hsl_to_rgb(h, s, (1.0 - l + 20.0 / 255.0).min(1.0)); - } - if let Some((bg, fade)) = adjust.fade_before_contrast { - let f = fade.min(100) as f32 / 100.0; - for (c, b) in rgb.iter_mut().zip(bg) { - *c += (b as f32 / 255.0 - *c) * f; - } - } - if adjust.contrast < 100 { - // Lightness is pulled towards the pivot, hue and saturation kept: - // contrast 0 leaves every colour at the pivot's lightness. - let (h, s, l) = rgb_to_hsl(rgb); - let k = adjust.contrast as f32 / 100.0; - let pivot = adjust.contrast_pivot as f32 / 1000.0; - rgb = hsl_to_rgb(h, s, pivot + (l - pivot) * k); - } - + let rgb = adjust_rgb([px[0] as f32 / 255.0, px[1] as f32 / 255.0, px[2] as f32 / 255.0], &adjust); for (dst, c) in px[..3].iter_mut().zip(rgb) { *dst = (c.clamp(0.0, 1.0) * 255.0).round() as u8; } diff --git a/src/scene/model/underlay_vector/mod.rs b/src/scene/model/underlay_vector/mod.rs index 7e68a881d..87daea5bf 100644 --- a/src/scene/model/underlay_vector/mod.rs +++ b/src/scene/model/underlay_vector/mod.rs @@ -11,6 +11,9 @@ mod dgn7; mod dgn8; mod dwf; mod model; +mod wires; + +pub(crate) use wires::display_wires; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; diff --git a/src/scene/model/underlay_vector/wires.rs b/src/scene/model/underlay_vector/wires.rs new file mode 100644 index 000000000..eade84736 --- /dev/null +++ b/src/scene/model/underlay_vector/wires.rs @@ -0,0 +1,197 @@ +//! A DWF sheet or DGN model drawn as vector wires (lines and flat fills) +//! in world space, so it stays sharp at every zoom as the reference draws +//! it, instead of as a raster rescaled to the view. + +use std::collections::HashMap; + +use codec::entities::{Underlay, UnderlayDefinition, UnderlayDisplayFlags, UnderlayType}; + +use super::{model, sheet_without, Backdrop, Segment, Sheet}; +use crate::scene::model::wire_model::WireModel; + +/// Wires for the underlay's content, one per colour and kind (lines, fills), +/// named after the underlay so they draw in its place. They are drawn and +/// plotted but never picked or snapped (the frame selects the underlay; the +/// underlay's own snap geometry serves object snaps). +pub(crate) fn display_wires( + u: &Underlay, + def: &UnderlayDefinition, + name: &str, + background: [f32; 4], +) -> Vec { + if def.underlay_type == UnderlayType::Pdf || def.unloaded || !u.flags.contains(UnderlayDisplayFlags::ON) { + return Vec::new(); + } + let page = crate::entities::underlay::page_of(def); + let hidden = crate::scene::model::pdf_layers::hidden_layers(u); + let Some(sheet) = sheet_without(def.underlay_type, &def.file_path, page, &hidden) else { + return Vec::new(); + }; + let adjust = crate::scene::model::image_model::underlay_adjust(u, def, background); + let backdrop = Backdrop::of([background[0], background[1], background[2]]); + // A DGN model's fade is in its colours; a DWF sheet's blends it with + // what is under it. + let alpha = if def.underlay_type == UnderlayType::Dgn { 1.0 } else { 1.0 - u.fade.min(100) as f32 / 100.0 }; + let shown = |c: [u8; 3]| -> [f32; 4] { + let c = if sheet.table_colors { model::dgn_table_color(c, backdrop.max_channel, backdrop.light) } else { c }; + let rgb = crate::scene::model::pdf_raster::adjust_rgb([c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0], &adjust); + [rgb[0], rgb[1], rgb[2], alpha] + }; + let world = |p: [f64; 2]| crate::entities::underlay::local_to_world(u, p); + let tolerance = curve_tolerance(&sheet); + let scale = u.x_scale.abs().max(u.y_scale.abs()); + + // Lines per (colour, width), fills per colour, each in first-drawn order. + let key = |c: [f32; 4]| c.map(f32::to_bits); + let mut lines: Vec<([f32; 4], f64, Vec<[f64; 3]>)> = Vec::new(); + let mut line_at: HashMap<([u32; 4], u64), usize> = HashMap::new(); + let mut fills: Vec<([f32; 4], Vec<[f64; 3]>)> = Vec::new(); + let mut fill_at: HashMap<[u32; 4], usize> = HashMap::new(); + for path in &sheet.paths { + if let Some(c) = path.fill { + let color = shown(c); + let rings: Vec> = path + .subpaths + .iter() + .map(|sp| polyline(&sp.segments, tolerance)) + .filter(|r| r.len() >= 3) + .collect(); + if !rings.is_empty() { + let (points, triangles) = kernel::geom2d::triangulate_rings(&rings); + let at = *fill_at.entry(key(color)).or_insert_with(|| { + fills.push((color, Vec::new())); + fills.len() - 1 + }); + for triangle in triangles { + for index in triangle { + if let Some(&p) = points.get(index) { + fills[at].1.push(world(p)); + } + } + } + } + } + if let Some((c, width)) = path.stroke { + let color = shown(c); + let at = *line_at.entry((key(color), width.to_bits())).or_insert_with(|| { + lines.push((color, width, Vec::new())); + lines.len() - 1 + }); + for sp in &path.subpaths { + let mut pts = polyline(&sp.segments, tolerance); + if sp.closed && pts.len() >= 2 && pts.first() != pts.last() { + pts.push(pts[0]); + } + if pts.len() < 2 { + continue; + } + let run = &mut lines[at].2; + if !run.is_empty() { + run.push([f64::NAN; 3]); + } + run.extend(pts.into_iter().map(world)); + } + } + } + + let mut wires = Vec::new(); + for (color, tris) in fills { + let (fp, fp_low) = crate::scene::convert::tessellate::points_to_ds(tris); + let mut wire = content_wire(name, Vec::new(), color); + wire.fill_tris = fp; + wire.fill_tris_low = fp_low; + wires.push(wire); + } + for (color, width, points) in lines { + let (lp, lp_low) = crate::scene::convert::tessellate::points_to_ds(points); + let mut wire = content_wire(name, lp, color); + wire.points_low = lp_low; + if width < 0.0 { + // A width in pixels (DGN line weights are not shown). + wire.set_fixed_screen_width((-width) as f32); + } else if width > 0.0 { + wire.world_width = (width * scale) as f32; + } else { + wire.set_fixed_screen_width(1.0); + } + wires.push(wire); + } + if crate::entities::underlay::is_clipped(u) { + let clip: Vec<[f64; 2]> = crate::entities::underlay::clip_polygon_local(u) + .into_iter() + .map(|p| { + let w = world(p); + [w[0], w[1]] + }) + .collect(); + let ring = if u.clip_inverted { + let [x0, y0, x1, y1] = sheet.rect; + let corners = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]].map(|p| { + let w = world(p); + [w[0], w[1]] + }); + let lo = [corners.iter().map(|p| p[0]).fold(f64::MAX, f64::min), corners.iter().map(|p| p[1]).fold(f64::MAX, f64::min)]; + let hi = [corners.iter().map(|p| p[0]).fold(f64::MIN, f64::max), corners.iter().map(|p| p[1]).fold(f64::MIN, f64::max)]; + crate::scene::pick::xclip::inverted_ring(&clip, (lo, hi)) + } else { + clip + }; + crate::scene::pick::xclip::clip_wires(&mut wires, &ring); + } + for wire in &mut wires { + crate::scene::convert::tess::set_wire_aabb(wire, WireModel::UNBOUNDED_AABB); + } + wires +} + +fn content_wire(name: &str, points: Vec<[f32; 3]>, color: [f32; 4]) -> WireModel { + let mut wire = WireModel::solid(name.to_string(), points, color, false); + wire.aci = 0; + wire.bg_adapt = None; + wire.display_visible = true; + wire.plot_visible = true; + // Drawn, but neither picked nor snapped (see `WireModel::snap_only`). + wire.snap_only = true; + wire +} + +/// How far a curve's polyline may stray from it: a small share of the +/// sheet, so curves stay smooth when zoomed far in. +fn curve_tolerance(sheet: &Sheet) -> f64 { + let [x0, y0, x1, y1] = sheet.rect; + ((x1 - x0).hypot(y1 - y0) * 2e-5).max(1e-9) +} + +/// A subpath's points, curves divided within `tolerance`. +fn polyline(segments: &[Segment], tolerance: f64) -> Vec<[f64; 2]> { + let mut out: Vec<[f64; 2]> = Vec::new(); + for seg in segments { + let start = seg.start(); + if out.last() != Some(&start) { + out.push(start); + } + match seg { + Segment::Line(_, b) => out.push(*b), + Segment::Cubic(a, c1, c2, b) => { + // The control polygon's length bounds the curve's; the + // flattening error of n pieces falls as 1/n². + let hull = dist(*a, *c1) + dist(*c1, *c2) + dist(*c2, *b); + let n = ((hull / tolerance).sqrt() * 0.5).ceil().clamp(2.0, 256.0) as usize; + for i in 1..=n { + let t = i as f64 / n as f64; + let u = 1.0 - t; + let (k0, k1, k2, k3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t); + out.push([ + k0 * a[0] + k1 * c1[0] + k2 * c2[0] + k3 * b[0], + k0 * a[1] + k1 * c1[1] + k2 * c2[1] + k3 * b[1], + ]); + } + } + } + } + out +} + +fn dist(a: [f64; 2], b: [f64; 2]) -> f64 { + (b[0] - a[0]).hypot(b[1] - a[1]) +} diff --git a/src/scene/model/wire_model.rs b/src/scene/model/wire_model.rs index dad7fef4d..c872803a6 100644 --- a/src/scene/model/wire_model.rs +++ b/src/scene/model/wire_model.rs @@ -506,8 +506,9 @@ pub struct WireModel { pub depth_override: Option, /// Whether this wire is drawn in the normal viewport pass. pub display_visible: bool, - /// Geometry that only object snaps use (the content of an underlay): - /// never drawn, plotted or picked. + /// Geometry that is never picked: the content of an underlay. Hidden + /// (`display_visible` false) it is what object snaps find; drawn, it is + /// display only and snaps skip it too ([`Self::is_display_only`]). pub snap_only: bool, /// Whether this wire is included in plotted output. pub plot_visible: bool, @@ -531,6 +532,11 @@ impl WireModel { pub const SELECTED: [f32; 4] = [0.15, 0.55, 1.00, 1.0]; /// Rollover (hover) highlight — orange, distinct from the blue selection. pub const HOVER: [f32; 4] = [0.95, 0.55, 0.10, 1.0]; + + /// Drawn underlay content: shown and plotted, never picked or snapped. + pub fn is_display_only(&self) -> bool { + self.snap_only && self.display_visible + } /// Sentinel AABB that never rejects any snap query. pub const UNBOUNDED_AABB: [f32; 4] = [ f32::NEG_INFINITY, diff --git a/src/scene/pick/interaction_index.rs b/src/scene/pick/interaction_index.rs index 0fb4988ae..efc62a2cd 100644 --- a/src/scene/pick/interaction_index.rs +++ b/src/scene/pick/interaction_index.rs @@ -673,6 +673,10 @@ impl WireIndexBatch { } fn append_wire_index_entries(wire_idx: u32, wire: &WireModel, batch: &mut WireIndexBatch) { + // Drawn underlay content: neither picked nor snapped. + if wire.is_display_only() { + return; + } if let Some(aabb) = finite_wire_aabb3(wire) { batch.wire_entries.push(Entry3 { aabb, diff --git a/src/snap.rs b/src/snap.rs index dad82e13e..d29883ae2 100644 --- a/src/snap.rs +++ b/src/snap.rs @@ -1349,7 +1349,7 @@ impl Snapper { let unindexed = wires.segments().is_none(); if unindexed { for w in wires.iter() { - if wire_in_range(w) { + if !w.is_display_only() && wire_in_range(w) { in_range_pts += w.points.len(); in_range_wires.push(w); } From e682f29ce60be044e9d9cf2dbbd3ba6b62ef2568 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 29 Sep 2026 14:01:56 +0300 Subject: [PATCH 07/17] fix(render): block text no longer hides behind its own background box Inside a block, text-bearing and wide wires got their child rank as a depth override, but fill-only wires stayed at the bare insert level. A dimension's baked MTEXT with a DIMTFILL background therefore drew its box in front of its own text whenever the text's rank fell below zero: cyan boxes with no numbers. Fill-only wires now take the rank as well, so a background ties with its text and the text pass wins. Refs #774 Co-Authored-By: Claude Opus 5.5 --- src/scene/cache/block_cache.rs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/scene/cache/block_cache.rs b/src/scene/cache/block_cache.rs index f72740a81..24abd04ab 100644 --- a/src/scene/cache/block_cache.rs +++ b/src/scene/cache/block_cache.rs @@ -2613,8 +2613,11 @@ fn emit_wire( // composed rank the text sits at the bare insert level, an exact tie with // every sibling fill, and the later wipeout pass erases it (unselected // block text vanished under its wipeout; selecting won only because the - // xray pass ignores depth). - let local_depth = (lw.world_width > 0.0 || !lw.text_verts.is_empty()) + // xray pass ignores depth). Fill-only wires take the rank as well: an + // MTEXT background (a dimension's DIMTFILL box) left at the bare insert + // level sat in front of its own text whenever that text's rank was + // below zero. + let local_depth = (lw.world_width > 0.0 || lw.is_fill_only || !lw.text_verts.is_empty()) .then(|| d_range.0 + lw.local_rank * d_range.1); let plot_visible = ctx.plot_visible && lw.plot_visible From ea91315ba8a1a5e047d9b0049b9bfedc78a44c55 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 29 Sep 2026 14:16:07 +0300 Subject: [PATCH 08/17] fix(layers): turning layers on no longer re-places associative dimensions Layer, text-style, dimension-style and object-style invalidation bumped every dependent entity as Modified, which also re-resolved associative dimensions, centre marks and hatches and re-ran the parametric solve. A dimension whose association disagrees with its own points (as older saves can) jumped its dimension line on LAYON. A restyle moves no geometry, so it now refreshes display only. Fixes #1536 Co-Authored-By: Claude Opus 5.5 --- src/scene/mod.rs | 68 ++++++++++++++++++++++++++++++------------------ 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/src/scene/mod.rs b/src/scene/mod.rs index 3a3b95216..84987dcda 100644 --- a/src/scene/mod.rs +++ b/src/scene/mod.rs @@ -3349,12 +3349,19 @@ impl Scene { retained_originals, &[], true, + false, ); } /// Refresh display and associations after a solved grip or exact history restore. pub(crate) fn bump_entities_after_parametric_solve(&mut self, changes: &[(Handle, ChangeKind)]) { - self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false); + self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false, false); + } + + /// Refresh display after a restyle that moved no geometry: nothing + /// associated to these entities is re-resolved or re-solved. + pub(crate) fn bump_entities_restyled(&mut self, changes: &[(Handle, ChangeKind)]) { + self.bump_entities_with_solve_policy(changes, &[], false, &[], &[], false, true); } /// Apply a newly-created ordered relation while temporarily anchoring @@ -3373,6 +3380,7 @@ impl Scene { &[], fixed_refs, true, + false, ); } @@ -3390,6 +3398,7 @@ impl Scene { retained_originals, transformed_refs, true, + false, ); } @@ -3401,6 +3410,7 @@ impl Scene { retained_originals: &[(Handle, EntityType)], fixed_refs: &[parametric_constraints::ParametricRef], solve_parametric: bool, + appearance_only: bool, ) { if changes.iter().any(|(handle, kind)| { matches!(kind, ChangeKind::Removed) @@ -3412,38 +3422,44 @@ impl Scene { self.associative_hatch_source_cache.borrow_mut().take(); } let mut changes = changes.to_vec(); - if self.associative_centers_possible(&changes) { - for change in self.refresh_associative_centerlines(&changes) { + // A restyle (layer on/off, colour, text or dimension style) moves no + // geometry, so nothing measured from it is re-resolved: an + // association that disagrees with its dimension would otherwise + // re-place the dimension on a layer toggle. + if !appearance_only { + if self.associative_centers_possible(&changes) { + for change in self.refresh_associative_centerlines(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } + for change in self.refresh_associative_center_marks(&changes) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } + } + } + for change in self.refresh_associative_dimensions(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } - for change in self.refresh_associative_center_marks(&changes) { + for change in self.refresh_associative_hatches(&changes) { if !changes.iter().any(|(handle, _)| *handle == change.0) { changes.push(change); } } - } - for change in self.refresh_associative_dimensions(&changes) { - if !changes.iter().any(|(handle, _)| *handle == change.0) { - changes.push(change); - } - } - for change in self.refresh_associative_hatches(&changes) { - if !changes.iter().any(|(handle, _)| *handle == change.0) { - changes.push(change); - } - } - if solve_parametric && !self.parametric_constraints.is_empty() { - for change in self.refresh_parametric_constraints_with_initial_policy( - &changes, - driven_refs, - retain_size, - retained_originals, - fixed_refs, - ) { - if !changes.iter().any(|(handle, _)| *handle == change.0) { - changes.push(change); + if solve_parametric && !self.parametric_constraints.is_empty() { + for change in self.refresh_parametric_constraints_with_initial_policy( + &changes, + driven_refs, + retain_size, + retained_originals, + fixed_refs, + ) { + if !changes.iter().any(|(handle, _)| *handle == change.0) { + changes.push(change); + } } } } @@ -11380,7 +11396,7 @@ vis_index={:.1} visible_probe={:.1}", .into_iter() .map(|handle| (handle, ChangeKind::Modified)) .collect(); - self.bump_entities(&changes); + self.bump_entities_restyled(&changes); } pub fn invalidate_layer_dependencies(&mut self, names: &[String]) { From 88169c1d7a2e8f33c02fb8d0c42a9d1679f8f76a Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 29 Sep 2026 10:40:24 +0100 Subject: [PATCH 09/17] feat(mcp): add text_search and text_replace with multiline MText and accent-insensitive matching - Add text_search (read) and text_replace (mutating) operations to control bridge, automation, and MCP - Support single and batch find/replace pairs under atomic undo transactions - Safely parse and preserve MText formatting tags, inline fonts, colors, and margins - Add flexible multiline MText matching and replacement across paragraph breaks (\n <-> \P) - Add accent/diacritic-insensitive matching across Unicode and ASCII character sets - Support Windows-1252 / ISO-8859-1 code decoding (%%nnn), DXF Unicode escapes (\U+XXXX), and special character codes - Expose ignore_accents parameter across ocs_read and ocs_execute MCP tool schemas --- src/app/automation.rs | 2 + src/app/control/mod.rs | 4 + src/app/control/p1_tests.rs | 89 +++ src/app/control/text_ops.rs | 1270 +++++++++++++++++++++++++++++++++++ src/lib.rs | 2 +- src/mcp.rs | 29 +- 6 files changed, 1394 insertions(+), 2 deletions(-) create mode 100644 src/app/control/text_ops.rs diff --git a/src/app/automation.rs b/src/app/automation.rs index d822e9465..94d878680 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -679,6 +679,8 @@ impl OpenCADStudio { "entities" => self.entity_summary(), "audit" => self.document_audit(&req), "query" => self.entity_query(&req), + "text_search" => self.automation_text_search(&req), + "text_replace" => self.automation_text_replace(&req), "records" => self.record_query(&req), "record_schema" => self.record_schema(&req), "capabilities" => self.record_capabilities(), diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index cd08c9916..0d6780491 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -471,6 +471,7 @@ impl OpenCADStudio { | "xdata_get" | "get_selection" | "audit" + | "text_search" ); if !query && !self.control.enabled { return ( @@ -729,6 +730,7 @@ impl OpenCADStudio { } "xdata_get" => self.xdata_read(&req), "get_selection" => self.control_get_selection(), + "text_search" => self.control_text_search(&req).unwrap_or_else(|e| e), "history" => { json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::>()}) } @@ -975,6 +977,7 @@ impl OpenCADStudio { "entities_delete" => self.control_entities_delete(req)?, "entities_transform" => self.control_entities_transform(req)?, "entities_copy_to" => self.control_entities_copy_to(req)?, + "text_replace" => self.control_text_replace(req)?, "xdata_set" => self.control_xdata_set(req)?, "block_define" => self.control_block_define(req)?, "block_delete" => self.control_block_delete(req)?, @@ -1340,6 +1343,7 @@ pub(crate) mod http_bridge; mod interactive; mod sheets; pub(crate) use sheets::new_guid_v4; +pub(crate) mod text_ops; #[cfg(test)] mod p1_tests; diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index 705e5e639..445368372 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -566,3 +566,92 @@ fn state_reports_the_hand_seed() { let after = u64::from_str_radix(state["hand_seed"].as_str().unwrap(), 16).unwrap(); assert!(after > before, "issuing a handle advances the seed"); } + +#[test] +fn text_search_op_queries_text_and_attributes() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"ts_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"}, + {"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;POMPE A EAU}","layer":"0"}, + {"type":"Text","position":[20,0,0],"value":"BAROMETRE","layer":"0"}, + {"type":"Text","position":[30,0,0],"value":"10 BAR","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Case-insensitive search finds CIRTUITS + let res = app.automation_op(r#"{"op":"text_search","find":"cirtuits"}"#); + assert_eq!(res["ok"], true); + assert_eq!(res["count"], 1); + assert_eq!(res["matches"][0]["match_text"], "cirtuits"); + assert_eq!(res["matches"][0]["plain_text"], "CIRTUITS 28-09-2026"); + + // Case-sensitive search with wrong case finds nothing + let res_cs = app.automation_op(r#"{"op":"text_search","find":"cirtuits","match_case":true}"#); + assert_eq!(res_cs["ok"], true); + assert_eq!(res_cs["count"], 0); + + // Whole-word search: "bar" matches "10 BAR" but not "BAROMETRE" + let res_ww = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":true}"#); + assert_eq!(res_ww["ok"], true); + assert_eq!(res_ww["count"], 1); + assert_eq!(res_ww["matches"][0]["plain_text"], "10 BAR"); + + // Non-whole-word search finds both "BAROMETRE" and "10 BAR" + let res_all = app.automation_op(r#"{"op":"text_search","find":"bar","whole_word":false}"#); + assert_eq!(res_all["ok"], true); + assert_eq!(res_all["count"], 2); +} + +#[test] +fn text_replace_op_batch_replaces_preserves_formatting_and_undoes() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"tr_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"VANNE A ARRET","layer":"0"}, + {"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic|b0|i0;V15-CUVE EAU NON TRAITE}","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Execute batch replace with 2 pairs in one atomic step + let rep = mutate( + &mut app, + r#"{"protocol":1,"op":"text_replace","request_id":"tr_r1","document_id":{doc},"pairs":[ + {"find":"NON TRAITE","replace":"NON TRAITÉE"}, + {"find":"VANNE A ARRET","replace":"VANNE D'ARRÊT"} + ]}"#, + ); + assert_eq!(rep["ok"], true, "{}", rep["error"]); + assert_eq!(rep["result"]["replaced"], 2); + assert_eq!(rep["result"]["entities_changed"], 2); + + // Verify MText formatting codes were preserved + let q = app.automation_op(r#"{"op":"text_search","find":"NON TRAITÉE"}"#); + assert_eq!(q["ok"], true); + assert_eq!(q["count"], 1); + let raw = q["matches"][0]["raw_value"].as_str().unwrap(); + assert!(raw.contains("Century Gothic"), "raw MText should keep font format code: {raw}"); + assert!(raw.contains("NON TRAITÉE"), "raw MText should contain replaced text: {raw}"); + + // Verify Text was replaced + let q2 = app.automation_op(r#"{"op":"text_search","find":"VANNE D'ARRÊT"}"#); + assert_eq!(q2["ok"], true); + assert_eq!(q2["count"], 1); + + // Undo restores the original text in one step + app.automation_op(r#"{"op":"undo"}"#); + let q_orig = app.automation_op(r#"{"op":"text_search","find":"NON TRAITE"}"#); + assert_eq!(q_orig["ok"], true); + assert_eq!(q_orig["count"], 1); + + let q_orig2 = app.automation_op(r#"{"op":"text_search","find":"VANNE A ARRET"}"#); + assert_eq!(q_orig2["ok"], true); + assert_eq!(q_orig2["count"], 1); +} + diff --git a/src/app/control/text_ops.rs b/src/app/control/text_ops.rs new file mode 100644 index 000000000..e0bf2f919 --- /dev/null +++ b/src/app/control/text_ops.rs @@ -0,0 +1,1270 @@ +//! High-performance text search and replacement operations for OpenCADStudio. +//! +//! Provides headless, control-bridge, and MCP-accessible text querying and batch +//! replacement across Text, MText, Dimension, AttributeDefinition, and Insert entities. +//! Safely preserves MText inline formatting codes (fonts, colors, heights, braces) +//! and handles DXF Unicode escapes (`\U+XXXX`) and special character codes (`%%d`, `%%p`, etc.). + +use super::{failure, OpenCADStudio}; +use crate::app::Message; +use codec::{CadDocument, EntityType, Handle}; +use iced::Task; +use serde_json::{json, Value}; + +/// Decodes DXF special character codes (`%%d`, `%%p`, `%%c`, `%%nnn`) and +/// DXF Unicode escape sequences (`\U+XXXX` / `\u+XXXX`) into UTF-8 characters. +pub fn decode_dxf_escapes(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + + while let Some(c) = chars.next() { + if c == '%' && chars.peek() == Some(&'%') { + chars.next(); // consume second '%' + match chars.peek().map(|ch| ch.to_ascii_lowercase()) { + Some('d') => { + chars.next(); + out.push('°'); + } + Some('p') => { + chars.next(); + out.push('±'); + } + Some('c') => { + chars.next(); + out.push('∅'); + } + Some('u') | Some('o') => { + chars.next(); // toggle codes — strip silently + } + Some('%') => { + chars.next(); + if chars.peek() == Some(&'%') { + chars.next(); + } + out.push('%'); + } + Some(d) if d.is_ascii_digit() => { + let mut digits = String::with_capacity(3); + for _ in 0..3 { + match chars.peek() { + Some(&ch) if ch.is_ascii_digit() => { + digits.push(chars.next().unwrap()); + } + _ => break, + } + } + if digits.len() == 3 { + if let Ok(n) = digits.parse::() { + let ch_opt = match n { + 128 => Some('€'), + 130 => Some('‚'), + 131 => Some('ƒ'), + 132 => Some('„'), + 133 => Some('…'), + 134 => Some('†'), + 135 => Some('‡'), + 136 => Some('ˆ'), + 137 => Some('‰'), + 138 => Some('Š'), + 139 => Some('‹'), + 140 => Some('Œ'), + 142 => Some('Ž'), + 145 => Some('‘'), + 146 => Some('’'), + 147 => Some('“'), + 148 => Some('”'), + 149 => Some('•'), + 150 => Some('–'), + 151 => Some('—'), + 152 => Some('˜'), + 153 => Some('™'), + 154 => Some('š'), + 155 => Some('›'), + 156 => Some('œ'), + 158 => Some('ž'), + 159 => Some('Ÿ'), + 160..=255 => char::from_u32(n), + _ => char::from_u32(n), + }; + if let Some(ch) = ch_opt { + out.push(ch); + continue; + } + } + } + out.push('%'); + out.push('%'); + out.push_str(&digits); + } + _ => { + out.push('%'); + out.push('%'); + } + } + continue; + } + + // DXF Unicode escape: \U+XXXX or \u+XXXX + if c == '\\' && (chars.peek() == Some(&'U') || chars.peek() == Some(&'u')) { + let mut clone = chars.clone(); + clone.next(); // consume U/u + if clone.peek() == Some(&'+') { + clone.next(); // consume '+' + let mut hex = String::with_capacity(4); + for _ in 0..4 { + match clone.peek() { + Some(&ch) if ch.is_ascii_hexdigit() => { + hex.push(clone.next().unwrap()); + } + _ => break, + } + } + if hex.len() == 4 { + if let Ok(codepoint) = u32::from_str_radix(&hex, 16) { + if let Some(ch) = char::from_u32(codepoint) { + out.push(ch); + chars = clone; // advance chars + continue; + } + } + } + } + } + + out.push(c); + } + + out +} + +/// Strips common Latin diacritics/accents into their plain ASCII equivalents (e.g. 'é' -> 'e', 'À' -> 'A'). +pub fn remove_diacritics(s: &str) -> String { + s.chars() + .map(|c| match c { + 'à' | 'á' | 'â' | 'ã' | 'ä' | 'å' => 'a', + 'À' | 'Á' | 'Â' | 'Ã' | 'Ä' | 'Å' => 'A', + 'è' | 'é' | 'ê' | 'ë' => 'e', + 'È' | 'É' | 'Ê' | 'Ë' => 'E', + 'ì' | 'í' | 'î' | 'ï' => 'i', + 'Ì' | 'Í' | 'Î' | 'Ï' => 'I', + 'ò' | 'ó' | 'ô' | 'õ' | 'ö' => 'o', + 'Ò' | 'Ó' | 'Ô' | 'Õ' | 'Ö' => 'O', + 'ù' | 'ú' | 'û' | 'ü' => 'u', + 'Ù' | 'Ú' | 'Û' | 'Ü' => 'U', + 'ý' | 'ÿ' => 'y', + 'Ý' => 'Y', + 'ç' => 'c', + 'Ç' => 'C', + 'ñ' => 'n', + 'Ñ' => 'N', + other => other, + }) + .collect() +} + +/// Converts LF / CRLF newlines into DXF MText paragraph breaks (`\P`). +pub fn to_raw_mtext_replacement(s: &str) -> String { + s.replace("\r\n", "\\P").replace('\n', "\\P") +} + +/// Converts raw DXF MText paragraph breaks (`\P` / `\p`) into plain text newlines (`\n`). +pub fn to_plain_text_search(s: &str) -> String { + s.replace("\\P", "\n").replace("\\p", "\n") +} + +/// Converts newlines (`\r\n` / `\n`) into raw DXF MText paragraph breaks (`\P`). +pub fn to_raw_mtext_search(s: &str) -> String { + s.replace("\r\n", "\\P").replace('\n', "\\P") +} + +/// Pattern matcher supporting case-sensitive/case-insensitive, whole-word, +/// and accent/diacritic-insensitive matching across Unicode and ASCII strings. +#[derive(Debug, Clone)] +pub struct TextMatcher { + pub search: String, + pub match_case: bool, + pub whole_word: bool, + pub ignore_accents: bool, +} + +impl TextMatcher { + pub fn new( + search: String, + match_case: bool, + whole_word: bool, + ignore_accents: bool, + ) -> Self { + Self { + search, + match_case, + whole_word, + ignore_accents, + } + } + + pub fn with_search(&self, search: String) -> Self { + Self { + search, + match_case: self.match_case, + whole_word: self.whole_word, + ignore_accents: self.ignore_accents, + } + } + + pub fn matches(&self, hay: &str) -> bool { + !self.find_all_in(hay).is_empty() + } + + /// Matches against both plain display text and raw CAD string, normalizing + /// newlines and MText paragraph break escapes (`\n` <-> `\P`). + pub fn matches_text_or_raw(&self, plain_text: &str, raw_val: &str) -> bool { + if self.matches(plain_text) || self.matches(raw_val) { + return true; + } + if self.search.contains('\n') || self.search.contains("\r\n") { + let p_matcher = self.with_search(to_raw_mtext_search(&self.search)); + if p_matcher.matches(raw_val) { + return true; + } + } + if self.search.contains("\\P") || self.search.contains("\\p") { + let lf_matcher = self.with_search(to_plain_text_search(&self.search)); + if lf_matcher.matches(plain_text) { + return true; + } + } + false + } + + /// Finds all non-overlapping match slices `(start_byte, end_byte)` in `hay`. + pub fn find_all_in(&self, hay: &str) -> Vec<(usize, usize)> { + if self.search.is_empty() || hay.is_empty() { + return Vec::new(); + } + + // Fast path: exact case-sensitive, accent-sensitive substring search + if self.match_case && !self.ignore_accents && !self.whole_word { + let mut matches = Vec::new(); + let mut cursor = 0; + while cursor < hay.len() { + if let Some(idx) = hay[cursor..].find(&self.search) { + let start = cursor + idx; + let end = start + self.search.len(); + matches.push((start, end)); + cursor = end; + } else { + break; + } + } + return matches; + } + + // Character-indexed search for case-insensitive, whole-word, and/or accent-insensitive matching + let hay_chars: Vec = hay.chars().collect(); + let mut byte_offsets = Vec::with_capacity(hay_chars.len() + 1); + let mut cur_byte = 0; + for &c in &hay_chars { + byte_offsets.push(cur_byte); + cur_byte += c.len_utf8(); + } + byte_offsets.push(cur_byte); + + let needle_norm: Vec = { + let s = if self.ignore_accents { + remove_diacritics(&self.search) + } else { + self.search.clone() + }; + if self.match_case { + s.chars().collect() + } else { + s.to_lowercase().chars().collect() + } + }; + + let hay_norm: Vec = { + let s = if self.ignore_accents { + remove_diacritics(hay) + } else { + hay.to_string() + }; + if self.match_case { + s.chars().collect() + } else { + s.to_lowercase().chars().collect() + } + }; + + let mut matches = Vec::new(); + let n_len = needle_norm.len(); + if n_len > 0 && n_len <= hay_norm.len() { + let mut i = 0; + while i + n_len <= hay_norm.len() { + if hay_norm[i..i + n_len] == needle_norm[..] { + let start_byte = byte_offsets[i]; + let end_byte = byte_offsets[i + n_len]; + + let is_word_boundary = if self.whole_word { + let before_ok = if i == 0 { + true + } else { + !is_word_char(hay_chars[i - 1]) + }; + let after_ok = if i + n_len >= hay_chars.len() { + true + } else { + !is_word_char(hay_chars[i + n_len]) + }; + before_ok && after_ok + } else { + true + }; + + if is_word_boundary { + matches.push((start_byte, end_byte)); + i += n_len; + continue; + } + } + i += 1; + } + } + + matches + } + + /// Replaces occurrences in `hay` with `replacement`. + pub fn replace_all_in( + &self, + hay: &str, + replacement: &str, + replace_all: bool, + ) -> (String, usize) { + let matches = self.find_all_in(hay); + if matches.is_empty() { + return (hay.to_string(), 0); + } + + let mut result = String::with_capacity(hay.len()); + let mut cursor = 0; + let mut count = 0; + + for (start, end) in matches { + result.push_str(&hay[cursor..start]); + result.push_str(replacement); + cursor = end; + count += 1; + if !replace_all { + break; + } + } + result.push_str(&hay[cursor..]); + (result, count) + } +} + +fn is_word_char(c: char) -> bool { + c.is_alphanumeric() || c == '_' +} + +fn has_trailing_paragraph_break(raw: &str) -> bool { + let s = raw.trim_end(); + let s = s.strip_suffix('}').unwrap_or(s); + match s.strip_suffix('P') { + Some(rest) => rest.chars().rev().take_while(|&c| c == '\\').count() % 2 == 1, + None => false, + } +} + +/// Safely replaces text within an MText string by modifying text runs inside +/// the parsed MText structure, preserving all font tags, colors, and line breaks. +/// Supports both single-span replacements and multiline replacements across +/// paragraph breaks (`\P` / `\n`). +pub fn replace_mtext_safe( + raw: &str, + matcher: &TextMatcher, + replacement: &str, + replace_all: bool, +) -> (String, usize) { + if raw.is_empty() { + return (raw.to_string(), 0); + } + + // Convert replacement newlines to \P for MText compatibility + let raw_replacement = to_raw_mtext_replacement(replacement); + + let mut doc = codec::entities::mtext_format::parse_mtext(raw, true); + let mut total_replaced = 0; + + // First attempt: replace within individual parsed spans (preserves fine-grained formatting) + for para in &mut doc.paragraphs { + for span in &mut para.spans { + if span.stacking.is_none() { + let (new_text, count) = + matcher.replace_all_in(&span.text, &raw_replacement, replace_all); + if count > 0 { + span.text = new_text; + total_replaced += count; + if !replace_all { + break; + } + } + } + } + if !replace_all && total_replaced > 0 { + break; + } + } + + if total_replaced > 0 { + if has_trailing_paragraph_break(raw) { + doc.paragraphs + .push(codec::entities::mtext_format::MTextParagraph::new()); + } + (doc.to_mtext_string(), total_replaced) + } else { + // Second attempt: raw string replacement + // Handles multiline text where the search pattern spans across paragraph breaks (\P / \n) + replace_mtext_raw_flexible(raw, matcher, &raw_replacement, replace_all) + } +} + +/// Helper for flexible raw MText replacement handling newlines and paragraph breaks. +fn replace_mtext_raw_flexible( + raw: &str, + matcher: &TextMatcher, + raw_replacement: &str, + replace_all: bool, +) -> (String, usize) { + // 1. Try direct raw replacement with current matcher + let (direct_res, direct_count) = matcher.replace_all_in(raw, raw_replacement, replace_all); + if direct_count > 0 { + return (direct_res, direct_count); + } + + // 2. If search pattern contains newlines (\r\n or \n), convert them to \P and try + if matcher.search.contains('\n') || matcher.search.contains("\r\n") { + let p_search = to_raw_mtext_search(&matcher.search); + let p_matcher = matcher.with_search(p_search); + let (p_res, p_count) = p_matcher.replace_all_in(raw, raw_replacement, replace_all); + if p_count > 0 { + return (p_res, p_count); + } + + // 3. Multiline paragraph-boundary matching with flexible whitespace: + // Handles cases where raw has spaces around \P, or paragraph formatting like \pxqc; + let lines: Vec<&str> = matcher + .search + .split('\n') + .map(|l| l.strip_suffix('\r').unwrap_or(l)) + .collect(); + + if lines.len() >= 2 { + let (flex_res, flex_count) = + replace_raw_lines(raw, &lines, matcher, raw_replacement, replace_all); + if flex_count > 0 { + return (flex_res, flex_count); + } + } + } else if matcher.search.contains("\\P") || matcher.search.contains("\\p") { + // Search contained literal \P; try with \n in case raw has raw LF + let lf_search = to_plain_text_search(&matcher.search); + let lf_matcher = matcher.with_search(lf_search); + let (lf_res, lf_count) = lf_matcher.replace_all_in(raw, raw_replacement, replace_all); + if lf_count > 0 { + return (lf_res, lf_count); + } + } + + (raw.to_string(), 0) +} + +/// Helper to match line sequences across raw MText paragraph breaks (\P / \p / tags / spaces). +fn replace_raw_lines( + raw: &str, + lines: &[&str], + matcher: &TextMatcher, + raw_replacement: &str, + replace_all: bool, +) -> (String, usize) { + if lines.len() < 2 || raw.is_empty() { + return (raw.to_string(), 0); + } + + let line0 = lines[0].trim_end(); + let line_last = lines[lines.len() - 1].trim_start(); + let middle_lines: Vec<&str> = lines[1..lines.len() - 1].iter().map(|l| l.trim()).collect(); + + let m0 = matcher.with_search(line0.to_string()); + let m_last = matcher.with_search(line_last.to_string()); + let m_mids: Vec = middle_lines + .iter() + .map(|l| matcher.with_search(l.to_string())) + .collect(); + + let matches0 = m0.find_all_in(raw); + if matches0.is_empty() { + return (raw.to_string(), 0); + } + + let mut result = String::with_capacity(raw.len()); + let mut cursor = 0; + let mut count = 0; + + for (start0, end0) in matches0 { + if start0 < cursor { + continue; // already replaced + } + + // Try to verify subsequent lines separated by MText paragraph breaks + let mut check_pos = end0; + let mut matched_all = true; + + for m_mid in &m_mids { + if let Some(next_pos) = skip_mtext_paragraph_break(raw, check_pos) { + let rest = &raw[next_pos..]; + let mid_matches = m_mid.find_all_in(rest); + if let Some(&(m_s, m_e)) = mid_matches.first() { + if m_s == 0 { + check_pos = next_pos + m_e; + continue; + } + } + } + matched_all = false; + break; + } + + if !matched_all { + continue; + } + + if let Some(next_pos) = skip_mtext_paragraph_break(raw, check_pos) { + let rest = &raw[next_pos..]; + let last_matches = m_last.find_all_in(rest); + if let Some(&(l_s, l_e)) = last_matches.first() { + if l_s == 0 { + let end_pos = next_pos + l_e; + result.push_str(&raw[cursor..start0]); + result.push_str(raw_replacement); + cursor = end_pos; + count += 1; + if !replace_all { + break; + } + continue; + } + } + } + } + + if count > 0 { + result.push_str(&raw[cursor..]); + (result, count) + } else { + (raw.to_string(), 0) + } +} + +/// Skips over an MText paragraph break: optional whitespace, `\P` or `\p`, optional format tags like `\pxqc;`, optional whitespace. +fn skip_mtext_paragraph_break(raw: &str, from: usize) -> Option { + if from >= raw.len() { + return None; + } + let bytes = raw.as_bytes(); + let mut i = from; + + // Skip whitespace before \P + while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + + // Must have \P or \p + if i + 1 < bytes.len() && bytes[i] == b'\\' && (bytes[i + 1] == b'P' || bytes[i + 1] == b'p') { + i += 2; + } else { + return None; + } + + // Skip optional paragraph format tags like \pxqc; or \pi...; + if i < bytes.len() && bytes[i] == b'\\' { + let mut tag_end = i + 1; + while tag_end < bytes.len() + && bytes[tag_end] != b';' + && bytes[tag_end] != b'\\' + && bytes[tag_end] != b'{' + && bytes[tag_end] != b'}' + { + tag_end += 1; + } + if tag_end < bytes.len() && bytes[tag_end] == b';' { + i = tag_end + 1; + } + } + + // Skip whitespace after \P + while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + + Some(i) +} + +/// Extracts the plain display text and position for an entity. +pub fn entity_text_info( + entity: &EntityType, + document: &CadDocument, +) -> Option<(String, String, [f64; 3])> { + // Returns (raw_value, plain_text, [x, y, z]) + match entity { + EntityType::Text(t) => { + let raw = t.value.clone(); + let plain = decode_dxf_escapes(&raw); + let pos = [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::MText(t) => { + let raw = t.value.clone(); + let plain_raw = codec::entities::mtext_format::parse_mtext(&raw, true).to_plain_text(); + let plain = decode_dxf_escapes(&plain_raw); + let pos = [t.insertion_point.x, t.insertion_point.y, t.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::AttributeDefinition(ad) => { + let raw = ad.default_value.clone(); + let plain = decode_dxf_escapes(&raw); + let pos = [ad.insertion_point.x, ad.insertion_point.y, ad.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::AttributeEntity(ae) => { + let raw = ae.get_value().to_string(); + let plain = decode_dxf_escapes(&raw); + let pos = [ae.insertion_point.x, ae.insertion_point.y, ae.insertion_point.z]; + Some((raw, plain, pos)) + } + EntityType::Dimension(dim) => { + let text = dim.base().text.clone(); + if text.is_empty() { + None + } else { + let plain = decode_dxf_escapes(&text); + let p = dim.base().text_middle_point; + Some((text, plain, [p.x, p.y, p.z])) + } + } + _ => { + let _ = document; + None + } + } +} + +fn parse_bounds_filter(req: &Value) -> Result, Value> { + let Some(bounds_val) = req.get("bounds") else { + return Ok(None); + }; + let bounds = bounds_val.as_array().and_then(|values| { + if values.len() != 4 { + return None; + } + let min_x = values[0].as_f64()?; + let min_y = values[1].as_f64()?; + let max_x = values[2].as_f64()?; + let max_y = values[3].as_f64()?; + Some([min_x, min_y, max_x, max_y]) + }); + let Some(bounds) = bounds else { + return Err(failure( + "invalid_bounds", + "bounds expects [min_x, min_y, max_x, max_y]", + )); + }; + if !bounds.iter().all(|v| v.is_finite()) || bounds[0] > bounds[2] || bounds[1] > bounds[3] { + return Err(failure( + "invalid_bounds", + "bounds must be finite with min <= max", + )); + } + Ok(Some(bounds)) +} + +fn parse_handles_filter(req: &Value) -> Option> { + if let Some(h) = req["handle"].as_str() { + u64::from_str_radix(h.trim_start_matches("0x"), 16) + .ok() + .map(|val| vec![Handle::new(val)]) + } else if let Some(arr) = req["handles"].as_array() { + let handles: Vec<_> = arr + .iter() + .filter_map(|v| { + let s = v.as_str()?; + u64::from_str_radix(s.trim_start_matches("0x"), 16) + .ok() + .map(Handle::new) + }) + .collect(); + (!handles.is_empty()).then_some(handles) + } else { + None + } +} + +fn parse_types_filter(req: &Value) -> Option> { + if let Some(t) = req["type"].as_str() { + Some(vec![t.to_string()]) + } else if let Some(arr) = req["types"].as_array() { + let types: Vec<_> = arr + .iter() + .filter_map(|v| v.as_str().map(str::to_string)) + .collect(); + (!types.is_empty()).then_some(types) + } else { + None + } +} + +fn entity_block_name(doc: &CadDocument, entity_handle: Handle, owner: Handle) -> Option { + if let Some(record) = doc.block_records.iter().find(|r| r.handle == owner) { + return Some(record.name.clone()); + } + for record in doc.block_records.iter() { + if record.entity_handles.contains(&entity_handle) { + return Some(record.name.clone()); + } + } + None +} + +impl OpenCADStudio { + /// `text_search` — find text occurrences across active space entities, inserts, + /// and block definitions with detailed machine-readable metadata. + pub(crate) fn control_text_search(&self, req: &Value) -> Result { + let find = req["find"] + .as_str() + .or_else(|| req["search"].as_str()) + .unwrap_or(""); + if find.is_empty() { + return Err(failure("missing_find", "Supply 'find' parameter")); + } + + let match_case = req["match_case"].as_bool().unwrap_or(false); + let whole_word = req["whole_word"].as_bool().unwrap_or(false); + let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case); + let scope = req["scope"].as_str().unwrap_or("all"); + let layer_filter = req["layer"].as_str(); + let handles_filter = parse_handles_filter(req); + let types_filter = parse_types_filter(req); + let bounds_filter = parse_bounds_filter(req)?; + let limit = req["limit"].as_u64().unwrap_or(500).min(5000) as usize; + + let matcher = TextMatcher::new(find.to_string(), match_case, whole_word, ignore_accents); + let i = self.active_tab; + let scene = &self.tabs[i].scene; + let doc = &scene.document; + + let mut matches = Vec::new(); + + for entity in doc.entities() { + let common = entity.common(); + let handle = common.handle; + + // Scope filter + let in_active = scene.entity_belongs_to_active_space(handle); + match scope { + "active_space" | "model_space" if !in_active => continue, + "blocks" if in_active => continue, + _ => {} + } + + // Layer filter + if let Some(l) = layer_filter { + if !common.layer.eq_ignore_ascii_case(l) { + continue; + } + } + + // Handle filter + if let Some(ref h_list) = handles_filter { + if !h_list.contains(&handle) { + continue; + } + } + + // Types filter + if let Some(ref t_list) = types_filter { + if !t_list + .iter() + .any(|t| crate::app::automation::entity_type_matches(entity, t)) + { + continue; + } + } + + // Bounds filter + if let Some(b) = bounds_filter { + let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity); + if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] { + continue; + } + } + + let block_name = entity_block_name(doc, handle, common.owner_handle); + + // Check standard text entities + if let Some((raw_val, plain_text, pos)) = entity_text_info(entity, doc) { + if matcher.matches_text_or_raw(&plain_text, &raw_val) { + let matched_text = find.to_string(); + matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "layer": common.layer, + "space": if in_active { "active" } else { "definition" }, + "block": block_name, + "position": pos, + "raw_value": raw_val, + "plain_text": plain_text, + "match_text": matched_text, + })); + if matches.len() >= limit { + break; + } + } + } + + // Check Insert attributes + if let EntityType::Insert(insert) = entity { + for (idx, attr) in insert.attributes.iter().enumerate() { + let raw = attr.get_value(); + let plain = decode_dxf_escapes(raw); + if matcher.matches_text_or_raw(&plain, raw) { + matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "layer": common.layer, + "space": if in_active { "active" } else { "definition" }, + "block": insert.block_name, + "attribute_tag": attr.tag, + "attribute_index": idx, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + "raw_value": raw, + "plain_text": plain, + "match_text": find, + })); + if matches.len() >= limit { + break; + } + } + } + if matches.len() >= limit { + break; + } + } + } + + let truncated = matches.len() >= limit; + Ok(json!({ + "ok": true, + "count": matches.len(), + "truncated": truncated, + "matches": matches, + })) + } + + /// Execute text replace across candidates with single-transaction undo support. + pub(crate) fn execute_text_replace( + &mut self, + req: &Value, + push_undo: bool, + ) -> Result { + let i = self.active_tab; + + // Parse search/replace pairs + let mut pairs: Vec<(String, String)> = Vec::new(); + if let Some(pair_array) = req["pairs"].as_array() { + for item in pair_array { + let find = item["find"].as_str().unwrap_or(""); + let replace = item["replace"].as_str().unwrap_or(""); + if !find.is_empty() { + pairs.push((find.to_string(), replace.to_string())); + } + } + } else if let Some(find) = req["find"].as_str() { + if !find.is_empty() { + let replace = req["replace"].as_str().unwrap_or(""); + pairs.push((find.to_string(), replace.to_string())); + } + } + + if pairs.is_empty() { + return Err(failure( + "missing_find", + "Supply 'find'/'replace' or 'pairs' for text_replace", + )); + } + + let match_case = req["match_case"].as_bool().unwrap_or(false); + let whole_word = req["whole_word"].as_bool().unwrap_or(false); + let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case); + let replace_all = req["replace_all"].as_bool().unwrap_or(true); + let scope = req["scope"].as_str().unwrap_or("all"); + let layer_filter = req["layer"].as_str(); + let handles_filter = parse_handles_filter(req); + let types_filter = parse_types_filter(req); + let bounds_filter = parse_bounds_filter(req)?; + + let matchers: Vec<(TextMatcher, String)> = pairs + .into_iter() + .map(|(f, r)| (TextMatcher::new(f, match_case, whole_word, ignore_accents), r)) + .collect(); + + if push_undo { + self.push_undo_snapshot(i, "TEXT REPLACE"); + } + + let mut total_replaced = 0usize; + let mut changed_handles = Vec::new(); + let mut changes_log = Vec::new(); + + // Collect all entity handles that match filters + let candidate_handles: Vec = { + let scene = &self.tabs[i].scene; + let doc = &scene.document; + let mut candidates = Vec::new(); + for entity in doc.entities() { + let common = entity.common(); + let handle = common.handle; + + let in_active = scene.entity_belongs_to_active_space(handle); + match scope { + "active_space" | "model_space" if !in_active => continue, + "blocks" if in_active => continue, + _ => {} + } + + if let Some(l) = layer_filter { + if !common.layer.eq_ignore_ascii_case(l) { + continue; + } + } + + if let Some(ref h_list) = handles_filter { + if !h_list.contains(&handle) { + continue; + } + } + + if let Some(ref t_list) = types_filter { + if !t_list + .iter() + .any(|t| crate::app::automation::entity_type_matches(entity, t)) + { + continue; + } + } + + if let Some(b) = bounds_filter { + let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity); + if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] { + continue; + } + } + + if scene.is_layer_locked(handle) { + continue; + } + + candidates.push(handle); + } + candidates + }; + + // Mutate matching entities + let doc = &mut self.tabs[i].scene.document; + for handle in candidate_handles { + let Some(entity) = doc.get_entity_mut(handle) else { + continue; + }; + + let mut entity_replaced = 0; + let mut before_val = None; + let mut after_val = None; + + match entity { + EntityType::Text(text) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(&text.value, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(text.value.clone()); + } + text.value = new_val; + entity_replaced += count; + after_val = Some(text.value.clone()); + } else { + // Check if matching decoded DXF escapes + let decoded = decode_dxf_escapes(&text.value); + if matcher.matches(&decoded) { + let (new_val, c) = + matcher.replace_all_in(&decoded, replacement, replace_all); + if c > 0 { + if before_val.is_none() { + before_val = Some(text.value.clone()); + } + text.value = new_val; + entity_replaced += c; + after_val = Some(text.value.clone()); + } + } + } + } + } + EntityType::MText(mtext) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + replace_mtext_safe(&mtext.value, matcher, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(mtext.value.clone()); + } + mtext.value = new_val; + entity_replaced += count; + after_val = Some(mtext.value.clone()); + } + } + } + EntityType::AttributeDefinition(ad) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(&ad.default_value, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(ad.default_value.clone()); + } + ad.default_value = new_val; + entity_replaced += count; + after_val = Some(ad.default_value.clone()); + } + } + } + EntityType::AttributeEntity(ae) => { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(ae.get_value(), replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(ae.get_value().to_string()); + } + ae.set_value(new_val); + entity_replaced += count; + after_val = Some(ae.get_value().to_string()); + } + } + } + EntityType::Dimension(dim) => { + let base = dim.base_mut(); + if !base.text.is_empty() { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(&base.text, replacement, replace_all); + if count > 0 { + if before_val.is_none() { + before_val = Some(base.text.clone()); + } + base.text = new_val; + entity_replaced += count; + after_val = Some(base.text.clone()); + } + } + } + } + EntityType::Insert(insert) => { + for attr in &mut insert.attributes { + for (matcher, replacement) in &matchers { + let (new_val, count) = + matcher.replace_all_in(attr.get_value(), replacement, replace_all); + if count > 0 { + let old_val = attr.get_value().to_string(); + attr.set_value(new_val.clone()); + total_replaced += count; + changes_log.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "attribute_tag": attr.tag, + "before": old_val, + "after": new_val, + "replaced": count, + })); + if !changed_handles.contains(&handle) { + changed_handles.push(handle); + } + } + } + } + } + _ => {} + } + + if entity_replaced > 0 { + total_replaced += entity_replaced; + changes_log.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "before": before_val, + "after": after_val, + "replaced": entity_replaced, + })); + if !changed_handles.contains(&handle) { + changed_handles.push(handle); + } + } + } + + if total_replaced == 0 { + if push_undo { + self.discard_last_undo_entry(i); + } + return Ok(json!({ + "ok": true, + "replaced": 0, + "entities_changed": 0, + "changes": [], + })); + } + + // Refresh scene caches and dirty flags + self.invalidate_property_targets(i, &changed_handles); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.refresh_properties(); + + self.command_line.push_output( + format!( + "TEXT REPLACE: replaced {total_replaced} occurrence(s) in {} object(s).", + changed_handles.len() + ) + .as_str(), + ); + + Ok(json!({ + "ok": true, + "replaced": total_replaced, + "entities_changed": changed_handles.len(), + "changes": changes_log, + })) + } + + /// Bridge for `control_request` mutating op `"text_replace"`. + pub(super) fn control_text_replace(&mut self, req: &Value) -> Result, Value> { + let result = self.execute_text_replace(req, true)?; + self.set_control_result(result); + Ok(Task::none()) + } + + /// Headless automation text search. + pub(crate) fn automation_text_search(&self, req: &Value) -> Value { + self.control_text_search(req).unwrap_or_else(|e| e) + } + + /// Headless automation text replace. + pub(crate) fn automation_text_replace(&mut self, req: &Value) -> Value { + self.execute_text_replace(req, true).unwrap_or_else(|e| e) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_decode_dxf_escapes() { + assert_eq!(decode_dxf_escapes("CIRCUITS"), "CIRCUITS"); + assert_eq!(decode_dxf_escapes("R\\U+00C9SERVOIR"), "RÉSERVOIR"); + assert_eq!(decode_dxf_escapes("r\\u+00e9servoir"), "réservoir"); + assert_eq!(decode_dxf_escapes("Angle %%d"), "Angle °"); + assert_eq!(decode_dxf_escapes("Tolerance %%p0.05"), "Tolerance ±0.05"); + assert_eq!(decode_dxf_escapes("Diameter %%c50"), "Diameter ∅50"); + assert_eq!(decode_dxf_escapes("100%%%%"), "100%"); + // Windows-1252 / ISO-8859-1 code decoding + assert_eq!(decode_dxf_escapes("Chaudi%%232re"), "Chaudière"); + assert_eq!(decode_dxf_escapes("St%%233 EVERWASH"), "Sté EVERWASH"); + } + + #[test] + fn test_text_matcher_case_and_word_boundaries() { + let tm_ci = TextMatcher::new("bar".into(), false, false, false); + assert!(tm_ci.matches("BAROMETRE")); + assert!(tm_ci.matches("bar")); + assert!(tm_ci.matches("BAR")); + + let tm_ww = TextMatcher::new("bar".into(), false, true, false); + assert!(!tm_ww.matches("BAROMETRE")); + assert!(!tm_ww.matches("MINIBAR")); + assert!(tm_ww.matches("BAR")); + assert!(tm_ww.matches("P = 10 BAR")); + assert!(tm_ww.matches("BAR;")); + + let tm_cs = TextMatcher::new("Bar".into(), true, false, false); + assert!(!tm_cs.matches("BAR")); + assert!(tm_cs.matches("Barometer")); + + let (replaced, count) = tm_ww.replace_all_in("10 BAR ET 20 BAROMETRE", "PSI", true); + assert_eq!(count, 1); + assert_eq!(replaced, "10 PSI ET 20 BAROMETRE"); + } + + #[test] + fn test_text_matcher_accent_insensitive() { + let tm_acc = TextMatcher::new("reservoir".into(), false, false, true); + assert!(tm_acc.matches("RÉSERVOIR")); + assert!(tm_acc.matches("Réservoir")); + assert!(tm_acc.matches("reservoir")); + + let tm_ch = TextMatcher::new("chaudiere".into(), false, false, true); + assert!(tm_ch.matches("Chaudière 1")); + let (replaced, count) = tm_ch.replace_all_in("Chaudière 1 et Chaudière 2", "Boiler", true); + assert_eq!(count, 2); + assert_eq!(replaced, "Boiler 1 et Boiler 2"); + + let tm_bache = TextMatcher::new("bache a eau".into(), false, false, true); + assert!(tm_bache.matches("Bâche à eau 2")); + } + + #[test] + fn test_replace_mtext_safe_preserves_formatting() { + let raw = "{\\fCentury Gothic|b0|i0|c0|p34;V15-CUVE EAU NON TRAITE}"; + let matcher = TextMatcher::new("NON TRAITE".into(), false, false, false); + let (replaced, count) = replace_mtext_safe(raw, &matcher, "NON TRAITÉE", true); + assert_eq!(count, 1); + assert!(replaced.contains("NON TRAITÉE")); + assert!(replaced.contains("Century Gothic")); + assert!(!replaced.contains("NON TRAITE}")); + } + + #[test] + fn test_replace_mtext_multiline() { + // Test 1: Plain LF query matching raw \P + let raw1 = "Loge \\Pgardien"; + let matcher1 = TextMatcher::new("Loge \ngardien".into(), false, false, true); + let (replaced1, count1) = replace_mtext_safe(raw1, &matcher1, "Security \nGuard Booth", true); + assert_eq!(count1, 1); + assert_eq!(replaced1, "Security \\PGuard Booth"); + + // Test 2: Multiline with complex formatting codes preserved + let raw2 = "\\pxqc;{\\fCentury Gothic|b1|i0|c0|p34;ACCÈS ET SORTIE\\PMARCHANDISES}"; + let matcher2 = TextMatcher::new("ACCÈS ET SORTIE\nMARCHANDISES".into(), false, false, true); + let (replaced2, count2) = replace_mtext_safe(raw2, &matcher2, "GOODS ACCESS AND EXIT", true); + assert_eq!(count2, 1); + assert!(replaced2.contains("GOODS ACCESS AND EXIT")); + assert!(replaced2.contains("\\pxqc;")); + assert!(replaced2.contains("Century Gothic")); + + // Test 3: Multiline with paragraph properties and flexible whitespace + let raw3 = "{\\pqc;Office \\PWAREHOUSE}"; + let matcher3 = TextMatcher::new("Office \nWAREHOUSE".into(), false, false, true); + let (replaced3, count3) = replace_mtext_safe(raw3, &matcher3, "WAREHOUSE OFFICE", true); + assert_eq!(count3, 1); + assert_eq!(replaced3, "{\\pqc;WAREHOUSE OFFICE}"); + } +} + diff --git a/src/lib.rs b/src/lib.rs index b0b83da1b..af79d3b6f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ #![allow(non_snake_case)] -#![recursion_limit = "256"] +#![recursion_limit = "512"] pub mod app; #[cfg(not(target_arch = "wasm32"))] diff --git a/src/mcp.rs b/src/mcp.rs index aa704f5a5..ef34d0380 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -42,6 +42,7 @@ const READ_OPS: &[&str] = &[ "operation", "xdata_get", "audit", + "text_search", ]; const EXECUTE_OPS: &[&str] = &[ "new", @@ -63,6 +64,7 @@ const EXECUTE_OPS: &[&str] = &[ "entities_create", "entities_delete", "entities_transform", + "text_replace", "block_define", "block_delete", "xdata_set", @@ -103,6 +105,7 @@ const BATCH_STEP_OPS: &[&str] = &[ "entities_create", "entities_delete", "entities_transform", + "text_replace", "block_define", "block_delete", "xdata_set", @@ -855,6 +858,18 @@ fn validate_execute_request(request: &Value, op: &str) -> Result<(), String> { r#"{"op":"save_verified","request_id":"deliver-1","path":"/absolute/output.dwg","target_version":"2018"}"#, ) } + "text_replace" => { + if request["pairs"].as_array().is_none() + && request["find"].as_str().is_none_or(str::is_empty) + { + missing( + "find or pairs", + r#"{"op":"text_replace","request_id":"tr1","find":"OLD","replace":"NEW"}"#, + ) + } else { + Ok(()) + } + } _ => Ok(()), } } @@ -1043,6 +1058,14 @@ fn execute_request_schema() -> Value { "camera_revision":{"type":"integer","minimum":0,"description":"Expected camera revision when view state matters."}, "selection":{"type":"array","items":handle.clone(),"description":"Expected selected handles from current state."}, "cmd":{"type":"string","minLength":1,"description":"Command name followed by its prompt answers separated by spaces. Points use x,y or x,y,z; option answers use their token. Read command details first when unsure.","examples":["LINE 0,0 10,10","CIRCLE 5,5 3","PLINE 0,0 10,0 10,10 C"]}, + "find":{"type":"string","description":"Search text to replace; supports DXF Unicode escapes and special characters."}, + "replace":{"type":"string","description":"Replacement text."}, + "pairs":{"type":"array","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]},"description":"Batch find/replace pairs executed in a single atomic undo transaction."}, + "match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search/replace."}, + "whole_word":{"type":"boolean","default":false,"description":"Match only whole words (bounded by non-alphanumeric characters)."}, + "ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."}, + "replace_all":{"type":"boolean","default":true,"description":"Replace all occurrences within matching entities (default true)."}, + "scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search/replace scope."}, "path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot; save_verified requires .dwg or .dxf."}, "block":{"type":"string","description":"Block definition name for wblock (exports its entities flattened into model space)."}, "linked":{"type":"boolean","description":"embed_image: true stores a path-linked RasterImage instead of an embedded OLE2FRAME."}, @@ -1138,6 +1161,7 @@ fn execute_request_schema() -> Value { {"properties":{"op":{"const":"entities_create"}},"required":["entities"]}, {"properties":{"op":{"const":"entities_delete"}},"required":["handles"]}, {"properties":{"op":{"const":"entities_transform"}},"required":["handles"]}, + {"properties":{"op":{"const":"text_replace"}}}, {"properties":{"op":{"const":"block_define"}},"required":["name","base","handles"]}, {"properties":{"op":{"const":"block_delete"}},"required":["name"]}, {"properties":{"op":{"const":"xdata_set"}},"required":["app","handles"]}, @@ -1199,7 +1223,7 @@ fn tool_definitions() -> Value { { "name":"ocs_read", "description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, @@ -1570,8 +1594,11 @@ mod tests { assert!(READ_OPS.contains(&"records")); assert!(READ_OPS.contains(&"record_schema")); assert!(READ_OPS.contains(&"audit")); + assert!(READ_OPS.contains(&"text_search")); assert!(EXECUTE_OPS.contains(&"set_properties")); assert!(EXECUTE_OPS.contains(&"save_verified")); + assert!(EXECUTE_OPS.contains(&"text_replace")); + assert!(BATCH_STEP_OPS.contains(&"text_replace")); assert_eq!( tools[2]["inputSchema"]["properties"]["request"]["properties"] ["target_version"]["enum"][0], From 97ea84d99a7f2a42cafb618fde802660ae5a2c9c Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 29 Sep 2026 13:01:31 +0100 Subject: [PATCH 10/17] feat(text): add text_audit spell-check and dry-run replacement simulation - Adds ext_audit operation under ocs_read, control bridge, and headless server - Implements pairs / dry_run_pairs simulation returning diffs, coordinates, and unmatched queries - Implements check_terms suspect matching and dictionary vocabulary verification - Adds dry_run: true to ext_replace to preview modifications without mutating entities or pushing undo - Supports bounds, layers, handles, and scope filtering - Updates MCP tool definitions, schemas, and automation test suites --- docs/automation/mcp_smoke.py | 6 +- docs/automation/native.md | 2 +- src/app/automation.rs | 1 + src/app/control/mod.rs | 2 + src/app/control/p1_tests.rs | 109 +++++++++ src/app/control/text_ops.rs | 418 ++++++++++++++++++++++++++++++++--- src/mcp.rs | 5 +- 7 files changed, 510 insertions(+), 33 deletions(-) diff --git a/docs/automation/mcp_smoke.py b/docs/automation/mcp_smoke.py index dd1585f49..bbbff2d31 100644 --- a/docs/automation/mcp_smoke.py +++ b/docs/automation/mcp_smoke.py @@ -52,16 +52,18 @@ def legacy(server: Path) -> None: names = set(definitions) assert names == TOOLS, names execute_request = definitions["ocs_execute"]["inputSchema"]["properties"]["request"] - assert len(execute_request["oneOf"]) == 38 + assert len(execute_request["oneOf"]) == 39 op_enum = execute_request["properties"]["op"]["enum"] for shipped in ("entities_create", "entities_delete", "entities_transform", "block_define", "block_delete", "file_identity", "xdata_set", "view_focus", "wblock", "plot", "entities_copy_to", "group_create", "selection_set_save", "selection_set_load", "close", "sysvar", "layout_create", - "page_setup_set"): + "page_setup_set", "text_replace"): assert shipped in op_enum, shipped assert "xdata_get" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] + assert "text_search" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] + assert "text_audit" in definitions["ocs_read"]["inputSchema"]["properties"]["op"]["enum"] assert execute_request["properties"]["steps"]["maxItems"] == 64 assert execute_request["properties"]["cmd"]["examples"][0] == "LINE 0,0 10,10" assert "set_properties" in execute_request["properties"]["op"]["enum"] diff --git a/docs/automation/native.md b/docs/automation/native.md index d86cb6b99..ddfbf978f 100644 --- a/docs/automation/native.md +++ b/docs/automation/native.md @@ -33,7 +33,7 @@ Two request styles share the channel, told apart by the `protocol` field: | Style | Shape | Operations | |---|---|---| -| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `save_verified`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`; reads `state`, `capabilities`, `audit`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` | +| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `save_verified`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`, `text_replace`; reads `state`, `capabilities`, `audit`, `text_audit`, `text_search`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` | | legacy | `{"op":"…"}` without `protocol` | `new`, `open`, `run`, `entities`, `audit`, `query`, `records`, `record_schema`, `capabilities`, `layers`, `header`, `select`, `save`, `undo`, `redo` | The two styles differ in how a waiting command is treated. A protocol-1 `run` diff --git a/src/app/automation.rs b/src/app/automation.rs index 94d878680..ed5bdaa58 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -680,6 +680,7 @@ impl OpenCADStudio { "audit" => self.document_audit(&req), "query" => self.entity_query(&req), "text_search" => self.automation_text_search(&req), + "text_audit" => self.automation_text_audit(&req), "text_replace" => self.automation_text_replace(&req), "records" => self.record_query(&req), "record_schema" => self.record_schema(&req), diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index 0d6780491..ce195c186 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -472,6 +472,7 @@ impl OpenCADStudio { | "get_selection" | "audit" | "text_search" + | "text_audit" ); if !query && !self.control.enabled { return ( @@ -731,6 +732,7 @@ impl OpenCADStudio { "xdata_get" => self.xdata_read(&req), "get_selection" => self.control_get_selection(), "text_search" => self.control_text_search(&req).unwrap_or_else(|e| e), + "text_audit" => self.control_text_audit(&req).unwrap_or_else(|e| e), "history" => { json!({"ok":true,"entries":self.command_line.history.iter().map(|e|json!({"kind":format!("{:?}",e.kind),"text":e.text})).collect::>()}) } diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index 445368372..44c5543ed 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -655,3 +655,112 @@ fn text_replace_op_batch_replaces_preserves_formatting_and_undoes() { assert_eq!(q_orig2["count"], 1); } +#[test] +fn text_audit_op_simulates_dry_run_checks_terms_and_dictionary() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"ta_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"CIRTUITS 28-09-2026","layer":"0"}, + {"type":"MText","position":[10,0,0],"value":"{\\fCentury Gothic;CUVE EAU NON TRAITE}","layer":"0"}, + {"type":"Text","position":[20,0,0],"value":"POMPE A EAU","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Test text_audit with pairs, check_terms, and dictionary + let audit_res = app.automation_op( + r#"{ + "op":"text_audit", + "pairs":[ + {"find":"CIRTUITS","replace":"CIRCUITS"}, + {"find":"NON TRAITE","replace":"NON TRAITÉE"}, + {"find":"INEXISTANT_PAIR","replace":"REPLACEMENT"} + ], + "check_terms":["CIRTUITS", "NON TRAITE", "INEXISTANT_TERM"], + "dictionary":["EAU", "POMPE", "28-09-2026"] + }"#, + ); + assert_eq!(audit_res["ok"], true, "{}", audit_res["error"]); + + // Check summary + assert_eq!(audit_res["summary"]["pairs_total"], 3); + assert_eq!(audit_res["summary"]["pairs_unmatched"], 1); + assert_eq!(audit_res["summary"]["check_terms_total"], 3); + assert_eq!(audit_res["summary"]["check_terms_found"], 2); + + // Unmatched reporting + assert_eq!(audit_res["unmatched_pairs"], json!(["INEXISTANT_PAIR"])); + assert_eq!(audit_res["unmatched_check_terms"], json!(["INEXISTANT_TERM"])); + + // Simulated changes has 2 entries (Text and MText) + let changes = audit_res["simulated_changes"].as_array().unwrap(); + assert_eq!(changes.len(), 2); + assert_eq!(changes[0]["find"], "CIRTUITS"); + assert_eq!(changes[0]["replace"], "CIRCUITS"); + assert_eq!(changes[0]["replaced"], 1); + assert!(changes[1]["after"].as_str().unwrap().contains("NON TRAITÉE")); + + // Suspect matches + let suspects = audit_res["suspect_matches"].as_array().unwrap(); + assert_eq!(suspects.len(), 2); + + // Verify document was NOT mutated (pure read op) + let q = app.automation_op(r#"{"op":"text_search","find":"CIRTUITS"}"#); + assert_eq!(q["ok"], true); + assert_eq!(q["count"], 1); +} + +#[test] +fn text_replace_op_supports_dry_run_without_mutating_or_undo() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"tr_dry1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"TEST BEFORE REPLACE","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Run text_replace with dry_run = true + let dry_res = mutate( + &mut app, + r#"{"protocol":1,"op":"text_replace","request_id":"tr_sim1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":true}"#, + ); + assert_eq!(dry_res["ok"], true, "{}", dry_res["error"]); + assert_eq!(dry_res["result"]["dry_run"], true); + assert_eq!(dry_res["result"]["replaced"], 1); + assert_eq!(dry_res["result"]["entities_changed"], 1); + + // Verify the document was NOT modified + let q_before = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#); + assert_eq!(q_before["ok"], true); + assert_eq!(q_before["count"], 1); + + let q_after = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#); + assert_eq!(q_after["ok"], true); + assert_eq!(q_after["count"], 0); + + // Now run with dry_run = false + let real_res = mutate( + &mut app, + r#"{"protocol":1,"op":"text_replace","request_id":"tr_real1","document_id":{doc},"find":"BEFORE","replace":"AFTER","dry_run":false}"#, + ); + assert_eq!(real_res["ok"], true, "{}", real_res["error"]); + assert_eq!(real_res["result"]["replaced"], 1); + + // Now it was modified + let q_after2 = app.automation_op(r#"{"op":"text_search","find":"TEST AFTER REPLACE"}"#); + assert_eq!(q_after2["ok"], true); + assert_eq!(q_after2["count"], 1); + + // Undo restores it + app.automation_op(r#"{"op":"undo"}"#); + let q_undone = app.automation_op(r#"{"op":"text_search","find":"TEST BEFORE REPLACE"}"#); + assert_eq!(q_undone["ok"], true); + assert_eq!(q_undone["count"], 1); +} + + diff --git a/src/app/control/text_ops.rs b/src/app/control/text_ops.rs index e0bf2f919..e331c45e4 100644 --- a/src/app/control/text_ops.rs +++ b/src/app/control/text_ops.rs @@ -758,7 +758,14 @@ impl OpenCADStudio { let limit = req["limit"].as_u64().unwrap_or(500).min(5000) as usize; let matcher = TextMatcher::new(find.to_string(), match_case, whole_word, ignore_accents); - let i = self.active_tab; + let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) { + self.tabs + .iter() + .position(|t| t.id == doc_id) + .ok_or_else(|| failure("document_not_found", "Specified document_id not found"))? + } else { + self.active_tab + }; let scene = &self.tabs[i].scene; let doc = &scene.document; @@ -870,13 +877,342 @@ impl OpenCADStudio { })) } + /// `text_audit` — comprehensive text quality check, spell-check, and replacement dry-run. + pub(crate) fn control_text_audit(&self, req: &Value) -> Result { + let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) { + self.tabs + .iter() + .position(|t| t.id == doc_id) + .ok_or_else(|| failure("document_not_found", "Specified document_id not found"))? + } else { + self.active_tab + }; + + let match_case = req["match_case"].as_bool().unwrap_or(false); + let whole_word = req["whole_word"].as_bool().unwrap_or(false); + let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case); + let scope = req["scope"].as_str().unwrap_or("all"); + let layer_filter = req["layer"].as_str(); + let handles_filter = parse_handles_filter(req); + let types_filter = parse_types_filter(req); + let bounds_filter = parse_bounds_filter(req)?; + let limit = req["limit"].as_u64().unwrap_or(1000).min(10000) as usize; + + // Parse dry-run replacement pairs if supplied + let mut pairs: Vec<(String, String)> = Vec::new(); + let pairs_val = req.get("pairs").or_else(|| req.get("dry_run_pairs")); + if let Some(pair_array) = pairs_val.and_then(|v| v.as_array()) { + for item in pair_array { + let find = item["find"].as_str().unwrap_or(""); + let replace = item["replace"].as_str().unwrap_or(""); + if !find.is_empty() { + pairs.push((find.to_string(), replace.to_string())); + } + } + } + + // Parse check terms if supplied + let mut check_terms: Vec = Vec::new(); + if let Some(terms_array) = req.get("check_terms").and_then(|v| v.as_array()) { + for item in terms_array { + if let Some(t) = item.as_str() { + if !t.is_empty() { + check_terms.push(t.to_string()); + } + } + } + } + + // Parse dictionary if supplied + let mut dictionary: Vec = Vec::new(); + if let Some(dict_array) = req.get("dictionary").and_then(|v| v.as_array()) { + for item in dict_array { + if let Some(w) = item.as_str() { + if !w.is_empty() { + dictionary.push(w.to_string()); + } + } + } + } + + let pair_matchers: Vec<(TextMatcher, String)> = pairs + .iter() + .map(|(f, r)| { + ( + TextMatcher::new(f.clone(), match_case, whole_word, ignore_accents), + r.clone(), + ) + }) + .collect(); + + let check_matchers: Vec = check_terms + .iter() + .map(|t| TextMatcher::new(t.clone(), match_case, whole_word, ignore_accents)) + .collect(); + + let dict_matchers: Vec = dictionary + .iter() + .map(|w| TextMatcher::new(w.clone(), false, true, true)) + .collect(); + + let mut pair_hit_counts = vec![0usize; pair_matchers.len()]; + let mut check_hit_counts = vec![0usize; check_matchers.len()]; + + let scene = &self.tabs[i].scene; + let doc = &scene.document; + + let mut entities_scanned = 0usize; + let mut entities_matched = 0usize; + let mut total_occurrences = 0usize; + let mut simulated_changes = Vec::new(); + let mut suspect_matches = Vec::new(); + let mut unrecognized_words = Vec::new(); + + for entity in doc.entities() { + let common = entity.common(); + let handle = common.handle; + + let in_active = scene.entity_belongs_to_active_space(handle); + match scope { + "active_space" | "model_space" if !in_active => continue, + "blocks" if in_active => continue, + _ => {} + } + + if let Some(l) = layer_filter { + if !common.layer.eq_ignore_ascii_case(l) { + continue; + } + } + + if let Some(ref h_list) = handles_filter { + if !h_list.contains(&handle) { + continue; + } + } + + if let Some(ref t_list) = types_filter { + if !t_list + .iter() + .any(|t| crate::app::automation::entity_type_matches(entity, t)) + { + continue; + } + } + + if let Some(b) = bounds_filter { + let (min, max) = crate::scene::convert::tess::entity_bounds_in(doc, entity); + if max[0] < b[0] || max[1] < b[1] || min[0] > b[2] || min[1] > b[3] { + continue; + } + } + + let Some((raw_val, plain_text, pos)) = entity_text_info(entity, doc) else { + continue; + }; + + entities_scanned += 1; + let mut entity_had_match = false; + + // 1. Dry run pairs simulation + if !pair_matchers.is_empty() { + match entity { + EntityType::MText(_) => { + let mut cur_raw = raw_val.clone(); + for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() { + let (new_raw, count) = + replace_mtext_safe(&cur_raw, matcher, replacement, true); + if count > 0 { + pair_hit_counts[idx] += count; + total_occurrences += count; + entity_had_match = true; + if simulated_changes.len() < limit { + simulated_changes.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "MText", + "layer": common.layer, + "position": pos, + "find": pairs[idx].0, + "replace": pairs[idx].1, + "before": cur_raw, + "after": new_raw.clone(), + "replaced": count, + })); + } + cur_raw = new_raw; + } + } + } + _ => { + let mut cur_text = plain_text.clone(); + for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() { + let (new_text, count) = + matcher.replace_all_in(&cur_text, replacement, true); + if count > 0 { + pair_hit_counts[idx] += count; + total_occurrences += count; + entity_had_match = true; + if simulated_changes.len() < limit { + simulated_changes.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "layer": common.layer, + "position": pos, + "find": pairs[idx].0, + "replace": pairs[idx].1, + "before": cur_text, + "after": new_text.clone(), + "replaced": count, + })); + } + cur_text = new_text; + } + } + } + } + } + + // 2. Check terms + for (idx, matcher) in check_matchers.iter().enumerate() { + if matcher.matches_text_or_raw(&plain_text, &raw_val) { + check_hit_counts[idx] += 1; + entity_had_match = true; + if suspect_matches.len() < limit { + suspect_matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": crate::entities::names::ui_name(entity), + "layer": common.layer, + "position": pos, + "term": check_terms[idx], + "plain_text": plain_text, + })); + } + } + } + + // 3. Dictionary check + if !dict_matchers.is_empty() { + for word in plain_text.split(|c: char| !c.is_alphabetic()) { + let w = word.trim(); + if w.len() >= 3 && !dict_matchers.iter().any(|dm| dm.matches(w)) { + if unrecognized_words.len() < limit + && !unrecognized_words + .iter() + .any(|item: &Value| item["word"].as_str() == Some(w)) + { + unrecognized_words.push(json!({ + "word": w, + "handle": format!("{:X}", handle.value()), + "position": pos, + })); + } + } + } + } + + // Check Insert attributes + if let EntityType::Insert(insert) = entity { + for attr in &insert.attributes { + let raw = attr.get_value(); + let plain = decode_dxf_escapes(raw); + + for (idx, (matcher, replacement)) in pair_matchers.iter().enumerate() { + let (new_text, count) = + matcher.replace_all_in(&plain, replacement, true); + if count > 0 { + pair_hit_counts[idx] += count; + total_occurrences += count; + entity_had_match = true; + if simulated_changes.len() < limit { + simulated_changes.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "attribute_tag": attr.tag, + "layer": common.layer, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + "find": pairs[idx].0, + "replace": pairs[idx].1, + "before": plain.clone(), + "after": new_text, + "replaced": count, + })); + } + } + } + + for (idx, matcher) in check_matchers.iter().enumerate() { + if matcher.matches_text_or_raw(&plain, raw) { + check_hit_counts[idx] += 1; + entity_had_match = true; + if suspect_matches.len() < limit { + suspect_matches.push(json!({ + "handle": format!("{:X}", handle.value()), + "type": "Insert", + "attribute_tag": attr.tag, + "layer": common.layer, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + "term": check_terms[idx], + "plain_text": plain, + })); + } + } + } + } + } + + if entity_had_match { + entities_matched += 1; + } + } + + // Unmatched pairs / queries + let unmatched_pairs: Vec = pair_hit_counts + .iter() + .enumerate() + .filter(|(_, &count)| count == 0) + .map(|(idx, _)| pairs[idx].0.clone()) + .collect(); + + let unmatched_check_terms: Vec = check_hit_counts + .iter() + .enumerate() + .filter(|(_, &count)| count == 0) + .map(|(idx, _)| check_terms[idx].clone()) + .collect(); + + Ok(json!({ + "ok": true, + "summary": { + "entities_scanned": entities_scanned, + "entities_matched": entities_matched, + "occurrences_matched": total_occurrences, + "pairs_total": pairs.len(), + "pairs_unmatched": unmatched_pairs.len(), + "check_terms_total": check_terms.len(), + "check_terms_found": check_terms.len() - unmatched_check_terms.len(), + }, + "unmatched_pairs": unmatched_pairs, + "unmatched_check_terms": unmatched_check_terms, + "simulated_changes": simulated_changes, + "suspect_matches": suspect_matches, + "unrecognized_words": unrecognized_words, + })) + } + /// Execute text replace across candidates with single-transaction undo support. pub(crate) fn execute_text_replace( &mut self, req: &Value, push_undo: bool, ) -> Result { - let i = self.active_tab; + let i = if let Some(doc_id) = req.get("document_id").and_then(|v| v.as_u64()) { + self.tabs + .iter() + .position(|t| t.id == doc_id) + .ok_or_else(|| failure("document_not_found", "Specified document_id not found"))? + } else { + self.active_tab + }; // Parse search/replace pairs let mut pairs: Vec<(String, String)> = Vec::new(); @@ -905,6 +1241,7 @@ impl OpenCADStudio { let match_case = req["match_case"].as_bool().unwrap_or(false); let whole_word = req["whole_word"].as_bool().unwrap_or(false); let ignore_accents = req["ignore_accents"].as_bool().unwrap_or(!match_case); + let dry_run = req["dry_run"].as_bool().unwrap_or(false); let replace_all = req["replace_all"].as_bool().unwrap_or(true); let scope = req["scope"].as_str().unwrap_or("all"); let layer_filter = req["layer"].as_str(); @@ -917,7 +1254,7 @@ impl OpenCADStudio { .map(|(f, r)| (TextMatcher::new(f, match_case, whole_word, ignore_accents), r)) .collect(); - if push_undo { + if push_undo && !dry_run { self.push_undo_snapshot(i, "TEXT REPLACE"); } @@ -998,9 +1335,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(text.value.clone()); } - text.value = new_val; + if !dry_run { + text.value = new_val.clone(); + } entity_replaced += count; - after_val = Some(text.value.clone()); + after_val = Some(new_val); } else { // Check if matching decoded DXF escapes let decoded = decode_dxf_escapes(&text.value); @@ -1011,9 +1350,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(text.value.clone()); } - text.value = new_val; + if !dry_run { + text.value = new_val.clone(); + } entity_replaced += c; - after_val = Some(text.value.clone()); + after_val = Some(new_val); } } } @@ -1027,9 +1368,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(mtext.value.clone()); } - mtext.value = new_val; + if !dry_run { + mtext.value = new_val.clone(); + } entity_replaced += count; - after_val = Some(mtext.value.clone()); + after_val = Some(new_val); } } } @@ -1041,9 +1384,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(ad.default_value.clone()); } - ad.default_value = new_val; + if !dry_run { + ad.default_value = new_val.clone(); + } entity_replaced += count; - after_val = Some(ad.default_value.clone()); + after_val = Some(new_val); } } } @@ -1055,9 +1400,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(ae.get_value().to_string()); } - ae.set_value(new_val); + if !dry_run { + ae.set_value(new_val.clone()); + } entity_replaced += count; - after_val = Some(ae.get_value().to_string()); + after_val = Some(new_val); } } } @@ -1071,9 +1418,11 @@ impl OpenCADStudio { if before_val.is_none() { before_val = Some(base.text.clone()); } - base.text = new_val; + if !dry_run { + base.text = new_val.clone(); + } entity_replaced += count; - after_val = Some(base.text.clone()); + after_val = Some(new_val); } } } @@ -1085,7 +1434,9 @@ impl OpenCADStudio { matcher.replace_all_in(attr.get_value(), replacement, replace_all); if count > 0 { let old_val = attr.get_value().to_string(); - attr.set_value(new_val.clone()); + if !dry_run { + attr.set_value(new_val.clone()); + } total_replaced += count; changes_log.push(json!({ "handle": format!("{:X}", handle.value()), @@ -1121,33 +1472,37 @@ impl OpenCADStudio { } if total_replaced == 0 { - if push_undo { + if push_undo && !dry_run { self.discard_last_undo_entry(i); } return Ok(json!({ "ok": true, + "dry_run": dry_run, "replaced": 0, "entities_changed": 0, "changes": [], })); } - // Refresh scene caches and dirty flags - self.invalidate_property_targets(i, &changed_handles); - self.tabs[i].scene.bump_geometry(); - self.tabs[i].dirty = true; - self.refresh_properties(); + if !dry_run { + // Refresh scene caches and dirty flags + self.invalidate_property_targets(i, &changed_handles); + self.tabs[i].scene.bump_geometry(); + self.tabs[i].dirty = true; + self.refresh_properties(); - self.command_line.push_output( - format!( - "TEXT REPLACE: replaced {total_replaced} occurrence(s) in {} object(s).", - changed_handles.len() - ) - .as_str(), - ); + self.command_line.push_output( + format!( + "TEXT REPLACE: replaced {total_replaced} occurrence(s) in {} object(s).", + changed_handles.len() + ) + .as_str(), + ); + } Ok(json!({ "ok": true, + "dry_run": dry_run, "replaced": total_replaced, "entities_changed": changed_handles.len(), "changes": changes_log, @@ -1166,6 +1521,11 @@ impl OpenCADStudio { self.control_text_search(req).unwrap_or_else(|e| e) } + /// Headless automation text audit. + pub(crate) fn automation_text_audit(&self, req: &Value) -> Value { + self.control_text_audit(req).unwrap_or_else(|e| e) + } + /// Headless automation text replace. pub(crate) fn automation_text_replace(&mut self, req: &Value) -> Value { self.execute_text_replace(req, true).unwrap_or_else(|e| e) diff --git a/src/mcp.rs b/src/mcp.rs index ef34d0380..4540ffaba 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -43,6 +43,7 @@ const READ_OPS: &[&str] = &[ "xdata_get", "audit", "text_search", + "text_audit", ]; const EXECUTE_OPS: &[&str] = &[ "new", @@ -1064,6 +1065,7 @@ fn execute_request_schema() -> Value { "match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search/replace."}, "whole_word":{"type":"boolean","default":false,"description":"Match only whole words (bounded by non-alphanumeric characters)."}, "ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."}, + "dry_run":{"type":"boolean","default":false,"description":"When true, simulates replacement without mutating entities or pushing undo."}, "replace_all":{"type":"boolean","default":true,"description":"Replace all occurrences within matching entities (default true)."}, "scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search/replace scope."}, "path":{"type":"string","minLength":1,"description":"Absolute path: drawing for open or save, image file for embed_image, target DWG/DXF for wblock, target PDF for plot; save_verified requires .dwg or .dxf."}, @@ -1223,7 +1225,7 @@ fn tool_definitions() -> Value { { "name":"ocs_read", "description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, @@ -1595,6 +1597,7 @@ mod tests { assert!(READ_OPS.contains(&"record_schema")); assert!(READ_OPS.contains(&"audit")); assert!(READ_OPS.contains(&"text_search")); + assert!(READ_OPS.contains(&"text_audit")); assert!(EXECUTE_OPS.contains(&"set_properties")); assert!(EXECUTE_OPS.contains(&"save_verified")); assert!(EXECUTE_OPS.contains(&"text_replace")); From 77fbb799173d02d43d577a3100cde1092c70262a Mon Sep 17 00:00:00 2001 From: Karim Jerbi Date: Tue, 29 Sep 2026 13:48:27 +0100 Subject: [PATCH 11/17] feat(text): native OS spell-checker for text_audit (Windows/macOS/Linux) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cross-platform SystemSpeller abstraction with three backends: - Windows: direct COM vtable calls to ISpellChecker (Win32 Spell API), no external crate — uses the same hand-rolled FFI pattern as file_association.rs. Handles ALL-CAPS words by also testing lowercase. - macOS: NSSpellChecker via objc2/objc2-app-kit (already in deps). - Linux: Hunspell .dic file reader + hunspell CLI pipe fallback. text_audit gains three new parameters: system_spellcheck: true — activate OS spell-checker language: en-US — BCP-47 language tag (default: OS default) suggest: true — include spelling suggestions The agent dictionary and system speller work as a layered pipeline: words matching the agent dictionary are accepted immediately; only remaining words are forwarded to the OS checker. Response includes a system_speller object (enabled, available, backend, language) and an unrecognized_words array with handle, position, and optional suggestions. Integration test: text_audit_system_spellcheck_flags_misspelled_words (20/20 p1_tests pass). Release build verified. --- docs/automation/README.md | 48 +++ src/app/control/mod.rs | 1 + src/app/control/p1_tests.rs | 83 ++++ src/app/control/spellcheck.rs | 767 ++++++++++++++++++++++++++++++++++ src/app/control/text_ops.rs | 114 ++++- src/mcp.rs | 2 +- 6 files changed, 1001 insertions(+), 14 deletions(-) create mode 100644 src/app/control/spellcheck.rs diff --git a/docs/automation/README.md b/docs/automation/README.md index 8c700d7f1..5a50320c5 100644 --- a/docs/automation/README.md +++ b/docs/automation/README.md @@ -301,10 +301,58 @@ maps onto these three steps rather than a native op. `query` entities of type `TEXT`/`MTEXT` return the raw stored string in `value` plus a formatting-free rendering in `text` (MTEXT inline codes such as `\A1;` or `\P` are resolved; `%%d`-style TEXT codes become their glyphs). With `detail:"full"`, degenerate-width text bounds are widened with a documented estimate (height × 0.8 × character count) so `bounds`-based region filters stay usable. +### Text operations — search, audit and replace + +Three dedicated text operations go beyond `query` for bulk text work: + +**`text_search`** (read) scans all `Text`, `MText`, and `Insert` attribute values for a pattern. It supports case-insensitive, whole-word, and accent-insensitive matching, scoped to a layer, entity type list, or handle set. + +```json +{"op":"text_search","find":"CUVE","match_case":false,"whole_word":true,"ignore_accents":true} +``` + +**`text_audit`** (read) is a non-mutating spell-check and dry-run replacement preview. It accepts three independent analysis modes, all optional, in one call: + +| Parameter | Effect | +|---|---| +| `pairs` | `[{"find":"X","replace":"Y"}, …]` — simulates a batch replacement and reports what would change, without touching the document | +| `check_terms` | `["CIRTUITS","NON TRAITE"]` — flags entities that contain suspect strings | +| `dictionary` | `["EAU","POMPE","VANNE"]` — any word ≥ 3 letters not found in this list is reported as unrecognised | +| `system_spellcheck` | `true` to use the OS native spell-checker alongside or instead of the agent dictionary | +| `language` | BCP-47 tag for the system speller, e.g. `"en-US"` or `"fr-FR"` (defaults to the OS default) | +| `suggest` | `true` (default) to include spelling suggestions for unrecognised words | + +The agent dictionary and system spell-checker work as a layered pipeline: a word that matches the agent dictionary is accepted immediately; only words that fail (or when no dictionary is supplied) are forwarded to the OS spell-checker when enabled. + +System spell-check backends: + +| Platform | Backend | Notes | +|---|---|---| +| Windows 8.1+ | Win32 `ISpellChecker` COM API | Per-language; checks available via Settings → Language. ALL-CAPS words are checked in lowercase since Windows skips all-caps by default. | +| macOS | `NSSpellChecker` | Uses the system language or the `language` parameter | +| Linux | Hunspell `.dic` files or `hunspell` CLI | Reads `/usr/share/hunspell/` dictionaries; falls back to pipe mode | + +Example combining an agent dictionary with system spell-check: + +```json +{ + "op": "text_audit", + "dictionary": ["VANNE", "CHAUDIÈRE", "POMPE"], + "system_spellcheck": true, + "language": "fr-FR", + "suggest": true +} +``` + +The response includes a `system_speller` object with `enabled`, `available`, `backend`, and `language` fields, plus an `unrecognized_words` array with each word's `handle`, `position`, and optional `suggestions`. + +**`text_replace`** (execute) performs the actual batch replacement with single-transaction undo. Add `"dry_run": true` to preview the result without mutating the document or pushing an undo entry. + ### Smoke test The stdio path is covered black-box by a Python script that draws two entities, exports them with `wblock`, plots them with `plot`, and checks the capability advertisement: + ```sh python3 docs/automation/serve_smoke.py target/debug/OpenCADStudio ``` diff --git a/src/app/control/mod.rs b/src/app/control/mod.rs index ce195c186..f2af51592 100644 --- a/src/app/control/mod.rs +++ b/src/app/control/mod.rs @@ -1346,6 +1346,7 @@ mod interactive; mod sheets; pub(crate) use sheets::new_guid_v4; pub(crate) mod text_ops; +pub(crate) mod spellcheck; #[cfg(test)] mod p1_tests; diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index 44c5543ed..3ad5b8b6a 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -763,4 +763,87 @@ fn text_replace_op_supports_dry_run_without_mutating_or_undo() { assert_eq!(q_undone["count"], 1); } +#[test] +fn text_audit_system_spellcheck_flags_misspelled_words() { + let mut app = OpenCADStudio::new_for_test(); + app.automation_op(r#"{"op":"new"}"#); + let r = mutate( + &mut app, + r#"{"protocol":1,"op":"entities_create","request_id":"sp_c1","document_id":{doc},"entities":[ + {"type":"Text","position":[0,0,0],"value":"The cirtuit breaker is conected","layer":"0"}, + {"type":"Text","position":[10,0,0],"value":"correct english text here","layer":"0"} + ]}"#, + ); + assert_eq!(r["ok"], true, "{}", r["error"]); + + // Run text_audit with system_spellcheck enabled, English language, + // and an agent dictionary that whitelists "breaker" + let audit = app.automation_op( + r#"{ + "op":"text_audit", + "system_spellcheck": true, + "language": "en-US", + "suggest": true, + "dictionary": ["breaker", "the"] + }"#, + ); + assert_eq!(audit["ok"], true, "{}", audit["error"]); + + // system_speller metadata should be present regardless of availability + let sp = &audit["system_speller"]; + assert_eq!(sp["enabled"], true); + + if sp["available"].as_bool() == Some(true) { + // Backend should be reported (e.g. "windows" on Windows) + assert!(sp["backend"].as_str().unwrap().len() > 0); + assert!(sp["language"].as_str().is_some()); + + // "cirtuit" and "conected" should be flagged as unrecognized + let words = audit["unrecognized_words"].as_array().unwrap(); + let flagged: Vec<&str> = words.iter().filter_map(|w| w["word"].as_str()).collect(); + assert!( + flagged.iter().any(|w| w.eq_ignore_ascii_case("cirtuit")), + "expected 'cirtuit' to be flagged, got: {:?}", + flagged + ); + assert!( + flagged.iter().any(|w| w.eq_ignore_ascii_case("conected")), + "expected 'conected' to be flagged, got: {:?}", + flagged + ); + + // "breaker" should NOT be flagged (it's in the agent dictionary) + assert!( + !flagged.iter().any(|w| w.eq_ignore_ascii_case("breaker")), + "expected 'breaker' to be whitelisted by agent dict, got: {:?}", + flagged + ); + + // At least one flagged word should have suggestions + let has_suggestions = words.iter().any(|w| { + w["suggestions"] + .as_array() + .map(|a| !a.is_empty()) + .unwrap_or(false) + }); + assert!( + has_suggestions, + "expected at least one word to have suggestions" + ); + + // Summary should reflect unrecognized words + assert!( + audit["summary"]["unrecognized_words_count"].as_u64().unwrap() >= 2, + "expected at least 2 unrecognized words in summary" + ); + } else { + // System speller not available (e.g. CI without spell-check service) + // Just verify the response structure is well-formed + assert!(audit["unrecognized_words"].is_array()); + eprintln!( + "System spellcheck not available (backend: {}), skipping content assertions", + sp["backend"] + ); + } +} diff --git a/src/app/control/spellcheck.rs b/src/app/control/spellcheck.rs new file mode 100644 index 000000000..8ab3abb23 --- /dev/null +++ b/src/app/control/spellcheck.rs @@ -0,0 +1,767 @@ +//! Native OS spell-checker abstraction supporting Windows, macOS, and Linux. +//! +//! - **Windows**: Windows 8+ native Spell Checking API (`ISpellChecker` via COM). +//! - **macOS**: Apple AppKit `NSSpellChecker` via Objective-C. +//! - **Linux**: Standard system dictionary wordlists (`/usr/share/hunspell`, `/usr/share/myspell`, +//! `/usr/share/dict`) and Hunspell / Aspell pipe integration. +//! - **Other**: Graceful no-op fallback. + +/// Unified system spell checker handle. +pub struct SystemSpeller { + backend: Backend, + language: String, +} + +enum Backend { + #[cfg(target_os = "windows")] + Windows(windows_impl::WindowsSpeller), + #[cfg(target_os = "macos")] + Mac(macos_impl::MacSpeller), + #[cfg(target_os = "linux")] + Linux(linux_impl::LinuxSpeller), + Fallback(fallback_impl::FallbackSpeller), +} + +impl SystemSpeller { + /// Create a system speller for the given BCP-47 language tag (e.g. "fr-FR", "en-US", "de-DE", "es-ES"), + /// or default to the host system's UI locale. + pub fn new(requested_lang: Option<&str>) -> Self { + let lang = normalize_language_tag(requested_lang); + + #[cfg(target_os = "windows")] + { + if let Some(win_speller) = windows_impl::WindowsSpeller::new(&lang) { + let actual_lang = win_speller.language().to_string(); + return Self { + backend: Backend::Windows(win_speller), + language: actual_lang, + }; + } + } + + #[cfg(target_os = "macos")] + { + if let Some(mac_speller) = macos_impl::MacSpeller::new(&lang) { + let actual_lang = mac_speller.language().to_string(); + return Self { + backend: Backend::Mac(mac_speller), + language: actual_lang, + }; + } + } + + #[cfg(target_os = "linux")] + { + if let Some(linux_speller) = linux_impl::LinuxSpeller::new(&lang) { + let actual_lang = linux_speller.language().to_string(); + return Self { + backend: Backend::Linux(linux_speller), + language: actual_lang, + }; + } + } + + Self { + backend: Backend::Fallback(fallback_impl::FallbackSpeller), + language: lang, + } + } + + /// Whether a native system spell-checker is active and available. + pub fn is_available(&self) -> bool { + match &self.backend { + #[cfg(target_os = "windows")] + Backend::Windows(_) => true, + #[cfg(target_os = "macos")] + Backend::Mac(_) => true, + #[cfg(target_os = "linux")] + Backend::Linux(l) => l.is_available(), + Backend::Fallback(_) => false, + } + } + + /// Returns the active backend name ("windows", "macos", "linux_system", or "none"). + pub fn backend_name(&self) -> &'static str { + match &self.backend { + #[cfg(target_os = "windows")] + Backend::Windows(_) => "windows", + #[cfg(target_os = "macos")] + Backend::Mac(_) => "macos", + #[cfg(target_os = "linux")] + Backend::Linux(_) => "linux_system", + Backend::Fallback(_) => "none", + } + } + + /// The active language tag (e.g. "fr-FR", "en-US"). + pub fn language(&self) -> &str { + &self.language + } + + /// Check if a word is spelled correctly according to the system spell-checker. + /// + /// Returns `(is_correct, suggestions)`. + /// When `is_correct` is false, `suggestions` contains system-suggested corrections. + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + if word.trim().is_empty() { + return (true, Vec::new()); + } + + match &self.backend { + #[cfg(target_os = "windows")] + Backend::Windows(w) => w.check_word(word, want_suggestions), + #[cfg(target_os = "macos")] + Backend::Mac(m) => m.check_word(word, want_suggestions), + #[cfg(target_os = "linux")] + Backend::Linux(l) => l.check_word(word, want_suggestions), + Backend::Fallback(_) => (true, Vec::new()), + } + } +} + +fn normalize_language_tag(input: Option<&str>) -> String { + let raw: String = match input { + Some(s) if !s.trim().is_empty() => s.trim().to_string(), + _ => { + #[cfg(target_os = "windows")] + { + let mut buf = [0u16; 85]; + let n = unsafe { windows_impl::GetUserDefaultLocaleName(buf.as_mut_ptr(), 85) }; + if n > 1 { + String::from_utf16_lossy(&buf[..(n - 1) as usize]) + } else { + "en-US".to_string() + } + } + #[cfg(not(target_os = "windows"))] + { + if let Ok(lang) = std::env::var("LANG").or_else(|_| std::env::var("LC_ALL")) { + let cleaned = lang.split('.').next().unwrap_or("").replace('_', "-"); + if !cleaned.is_empty() { + cleaned + } else { + "en-US".to_string() + } + } else { + "en-US".to_string() + } + } + } + }; + + let lower = raw.to_lowercase(); + match lower.as_str() { + "fr" | "french" | "francais" | "français" => "fr-FR".to_string(), + "en" | "english" => "en-US".to_string(), + "de" | "german" | "deutsch" => "de-DE".to_string(), + "es" | "spanish" | "espanol" | "español" => "es-ES".to_string(), + "it" | "italian" | "italiano" => "it-IT".to_string(), + _ => raw.to_string(), + } +} + +// ============================================================================ +// Windows Backend: Win32 Spell Checking API (ISpellChecker via COM) +// ============================================================================ +#[cfg(target_os = "windows")] +pub(crate) mod windows_impl { + use std::ffi::c_void; + use std::os::windows::ffi::OsStrExt; + + type HRESULT = i32; + type BOOL = i32; + + #[repr(C)] + struct GUID { + data1: u32, + data2: u16, + data3: u16, + data4: [u8; 8], + } + + const CLSID_SPELL_CHECKER_FACTORY: GUID = GUID { + data1: 0x7ab36653, + data2: 0x1796, + data3: 0x484b, + data4: [0xbd, 0xfa, 0xe7, 0x4f, 0x1d, 0xb7, 0xc1, 0xdc], + }; + + const IID_ISPELL_CHECKER_FACTORY: GUID = GUID { + data1: 0x8e018a9d, + data2: 0x2415, + data3: 0x4677, + data4: [0xbf, 0x08, 0x79, 0x4e, 0xa6, 0x1f, 0x94, 0xbb], + }; + + #[repr(C)] + struct ISpellCheckerFactoryVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + get_supported_languages: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + is_supported: unsafe extern "system" fn(*mut c_void, *const u16, *mut BOOL) -> HRESULT, + create_spell_checker: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT, + } + + #[repr(C)] + struct ISpellCheckerVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + get_language_tag: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + check: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT, + suggest: unsafe extern "system" fn(*mut c_void, *const u16, *mut *mut c_void) -> HRESULT, + add: unsafe extern "system" fn(*mut c_void, *const u16) -> HRESULT, + ignore: unsafe extern "system" fn(*mut c_void, *const u16) -> HRESULT, + auto_correct: unsafe extern "system" fn(*mut c_void, *const u16, *const u16) -> HRESULT, + get_option_value: unsafe extern "system" fn(*mut c_void, *const u16, *mut u8) -> HRESULT, + get_option_ids: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + get_id: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + get_localized_name: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + } + + #[repr(C)] + struct IEnumSpellingErrorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + next: unsafe extern "system" fn(*mut c_void, *mut *mut c_void) -> HRESULT, + } + + #[repr(C)] + struct ISpellingErrorVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + get_start_index: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + get_length: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + get_corrective_action: unsafe extern "system" fn(*mut c_void, *mut u32) -> HRESULT, + get_replacement: unsafe extern "system" fn(*mut c_void, *mut *mut u16) -> HRESULT, + } + + #[repr(C)] + struct IEnumStringVtbl { + query_interface: unsafe extern "system" fn(*mut c_void, *const GUID, *mut *mut c_void) -> HRESULT, + add_ref: unsafe extern "system" fn(*mut c_void) -> u32, + release: unsafe extern "system" fn(*mut c_void) -> u32, + next: unsafe extern "system" fn(*mut c_void, u32, *mut *mut u16, *mut u32) -> HRESULT, + } + + #[link(name = "ole32")] + extern "system" { + fn CoInitializeEx(pv_reserved: *const c_void, dw_co_init: u32) -> HRESULT; + fn CoCreateInstance( + rclsid: *const GUID, + p_unk_outer: *mut c_void, + dw_cls_context: u32, + riid: *const GUID, + ppv: *mut *mut c_void, + ) -> HRESULT; + fn CoTaskMemFree(pv: *mut c_void); + } + + #[link(name = "kernel32")] + extern "system" { + pub fn GetUserDefaultLocaleName(lp_locale_name: *mut u16, cch_locale_name: i32) -> i32; + } + + fn to_wide(s: &str) -> Vec { + std::ffi::OsStr::new(s).encode_wide().chain(Some(0)).collect() + } + + unsafe fn from_wide_ptr(ptr: *mut u16) -> String { + if ptr.is_null() { + return String::new(); + } + let mut len = 0; + while *ptr.add(len) != 0 { + len += 1; + } + let slice = std::slice::from_raw_parts(ptr, len); + let s = String::from_utf16_lossy(slice); + CoTaskMemFree(ptr as *mut c_void); + s + } + + pub struct WindowsSpeller { + checker_obj: *mut c_void, + language: String, + } + + // Windows COM objects created with CLSCTX_INPROC_SERVER are safe to invoke from our control thread. + unsafe impl Send for WindowsSpeller {} + unsafe impl Sync for WindowsSpeller {} + + impl WindowsSpeller { + pub fn new(lang_tag: &str) -> Option { + unsafe { + CoInitializeEx(std::ptr::null(), 0); + + let mut factory_obj: *mut c_void = std::ptr::null_mut(); + let hr = CoCreateInstance( + &CLSID_SPELL_CHECKER_FACTORY, + std::ptr::null_mut(), + 1, // CLSCTX_INPROC_SERVER + &IID_ISPELL_CHECKER_FACTORY, + &mut factory_obj, + ); + if hr != 0 || factory_obj.is_null() { + return None; + } + + let factory_vtbl = *(factory_obj as *const *const ISpellCheckerFactoryVtbl); + + // Try requested language, then fallback languages + let mut candidates = vec![lang_tag.to_string()]; + if !candidates.contains(&"en-US".to_string()) { + candidates.push("en-US".to_string()); + } + + let mut chosen_checker = None; + let mut chosen_lang = String::new(); + + for candidate in candidates { + let wide_cand = to_wide(&candidate); + let mut supported: BOOL = 0; + let hr_supp = ((*factory_vtbl).is_supported)( + factory_obj, + wide_cand.as_ptr(), + &mut supported, + ); + if hr_supp == 0 && supported != 0 { + let mut checker_obj: *mut c_void = std::ptr::null_mut(); + let hr_create = ((*factory_vtbl).create_spell_checker)( + factory_obj, + wide_cand.as_ptr(), + &mut checker_obj, + ); + if hr_create == 0 && !checker_obj.is_null() { + chosen_checker = Some(checker_obj); + chosen_lang = candidate; + break; + } + } + } + + ((*factory_vtbl).release)(factory_obj); + + chosen_checker.map(|obj| Self { + checker_obj: obj, + language: chosen_lang, + }) + } + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + if self.checker_obj.is_null() { + return (true, Vec::new()); + } + + unsafe { + let checker_vtbl = *(self.checker_obj as *const *const ISpellCheckerVtbl); + let wide_text = to_wide(word); + + let has_err_exact = has_spelling_error(checker_vtbl, self.checker_obj, &wide_text); + + let is_all_caps = word.chars().any(char::is_alphabetic) + && word == word.to_uppercase(); + + let mut is_misspelled = has_err_exact; + + // Windows spellcheck by default ignores all-caps words (to avoid flagging acronyms). + // In CAD drawings, virtually all text is uppercase (e.g. CIRTUITS, VANNE). + // If it wasn't flagged in all-caps, test its lowercase form to check if it's a real word. + if !is_misspelled && is_all_caps { + let lower = word.to_lowercase(); + let wide_lower = to_wide(&lower); + if has_spelling_error(checker_vtbl, self.checker_obj, &wide_lower) { + is_misspelled = true; + } + } + + let mut suggestions = Vec::new(); + if is_misspelled && want_suggestions { + // Query suggestions for word and/or its lowercase counterpart + let mut enum_str: *mut c_void = std::ptr::null_mut(); + let target_wide = if is_all_caps { + to_wide(&word.to_lowercase()) + } else { + wide_text.clone() + }; + + if ((*checker_vtbl).suggest)(self.checker_obj, target_wide.as_ptr(), &mut enum_str) == 0 + && !enum_str.is_null() + { + let enum_vtbl = *(enum_str as *const *const IEnumStringVtbl); + loop { + let mut str_ptr: *mut u16 = std::ptr::null_mut(); + let mut fetched: u32 = 0; + if ((*enum_vtbl).next)(enum_str, 1, &mut str_ptr, &mut fetched) != 0 + || fetched == 0 + { + break; + } + let s = from_wide_ptr(str_ptr); + if !s.is_empty() { + // Match uppercase if original word was uppercase + let final_s = if is_all_caps { s.to_uppercase() } else { s }; + if !suggestions.contains(&final_s) { + suggestions.push(final_s); + } + } + if suggestions.len() >= 5 { + break; + } + } + ((*enum_vtbl).release)(enum_str); + } + } + + (!is_misspelled, suggestions) + } + } + } + + unsafe fn has_spelling_error( + checker_vtbl: *const ISpellCheckerVtbl, + checker_obj: *mut c_void, + wide_text: &[u16], + ) -> bool { + let mut errors_obj: *mut c_void = std::ptr::null_mut(); + let hr_check = ((*checker_vtbl).check)(checker_obj, wide_text.as_ptr(), &mut errors_obj); + let mut has_error = false; + if hr_check == 0 && !errors_obj.is_null() { + let enum_vtbl = *(errors_obj as *const *const IEnumSpellingErrorVtbl); + let mut err_item: *mut c_void = std::ptr::null_mut(); + if ((*enum_vtbl).next)(errors_obj, &mut err_item) == 0 && !err_item.is_null() { + has_error = true; + ((*(*(err_item as *const *const ISpellingErrorVtbl))).release)(err_item); + } + ((*enum_vtbl).release)(errors_obj); + } + has_error + } + + impl Drop for WindowsSpeller { + fn drop(&mut self) { + if !self.checker_obj.is_null() { + unsafe { + let vtbl = *(self.checker_obj as *const *const ISpellCheckerVtbl); + ((*vtbl).release)(self.checker_obj); + } + } + } + } +} + +// ============================================================================ +// macOS Backend: NSSpellChecker via AppKit +// ============================================================================ +#[cfg(target_os = "macos")] +pub(crate) mod macos_impl { + use objc2::msg_send; + use objc2::runtime::{AnyClass, AnyObject}; + use objc2_foundation::NSString; + + pub struct MacSpeller { + checker: *mut AnyObject, + language: String, + } + + unsafe impl Send for MacSpeller {} + unsafe impl Sync for MacSpeller {} + + impl MacSpeller { + pub fn new(lang_tag: &str) -> Option { + unsafe { + let cls = AnyClass::get("NSSpellChecker")?; + let checker: *mut AnyObject = msg_send![cls, sharedSpellChecker]; + if checker.is_null() { + return None; + } + + let ns_lang = NSString::from_str(lang_tag); + let ok: bool = msg_send![checker, setLanguage: &*ns_lang]; + let actual_lang = if ok { + lang_tag.to_string() + } else { + let cur_lang: *mut AnyObject = msg_send![checker, language]; + if !cur_lang.is_null() { + let cur: &NSString = &*(cur_lang as *const NSString); + cur.to_string() + } else { + lang_tag.to_string() + } + }; + + Some(Self { + checker, + language: actual_lang, + }) + } + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + if self.checker.is_null() || word.is_empty() { + return (true, Vec::new()); + } + + unsafe { + let is_all_caps = word.chars().any(char::is_alphabetic) + && word == word.to_uppercase(); + + let ns_word = NSString::from_str(word); + let range: objc2_foundation::NSRange = msg_send![ + self.checker, + checkSpellingOfString: &*ns_word, + startingAt: 0isize, + language: std::ptr::null::(), + wrap: false, + inSpellDocumentWithTag: 0isize, + wordCount: std::ptr::null_mut::() + ]; + + let mut is_misspelled = range.length > 0; + + // Also check lowercase if all-caps + if !is_misspelled && is_all_caps { + let lower = word.to_lowercase(); + let ns_lower = NSString::from_str(&lower); + let range_lower: objc2_foundation::NSRange = msg_send![ + self.checker, + checkSpellingOfString: &*ns_lower, + startingAt: 0isize, + language: std::ptr::null::(), + wrap: false, + inSpellDocumentWithTag: 0isize, + wordCount: std::ptr::null_mut::() + ]; + if range_lower.length > 0 { + is_misspelled = true; + } + } + + let mut suggestions = Vec::new(); + if is_misspelled && want_suggestions { + let target_str = if is_all_caps { word.to_lowercase() } else { word.to_string() }; + let ns_target = NSString::from_str(&target_str); + let word_len = target_str.encode_utf16().count(); + let r = objc2_foundation::NSRange { + location: 0, + length: word_len, + }; + let arr: *mut AnyObject = msg_send![ + self.checker, + guessesForWordRange: r, + inString: &*ns_target, + language: std::ptr::null::(), + inSpellDocumentWithTag: 0isize + ]; + if !arr.is_null() { + let count: usize = msg_send![arr, count]; + for i in 0..count.min(5) { + let item: *mut AnyObject = msg_send![arr, objectAtIndex: i]; + if !item.is_null() { + let ns_item: &NSString = &*(item as *const NSString); + let mut s = ns_item.to_string(); + if is_all_caps { + s = s.to_uppercase(); + } + suggestions.push(s); + } + } + } + } + + (!is_misspelled, suggestions) + } + } + } +} + +// ============================================================================ +// Linux Backend: System Dictionaries & Hunspell / Aspell +// ============================================================================ +#[cfg(target_os = "linux")] +pub(crate) mod linux_impl { + use std::collections::HashSet; + use std::fs::File; + use std::io::{BufRead, BufReader}; + use std::path::{Path, PathBuf}; + + const LINUX_DICT_DIRS: &[&str] = &[ + "/usr/share/hunspell", + "/usr/share/myspell", + "/usr/share/myspell/dicts", + "/var/lib/dictionaries-common/hunspell_dic", + "/usr/share/dict", + ]; + + pub struct LinuxSpeller { + words: HashSet, + language: String, + has_cli_tool: bool, + } + + impl LinuxSpeller { + pub fn new(lang_tag: &str) -> Option { + let mut words = HashSet::new(); + let mut found_any = false; + + // Search system directories for .dic or wordlist files matching the requested language + let short_lang = lang_tag.split('-').next().unwrap_or(lang_tag); + let candidates = [ + format!("{}.dic", lang_tag.replace('-', "_")), + format!("{}.dic", lang_tag), + format!("{}.dic", short_lang), + "words".to_string(), + short_lang.to_string(), + ]; + + for dir in LINUX_DICT_DIRS { + let base = Path::new(dir); + if !base.is_dir() { + continue; + } + for candidate in &candidates { + let path = base.join(candidate); + if path.is_file() { + if load_wordlist(&path, &mut words) { + found_any = true; + break; + } + } + } + if found_any { + break; + } + } + + let has_cli = is_command_in_path("hunspell") || is_command_in_path("aspell"); + + if !found_any && !has_cli { + return None; + } + + Some(Self { + words, + language: lang_tag.to_string(), + has_cli_tool: has_cli, + }) + } + + pub fn is_available(&self) -> bool { + !self.words.is_empty() || self.has_cli_tool + } + + pub fn language(&self) -> &str { + &self.language + } + + pub fn check_word(&self, word: &str, want_suggestions: bool) -> (bool, Vec) { + let clean = word.trim().to_lowercase(); + if clean.is_empty() { + return (true, Vec::new()); + } + + if self.words.contains(&clean) { + return (true, Vec::new()); + } + + // If CLI hunspell is available, fallback to pipe check + if self.has_cli_tool { + if let Some((valid, suggs)) = check_via_cli(word, &self.language, want_suggestions) { + return (valid, suggs); + } + } + + (!self.words.is_empty() && !self.words.contains(&clean), Vec::new()) + } + } + + fn load_wordlist(path: &Path, words: &mut HashSet) -> bool { + let Ok(file) = File::open(path) else { return false }; + let reader = BufReader::new(file); + for line in reader.lines().flatten() { + let entry = line.trim(); + if entry.is_empty() || entry.starts_with('#') { + continue; + } + // Strip Hunspell flags: "word/flags" + let word_part = entry.split('/').next().unwrap_or(entry).trim(); + if !word_part.is_empty() && !word_part.chars().all(|c| c.is_ascii_digit()) { + words.insert(word_part.to_lowercase()); + } + } + !words.is_empty() + } + + fn is_command_in_path(cmd: &str) -> bool { + if let Ok(path_var) = std::env::var("PATH") { + for dir in std::env::split_paths(&path_var) { + if dir.join(cmd).is_file() { + return true; + } + } + } + false + } + + fn check_via_cli(word: &str, lang: &str, want_suggestions: bool) -> Option<(bool, Vec)> { + use std::io::Write; + use std::process::{Command, Stdio}; + + let lang_param = lang.replace('-', "_"); + let mut child = Command::new("hunspell") + .arg("-d") + .arg(&lang_param) + .arg("-a") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .ok()?; + + if let Some(mut stdin) = child.stdin.take() { + let _ = writeln!(stdin, "^{word}"); + } + + let output = child.wait_with_output().ok()?; + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if line.starts_with('*') || line.starts_with('+') || line.starts_with('-') { + return Some((true, Vec::new())); + } + if line.starts_with('&') { + let mut suggs = Vec::new(); + if want_suggestions { + if let Some(pos) = line.find(':') { + for s in line[pos + 1..].split(',') { + let item = s.trim(); + if !item.is_empty() && suggs.len() < 5 { + suggs.push(item.to_string()); + } + } + } + } + return Some((false, suggs)); + } + } + None + } +} + +// ============================================================================ +// Fallback Backend +// ============================================================================ +mod fallback_impl { + pub struct FallbackSpeller; +} diff --git a/src/app/control/text_ops.rs b/src/app/control/text_ops.rs index e331c45e4..33ffe7256 100644 --- a/src/app/control/text_ops.rs +++ b/src/app/control/text_ops.rs @@ -955,6 +955,20 @@ impl OpenCADStudio { .map(|w| TextMatcher::new(w.clone(), false, true, true)) .collect(); + let use_system_speller = req["system_spellcheck"] + .as_bool() + .or_else(|| req["use_system_speller"].as_bool()) + .or_else(|| req["system_speller"].as_bool()) + .unwrap_or(false); + let language_param = req["language"].as_str(); + let want_suggestions = req["suggest"].as_bool().unwrap_or(true); + + let system_speller = if use_system_speller { + Some(super::spellcheck::SystemSpeller::new(language_param)) + } else { + None + }; + let mut pair_hit_counts = vec![0usize; pair_matchers.len()]; let mut check_hit_counts = vec![0usize; check_matchers.len()]; @@ -1090,21 +1104,39 @@ impl OpenCADStudio { } } - // 3. Dictionary check - if !dict_matchers.is_empty() { + // 3. Dictionary & System Spell-check + if !dict_matchers.is_empty() || system_speller.is_some() { for word in plain_text.split(|c: char| !c.is_alphabetic()) { let w = word.trim(); - if w.len() >= 3 && !dict_matchers.iter().any(|dm| dm.matches(w)) { - if unrecognized_words.len() < limit - && !unrecognized_words - .iter() - .any(|item: &Value| item["word"].as_str() == Some(w)) - { - unrecognized_words.push(json!({ - "word": w, - "handle": format!("{:X}", handle.value()), - "position": pos, - })); + if w.len() >= 3 { + let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w)); + if in_agent_dict { + continue; + } + + let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller { + speller.check_word(w, want_suggestions) + } else { + (false, Vec::new()) + }; + + if !is_system_valid { + entity_had_match = true; + if unrecognized_words.len() < limit + && !unrecognized_words + .iter() + .any(|item: &Value| item["word"].as_str() == Some(w)) + { + let mut item = json!({ + "word": w, + "handle": format!("{:X}", handle.value()), + "position": pos, + }); + if !suggestions.is_empty() { + item["suggestions"] = json!(suggestions); + } + unrecognized_words.push(item); + } } } } @@ -1157,6 +1189,44 @@ impl OpenCADStudio { } } } + + if !dict_matchers.is_empty() || system_speller.is_some() { + for word in plain.split(|c: char| !c.is_alphabetic()) { + let w = word.trim(); + if w.len() >= 3 { + let in_agent_dict = dict_matchers.iter().any(|dm| dm.matches(w)); + if in_agent_dict { + continue; + } + + let (is_system_valid, suggestions) = if let Some(ref speller) = system_speller { + speller.check_word(w, want_suggestions) + } else { + (false, Vec::new()) + }; + + if !is_system_valid { + entity_had_match = true; + if unrecognized_words.len() < limit + && !unrecognized_words + .iter() + .any(|item: &Value| item["word"].as_str() == Some(w)) + { + let mut item = json!({ + "word": w, + "handle": format!("{:X}", handle.value()), + "attribute_tag": attr.tag, + "position": [insert.insert_point.x, insert.insert_point.y, insert.insert_point.z], + }); + if !suggestions.is_empty() { + item["suggestions"] = json!(suggestions); + } + unrecognized_words.push(item); + } + } + } + } + } } } @@ -1180,6 +1250,22 @@ impl OpenCADStudio { .map(|(idx, _)| check_terms[idx].clone()) .collect(); + let speller_info = if let Some(ref speller) = system_speller { + json!({ + "enabled": true, + "available": speller.is_available(), + "backend": speller.backend_name(), + "language": speller.language(), + }) + } else { + json!({ + "enabled": false, + "available": false, + "backend": "none", + "language": null, + }) + }; + Ok(json!({ "ok": true, "summary": { @@ -1190,7 +1276,9 @@ impl OpenCADStudio { "pairs_unmatched": unmatched_pairs.len(), "check_terms_total": check_terms.len(), "check_terms_found": check_terms.len() - unmatched_check_terms.len(), + "unrecognized_words_count": unrecognized_words.len(), }, + "system_speller": speller_info, "unmatched_pairs": unmatched_pairs, "unmatched_check_terms": unmatched_check_terms, "simulated_changes": simulated_changes, diff --git a/src/mcp.rs b/src/mcp.rs index 4540ffaba..b3f9eeaeb 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1225,7 +1225,7 @@ fn tool_definitions() -> Value { { "name":"ocs_read", "description":"Discover capabilities and record schemas, or read state, complete database records, command manifests, entities, properties, kernel measurements and spatial relationships, history, events or operation status from a live OCS session.", - "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, + "inputSchema":{"type":"object","properties":{"ocs_session_id":{"type":"string","minLength":1,"description":"Value of session_id returned by ocs_sessions."},"op":{"type":"string","enum":READ_OPS,"default":"state"},"parameters":{"type":"object","description":"Operation-specific filters.","properties":{"name":{"type":"string","description":"Command name or record name."},"search":{"type":"string","description":"Case-insensitive command or record-type search."},"find":{"type":"string","description":"Text query string for text_search."},"match_case":{"type":"boolean","default":false,"description":"Case-sensitive text search."},"whole_word":{"type":"boolean","default":false,"description":"Match only whole words."},"ignore_accents":{"type":"boolean","description":"Ignore accents/diacritics in text (defaults to true when match_case is false)."},"scope":{"type":"string","enum":["all","active_space","blocks"],"default":"all","description":"Text search scope."},"system_spellcheck":{"type":"boolean","default":false,"description":"Use native OS spell-checker (Windows, macOS, Linux)."},"language":{"type":"string","description":"Language tag for spell-checker (e.g. 'fr-FR', 'en-US', 'de-DE', 'es-ES')."},"suggest":{"type":"boolean","default":true,"description":"Include suggested corrections for misspelled words."},"check_terms":{"type":"array","items":{"type":"string"},"description":"List of terms or suspect misspellings to flag in text_audit."},"dictionary":{"type":"array","items":{"type":"string"},"description":"Known valid words for text_audit dictionary check."},"pairs":{"type":"array","description":"Find/replace pairs for dry-run simulation in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"dry_run_pairs":{"type":"array","description":"Alias for pairs in text_audit.","items":{"type":"object","properties":{"find":{"type":"string"},"replace":{"type":"string"}},"required":["find","replace"]}},"document_id":{"type":"integer","minimum":0},"path":{"type":"string","description":"Optional intended output path for audit; extension determines target format."},"target_format":{"type":"string","enum":["dwg","dxf"],"description":"Intended output format for audit."},"target_version":{"type":"string","enum":["R14","2000","2004","2007","2010","2013","2018","AC1014","AC1015","AC1018","AC1021","AC1024","AC1027","AC1032"],"description":"Intended CAD output version for audit."},"collection":{"type":"string","description":"Record collection, all for records, or omit to discover collections and schema types."},"handle":{"type":"string"},"handles":{"type":"array","items":{"type":"string"},"description":"Exact entity or record handles."},"type":{"type":"string","description":"Entity or record type filter; for record_schema, returns its complete type graph and writable field paths."},"layer":{"type":"string","description":"Layer name filter for query."},"detail":{"type":"string","enum":["summary","geometry","full"],"default":"geometry","description":"Entity detail returned by query."},"fields":{"type":"array","items":{"type":"string"},"description":"Return only these entity fields plus handle."},"paths":{"type":"array","items":{"type":"string"},"description":"Project RFC 6901 JSON Pointer paths relative to record.properties."},"where":{"type":"array","description":"All property filters must match.","items":{"type":"object","properties":{"path":{"type":"string"},"op":{"type":"string","enum":["eq","ne","lt","lte","gt","gte","contains","starts_with","ends_with","in","exists","not_exists"],"default":"eq"},"value":{}},"required":["path"],"additionalProperties":false}},"near":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Rank planar curves by exact kernel distance to this world XY point."},"point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"World point whose object snap the snap op reports."},"from":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Base point for perpendicular and tangent snaps (snap op)."},"contains_point":{"type":"array","items":{"type":"number"},"minItems":2,"maxItems":3,"description":"Return closed planar curves containing this world XY point."},"bounds":{"type":"array","items":{"type":"number"},"minItems":4,"maxItems":4,"description":"Filter entities whose world XY bounds overlap [min_x,min_y,max_x,max_y]."},"intersections":{"type":"array","items":{"type":"string"},"minItems":2,"maxItems":2,"description":"Return exact kernel intersections between two planar curve handles."},"after":{"type":"integer","minimum":0,"description":"Event cursor."},"request_id":{"type":"string","description":"Operation id to query."},"offset":{"type":"integer","minimum":0},"limit":{"type":"integer","minimum":1,"maximum":10000}},"additionalProperties":false}},"required":["ocs_session_id"],"additionalProperties":false}, "outputSchema":read_output_schema(), "annotations":{"title":"Read OCS state","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false} }, From 7ea5c3d4593cf2c1593c9fc276c159d0b919177d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 29 Sep 2026 22:58:12 +0300 Subject: [PATCH 12/17] chore: bump codec 8464e0a, kernel 7dc1e79, graph 2e40a7b (ellipse transform) Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 ++++---- Cargo.toml | 8 ++++---- crates/ocs_plugin_api/Cargo.toml | 4 ++-- crates/ocs_web_worker/Cargo.toml | 2 +- plugins/opencad-python/Cargo.lock | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e37adf84..dba57d1f9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4205,7 +4205,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=cdf2277#cdf227782acab8b0b7035d99ed02530a90ae6773" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=8464e0a#8464e0a63439dcb724aae446093979103cd8bfbd" dependencies = [ "ahash 0.8.12", "anyhow", @@ -4228,7 +4228,7 @@ dependencies = [ [[package]] name = "opencadgraph" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=597c8b8#597c8b817703491dd41bbbdb06e8c69d524ff2f8" +source = "git+https://github.com/HakanSeven12/opencadgraph.git?rev=2e40a7b#2e40a7b1ed45a94d9994789ed4a60d8bf32b7f97" dependencies = [ "opencadkernel", "serde_json", @@ -4237,7 +4237,7 @@ dependencies = [ [[package]] name = "opencadkernel" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=52a9ebe#52a9ebeb9e7cd7815154ed261437908a88271753" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=7dc1e79#7dc1e79fdb1e1ff0f3bcdb054bbf5b70ac5ff5d0" dependencies = [ "cavalier_contours", "opencadcodec", @@ -4248,7 +4248,7 @@ dependencies = [ [[package]] name = "opencadkernel-constraints" version = "0.1.0" -source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=52a9ebe#52a9ebeb9e7cd7815154ed261437908a88271753" +source = "git+https://github.com/HakanSeven12/opencadkernel.git?rev=7dc1e79#7dc1e79fdb1e1ff0f3bcdb054bbf5b70ac5ff5d0" dependencies = [ "nalgebra", ] diff --git a/Cargo.toml b/Cargo.toml index 4a0bb0fb7..0342bc268 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,10 +33,10 @@ rfd = "0.17" clap = { version = "4", features = ["derive", "string"] } env_logger = "0.11" log = "0.4" -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "cdf2277", features = ["serde"] } -kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "52a9ebe", features = ["acis", "offset"] } -kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "52a9ebe" } -graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "597c8b8" } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8464e0a", features = ["serde"] } +kernel = { package = "opencadkernel", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "7dc1e79", features = ["acis", "offset"] } +kernel-constraints = { package = "opencadkernel-constraints", git = "https://github.com/HakanSeven12/opencadkernel.git", rev = "7dc1e79" } +graph = { package = "opencadgraph", git = "https://github.com/HakanSeven12/opencadgraph.git", rev = "2e40a7b" } dwg-thumbnailer = { path = "crates/dwg-thumbnailer" } flate2 = "1" image = { version = "0.25", default-features = false, features = ["png", "jpeg", "bmp", "tiff"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index 79a005a31..ffd66d723 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -15,7 +15,7 @@ serde_json = { version = "1", optional = true } # Pulled in only by the `host` feature, which adds the `opencadcodec`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "cdf2277", optional = true, features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8464e0a", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } @@ -39,7 +39,7 @@ serde_json = "1" serde = { version = "1", features = ["derive"] } cargo-lock = "11" # opencadcodec is scanned at build time to generate the embedded type registry. -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "cdf2277", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8464e0a", features = ["serde"] } [dev-dependencies] serde_json = "1" diff --git a/crates/ocs_web_worker/Cargo.toml b/crates/ocs_web_worker/Cargo.toml index b7b71c0b1..4fadf975c 100644 --- a/crates/ocs_web_worker/Cargo.toml +++ b/crates/ocs_web_worker/Cargo.toml @@ -8,7 +8,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "cdf2277", features = ["serde"] } +codec = { package = "opencadcodec", git = "https://github.com/HakanSeven12/opencadcodec.git", rev = "8464e0a", features = ["serde"] } bincode = "1.3" serde = { version = "1", features = ["derive"] } console_error_panic_hook = "0.1" diff --git a/plugins/opencad-python/Cargo.lock b/plugins/opencad-python/Cargo.lock index 8bfcaff55..44c3bdfdf 100644 --- a/plugins/opencad-python/Cargo.lock +++ b/plugins/opencad-python/Cargo.lock @@ -1265,7 +1265,7 @@ dependencies = [ [[package]] name = "opencadcodec" version = "0.5.5" -source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=cdf2277#cdf227782acab8b0b7035d99ed02530a90ae6773" +source = "git+https://github.com/HakanSeven12/opencadcodec.git?rev=8464e0a#8464e0a63439dcb724aae446093979103cd8bfbd" dependencies = [ "ahash 0.8.12", "anyhow", From 69a2e7c73acf6211d7062b8d40ce0ecabb572222 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 29 Sep 2026 23:04:23 +0300 Subject: [PATCH 13/17] =?UTF-8?q?fix(ellipse):=20#1532=20follow-up=20?= =?UTF-8?q?=E2=80=94=20one=20transform,=20in=20the=20codec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR's principal-axis math now lives in the codec's transform_ellipse (8464e0a), which explode, INSERT and every other codec path share. transform_ellipse_geometry delegates to it for the block-cache tangent, and the entity goes back through apply_standard_entity_transform, with MIRROR delegated to the codec's apply_mirror: the old closure reflected only the centre and major axis, leaving mirrored arcs on the wrong side, and the PR's version turned the normal to -Z. The mirror test now checks the kept +Z normal and the negated parameters. Co-Authored-By: Claude Opus 5.5 --- src/entities/ellipse.rs | 69 +++++------------------- src/scene/view/transform.rs | 101 +++++++++++------------------------- 2 files changed, 43 insertions(+), 127 deletions(-) diff --git a/src/entities/ellipse.rs b/src/entities/ellipse.rs index 0cf40a5b2..d64184dd3 100644 --- a/src/entities/ellipse.rs +++ b/src/entities/ellipse.rs @@ -354,58 +354,14 @@ fn apply_grip(ell: &mut Ellipse, grip_id: usize, apply: GripApply) { } fn apply_transform(ell: &mut Ellipse, t: &EntityTransform) { - use codec::types::{Transform, Vector3}; - let transform = match t { - EntityTransform::Translate(d) => { - ell.center = ell.center + Vector3::new(d.x, d.y, d.z); - return; - } - EntityTransform::Rotate { center, axis, angle_rad } => { - let axis_norm = Vector3::new(axis.x, axis.y, axis.z); - let axis_len = axis_norm.length(); - let axis_v = if axis_len > 1e-12 { axis_norm / axis_len } else { Vector3::UNIT_Z }; - Transform::from_translation(Vector3::new(-center.x, -center.y, -center.z)) - .then(&Transform::from_rotation(axis_v, *angle_rad)) - .then(&Transform::from_translation(Vector3::new(center.x, center.y, center.z))) - } - EntityTransform::Scale { center, factor } => { - let s = *factor; - Transform::from_scaling_with_origin( - Vector3::new(s, s, s), - Vector3::new(center.x, center.y, center.z), - ) - } - EntityTransform::Mirror { p1, p2, working_normal } => { - crate::scene::view::transform::reflection_about_working_line( - *p1, - *p2, - *working_normal, - ) - } - EntityTransform::Affine(tr) => tr.clone(), - }; - - if let Some((c, m, n, ratio, s, e)) = crate::scene::view::transform::transform_ellipse_geometry( - ell.center, - ell.major_axis, - ell.normal, - ell.minor_axis_ratio, - ell.start_parameter, - ell.end_parameter, - &transform, - ) { - ell.center = c; - ell.major_axis = m; - ell.normal = n; - ell.minor_axis_ratio = ratio; - ell.start_parameter = s; - let is_full = (e - s - std::f64::consts::TAU).abs() < 1e-9; - ell.end_parameter = if is_full { - std::f64::consts::TAU - } else { - e.rem_euclid(std::f64::consts::TAU) - }; - } + // The codec maps the whole ellipse — ratio, principal axes and arc + // parameters — under rotate, scale, affine and mirror alike. + crate::scene::view::transform::apply_standard_entity_transform(ell, t, |entity, p1, p2| { + codec::Entity::apply_mirror( + entity, + &crate::scene::view::transform::reflection_about_xy_line(p1, p2), + ); + }); } impl RenderConvertible for Ellipse { @@ -581,9 +537,12 @@ mod grip_tests { }; apply_transform(&mut e, &mirror); - // Major axis should be reflected to (-10, 0, 0) + // Major axis reflected to (-10, 0, 0); MIRROR keeps the normal facing + // the mirrored plane and negates the parameters instead. assert!((e.major_axis.x - (-10.0)).abs() < 1e-9); - // Normal should flip to -Z to preserve counter-clockwise traversal - assert!(e.normal.z < 0.0); + assert!((e.normal.z - 1.0).abs() < 1e-9); + let (s, end) = (e.start_parameter, e.end_parameter); + assert!((s - (std::f64::consts::TAU - 1.5)).abs() < 1e-9, "{s}"); + assert!((end - s - 1.4).abs() < 1e-9, "{end}"); } } diff --git a/src/scene/view/transform.rs b/src/scene/view/transform.rs index 151995ba9..52cc26321 100644 --- a/src/scene/view/transform.rs +++ b/src/scene/view/transform.rs @@ -120,12 +120,13 @@ pub fn reflect_xy_point(x: &mut f64, y: &mut f64, p1: DVec3, p2: DVec3) { *y = p1.y as f64 + my; } -/// Fully transforms ellipse / elliptical arc geometry under any 3D affine transform -/// (including negative scales, reflections, negative rotations, non-uniform scales, and translation). +/// An ellipse / elliptical arc's image under any affine transform, through the +/// codec's `transform_ellipse` so the block-cache fast path and the entity +/// agree with every other path (explode, insert) by construction. /// -/// Returns `(new_center, new_major_axis, new_normal, new_minor_axis_ratio, new_start_param, new_end_param)` -/// where `new_major_axis` is guaranteed to be orthogonal to `new_normal`, its length is >= semi-minor axis length, -/// and `new_minor_axis_ratio <= 1.0`. +/// Returns `(center, major_axis, normal, minor_axis_ratio, start_param, +/// end_param)`; the major axis is the longer one and a reflection flips the +/// normal. pub fn transform_ellipse_geometry( center: Vector3, major_axis: Vector3, @@ -135,76 +136,32 @@ pub fn transform_ellipse_geometry( end_param: f64, transform: &Transform, ) -> Option<(Vector3, Vector3, Vector3, f64, f64, f64)> { - let norm_len = normal.length(); - if norm_len <= 1e-12 || !norm_len.is_finite() { + use codec::Entity; + if normal.length() <= 1e-12 || major_axis.length() <= 1e-12 { return None; } - let norm = normal / norm_len; - let m = major_axis; - let m_len = m.length(); - if m_len <= 1e-12 || !m_len.is_finite() { - return None; - } - let ratio = minor_axis_ratio.clamp(1e-12, 1.0); - // V = (N x M) * ratio - let v = norm.cross(&m) * ratio; - - let c_prime = transform.apply(center); - let m0 = transform.apply_rotation(m); - let v0 = transform.apply_rotation(v); - - let m0_len_sq = m0.length_squared(); - let v0_len_sq = v0.length_squared(); - if m0_len_sq <= 1e-24 || v0_len_sq <= 1e-24 || !m0_len_sq.is_finite() || !v0_len_sq.is_finite() { - return None; - } - - // Extremum angle theta to find the principal axes of the transformed ellipse - let dot = m0.dot(&v0); - let diff = m0_len_sq - v0_len_sq; - let theta = 0.5 * (2.0 * dot).atan2(diff); - - let (sin_t, cos_t) = theta.sin_cos(); - let m_new = m0 * cos_t + v0 * sin_t; - let v_new = m0 * (-sin_t) + v0 * cos_t; - - let a = m_new.length(); - let b = v_new.length(); - if a <= 1e-12 || !a.is_finite() { - return None; - } - - let cross = m_new.cross(&v_new); - let cross_len = cross.length(); - if cross_len <= 1e-12 || !cross_len.is_finite() { - return None; - } - let n_new = cross / cross_len; - let new_ratio = (b / a).clamp(1e-12, 1.0); - - // Sweep and parameters - use std::f64::consts::TAU; - let raw = end_param - start_param; - let is_full = (raw - TAU).abs() < 1e-9 - || (raw.abs() >= TAU - 1e-9) - || (raw.rem_euclid(TAU).abs() < 1e-9 && raw.abs() > 1e-9); - - let (new_start, new_end) = if is_full { - (0.0, TAU) - } else { - let sweep = if raw <= 0.0 { - let s = raw.rem_euclid(TAU); - if s <= 1e-9 { TAU } else { s } - } else if raw > TAU { - TAU - } else { - raw - }; - let s = (start_param - theta).rem_euclid(TAU); - (s, s + sweep) + let mut ellipse = codec::entities::Ellipse { + center, + major_axis, + normal, + minor_axis_ratio, + start_parameter: start_param, + end_parameter: end_param, + ..Default::default() }; - - Some((c_prime, m_new, n_new, new_ratio, new_start, new_end)) + ellipse.apply_transform(transform); + let values = [ellipse.center, ellipse.major_axis, ellipse.normal]; + if values.iter().any(|v| !(v.x.is_finite() && v.y.is_finite() && v.z.is_finite())) { + return None; + } + Some(( + ellipse.center, + ellipse.major_axis, + ellipse.normal, + ellipse.minor_axis_ratio, + ellipse.start_parameter, + ellipse.end_parameter, + )) } /// DXF arbitrary-axis algorithm — returns the OCS X and Y basis vectors in WCS From bce9fff6bbb429d40d42383c14514c462684c5fb Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 29 Sep 2026 23:04:23 +0300 Subject: [PATCH 14/17] fix(bench): pass the draw-depth map click_hit now takes Co-Authored-By: Claude Opus 5.5 --- benches/performance_benchmarks.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/benches/performance_benchmarks.rs b/benches/performance_benchmarks.rs index f3f076d11..ffae1f7a2 100644 --- a/benches/performance_benchmarks.rs +++ b/benches/performance_benchmarks.rs @@ -393,6 +393,7 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { let n_queries = if runner.quick_mode { 200 } else { 1_000 }; let mut click_samples = Vec::with_capacity(5); + let draw_depth = rustc_hash::FxHashMap::default(); for _ in 0..5 { let t0 = Instant::now(); for q in 0..n_queries { @@ -406,6 +407,7 @@ fn bench_hit_test_picking(runner: &mut BenchmarkRunner) { bounds, false, 8.0, + &draw_depth, ); black_box(hit); } From 8022e25c0830256803be6f15fab7db14a5ea751c Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 29 Sep 2026 23:11:33 +0300 Subject: [PATCH 15/17] fix(dgn): a short 2D text element no longer panics the reader The byte count at 110 was read with direct indexing; a truncated element now skips like every other field read does. Co-Authored-By: Claude Opus 5.5 --- src/scene/model/underlay_vector/dgn8.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/scene/model/underlay_vector/dgn8.rs b/src/scene/model/underlay_vector/dgn8.rs index 96e81e286..fb25c5c66 100644 --- a/src/scene/model/underlay_vector/dgn8.rs +++ b/src/scene/model/underlay_vector/dgn8.rs @@ -477,7 +477,7 @@ fn read_run(run: &[&[u8]], styles: &Styles, xf: &Xf) -> Raw { (|| Some((f64_at(e, 112)?, f64_at(e, 120)?, quat_axes(e, 144)?, p3(176)?, marked_string(e, 200)?)))() } else { (|| { - let count = u16::from_le_bytes([e[110], e[111]]) as usize; + let count = u16::from_le_bytes(e.get(110..112)?.try_into().ok()?) as usize; let chars = e.get(174..170 + count.max(4))?; let text = String::from_utf8_lossy(chars).trim_end_matches(char::from(0)).to_string(); Some((f64_at(e, 112)?, f64_at(e, 120)?, turned_axes(f64_at(e, 144)?), p3(152)?, text)) From 60e23a43a64557406c887105f5981b5962fc1698 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 29 Sep 2026 23:13:48 +0300 Subject: [PATCH 16/17] =?UTF-8?q?fix(gpu):=20#1540=20follow-up=20=E2=80=94?= =?UTF-8?q?=20only=20this=20run's=20report,=20only=20the=20device's=20word?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Process ids are reused: report_for_pid now takes the sentinel's arm time and ignores a report written before it, so an old crash of an unrelated run with the same pid no longer costs a backend. The test plants such a stale report. - is_device_failure drops "buffer" and "surface": drawing code and the modelling kernel panic with those words too. It keeps wgpu, out of memory and device lost, and adds wgpu's "validation error". Co-Authored-By: Claude Opus 5.5 --- src/gpu_backend.rs | 26 ++++++++++++++++++++++++-- src/sys.rs | 21 ++++++++++++++------- 2 files changed, 38 insertions(+), 9 deletions(-) diff --git a/src/gpu_backend.rs b/src/gpu_backend.rs index ad16e13ab..8b5141cd1 100644 --- a/src/gpu_backend.rs +++ b/src/gpu_backend.rs @@ -357,7 +357,12 @@ fn crash_report_blames_the_device(sentinel: &str) -> bool { else { return false; }; - crate::sys::crash_log::report_for_pid(pid) + let armed = sentinel + .lines() + .find_map(|line| line.strip_prefix("t=")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + crate::sys::crash_log::report_for_pid(pid, armed) .is_some_and(|report| crate::sys::crash_log::is_device_failure(&report)) } @@ -1129,7 +1134,14 @@ mod tests { let reports = crate::sys::crash_log::directory().expect("a config directory"); let _ = std::fs::create_dir_all(&reports); - let report = reports.join(format!("crash-1-{}.log", std::process::id())); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // A report older than the run, left by another process that once + // had this id, blames nobody. + let stale = reports.join(format!("crash-1-{}.log", std::process::id())); + let report = reports.join(format!("crash-{}-{}.log", now + 1, std::process::id())); arm_sentinel("dx12"); mark_sentinel_survived(); @@ -1138,6 +1150,16 @@ mod tests { crashed_backends().is_empty(), "with no crash report, a survived run blames nobody" ); + std::fs::write( + &stale, + crate::sys::crash_log::report_from("Out of Memory", "wgpu-29.0.4/src/lib.rs:1:1", 1), + ) + .unwrap(); + assert!( + crashed_backends().is_empty(), + "a report from before the run belongs to another process" + ); + let _ = std::fs::remove_file(&stale); // The run left a report, and it is the device's own failure. std::fs::write( diff --git a/src/sys.rs b/src/sys.rs index 52d27561e..9c0a9c757 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -435,17 +435,22 @@ pub mod crash_log { /// a process id from somewhere else — the GPU crash sentinel does — can /// ask what killed that exact run instead of guessing from the newest /// file, which on a busy machine may belong to a different one. - pub fn report_for_pid(pid: u32) -> Option { + /// + /// Process ids are reused, so only a report written at or after + /// `not_before` (Unix seconds — when that run started) counts: an old + /// report from an unrelated run that once had the same id does not. + pub fn report_for_pid(pid: u32, not_before: u64) -> Option { let directory = directory()?; let suffix = format!("-{pid}.log"); let entry = std::fs::read_dir(directory) .ok()? .flatten() .find(|entry| { - entry - .file_name() - .to_string_lossy() - .ends_with(suffix.as_str()) + let name = entry.file_name().to_string_lossy().into_owned(); + name.strip_prefix("crash-") + .and_then(|rest| rest.strip_suffix(suffix.as_str())) + .and_then(|when| when.parse::().ok()) + .is_some_and(|when| when >= not_before) })?; std::fs::read_to_string(entry.path()).ok() } @@ -468,9 +473,11 @@ pub mod crash_log { } // The message wgpu panics with names the call, and the location names // wgpu itself; either alone is enough, since a stripped build can - // leave the message terse. + // leave the message terse. Plain words like "buffer" or "surface" + // are not: drawing code and the modelling kernel panic with those + // too, and that is no reason to give up a working backend. let haystack = format!("{panic_line} {at_line}").to_ascii_lowercase(); - ["wgpu", "out of memory", "buffer", "surface", "device lost"] + ["wgpu", "out of memory", "device lost", "validation error"] .iter() .any(|needle| haystack.contains(needle)) } From ab0682153dd63af6185c21ea4ef6e2f1230d25c3 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 29 Sep 2026 23:25:25 +0300 Subject: [PATCH 17/17] fix(spellcheck): Linux wordlist flags unknown words; only hunspell counts as CLI - check_word returned "valid" for a word missing from a loaded wordlist (!words.is_empty() && !contains, with contains already false), so the wordlist backend never flagged anything. - aspell on PATH counted as a CLI speller, but only hunspell is invoked, so on a machine with aspell and no hunspell every CLI check silently failed. - A bare wordlist offers no suggestions; the test no longer demands them from the linux_system backend. Unused PathBuf import dropped. Co-Authored-By: Claude Opus 5.5 --- src/app/control/p1_tests.rs | 6 ++++-- src/app/control/spellcheck.rs | 9 ++++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/control/p1_tests.rs b/src/app/control/p1_tests.rs index 3ad5b8b6a..331142ce6 100644 --- a/src/app/control/p1_tests.rs +++ b/src/app/control/p1_tests.rs @@ -819,7 +819,9 @@ fn text_audit_system_spellcheck_flags_misspelled_words() { flagged ); - // At least one flagged word should have suggestions + // At least one flagged word should have suggestions — except from a + // bare Linux wordlist, which can tell a word is unknown but has no + // way to suggest one without the hunspell CLI. let has_suggestions = words.iter().any(|w| { w["suggestions"] .as_array() @@ -827,7 +829,7 @@ fn text_audit_system_spellcheck_flags_misspelled_words() { .unwrap_or(false) }); assert!( - has_suggestions, + has_suggestions || sp["backend"] == "linux_system", "expected at least one word to have suggestions" ); diff --git a/src/app/control/spellcheck.rs b/src/app/control/spellcheck.rs index 8ab3abb23..704a961be 100644 --- a/src/app/control/spellcheck.rs +++ b/src/app/control/spellcheck.rs @@ -595,7 +595,7 @@ pub(crate) mod linux_impl { use std::collections::HashSet; use std::fs::File; use std::io::{BufRead, BufReader}; - use std::path::{Path, PathBuf}; + use std::path::Path; const LINUX_DICT_DIRS: &[&str] = &[ "/usr/share/hunspell", @@ -645,7 +645,8 @@ pub(crate) mod linux_impl { } } - let has_cli = is_command_in_path("hunspell") || is_command_in_path("aspell"); + // Only hunspell is ever invoked (`check_via_cli`), so only it counts. + let has_cli = is_command_in_path("hunspell"); if !found_any && !has_cli { return None; @@ -683,7 +684,9 @@ pub(crate) mod linux_impl { } } - (!self.words.is_empty() && !self.words.contains(&clean), Vec::new()) + // Not in the wordlist and no CLI answer: misspelled when there is + // a wordlist to judge by, unjudged (accepted) when there is none. + (self.words.is_empty(), Vec::new()) } }