Skip to main content

brep_render/sketch/
mod.rs

1//! Engine-native sketch mode — S0 (data model + solver plumbing + read-only
2//! display). See `docs/developer/sketch-mode-rust-plan.md`.
3//!
4//! This module is the foundation the interactive slices (S1 enter/exit, S2
5//! picking, S3 tools, S4 constraints, S5 dimensions) build on. S0 provides:
6//!
7//! - [`doc`] — [`SketchDoc`], the typed serde mirror of the solver's
8//!   `{points, geometries, constraints}` (+ [`SketchDiagnostics`]).
9//! - [`solve`] — a direct in-process call to the kernel's 2D constraint solver
10//!   (`brep_kernel::solve_sketch`) for solved coordinates + DOF/mobility.
11//! - [`tessellate`] — a port of the previous sketcher's overlay-refresh pass:
12//!   solved geometry → world-space overlay lines/points colored by mobility.
13//! - [`session`] — [`SketchSession`], the `{doc, plane, diagnostics}` holder (with
14//!   stubs for the interaction state later slices own).
15//!
16//! The engine displays a session read-only via
17//! [`crate::engine_state::EngineState::set_sketch_overlay`].
18
19pub mod constraint_glyphs;
20pub mod dimensions;
21pub mod doc;
22pub mod external_ref;
23pub mod handdraw;
24pub mod infer;
25pub mod session;
26pub mod solve;
27pub mod tessellate;
28pub mod trim;
29
30pub use doc::{SketchConstraint, SketchDiagnostics, SketchDoc, SketchGeometry, SketchPoint};
31pub use external_ref::{classify_uv, EdgeLink, ExternalRef};
32pub use session::{
33    constraint_ref, entity_ref_eq, geometry_ref, point_ref, refs_equal, SketchSession,
34};
35pub use solve::SketchSolverSettings;
36pub use tessellate::SketchTessellation;
37
38// The CONSTRAINT annotation color (the green for dimension leaders/labels + the
39// geometric-constraint glyphs) now lives in the display settings alongside every
40// other sketch color: see [`crate::style::SketchColors::constraint`], fed to the
41// overlay builders via [`crate::style::RenderSettings::sketch_colors`]. Constraints
42// read in green so they stand apart from the blue/white sketch GEOMETRY (user
43// directive, 2026-08-22); the egui host (brep-app) tints the dimension VALUE labels
44// with the SAME setting — one editable source of truth for the whole app.
45
46/// An orthonormal placement frame for a sketch plane — origin + in-plane `x`/`y`
47/// axes + the `z` normal, all in world space (`f64`). A plane `(u, v)` coordinate
48/// maps to world `origin + u·x + v·y` (via [`to_world`](Self::to_world)).
49///
50/// Mirrors the kernel's `feature_pipeline::Frame` shape; kept local (and `f64`) so
51/// the sketch module stays self-contained and matches the solver's double-precision
52/// coordinates. Later slices resolve this from the sketch's plane reference (a
53/// DATUM/PLANE frame or a face frame); S0 uses the XY plane.
54#[derive(Clone, Copy, Debug, PartialEq)]
55pub struct PlaneFrame {
56    pub origin: [f64; 3],
57    pub x_axis: [f64; 3],
58    pub y_axis: [f64; 3],
59    pub z_axis: [f64; 3],
60}
61
62impl PlaneFrame {
63    /// The world XY plane (identity frame): `u → +x`, `v → +y`, normal `+z`.
64    pub fn xy() -> Self {
65        Self {
66            origin: [0.0, 0.0, 0.0],
67            x_axis: [1.0, 0.0, 0.0],
68            y_axis: [0.0, 1.0, 0.0],
69            z_axis: [0.0, 0.0, 1.0],
70        }
71    }
72
73    /// The world XZ base plane (`datum.rs` normal `(0, -1, 0)`), resolved through
74    /// the same worldUp convention the kernel uses (see [`from_normal`](Self::from_normal)).
75    pub fn xz() -> Self {
76        Self::from_normal([0.0, 0.0, 0.0], [0.0, -1.0, 0.0])
77    }
78
79    /// The world YZ base plane (`datum.rs` normal `(1, 0, 0)`), resolved through
80    /// the same worldUp convention the kernel uses (see [`from_normal`](Self::from_normal)).
81    pub fn yz() -> Self {
82        Self::from_normal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
83    }
84
85    /// Derive an orthonormal frame from an `origin` + plane `normal`, a faithful
86    /// port of `feature_pipeline::Frame::from_origin_normal` — the kernel's SINGLE
87    /// source of truth for how a plane reference becomes in-plane axes:
88    ///
89    /// ```text
90    /// refUp = |n·(0,1,0)| > 0.9 ? (1,0,0) : (0,1,0)
91    /// x = norm(refUp × n);  y = norm(n × x);  z = n
92    /// ```
93    ///
94    /// A degenerate (zero / non-finite) normal — or a normal collinear with the
95    /// picked `refUp` — returns the XY identity axes (at `origin`) rather than
96    /// erroring, so callers always get a usable frame.
97    pub fn from_normal(origin: [f64; 3], normal: [f64; 3]) -> Self {
98        let identity = Self {
99            origin,
100            ..Self::xy()
101        };
102        let Some(z) = normalize(normal) else {
103            return identity;
104        };
105        let world_up = [0.0, 1.0, 0.0];
106        let ref_up = if dot(z, world_up).abs() > 0.9 {
107            [1.0, 0.0, 0.0]
108        } else {
109            world_up
110        };
111        let Some(x) = normalize(cross(ref_up, z)) else {
112            return identity;
113        };
114        let Some(y) = normalize(cross(z, x)) else {
115            return identity;
116        };
117        Self {
118            origin,
119            x_axis: x,
120            y_axis: y,
121            z_axis: z,
122        }
123    }
124
125    /// Read a persisted `persistentData.basis` object (`{origin, x, y, z}`, each a
126    /// `[x, y, z]` array) into a frame, mirroring the kernel's `persisted_basis_frame`
127    /// (`features/sketch.rs`). Missing keys default to the identity components, so a
128    /// partial / absent basis still yields a usable XY-ish frame.
129    pub fn from_basis_json(basis: &serde_json::Value) -> Self {
130        Self {
131            origin: read_vec3(basis.get("origin"), [0.0, 0.0, 0.0]),
132            x_axis: read_vec3(basis.get("x"), [1.0, 0.0, 0.0]),
133            y_axis: read_vec3(basis.get("y"), [0.0, 1.0, 0.0]),
134            z_axis: read_vec3(basis.get("z"), [0.0, 0.0, 1.0]),
135        }
136    }
137
138    /// Map a plane `(u, v)` coordinate to world `[x, y, z]`.
139    pub fn to_world(&self, u: f64, v: f64) -> [f64; 3] {
140        [
141            self.origin[0] + self.x_axis[0] * u + self.y_axis[0] * v,
142            self.origin[1] + self.x_axis[1] * u + self.y_axis[1] * v,
143            self.origin[2] + self.x_axis[2] * u + self.y_axis[2] * v,
144        ]
145    }
146
147    /// Project a world point onto the plane's `(u, v)` frame — the inverse of
148    /// [`to_world`](Self::to_world). With orthonormal axes this is plain dot
149    /// products against the offset from the origin (`d = world − origin`;
150    /// `u = d·x_axis`, `v = d·y_axis`); a point off the plane projects orthogonally
151    /// (its normal component is dropped). Mirrors the previous sketcher's
152    /// world→UV projection.
153    pub fn to_uv(&self, world: [f64; 3]) -> (f64, f64) {
154        let d = [
155            world[0] - self.origin[0],
156            world[1] - self.origin[1],
157            world[2] - self.origin[2],
158        ];
159        (dot(d, self.x_axis), dot(d, self.y_axis))
160    }
161}
162
163impl Default for PlaneFrame {
164    fn default() -> Self {
165        Self::xy()
166    }
167}
168
169/// Intersect a world-space ray (`origin` + `dir`) with a sketch `plane` and return
170/// the hit's in-plane `(u, v)` coordinate, or `None` when the ray is parallel to
171/// the plane (`|dir·n| < 1e-9`) or the hit is behind the ray origin (`t <= 0`).
172///
173/// This is the pure pixel→plane math behind
174/// [`EngineState::sketch_uv_at`](crate::engine_state::EngineState::sketch_uv_at):
175/// the caller supplies the camera ray (`camera.pick_ray(x, y)`); the plane's axes
176/// are assumed orthonormal, so the world→uv projection is plain dot products.
177pub fn ray_plane_uv(plane: &PlaneFrame, origin: [f64; 3], dir: [f64; 3]) -> Option<(f64, f64)> {
178    let n = plane.z_axis;
179    let denom = dot(dir, n);
180    if denom.abs() < 1e-9 {
181        return None; // ray parallel to the plane
182    }
183    let t = dot(sub(plane.origin, origin), n) / denom;
184    if t <= 0.0 {
185        return None; // plane is behind the ray origin
186    }
187    let hit = [
188        origin[0] + t * dir[0],
189        origin[1] + t * dir[1],
190        origin[2] + t * dir[2],
191    ];
192    let w = sub(hit, plane.origin);
193    Some((dot(w, plane.x_axis), dot(w, plane.y_axis)))
194}
195
196/// Dot product of two 3-vectors.
197fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
198    a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
199}
200
201/// `a - b` for two 3-vectors.
202fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
203    [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
204}
205
206/// Cross product `a × b`.
207fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
208    [
209        a[1] * b[2] - a[2] * b[1],
210        a[2] * b[0] - a[0] * b[2],
211        a[0] * b[1] - a[1] * b[0],
212    ]
213}
214
215/// Normalize `v`, or `None` when it is (near) zero / non-finite.
216fn normalize(v: [f64; 3]) -> Option<[f64; 3]> {
217    let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
218    if len.is_finite() && len > 1e-12 {
219        Some([v[0] / len, v[1] / len, v[2] / len])
220    } else {
221        None
222    }
223}
224
225/// Read a `[x, y, z]` JSON array as an `[f64; 3]`, falling back per-component to
226/// `default` (mirrors the kernel's `read_vec3` in `features/sketch.rs`).
227fn read_vec3(value: Option<&serde_json::Value>, default: [f64; 3]) -> [f64; 3] {
228    let Some(array) = value.and_then(|v| v.as_array()) else {
229        return default;
230    };
231    let component = |index: usize| {
232        array
233            .get(index)
234            .and_then(serde_json::Value::as_f64)
235            .unwrap_or(default[index])
236    };
237    [component(0), component(1), component(2)]
238}
239
240#[cfg(test)]
241mod tests {
242    use super::doc::{id_key, SketchDiagnostics, SketchDoc};
243    use super::*;
244    use serde_json::{json, Value};
245
246    /// Solve a sketch `Value` directly and return the full solved sketch object
247    /// (`{points, geometries, constraints, diagnostics}`).
248    fn solve_value(sketch: Value) -> Value {
249        let request = brep_kernel::SolveSketchRequest {
250            sketch,
251            iterations: Some(1000),
252            remove_implied_duplicates: false,
253            tolerance: None,
254            distance_slide_threshold_ratio: None,
255            distance_slide_step_ratio: None,
256            distance_slide_min_step: None,
257            polish: None,
258        };
259        brep_kernel::solve_sketch(&request).expect("solve_sketch")["sketch"].clone()
260    }
261
262    #[test]
263    fn sketchdoc_round_trips_solver_json() {
264        // A solved rectangle produces `{points, geometries, constraints,
265        // diagnostics}`. `SketchDoc` mirrors the three editable arrays; splitting
266        // diagnostics off, the doc must re-serialize BYTE-EQUAL to the input.
267        let session = SketchSession::seed_rectangle_circle().expect("seed session");
268        let solved = solve_value(serde_json::to_value(&session.doc).unwrap());
269
270        let mut doc_value = solved.clone();
271        doc_value
272            .as_object_mut()
273            .unwrap()
274            .remove("diagnostics")
275            .expect("solved sketch carries diagnostics");
276
277        let doc: SketchDoc = serde_json::from_value(doc_value.clone()).expect("doc from value");
278        let back = serde_json::to_value(&doc).expect("doc to value");
279        assert_eq!(back, doc_value, "SketchDoc did not round-trip the solver JSON");
280
281        // Diagnostics round-trips independently.
282        let diag: SketchDiagnostics =
283            serde_json::from_value(solved["diagnostics"].clone()).expect("diag from value");
284        let diag_back = serde_json::to_value(&diag).expect("diag to value");
285        assert_eq!(diag_back, solved["diagnostics"], "diagnostics did not round-trip");
286    }
287
288    #[test]
289    fn seed_rectangle_solves_with_plausible_dof_and_mobility() {
290        let session = SketchSession::seed_rectangle_circle().expect("seed session");
291        let diag = &session.diagnostics;
292
293        // The rectangle is fully constrained; the free circle adds exactly its
294        // four coordinate DOF.
295        assert_eq!(diag.dof, 4, "diag = {diag:?}");
296        assert_eq!(diag.status, "under");
297        assert_eq!(diag.redundant, 0);
298        assert!(!diag.conflicting);
299
300        // Rectangle corners (grounded/dimensioned) are locked; circle points free.
301        for id in [0, 1, 2, 3] {
302            assert_eq!(
303                diag.point_movable(&json!(id)),
304                Some(false),
305                "rectangle point {id} should be locked"
306            );
307        }
308        for id in [4, 5] {
309            assert_eq!(
310                diag.point_movable(&json!(id)),
311                Some(true),
312                "circle point {id} should be movable"
313            );
314        }
315
316        // Rectangle sides locked (white); circle movable (blue).
317        for gid in [10, 11, 12, 13] {
318            assert_eq!(diag.geometry_movable(&json!(gid)), Some(false));
319        }
320        assert_eq!(diag.geometry_movable(&json!(20)), Some(true));
321
322        // Solved coordinates: the grounded corner stays at the origin and the
323        // dimensioned corner sits at (20, 12).
324        let p2 = session.doc.point(&json!(2)).expect("point 2");
325        assert!((p2.x - 20.0).abs() < 1e-6 && (p2.y - 12.0).abs() < 1e-6, "p2 = {p2:?}");
326    }
327
328    #[test]
329    fn tessellation_yields_expected_segment_and_point_counts() {
330        let session = SketchSession::seed_rectangle_circle().expect("seed session");
331        let tess = session.tessellation(0.05);
332
333        // 4 rectangle lines (1 segment each) + a 64-gon circle = 68 segments.
334        assert_eq!(tess.line_segment_count(), 4 + 64);
335        // 6 points (4 rectangle corners + circle center/radius), none dropped.
336        assert_eq!(tess.point_count(), 6);
337
338        // Every emitted vertex carries an rgb triple.
339        assert_eq!(tess.line_positions.len(), tess.line_colors.len());
340        assert_eq!(tess.point_positions.len(), tess.point_colors.len());
341
342        // XY plane: solved z is flat zero on every line vertex.
343        assert!(tess.line_positions.chunks(3).all(|c| c[2].abs() < 1e-6));
344
345        // The circle center point (id 4, movable) is colored blue (0x4aa3ff).
346        let center = session.doc.point(&json!(4)).unwrap();
347        let cx = center.x as f32;
348        let idx = tess
349            .point_positions
350            .chunks(3)
351            .position(|c| (c[0] - cx).abs() < 1e-4)
352            .expect("circle center among overlay points");
353        let col = &tess.point_colors[idx * 3..idx * 3 + 3];
354        assert!((col[0] - 0x4a as f32 / 255.0).abs() < 1e-3, "movable point not blue: {col:?}");
355    }
356
357    #[test]
358    fn construction_geometry_is_dashed_into_multiple_segments() {
359        // A single long construction line dashes into many short segments; a
360        // solid line of the same span stays one segment.
361        let doc: SketchDoc = serde_json::from_value(json!({
362            "points": [
363                { "id": 0, "x": 0.0,  "y": 0.0 },
364                { "id": 1, "x": 100.0, "y": 0.0 }
365            ],
366            "geometries": [
367                { "id": 10, "type": "line", "points": [0, 1], "construction": true }
368            ],
369            "constraints": []
370        }))
371        .unwrap();
372        let session = SketchSession::new(doc, PlaneFrame::xy()).expect("session");
373        let tess = session.tessellation(0.05); // dash ~0.4, gap ~0.3 over a 100-long span
374        assert!(
375            tess.line_segment_count() > 10,
376            "construction line should dash into many segments, got {}",
377            tess.line_segment_count()
378        );
379    }
380
381    #[test]
382    fn id_key_matches_solver_formatting() {
383        assert_eq!(id_key(&json!(10)), "10");
384        assert_eq!(id_key(&json!(10.0)), "10");
385        assert_eq!(id_key(&json!(0)), "0");
386        assert_eq!(id_key(&json!(-0.0)), "0");
387        assert_eq!(id_key(&json!("edge:3")), "edge:3");
388    }
389
390    #[test]
391    fn plane_frame_embeds_uv_in_world() {
392        let f = PlaneFrame::xy();
393        assert_eq!(f.to_world(3.0, 4.0), [3.0, 4.0, 0.0]);
394    }
395
396    #[test]
397    fn to_uv_inverts_to_world_on_a_tilted_frame() {
398        // A round-trip uv → world → uv recovers the original coordinate on a
399        // non-identity (tilted, offset) frame.
400        let f = PlaneFrame::from_normal([5.0, -2.0, 3.0], [1.0, 2.0, 3.0]);
401        for &(u, v) in &[(0.0, 0.0), (2.5, -1.5), (-4.0, 7.0)] {
402            let world = f.to_world(u, v);
403            let (ru, rv) = f.to_uv(world);
404            assert!((ru - u).abs() < 1e-9 && (rv - v).abs() < 1e-9, "uv=({u},{v}) -> ({ru},{rv})");
405        }
406        // A point pushed off the plane along the normal projects to the same uv
407        // (orthogonal projection drops the normal component).
408        let base = f.to_world(1.0, 2.0);
409        let off = [
410            base[0] + f.z_axis[0] * 9.0,
411            base[1] + f.z_axis[1] * 9.0,
412            base[2] + f.z_axis[2] * 9.0,
413        ];
414        let (ou, ov) = f.to_uv(off);
415        assert!((ou - 1.0).abs() < 1e-9 && (ov - 2.0).abs() < 1e-9, "off-plane uv=({ou},{ov})");
416    }
417
418    // --- S1: PlaneFrame::from_normal / xz / yz / from_basis_json --------------
419
420    fn approx(a: [f64; 3], b: [f64; 3]) -> bool {
421        a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)
422    }
423
424    /// A frame is orthonormal + right-handed: unit axes, mutually perpendicular,
425    /// and `x × y == z`.
426    fn assert_orthonormal(f: &PlaneFrame) {
427        for axis in [f.x_axis, f.y_axis, f.z_axis] {
428            let len = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
429            assert!((len - 1.0).abs() < 1e-9, "axis not unit: {axis:?}");
430        }
431        assert!(super::dot(f.x_axis, f.y_axis).abs() < 1e-9, "x·y != 0");
432        assert!(super::dot(f.y_axis, f.z_axis).abs() < 1e-9, "y·z != 0");
433        assert!(super::dot(f.z_axis, f.x_axis).abs() < 1e-9, "z·x != 0");
434        assert!(
435            approx(super::cross(f.x_axis, f.y_axis), f.z_axis),
436            "not right-handed: {f:?}"
437        );
438    }
439
440    #[test]
441    fn from_normal_xy_is_the_identity_frame() {
442        let f = PlaneFrame::from_normal([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
443        assert_eq!(f, PlaneFrame::xy());
444        assert_orthonormal(&f);
445    }
446
447    #[test]
448    fn base_planes_match_the_datum_normals_and_are_orthonormal() {
449        // datum.rs base-plane normals: XY (0,0,1), XZ (0,-1,0), YZ (1,0,0).
450        let xz = PlaneFrame::xz();
451        assert!(approx(xz.z_axis, [0.0, -1.0, 0.0]), "XZ normal: {:?}", xz.z_axis);
452        assert!(approx(xz.origin, [0.0, 0.0, 0.0]));
453        assert_orthonormal(&xz);
454
455        let yz = PlaneFrame::yz();
456        assert!(approx(yz.z_axis, [1.0, 0.0, 0.0]), "YZ normal: {:?}", yz.z_axis);
457        assert!(approx(yz.origin, [0.0, 0.0, 0.0]));
458        assert_orthonormal(&yz);
459    }
460
461    #[test]
462    fn from_normal_carries_origin_and_normalizes() {
463        let f = PlaneFrame::from_normal([5.0, 6.0, 7.0], [0.0, 0.0, 4.0]);
464        assert_eq!(f.origin, [5.0, 6.0, 7.0]);
465        assert!(approx(f.z_axis, [0.0, 0.0, 1.0]), "unnormalized normal: {:?}", f.z_axis);
466        assert_orthonormal(&f);
467    }
468
469    #[test]
470    fn from_normal_degenerate_returns_identity_axes_at_origin() {
471        let f = PlaneFrame::from_normal([2.0, 3.0, 4.0], [0.0, 0.0, 0.0]);
472        assert_eq!(
473            f,
474            PlaneFrame {
475                origin: [2.0, 3.0, 4.0],
476                ..PlaneFrame::xy()
477            }
478        );
479    }
480
481    #[test]
482    fn from_basis_json_round_trips_a_basis_object() {
483        // The exact shape the kernel persists (features/sketch.rs `persisted_basis_frame`).
484        let f = PlaneFrame::yz();
485        let basis = json!({
486            "origin": f.origin,
487            "x": f.x_axis,
488            "y": f.y_axis,
489            "z": f.z_axis,
490        });
491        let back = PlaneFrame::from_basis_json(&basis);
492        assert_eq!(back, f);
493
494        // Missing keys fall back to the identity components.
495        let partial = json!({ "origin": [5.0, 0.0, 0.0] });
496        let g = PlaneFrame::from_basis_json(&partial);
497        assert_eq!(g.origin, [5.0, 0.0, 0.0]);
498        assert_eq!(g.x_axis, [1.0, 0.0, 0.0]);
499        assert_eq!(g.y_axis, [0.0, 1.0, 0.0]);
500        assert_eq!(g.z_axis, [0.0, 0.0, 1.0]);
501    }
502
503    // --- S2: pixel → plane ray∩plane → uv (pure helper) -----------------------
504
505    #[test]
506    fn ray_plane_uv_hits_the_xy_plane_and_recovers_uv() {
507        // A ray straight down onto the XY plane at world (3, 4, 0) recovers (3, 4).
508        let plane = PlaneFrame::xy();
509        let uv = super::ray_plane_uv(&plane, [3.0, 4.0, 10.0], [0.0, 0.0, -1.0]).unwrap();
510        assert!((uv.0 - 3.0).abs() < 1e-9 && (uv.1 - 4.0).abs() < 1e-9, "uv = {uv:?}");
511    }
512
513    #[test]
514    fn ray_plane_uv_rejects_parallel_and_behind_rays() {
515        let plane = PlaneFrame::xy();
516        // Parallel to the plane (dir in-plane) → None.
517        assert!(super::ray_plane_uv(&plane, [0.0, 0.0, 5.0], [1.0, 0.0, 0.0]).is_none());
518        // Plane behind the origin (looking away from it) → None (t <= 0).
519        assert!(super::ray_plane_uv(&plane, [0.0, 0.0, 5.0], [0.0, 0.0, 1.0]).is_none());
520    }
521
522    #[test]
523    fn ray_plane_uv_uses_the_plane_axes_on_a_tilted_plane() {
524        // On the YZ plane (normal +x, x_axis/y_axis per the datum convention), a
525        // ray from +x recovers the in-plane coordinates in that frame.
526        let plane = PlaneFrame::yz();
527        let target = plane.to_world(2.5, -1.5);
528        let origin = [
529            target[0] + plane.z_axis[0] * 8.0,
530            target[1] + plane.z_axis[1] * 8.0,
531            target[2] + plane.z_axis[2] * 8.0,
532        ];
533        let dir = [-plane.z_axis[0], -plane.z_axis[1], -plane.z_axis[2]];
534        let uv = super::ray_plane_uv(&plane, origin, dir).unwrap();
535        assert!((uv.0 - 2.5).abs() < 1e-9 && (uv.1 + 1.5).abs() < 1e-9, "uv = {uv:?}");
536    }
537}