1pub 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#[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 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 pub fn xz() -> Self {
76 Self::from_normal([0.0, 0.0, 0.0], [0.0, -1.0, 0.0])
77 }
78
79 pub fn yz() -> Self {
82 Self::from_normal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
83 }
84
85 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 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 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 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
169pub 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; }
183 let t = dot(sub(plane.origin, origin), n) / denom;
184 if t <= 0.0 {
185 return None; }
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
196fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
198 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
199}
200
201fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
203 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
204}
205
206fn 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
215fn 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
225fn 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 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 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 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 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 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 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 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 assert_eq!(tess.line_segment_count(), 4 + 64);
335 assert_eq!(tess.point_count(), 6);
337
338 assert_eq!(tess.line_positions.len(), tess.line_colors.len());
340 assert_eq!(tess.point_positions.len(), tess.point_colors.len());
341
342 assert!(tess.line_positions.chunks(3).all(|c| c[2].abs() < 1e-6));
344
345 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 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); 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 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 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 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 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 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 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 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 #[test]
506 fn ray_plane_uv_hits_the_xy_plane_and_recovers_uv() {
507 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 assert!(super::ray_plane_uv(&plane, [0.0, 0.0, 5.0], [1.0, 0.0, 0.0]).is_none());
518 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 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}