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 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#[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 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 pub fn xz() -> Self {
77 Self::from_normal([0.0, 0.0, 0.0], [0.0, -1.0, 0.0])
78 }
79
80 pub fn yz() -> Self {
83 Self::from_normal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
84 }
85
86 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 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 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 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
170pub 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; }
184 let t = dot(sub(plane.origin, origin), n) / denom;
185 if t <= 0.0 {
186 return None; }
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
197fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
199 a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
200}
201
202fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
204 [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
205}
206
207fn 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
216fn 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
226fn 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 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 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 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 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 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 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 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 assert_eq!(tess.line_segment_count(), 4 + 64);
336 assert_eq!(tess.point_count(), 6);
338
339 assert_eq!(tess.line_positions.len(), tess.line_colors.len());
341 assert_eq!(tess.point_positions.len(), tess.point_colors.len());
342
343 assert!(tess.line_positions.chunks(3).all(|c| c[2].abs() < 1e-6));
345
346 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 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); 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 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 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 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 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 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 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 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 #[test]
507 fn ray_plane_uv_hits_the_xy_plane_and_recovers_uv() {
508 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 assert!(super::ray_plane_uv(&plane, [0.0, 0.0, 5.0], [1.0, 0.0, 0.0]).is_none());
519 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 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}