pub mod constraint_glyphs;
pub mod dimensions;
pub mod doc;
pub mod external_ref;
pub mod handdraw;
pub mod infer;
pub mod session;
pub mod solve;
pub mod tessellate;
pub mod trim;
pub use doc::{SketchConstraint, SketchDiagnostics, SketchDoc, SketchGeometry, SketchPoint};
pub use external_ref::{classify_uv, EdgeLink, ExternalRef};
pub use session::{
constraint_ref, entity_ref_eq, geometry_ref, point_ref, refs_equal, SketchSession,
};
pub use solve::SketchSolverSettings;
pub use tessellate::SketchTessellation;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PlaneFrame {
pub origin: [f64; 3],
pub x_axis: [f64; 3],
pub y_axis: [f64; 3],
pub z_axis: [f64; 3],
}
impl PlaneFrame {
pub fn xy() -> Self {
Self {
origin: [0.0, 0.0, 0.0],
x_axis: [1.0, 0.0, 0.0],
y_axis: [0.0, 1.0, 0.0],
z_axis: [0.0, 0.0, 1.0],
}
}
pub fn xz() -> Self {
Self::from_normal([0.0, 0.0, 0.0], [0.0, -1.0, 0.0])
}
pub fn yz() -> Self {
Self::from_normal([0.0, 0.0, 0.0], [1.0, 0.0, 0.0])
}
pub fn from_normal(origin: [f64; 3], normal: [f64; 3]) -> Self {
let identity = Self {
origin,
..Self::xy()
};
let Some(z) = normalize(normal) else {
return identity;
};
let world_up = [0.0, 1.0, 0.0];
let ref_up = if dot(z, world_up).abs() > 0.9 {
[1.0, 0.0, 0.0]
} else {
world_up
};
let Some(x) = normalize(cross(ref_up, z)) else {
return identity;
};
let Some(y) = normalize(cross(z, x)) else {
return identity;
};
Self {
origin,
x_axis: x,
y_axis: y,
z_axis: z,
}
}
pub fn from_basis_json(basis: &serde_json::Value) -> Self {
Self {
origin: read_vec3(basis.get("origin"), [0.0, 0.0, 0.0]),
x_axis: read_vec3(basis.get("x"), [1.0, 0.0, 0.0]),
y_axis: read_vec3(basis.get("y"), [0.0, 1.0, 0.0]),
z_axis: read_vec3(basis.get("z"), [0.0, 0.0, 1.0]),
}
}
pub fn to_world(&self, u: f64, v: f64) -> [f64; 3] {
[
self.origin[0] + self.x_axis[0] * u + self.y_axis[0] * v,
self.origin[1] + self.x_axis[1] * u + self.y_axis[1] * v,
self.origin[2] + self.x_axis[2] * u + self.y_axis[2] * v,
]
}
pub fn to_uv(&self, world: [f64; 3]) -> (f64, f64) {
let d = [
world[0] - self.origin[0],
world[1] - self.origin[1],
world[2] - self.origin[2],
];
(dot(d, self.x_axis), dot(d, self.y_axis))
}
}
impl Default for PlaneFrame {
fn default() -> Self {
Self::xy()
}
}
pub fn ray_plane_uv(plane: &PlaneFrame, origin: [f64; 3], dir: [f64; 3]) -> Option<(f64, f64)> {
let n = plane.z_axis;
let denom = dot(dir, n);
if denom.abs() < 1e-9 {
return None; }
let t = dot(sub(plane.origin, origin), n) / denom;
if t <= 0.0 {
return None; }
let hit = [
origin[0] + t * dir[0],
origin[1] + t * dir[1],
origin[2] + t * dir[2],
];
let w = sub(hit, plane.origin);
Some((dot(w, plane.x_axis), dot(w, plane.y_axis)))
}
fn dot(a: [f64; 3], b: [f64; 3]) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
fn sub(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[a[0] - b[0], a[1] - b[1], a[2] - b[2]]
}
fn cross(a: [f64; 3], b: [f64; 3]) -> [f64; 3] {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn normalize(v: [f64; 3]) -> Option<[f64; 3]> {
let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if len.is_finite() && len > 1e-12 {
Some([v[0] / len, v[1] / len, v[2] / len])
} else {
None
}
}
fn read_vec3(value: Option<&serde_json::Value>, default: [f64; 3]) -> [f64; 3] {
let Some(array) = value.and_then(|v| v.as_array()) else {
return default;
};
let component = |index: usize| {
array
.get(index)
.and_then(serde_json::Value::as_f64)
.unwrap_or(default[index])
};
[component(0), component(1), component(2)]
}
#[cfg(test)]
mod tests {
use super::doc::{id_key, SketchDiagnostics, SketchDoc};
use super::*;
use serde_json::{json, Value};
fn solve_value(sketch: Value) -> Value {
let request = brep_kernel::SolveSketchRequest {
sketch,
iterations: Some(1000),
remove_implied_duplicates: false,
tolerance: None,
distance_slide_threshold_ratio: None,
distance_slide_step_ratio: None,
distance_slide_min_step: None,
polish: None,
};
brep_kernel::solve_sketch(&request).expect("solve_sketch")["sketch"].clone()
}
#[test]
fn sketchdoc_round_trips_solver_json() {
let session = SketchSession::seed_rectangle_circle().expect("seed session");
let solved = solve_value(serde_json::to_value(&session.doc).unwrap());
let mut doc_value = solved.clone();
doc_value
.as_object_mut()
.unwrap()
.remove("diagnostics")
.expect("solved sketch carries diagnostics");
let doc: SketchDoc = serde_json::from_value(doc_value.clone()).expect("doc from value");
let back = serde_json::to_value(&doc).expect("doc to value");
assert_eq!(back, doc_value, "SketchDoc did not round-trip the solver JSON");
let diag: SketchDiagnostics =
serde_json::from_value(solved["diagnostics"].clone()).expect("diag from value");
let diag_back = serde_json::to_value(&diag).expect("diag to value");
assert_eq!(diag_back, solved["diagnostics"], "diagnostics did not round-trip");
}
#[test]
fn seed_rectangle_solves_with_plausible_dof_and_mobility() {
let session = SketchSession::seed_rectangle_circle().expect("seed session");
let diag = &session.diagnostics;
assert_eq!(diag.dof, 4, "diag = {diag:?}");
assert_eq!(diag.status, "under");
assert_eq!(diag.redundant, 0);
assert!(!diag.conflicting);
for id in [0, 1, 2, 3] {
assert_eq!(
diag.point_movable(&json!(id)),
Some(false),
"rectangle point {id} should be locked"
);
}
for id in [4, 5] {
assert_eq!(
diag.point_movable(&json!(id)),
Some(true),
"circle point {id} should be movable"
);
}
for gid in [10, 11, 12, 13] {
assert_eq!(diag.geometry_movable(&json!(gid)), Some(false));
}
assert_eq!(diag.geometry_movable(&json!(20)), Some(true));
let p2 = session.doc.point(&json!(2)).expect("point 2");
assert!((p2.x - 20.0).abs() < 1e-6 && (p2.y - 12.0).abs() < 1e-6, "p2 = {p2:?}");
}
#[test]
fn tessellation_yields_expected_segment_and_point_counts() {
let session = SketchSession::seed_rectangle_circle().expect("seed session");
let tess = session.tessellation(0.05);
assert_eq!(tess.line_segment_count(), 4 + 64);
assert_eq!(tess.point_count(), 6);
assert_eq!(tess.line_positions.len(), tess.line_colors.len());
assert_eq!(tess.point_positions.len(), tess.point_colors.len());
assert!(tess.line_positions.chunks(3).all(|c| c[2].abs() < 1e-6));
let center = session.doc.point(&json!(4)).unwrap();
let cx = center.x as f32;
let idx = tess
.point_positions
.chunks(3)
.position(|c| (c[0] - cx).abs() < 1e-4)
.expect("circle center among overlay points");
let col = &tess.point_colors[idx * 3..idx * 3 + 3];
assert!((col[0] - 0x4a as f32 / 255.0).abs() < 1e-3, "movable point not blue: {col:?}");
}
#[test]
fn construction_geometry_is_dashed_into_multiple_segments() {
let doc: SketchDoc = serde_json::from_value(json!({
"points": [
{ "id": 0, "x": 0.0, "y": 0.0 },
{ "id": 1, "x": 100.0, "y": 0.0 }
],
"geometries": [
{ "id": 10, "type": "line", "points": [0, 1], "construction": true }
],
"constraints": []
}))
.unwrap();
let session = SketchSession::new(doc, PlaneFrame::xy()).expect("session");
let tess = session.tessellation(0.05); assert!(
tess.line_segment_count() > 10,
"construction line should dash into many segments, got {}",
tess.line_segment_count()
);
}
#[test]
fn id_key_matches_solver_formatting() {
assert_eq!(id_key(&json!(10)), "10");
assert_eq!(id_key(&json!(10.0)), "10");
assert_eq!(id_key(&json!(0)), "0");
assert_eq!(id_key(&json!(-0.0)), "0");
assert_eq!(id_key(&json!("edge:3")), "edge:3");
}
#[test]
fn plane_frame_embeds_uv_in_world() {
let f = PlaneFrame::xy();
assert_eq!(f.to_world(3.0, 4.0), [3.0, 4.0, 0.0]);
}
#[test]
fn to_uv_inverts_to_world_on_a_tilted_frame() {
let f = PlaneFrame::from_normal([5.0, -2.0, 3.0], [1.0, 2.0, 3.0]);
for &(u, v) in &[(0.0, 0.0), (2.5, -1.5), (-4.0, 7.0)] {
let world = f.to_world(u, v);
let (ru, rv) = f.to_uv(world);
assert!((ru - u).abs() < 1e-9 && (rv - v).abs() < 1e-9, "uv=({u},{v}) -> ({ru},{rv})");
}
let base = f.to_world(1.0, 2.0);
let off = [
base[0] + f.z_axis[0] * 9.0,
base[1] + f.z_axis[1] * 9.0,
base[2] + f.z_axis[2] * 9.0,
];
let (ou, ov) = f.to_uv(off);
assert!((ou - 1.0).abs() < 1e-9 && (ov - 2.0).abs() < 1e-9, "off-plane uv=({ou},{ov})");
}
fn approx(a: [f64; 3], b: [f64; 3]) -> bool {
a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-9)
}
fn assert_orthonormal(f: &PlaneFrame) {
for axis in [f.x_axis, f.y_axis, f.z_axis] {
let len = (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
assert!((len - 1.0).abs() < 1e-9, "axis not unit: {axis:?}");
}
assert!(super::dot(f.x_axis, f.y_axis).abs() < 1e-9, "x·y != 0");
assert!(super::dot(f.y_axis, f.z_axis).abs() < 1e-9, "y·z != 0");
assert!(super::dot(f.z_axis, f.x_axis).abs() < 1e-9, "z·x != 0");
assert!(
approx(super::cross(f.x_axis, f.y_axis), f.z_axis),
"not right-handed: {f:?}"
);
}
#[test]
fn from_normal_xy_is_the_identity_frame() {
let f = PlaneFrame::from_normal([0.0, 0.0, 0.0], [0.0, 0.0, 1.0]);
assert_eq!(f, PlaneFrame::xy());
assert_orthonormal(&f);
}
#[test]
fn base_planes_match_the_datum_normals_and_are_orthonormal() {
let xz = PlaneFrame::xz();
assert!(approx(xz.z_axis, [0.0, -1.0, 0.0]), "XZ normal: {:?}", xz.z_axis);
assert!(approx(xz.origin, [0.0, 0.0, 0.0]));
assert_orthonormal(&xz);
let yz = PlaneFrame::yz();
assert!(approx(yz.z_axis, [1.0, 0.0, 0.0]), "YZ normal: {:?}", yz.z_axis);
assert!(approx(yz.origin, [0.0, 0.0, 0.0]));
assert_orthonormal(&yz);
}
#[test]
fn from_normal_carries_origin_and_normalizes() {
let f = PlaneFrame::from_normal([5.0, 6.0, 7.0], [0.0, 0.0, 4.0]);
assert_eq!(f.origin, [5.0, 6.0, 7.0]);
assert!(approx(f.z_axis, [0.0, 0.0, 1.0]), "unnormalized normal: {:?}", f.z_axis);
assert_orthonormal(&f);
}
#[test]
fn from_normal_degenerate_returns_identity_axes_at_origin() {
let f = PlaneFrame::from_normal([2.0, 3.0, 4.0], [0.0, 0.0, 0.0]);
assert_eq!(
f,
PlaneFrame {
origin: [2.0, 3.0, 4.0],
..PlaneFrame::xy()
}
);
}
#[test]
fn from_basis_json_round_trips_a_basis_object() {
let f = PlaneFrame::yz();
let basis = json!({
"origin": f.origin,
"x": f.x_axis,
"y": f.y_axis,
"z": f.z_axis,
});
let back = PlaneFrame::from_basis_json(&basis);
assert_eq!(back, f);
let partial = json!({ "origin": [5.0, 0.0, 0.0] });
let g = PlaneFrame::from_basis_json(&partial);
assert_eq!(g.origin, [5.0, 0.0, 0.0]);
assert_eq!(g.x_axis, [1.0, 0.0, 0.0]);
assert_eq!(g.y_axis, [0.0, 1.0, 0.0]);
assert_eq!(g.z_axis, [0.0, 0.0, 1.0]);
}
#[test]
fn ray_plane_uv_hits_the_xy_plane_and_recovers_uv() {
let plane = PlaneFrame::xy();
let uv = super::ray_plane_uv(&plane, [3.0, 4.0, 10.0], [0.0, 0.0, -1.0]).unwrap();
assert!((uv.0 - 3.0).abs() < 1e-9 && (uv.1 - 4.0).abs() < 1e-9, "uv = {uv:?}");
}
#[test]
fn ray_plane_uv_rejects_parallel_and_behind_rays() {
let plane = PlaneFrame::xy();
assert!(super::ray_plane_uv(&plane, [0.0, 0.0, 5.0], [1.0, 0.0, 0.0]).is_none());
assert!(super::ray_plane_uv(&plane, [0.0, 0.0, 5.0], [0.0, 0.0, 1.0]).is_none());
}
#[test]
fn ray_plane_uv_uses_the_plane_axes_on_a_tilted_plane() {
let plane = PlaneFrame::yz();
let target = plane.to_world(2.5, -1.5);
let origin = [
target[0] + plane.z_axis[0] * 8.0,
target[1] + plane.z_axis[1] * 8.0,
target[2] + plane.z_axis[2] * 8.0,
];
let dir = [-plane.z_axis[0], -plane.z_axis[1], -plane.z_axis[2]];
let uv = super::ray_plane_uv(&plane, origin, dir).unwrap();
assert!((uv.0 - 2.5).abs() < 1e-9 && (uv.1 + 1.5).abs() < 1e-9, "uv = {uv:?}");
}
}