brep_render/sketch/mod.rs
1//! Sketch documents, constraint solving, interaction state, and overlay geometry.
2
3use crate::json_support::vec3_or as read_vec3;
4use crate::geometry3d::{cross3 as cross, dot3 as dot, sub3 as sub};
5
6pub mod constraint_glyphs;
7pub mod dimensions;
8pub mod doc;
9pub mod external_ref;
10pub mod handdraw;
11pub mod infer;
12pub mod session;
13pub mod solve;
14pub mod spline;
15pub mod tessellate;
16pub mod trim;
17
18pub use doc::{SketchConstraint, SketchDiagnostics, SketchDoc, SketchGeometry, SketchPoint};
19pub use external_ref::{classify_uv, EdgeLink, ExternalRef};
20pub use session::{
21 constraint_ref, entity_ref_eq, geometry_ref, point_ref, refs_equal, SketchSession,
22};
23pub use solve::SketchSolverSettings;
24pub use tessellate::SketchTessellation;
25
26/// An orthonormal placement frame for a sketch plane — origin + in-plane `x`/`y`
27/// axes + the `z` normal, all in world space (`f64`). A plane `(u, v)` coordinate
28/// maps to world `origin + u·x + v·y` (via [`to_world`](Self::to_world)).
29///
30/// Uses double precision to match the sketch solver's coordinates.
31#[derive(Clone, Copy, Debug, PartialEq)]
32pub struct PlaneFrame {
33 pub origin: [f64; 3],
34 pub x_axis: [f64; 3],
35 pub y_axis: [f64; 3],
36 pub z_axis: [f64; 3],
37}
38
39impl PlaneFrame {
40 /// The world XY plane (identity frame): `u → +x`, `v → +y`, normal `+z`.
41 pub fn xy() -> Self {
42 Self {
43 origin: [0.0, 0.0, 0.0],
44 x_axis: [1.0, 0.0, 0.0],
45 y_axis: [0.0, 1.0, 0.0],
46 z_axis: [0.0, 0.0, 1.0],
47 }
48 }
49
50 /// The world XZ base plane (`datum.rs` normal `(0, -1, 0)`), resolved through
51 /// the same worldUp convention the kernel uses (see [`from_normal`](Self::from_normal)).
52 pub fn xz() -> Self {
53 Self::from_normal([0.0, 0.0, 0.0], [0.0, -1.0, 0.0])
54 }
55
56 /// The world YZ base plane (`datum.rs` normal `(1, 0, 0)`), resolved through
57 /// the same worldUp convention the kernel uses (see [`from_normal`](Self::from_normal)).
58 pub fn yz() -> Self {
59 Self::from_normal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
60 }
61
62 /// Derive an orthonormal frame from an `origin` + plane `normal`, a faithful
63 /// port of `feature_pipeline::Frame::from_origin_normal` — the kernel's SINGLE
64 /// source of truth for how a plane reference becomes in-plane axes:
65 ///
66 /// ```text
67 /// refUp = |n·(0,1,0)| > 0.9 ? (1,0,0) : (0,1,0)
68 /// x = norm(refUp × n); y = norm(n × x); z = n
69 /// ```
70 ///
71 /// A degenerate (zero / non-finite) normal — or a normal collinear with the
72 /// picked `refUp` — returns the XY identity axes (at `origin`) rather than
73 /// erroring, so callers always get a usable frame.
74 pub fn from_normal(origin: [f64; 3], normal: [f64; 3]) -> Self {
75 let identity = Self {
76 origin,
77 ..Self::xy()
78 };
79 let Some(z) = normalize(normal) else {
80 return identity;
81 };
82 let world_up = [0.0, 1.0, 0.0];
83 let ref_up = if dot(z, world_up).abs() > 0.9 {
84 [1.0, 0.0, 0.0]
85 } else {
86 world_up
87 };
88 let Some(x) = normalize(cross(ref_up, z)) else {
89 return identity;
90 };
91 let Some(y) = normalize(cross(z, x)) else {
92 return identity;
93 };
94 Self {
95 origin,
96 x_axis: x,
97 y_axis: y,
98 z_axis: z,
99 }
100 }
101
102 /// Read a persisted `persistentData.basis` object (`{origin, x, y, z}`, each a
103 /// `[x, y, z]` array) into a frame, mirroring the kernel's `persisted_basis_frame`
104 /// (`features/sketch.rs`). Missing keys default to the identity components, so a
105 /// partial / absent basis still yields a usable XY-ish frame.
106 pub fn from_basis_json(basis: &serde_json::Value) -> Self {
107 Self {
108 origin: read_vec3(basis.get("origin"), [0.0, 0.0, 0.0]),
109 x_axis: read_vec3(basis.get("x"), [1.0, 0.0, 0.0]),
110 y_axis: read_vec3(basis.get("y"), [0.0, 1.0, 0.0]),
111 z_axis: read_vec3(basis.get("z"), [0.0, 0.0, 1.0]),
112 }
113 }
114
115 /// Map a plane `(u, v)` coordinate to world `[x, y, z]`.
116 pub fn to_world(&self, u: f64, v: f64) -> [f64; 3] {
117 [
118 self.origin[0] + self.x_axis[0] * u + self.y_axis[0] * v,
119 self.origin[1] + self.x_axis[1] * u + self.y_axis[1] * v,
120 self.origin[2] + self.x_axis[2] * u + self.y_axis[2] * v,
121 ]
122 }
123
124 /// Project a world point onto the plane's `(u, v)` frame — the inverse of
125 /// [`to_world`](Self::to_world). With orthonormal axes this is plain dot
126 /// products against the offset from the origin (`d = world − origin`;
127 /// `u = d·x_axis`, `v = d·y_axis`); a point off the plane projects orthogonally
128 /// (its normal component is dropped). Mirrors the previous sketcher's
129 /// world→UV projection.
130 pub fn to_uv(&self, world: [f64; 3]) -> (f64, f64) {
131 let d = [
132 world[0] - self.origin[0],
133 world[1] - self.origin[1],
134 world[2] - self.origin[2],
135 ];
136 (dot(d, self.x_axis), dot(d, self.y_axis))
137 }
138}
139
140impl Default for PlaneFrame {
141 fn default() -> Self {
142 Self::xy()
143 }
144}
145
146/// Intersect a world-space ray (`origin` + `dir`) with a sketch `plane` and return
147/// the hit's in-plane `(u, v)` coordinate, or `None` when the ray is parallel to
148/// the plane (`|dir·n| < 1e-9`) or the hit is behind the ray origin (`t <= 0`).
149///
150/// This is the pure pixel→plane math behind
151/// [`EngineState::sketch_uv_at`](crate::engine_state::EngineState::sketch_uv_at):
152/// the caller supplies the camera ray (`camera.pick_ray(x, y)`); the plane's axes
153/// are assumed orthonormal, so the world→uv projection is plain dot products.
154pub fn ray_plane_uv(plane: &PlaneFrame, origin: [f64; 3], dir: [f64; 3]) -> Option<(f64, f64)> {
155 let n = plane.z_axis;
156 let denom = dot(dir, n);
157 if denom.abs() < 1e-9 {
158 return None; // ray parallel to the plane
159 }
160 let t = dot(sub(plane.origin, origin), n) / denom;
161 if t <= 0.0 {
162 return None; // plane is behind the ray origin
163 }
164 let hit = [
165 origin[0] + t * dir[0],
166 origin[1] + t * dir[1],
167 origin[2] + t * dir[2],
168 ];
169 let w = sub(hit, plane.origin);
170 Some((dot(w, plane.x_axis), dot(w, plane.y_axis)))
171}
172
173/// The BASE overlay color for one constraint's annotation — the shared constraint
174/// green, or the conflict red while the solver names this constraint in a conflict.
175/// The ONE place that decision is made, so a glyph and a dimension leader in the
176/// same conflict are never colored differently. Selection / hover emphasis is
177/// layered ON TOP of this by
178/// [`tessellate::interaction_color`](crate::sketch::tessellate), so a picked
179/// conflicting constraint still reads as picked.
180pub(crate) fn constraint_base_color(
181 colors: &crate::style::SketchColors,
182 diag: &SketchDiagnostics,
183 id: &serde_json::Value,
184) -> u32 {
185 if diag.constraint_conflicting(id) {
186 colors.conflict
187 } else {
188 colors.constraint
189 }
190}
191
192/// Normalize `v`, or `None` when it is (near) zero / non-finite.
193fn normalize(v: [f64; 3]) -> Option<[f64; 3]> {
194 let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
195 if len.is_finite() && len > 1e-12 {
196 Some([v[0] / len, v[1] / len, v[2] / len])
197 } else {
198 None
199 }
200}
201
202// BREP private tests: f02820da9e62fcac