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