From fc2caf8aa27c707d0e9a9fe01b04f48daa7db9ae Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Thu, 12 Nov 2026 00:41:48 +0100 Subject: [PATCH 01/10] Add failing tests for union and intersection of planar polyhedra New creating tests cover crossing, nested, touching and non-convex pairs of flat-faced solids. Union and intersection of the crossing and non-convex pairs are refused today with CoverageGap, and subtracting a box that stands on a turned cuboid fails with InvalidTopology, so those tests are red. The nested turned boxes, the crossing-gable subtraction and the edge-only and corner-only contacts pass as guards: touching bodies union into two separate solids and intersect into an empty body through generic_non_intersecting_boolean, and their subtraction leaves the host unchanged. The multi-tool failure test now uses a tilted cylinder, whose union stays refused. The rotated-box matrix test now expects both rows to build through boolean_planar_polyhedra, and the step oracle expects 17 exported rows; both are red until the change and its fixture update land. --- main/opengeometry/tests/cases/matrix.rs | 77 ++-- main/opengeometry/tests/creating/main.rs | 3 + .../opengeometry/tests/creating/polyhedron.rs | 10 +- .../creating/polyhedron_boolean_checks.rs | 340 ++++++++++++++++++ .../creating/polyhedron_boolean_contacts.rs | 159 ++++++++ .../tests/creating/polyhedron_booleans.rs | 199 ++++++++++ main/opengeometry/tests/errors.rs | 40 ++- .../tests/step_oracle/fixtures.rs | 2 +- 8 files changed, 771 insertions(+), 59 deletions(-) create mode 100644 main/opengeometry/tests/creating/polyhedron_boolean_checks.rs create mode 100644 main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs create mode 100644 main/opengeometry/tests/creating/polyhedron_booleans.rs diff --git a/main/opengeometry/tests/cases/matrix.rs b/main/opengeometry/tests/cases/matrix.rs index d9c3eeec..43d697bb 100644 --- a/main/opengeometry/tests/cases/matrix.rs +++ b/main/opengeometry/tests/cases/matrix.rs @@ -3,6 +3,7 @@ use opengeometry::operations::modifying::boolean::{ boolean_brep_outcome_with_handlers, boolean_brep_with_handlers, BooleanOp, }; use opengeometry::primitives; +use opengeometry::query::validity; use opengeometry_test_support::{stored, volume}; use serde_json::json; @@ -152,47 +153,47 @@ fn boolean_support_matrix_matches_stored_outcomes_and_handlers() { } #[test] -fn i2_uncovered_rotated_box_returns_coverage_gap_without_mesh_fallback() { - let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures/cases/boolean-matrix/rotated-box-union.json"); - let fixture: serde_json::Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); - let a: BrepEnvelope = serde_json::from_value(fixture["a"].clone()).unwrap(); - let b: BrepEnvelope = serde_json::from_value(fixture["b"].clone()).unwrap(); - let nearest_vertex_to_plane = |vertices: &BrepEnvelope, planes: &BrepEnvelope| { - vertices - .topology - .vertices - .iter() - .flat_map(|vertex| { - planes.geometry.surfaces.iter().filter_map(move |surface| { - if let opengeometry::brep::SurfaceGeometry::Plane { frame } = surface { - let delta = std::array::from_fn::<_, 3, _>(|i| { - vertex.position[i] - frame.origin[i] - }); - Some((0..3).map(|i| delta[i] * frame.z[i]).sum::().abs()) - } else { - None - } - }) - }) - .fold(f64::INFINITY, f64::min) +fn rotated_box_union_and_intersection_build_through_planar_polyhedra() { + let fixture = |row: &str| -> serde_json::Value { + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join(format!("tests/fixtures/cases/boolean-matrix/{row}.json")); + serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap() }; - let clearance = nearest_vertex_to_plane(&a, &b).min(nearest_vertex_to_plane(&b, &a)); + let rows = [ + (fixture("rotated-box-union"), BooleanOp::Union), + (fixture("rotated-box-intersection"), BooleanOp::Intersection), + ]; + assert_eq!(rows[0].0["a"], rows[1].0["a"]); + assert_eq!(rows[0].0["b"], rows[1].0["b"]); + let a: BrepEnvelope = serde_json::from_value(rows[0].0["a"].clone()).unwrap(); + let b: BrepEnvelope = serde_json::from_value(rows[0].0["b"].clone()).unwrap(); + let [union, intersection] = rows.map(|(_, operation)| { + let (result, handlers) = + boolean_brep_outcome_with_handlers(&a, &b, operation, format!("{operation:?}")); + let body = result.unwrap().brep; + body.validate().unwrap(); + let checked = validity(&body); + assert!(checked.valid && checked.closed, "{operation:?}"); + let value = serde_json::to_value(&body).unwrap(); + assert_eq!( + value["solids"].as_array().unwrap().len(), + 1, + "{operation:?}" + ); + assert_eq!(handlers, ["boolean_planar_polyhedra"], "{operation:?}"); + volume::estimate(&body, 0.01) + }); + let [a, b] = [&a, &b].map(|body| volume::estimate(body, 0.01)); + let slack = a.error_bound + b.error_bound + union.error_bound + intersection.error_bound; + assert!(intersection.value > 0.0); assert!( - clearance > 10.0 * a.accuracy.geometric, - "clearance={clearance}" - ); - let (result, handlers) = boolean_brep_outcome_with_handlers( - &a, - &b, - BooleanOp::Union, - "matrix-rotated-box-union".into(), + (union.value - (a.value + b.value - intersection.value)).abs() <= slack + 1e-9, + "union={} a={} b={} intersection={}", + union.value, + a.value, + b.value, + intersection.value ); - assert!(matches!( - result, - Err(opengeometry::brep::GeometryError::CoverageGap { .. }) - )); - assert_eq!(json!(handlers), fixture["handlers"]); } #[test] diff --git a/main/opengeometry/tests/creating/main.rs b/main/opengeometry/tests/creating/main.rs index 011270bb..2f869988 100644 --- a/main/opengeometry/tests/creating/main.rs +++ b/main/opengeometry/tests/creating/main.rs @@ -3,6 +3,9 @@ mod holes; mod mesh_walls; mod pipe_segment; mod polyhedron; +mod polyhedron_boolean_checks; +mod polyhedron_boolean_contacts; +mod polyhedron_booleans; mod polysolid; mod polysolid_arcs; mod profiles; diff --git a/main/opengeometry/tests/creating/polyhedron.rs b/main/opengeometry/tests/creating/polyhedron.rs index 287ec158..fd0208b4 100644 --- a/main/opengeometry/tests/creating/polyhedron.rs +++ b/main/opengeometry/tests/creating/polyhedron.rs @@ -7,7 +7,7 @@ use opengeometry_test_support::part21::Document; use opengeometry_test_support::volume; use opengeometry_test_support::world_graph::{graph, named}; -const HIP_FACES: [&[u32]; 5] = [ +pub(super) const HIP_FACES: [&[u32]; 5] = [ &[0, 1, 2, 3], &[1, 0, 4, 5], &[2, 1, 5], @@ -22,7 +22,7 @@ const EAVES: [Point3; 4] = [ [0.0, 0.0, 2.0], ]; -fn polyhedron(points: &[Point3], faces: &[&[u32]]) -> Primitive { +pub(super) fn polyhedron(points: &[Point3], faces: &[&[u32]]) -> Primitive { Primitive::Polyhedron { points: points.to_vec(), faces: faces.iter().map(|ring| ring.to_vec()).collect(), @@ -34,15 +34,15 @@ fn roof(ridge: [Point3; 2]) -> Primitive { polyhedron(&points, &HIP_FACES) } -fn hip_roof() -> Primitive { +pub(super) fn hip_roof() -> Primitive { roof([[1.0, 1.0, 1.0], [3.0, 1.0, 1.0]]) } -fn gable_roof() -> Primitive { +pub(super) fn gable_roof() -> Primitive { roof([[0.0, 1.0, 1.0], [4.0, 1.0, 1.0]]) } -fn l_roof() -> Primitive { +pub(super) fn l_roof() -> Primitive { let points = [ [0.0, 0.0, 0.0], [4.0, 0.0, 0.0], diff --git a/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs b/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs new file mode 100644 index 00000000..6fe4c2f9 --- /dev/null +++ b/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs @@ -0,0 +1,340 @@ +use opengeometry::brep::{BrepEnvelope, FaceRole}; +use opengeometry::math::Point3; +use opengeometry::operations::modifying::boolean::{boolean_brep_outcome_with_handlers, BooleanOp}; +use opengeometry::query::{classify_point, validity, PointClassification}; +use opengeometry::tessellation::tessellate; +use opengeometry::world_graph::{Primitive, Transform}; +use opengeometry_test_support::volume::{self, VolumeEstimate}; +use opengeometry_test_support::world_graph::{graph, named}; +use serde_json::{json, Value}; +use std::collections::BTreeSet; + +const OFFSET: f64 = 1e-4; + +const NEAR: f64 = 1e-7; + +pub(super) struct Pair { + a: BrepEnvelope, + b: BrepEnvelope, +} + +pub(super) struct Built { + brep: BrepEnvelope, + handlers: Vec, + report: Value, + volume: VolumeEstimate, +} + +struct FaceSample { + face: u32, + point: Point3, + normal: Point3, +} + +impl Pair { + pub(super) fn new(a: Primitive, b: Primitive) -> Self { + Self::placed([(a, &[]), (b, &[])]) + } + + pub(super) fn placed(parts: [(Primitive, &[Transform]); 2]) -> Self { + let mut world = graph(); + for (og_id, (primitive, transforms)) in ["a", "b"].into_iter().zip(parts) { + world.create_primitive(primitive, named(og_id)).unwrap(); + for transform in transforms { + world.transform(og_id, *transform).unwrap(); + } + } + let [a, b] = ["a", "b"].map(|og_id| world.placed_brep(og_id).unwrap().as_ref().clone()); + assert_ne!(a.id, b.id); + Self { a, b } + } + + pub(super) fn built(&self, operation: BooleanOp, solids: usize) -> Built { + let label = format!("{operation:?}"); + let (result, handlers) = + boolean_brep_outcome_with_handlers(&self.a, &self.b, operation, label.clone()); + let result = result.unwrap_or_else(|error| panic!("{label} via {handlers:?}: {error:?}")); + let report = serde_json::to_value(&result.report).unwrap(); + assert_eq!(report["operation"], json!(operation), "{label}"); + assert_closed_solids(&result.brep, solids, &label); + assert_outward(&result.brep, &label); + let volume = volume::estimate(&result.brep, 0.001); + Built { + brep: result.brep, + handlers, + report, + volume, + } + } + + pub(super) fn planar(&self, operation: BooleanOp, solids: usize) -> Built { + let built = self.built(operation, solids); + assert_eq!( + built.handlers, + ["boolean_planar_polyhedra"], + "{operation:?}" + ); + for face in &built.brep.topology.faces { + let provenance = &face.provenance; + assert_ne!(provenance.role, FaceRole::Cut, "{operation:?} {}", face.id); + assert!(!provenance.sources.is_empty(), "{operation:?} {}", face.id); + assert!( + provenance + .sources + .iter() + .all(|source| source.body == self.a.id || source.body == self.b.id), + "{operation:?} {}", + face.id + ); + } + for input in [&self.a, &self.b] { + let every_face = (0..input.topology.faces.len() as u64).collect::>(); + assert_eq!( + mapped_faces(&built.report, &input.id), + every_face, + "{operation:?}" + ); + } + built + } + + pub(super) fn assert_touching_kept_apart(&self) { + let [a, b] = [&self.a, &self.b].map(|body| volume::estimate(body, 0.001)); + let union = self.built(BooleanOp::Union, 2); + assert_eq!(union.handlers, ["generic_non_intersecting_boolean"]); + union.assert_volume(a.value + b.value); + let operation = BooleanOp::Intersection; + let (result, handlers) = + boolean_brep_outcome_with_handlers(&self.a, &self.b, operation, "touching".into()); + let result = result.unwrap_or_else(|error| panic!("{operation:?}: {error:?}")); + assert_eq!(handlers, ["generic_non_intersecting_boolean"]); + result.brep.validate().unwrap(); + let topology = &result.brep.topology; + assert!(topology.vertices.is_empty() && topology.edges.is_empty()); + assert!(topology.faces.is_empty()); + let value = serde_json::to_value(&result.brep).unwrap(); + assert_eq!(value["solids"], json!([])); + assert_eq!(value["topology"]["shells"], json!([])); + let report = serde_json::to_value(&result.report).unwrap(); + assert_eq!(report["operation"], json!(operation)); + let difference = self.built(BooleanOp::Subtraction, 1); + assert_eq!(difference.handlers, ["subtract_planar_polyhedra"]); + difference.assert_volume(a.value); + } + + pub(super) fn assert_identities( + &self, + union: &Built, + intersection: &Built, + difference: &Built, + ) { + let [a, b] = [&self.a, &self.b].map(|body| volume::estimate(body, 0.001)); + let [u, i, d] = [union, intersection, difference].map(|built| &built.volume); + let union_slack = a.error_bound + b.error_bound + u.error_bound + i.error_bound + 1e-9; + assert!( + (u.value - (a.value + b.value - i.value)).abs() <= union_slack, + "union {} versus {} + {} - {}", + u.value, + a.value, + b.value, + i.value + ); + let difference_slack = a.error_bound + d.error_bound + i.error_bound + 1e-9; + assert!( + (d.value - (a.value - i.value)).abs() <= difference_slack, + "difference {} versus {} - {}", + d.value, + a.value, + i.value + ); + } + + pub(super) fn assert_both_contribute(&self, built: &Built) { + for input in [&self.a, &self.b] { + assert!( + built + .brep + .topology + .faces + .iter() + .any(|face| source_bodies(&built.brep, face.id).contains(&input.id)), + "{}", + input.id + ); + } + } + + pub(super) fn assert_edge_between_inputs(&self, built: &Built, ends: [Point3; 2]) { + let faces = edge_faces(&built.brep, ends); + assert_eq!(faces.len(), 2, "{ends:?}"); + let bodies = faces + .into_iter() + .map(|face| source_bodies(&built.brep, face)) + .collect::>(); + let [a, b] = [&self.a.id, &self.b.id].map(|id| BTreeSet::from([id.clone()])); + assert!( + bodies == [a.clone(), b.clone()] || bodies == [b, a], + "{ends:?}: {bodies:?}" + ); + } + + pub(super) fn assert_one_coincident_face(&self, built: &Built, point: Point3, normal: Point3) { + let faces = built.faces_on_plane(point, normal); + assert_eq!(faces.len(), 1, "{faces:?}"); + let face = &built.brep.topology.faces[faces[0] as usize]; + assert_eq!(face.provenance.role, FaceRole::Coincident); + assert_eq!( + source_bodies(&built.brep, face.id), + BTreeSet::from([self.a.id.clone(), self.b.id.clone()]) + ); + } +} + +impl Built { + pub(super) fn assert_volume(&self, expected: f64) { + assert!( + (self.volume.value - expected).abs() <= self.volume.error_bound, + "{} versus {expected} with bound {}", + self.volume.value, + self.volume.error_bound + ); + } + + pub(super) fn handlers(&self) -> &[String] { + &self.handlers + } + + pub(super) fn faces_on_plane(&self, point: Point3, normal: Point3) -> Vec { + face_samples(&self.brep) + .into_iter() + .filter(|sample| { + dot(sample.normal, normal) > 1.0 - NEAR + && dot(difference(sample.point, point), normal).abs() < NEAR + }) + .map(|sample| sample.face) + .collect() + } + + pub(super) fn assert_vertex(&self, point: Point3) { + assert!( + self.brep + .topology + .vertices + .iter() + .any(|vertex| near(vertex.position, point)), + "{point:?}" + ); + } +} + +fn assert_closed_solids(body: &BrepEnvelope, solids: usize, label: &str) { + body.validate().unwrap(); + let checked = validity(body); + assert!(checked.valid && checked.closed, "{label}: {checked:?}"); + let value = serde_json::to_value(body).unwrap(); + assert_eq!(value["solids"].as_array().unwrap().len(), solids, "{label}"); +} + +fn assert_outward(body: &BrepEnvelope, label: &str) { + for sample in face_samples(body) { + let side = |sign: f64| { + let point = std::array::from_fn(|i| sample.point[i] + sign * OFFSET * sample.normal[i]); + classify_point(body, point).unwrap() + }; + assert_eq!( + [side(1.0), side(-1.0)], + [PointClassification::Outside, PointClassification::Inside], + "{label} face {} at {:?}", + sample.face, + sample.point + ); + } +} + +fn face_samples(body: &BrepEnvelope) -> Vec { + let mesh = tessellate(body, 0.01, 1 << 20).unwrap(); + let corner = + |index: u32| -> Point3 { std::array::from_fn(|i| mesh.positions[index as usize * 3 + i]) }; + let triangles = mesh + .indices + .chunks(3) + .zip(&mesh.triangle_face_ids) + .map(|(corners, face)| (*face, [0, 1, 2].map(|i| corner(corners[i])))) + .collect::>(); + body.topology + .faces + .iter() + .map(|face| { + let [p, q, r] = triangles + .iter() + .filter(|(owner, _)| *owner == face.id) + .map(|(_, points)| *points) + .max_by(|x, y| area(*x).total_cmp(&area(*y))) + .unwrap_or_else(|| panic!("face {} has no triangle", face.id)); + let point = std::array::from_fn(|i| (p[i] + q[i] + r[i]) / 3.0); + FaceSample { + face: face.id, + point, + normal: body.face_normal_at(face.id, point).unwrap(), + } + }) + .collect() +} + +fn edge_faces(body: &BrepEnvelope, ends: [Point3; 2]) -> Vec { + let value = serde_json::to_value(body).unwrap(); + let halfedges = value["topology"]["halfedges"].as_array().unwrap(); + let position = |vertex: u32| body.topology.vertices[vertex as usize].position; + body.topology + .halfedges + .iter() + .zip(halfedges) + .filter(|(halfedge, _)| { + let found = [position(halfedge.from), position(halfedge.to)]; + (near(found[0], ends[0]) && near(found[1], ends[1])) + || (near(found[0], ends[1]) && near(found[1], ends[0])) + }) + .map(|(_, json)| json["face"].as_u64().unwrap() as u32) + .collect() +} + +fn source_bodies(body: &BrepEnvelope, face: u32) -> BTreeSet { + body.topology.faces[face as usize] + .provenance + .sources + .iter() + .map(|source| source.body.clone()) + .collect() +} + +fn mapped_faces(report: &Value, body: &str) -> BTreeSet { + report["face_mappings"] + .as_array() + .unwrap() + .iter() + .filter(|mapping| mapping["source"]["body"] == json!(body)) + .map(|mapping| mapping["source"]["face"].as_u64().unwrap()) + .collect() +} + +fn area([p, q, r]: [Point3; 3]) -> f64 { + let [u, v] = [difference(q, p), difference(r, p)]; + let normal = [ + u[1] * v[2] - u[2] * v[1], + u[2] * v[0] - u[0] * v[2], + u[0] * v[1] - u[1] * v[0], + ]; + dot(normal, normal).sqrt() / 2.0 +} + +fn difference(p: Point3, q: Point3) -> Point3 { + std::array::from_fn(|i| p[i] - q[i]) +} + +fn dot(p: Point3, q: Point3) -> f64 { + (0..3).map(|i| p[i] * q[i]).sum() +} + +fn near(p: Point3, q: Point3) -> bool { + (0..3).all(|i| (p[i] - q[i]).abs() < NEAR) +} diff --git a/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs b/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs new file mode 100644 index 00000000..18320966 --- /dev/null +++ b/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs @@ -0,0 +1,159 @@ +use super::polyhedron::{gable_roof, l_roof, polyhedron}; +use super::polyhedron_boolean_checks::Pair; +use super::polyhedron_booleans::{tetrahedron, triangular_prism}; +use opengeometry::math::Point3; +use opengeometry::operations::modifying::boolean::BooleanOp; +use opengeometry::world_graph::{Primitive, Transform}; + +fn cuboid(size: f64) -> Primitive { + Primitive::Cuboid { + width: size, + height: size, + depth: size, + } +} + +fn turned(degrees: f64, pivot: Point3) -> Transform { + Transform::Rotate { + axis: [0.0, 1.0, 0.0], + degrees, + pivot: Some(pivot), + } +} + +fn hexahedron(origin: Point3, [a, b, c]: [Point3; 3]) -> Primitive { + let points = (0..8) + .map(|corner| { + let steps = [corner & 1, (corner >> 1) & 1, (corner >> 2) & 1].map(f64::from); + std::array::from_fn(|i| origin[i] + steps[0] * a[i] + steps[1] * b[i] + steps[2] * c[i]) + }) + .collect::>(); + let faces: [&[u32]; 6] = [ + &[0, 4, 6, 2], + &[1, 3, 7, 5], + &[0, 1, 5, 4], + &[2, 6, 7, 3], + &[0, 2, 3, 1], + &[4, 5, 7, 6], + ]; + polyhedron(&points, &faces) +} + +#[test] +fn turned_unit_cuboid_unions_and_intersects_with_a_cuboid() { + let pair = Pair::placed([ + (cuboid(2.0), &[]), + ( + cuboid(1.0), + &[ + Transform::Translate { + offset: [1.5, 0.5, 0.0], + }, + turned(30.0, [1.5, 0.5, 0.0]), + ], + ), + ]); + let clipped = (2.0 - 3f64.sqrt()) / (4.0 * 3f64.sqrt()); + let union = pair.planar(BooleanOp::Union, 1); + let intersection = pair.planar(BooleanOp::Intersection, 1); + let difference = pair.built(BooleanOp::Subtraction, 1); + union.assert_volume(9.0 - clipped); + intersection.assert_volume(clipped); + difference.assert_volume(8.0 - clipped); + pair.assert_identities(&union, &intersection, &difference); + pair.assert_both_contribute(&union); + pair.assert_both_contribute(&intersection); +} + +#[test] +fn bar_across_both_arms_of_an_l_roof_intersects_in_two_solids() { + let bar = hexahedron( + [-0.75, -1.0, 5.25], + [[6.0, 0.0, -6.0], [0.0, 3.0, 0.0], [0.5, 0.0, 0.5]], + ); + let pair = Pair::new(l_roof(), bar); + let union = pair.planar(BooleanOp::Union, 1); + let intersection = pair.planar(BooleanOp::Intersection, 2); + let difference = pair.built(BooleanOp::Subtraction, 3); + union.assert_volume(547.0 / 24.0); + intersection.assert_volume(13.0 / 24.0); + difference.assert_volume(115.0 / 24.0); + pair.assert_identities(&union, &intersection, &difference); + pair.assert_both_contribute(&intersection); +} + +#[test] +fn l_roof_and_a_box_through_one_arm_union_into_one_solid() { + let post = hexahedron( + [0.5, -0.5, 0.5], + [[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]], + ); + let pair = Pair::new(l_roof(), post); + let union = pair.planar(BooleanOp::Union, 1); + let intersection = pair.planar(BooleanOp::Intersection, 1); + let difference = pair.built(BooleanOp::Subtraction, 1); + union.assert_volume(73.0 / 8.0); + intersection.assert_volume(53.0 / 24.0); + difference.assert_volume(25.0 / 8.0); + pair.assert_identities(&union, &intersection, &difference); + pair.assert_both_contribute(&union); + pair.assert_both_contribute(&intersection); +} + +#[test] +fn turned_box_inside_a_turned_box_unions_to_the_outer_and_intersects_to_the_inner() { + let pair = Pair::placed([ + (cuboid(2.0), &[turned(30.0, [0.0, 1.0, 0.0])]), + ( + cuboid(1.0), + &[ + Transform::Translate { + offset: [0.0, 0.5, 0.0], + }, + turned(45.0, [0.0, 1.0, 0.0]), + ], + ), + ]); + let union = pair.built(BooleanOp::Union, 1); + let intersection = pair.built(BooleanOp::Intersection, 1); + let difference = pair.built(BooleanOp::Subtraction, 1); + union.assert_volume(8.0); + intersection.assert_volume(1.0); + difference.assert_volume(7.0); + pair.assert_identities(&union, &intersection, &difference); +} + +#[test] +fn wedge_touching_the_ridge_along_an_edge_unions_apart_and_intersects_empty() { + let pair = Pair::new( + gable_roof(), + triangular_prism([[1.0, 1.0], [2.0, 0.5], [2.0, 1.5]], [1.0, 3.0]), + ); + pair.assert_touching_kept_apart(); +} + +#[test] +fn tetrahedron_touching_the_ridge_end_at_a_corner_unions_apart_and_intersects_empty() { + let tetrahedron = tetrahedron([ + [4.0, 1.0, 1.0], + [5.0, 1.0, 2.0], + [5.0, 1.0, 0.0], + [5.0, 2.0, 1.0], + ]); + let pair = Pair::new(gable_roof(), tetrahedron); + pair.assert_touching_kept_apart(); +} + +#[test] +fn box_standing_on_a_turned_cuboid_subtracts_to_the_unchanged_host() { + let pair = Pair::placed([ + (cuboid(1.0), &[turned(30.0, [0.0, 0.5, 0.0])]), + ( + cuboid(0.2), + &[Transform::Translate { + offset: [0.0, 1.0, 0.0], + }], + ), + ]); + pair.built(BooleanOp::Subtraction, 1).assert_volume(1.0); +} diff --git a/main/opengeometry/tests/creating/polyhedron_booleans.rs b/main/opengeometry/tests/creating/polyhedron_booleans.rs new file mode 100644 index 00000000..0e01847e --- /dev/null +++ b/main/opengeometry/tests/creating/polyhedron_booleans.rs @@ -0,0 +1,199 @@ +use super::polyhedron::{gable_roof, hip_roof, polyhedron, HIP_FACES}; +use super::polyhedron_boolean_checks::Pair; +use opengeometry::math::Point3; +use opengeometry::operations::modifying::boolean::BooleanOp; +use opengeometry::world_graph::Primitive; + +const GROUND: Point3 = [2.0, 0.0, 1.0]; + +const DOWN: Point3 = [0.0, -1.0, 0.0]; + +const SUNK_VALLEYS: [[Point3; 2]; 4] = [ + [[1.625, 0.0, 0.0], [1.875, 1.0, 1.0]], + [[1.875, 1.0, 1.0], [1.625, 0.0, 2.0]], + [[2.375, 0.0, 0.0], [2.125, 1.0, 1.0]], + [[2.125, 1.0, 1.0], [2.375, 0.0, 2.0]], +]; + +const GROUND_VALLEYS: [[Point3; 2]; 4] = [ + [[1.5, 0.0, 0.0], [1.75, 1.0, 1.0]], + [[1.75, 1.0, 1.0], [1.5, 0.0, 2.0]], + [[2.5, 0.0, 0.0], [2.25, 1.0, 1.0]], + [[2.25, 1.0, 1.0], [2.5, 0.0, 2.0]], +]; + +fn cross_gable(base: f64, ridge: f64) -> Primitive { + let points = [ + [1.5, base, -1.0], + [2.5, base, -1.0], + [2.5, base, 3.0], + [1.5, base, 3.0], + [2.0, ridge, -1.0], + [2.0, ridge, 3.0], + ]; + let faces: [&[u32]; 5] = [ + &[0, 1, 2, 3], + &[1, 0, 4], + &[3, 2, 5], + &[0, 3, 5, 4], + &[2, 1, 4, 5], + ]; + polyhedron(&points, &faces) +} + +fn poking_hip() -> Primitive { + let points = [ + [1.0, -0.5, 0.5], + [3.0, -0.5, 0.5], + [3.0, -0.5, 1.5], + [1.0, -0.5, 1.5], + [1.5, 2.0, 1.0], + [2.5, 2.0, 1.0], + ]; + polyhedron(&points, &HIP_FACES) +} + +pub(super) fn triangular_prism(section: [[f64; 2]; 3], ends: [f64; 2]) -> Primitive { + let points = ends + .iter() + .flat_map(|x| section.map(|[y, z]| [*x, y, z])) + .collect::>(); + let faces: [&[u32]; 5] = [ + &[0, 2, 1], + &[3, 4, 5], + &[0, 1, 4, 3], + &[1, 2, 5, 4], + &[2, 0, 3, 5], + ]; + polyhedron(&points, &faces) +} + +pub(super) fn tetrahedron(points: [Point3; 4]) -> Primitive { + let faces: [&[u32]; 4] = [&[0, 2, 1], &[0, 1, 3], &[0, 3, 2], &[1, 2, 3]]; + polyhedron(&points, &faces) +} + +#[test] +fn cross_gable_sunk_below_the_eaves_unions_and_intersects_along_its_valleys() { + let pair = Pair::new(gable_roof(), cross_gable(-0.5, 1.5)); + let union = pair.planar(BooleanOp::Union, 1); + let intersection = pair.planar(BooleanOp::Intersection, 1); + let difference = pair.built(BooleanOp::Subtraction, 2); + union.assert_volume(89.0 / 12.0); + intersection.assert_volume(7.0 / 12.0); + difference.assert_volume(41.0 / 12.0); + pair.assert_identities(&union, &intersection, &difference); + for built in [&union, &intersection] { + pair.assert_both_contribute(built); + for valley in SUNK_VALLEYS { + pair.assert_edge_between_inputs(built, valley); + } + } +} + +#[test] +fn crossing_gable_subtraction_keeps_subtract_planar_polyhedra() { + let pair = Pair::new(gable_roof(), cross_gable(-0.5, 1.5)); + let difference = pair.built(BooleanOp::Subtraction, 2); + assert_eq!(difference.handlers(), ["subtract_planar_polyhedra"]); + difference.assert_volume(41.0 / 12.0); +} + +#[test] +fn cross_gable_on_the_same_ground_unions_into_one_merged_bottom_face() { + let pair = Pair::new(gable_roof(), cross_gable(0.0, 2.0)); + let union = pair.planar(BooleanOp::Union, 1); + let intersection = pair.planar(BooleanOp::Intersection, 1); + let difference = pair.built(BooleanOp::Subtraction, 2); + union.assert_volume(43.0 / 6.0); + intersection.assert_volume(5.0 / 6.0); + difference.assert_volume(19.0 / 6.0); + pair.assert_identities(&union, &intersection, &difference); + pair.assert_one_coincident_face(&union, GROUND, DOWN); + assert_eq!(intersection.faces_on_plane(GROUND, DOWN).len(), 1); + for built in [&union, &intersection] { + pair.assert_both_contribute(built); + for valley in GROUND_VALLEYS { + pair.assert_edge_between_inputs(built, valley); + } + } +} + +#[test] +fn hip_poking_through_the_gable_unions_and_intersects() { + let pair = Pair::new(gable_roof(), poking_hip()); + let union = pair.planar(BooleanOp::Union, 1); + let intersection = pair.planar(BooleanOp::Intersection, 1); + let difference = pair.built(BooleanOp::Subtraction, 1); + union.assert_volume(3109.0 / 600.0); + intersection.assert_volume(541.0 / 600.0); + difference.assert_volume(1859.0 / 600.0); + pair.assert_identities(&union, &intersection, &difference); + pair.assert_both_contribute(&union); + pair.assert_both_contribute(&intersection); +} + +#[test] +fn hip_and_gable_on_shared_eaves_union_to_the_gable_and_intersect_to_the_hip() { + let pair = Pair::new(gable_roof(), hip_roof()); + let union = pair.planar(BooleanOp::Union, 1); + let intersection = pair.planar(BooleanOp::Intersection, 1); + let difference = pair.built(BooleanOp::Subtraction, 2); + union.assert_volume(4.0); + intersection.assert_volume(10.0 / 3.0); + difference.assert_volume(2.0 / 3.0); + pair.assert_identities(&union, &intersection, &difference); + pair.assert_one_coincident_face(&union, GROUND, DOWN); + assert_eq!(intersection.faces_on_plane(GROUND, DOWN).len(), 1); + for built in [&union, &intersection] { + for corner in [ + [0.0, 0.0, 0.0], + [4.0, 0.0, 0.0], + [4.0, 0.0, 2.0], + [0.0, 0.0, 2.0], + ] { + built.assert_vertex(corner); + } + } +} + +#[test] +fn prism_with_an_edge_on_a_gable_slope_unions_and_intersects() { + let pair = Pair::new( + gable_roof(), + triangular_prism([[0.5, 0.5], [1.0, 0.5], [0.5, 1.0]], [1.0, 3.0]), + ); + let union = pair.planar(BooleanOp::Union, 1); + let intersection = pair.planar(BooleanOp::Intersection, 1); + let difference = pair.built(BooleanOp::Subtraction, 1); + union.assert_volume(33.0 / 8.0); + intersection.assert_volume(1.0 / 8.0); + difference.assert_volume(31.0 / 8.0); + pair.assert_identities(&union, &intersection, &difference); + for built in [&union, &intersection] { + pair.assert_both_contribute(built); + pair.assert_edge_between_inputs(built, [[1.0, 0.5, 0.5], [3.0, 0.5, 0.5]]); + } +} + +#[test] +fn tetrahedron_sharing_the_ridge_end_unions_and_intersects() { + let tetrahedron = tetrahedron([ + [4.0, 1.0, 1.0], + [3.0, 2.0, 0.5], + [2.0, 0.5, 1.0], + [3.0, 0.25, 2.5], + ]); + let pair = Pair::new(gable_roof(), tetrahedron); + let union = pair.planar(BooleanOp::Union, 1); + let intersection = pair.planar(BooleanOp::Intersection, 1); + let difference = pair.built(BooleanOp::Subtraction, 1); + union.assert_volume(6051.0 / 1360.0); + intersection.assert_volume(377.0 / 4080.0); + difference.assert_volume(15943.0 / 4080.0); + pair.assert_identities(&union, &intersection, &difference); + for built in [&union, &intersection] { + pair.assert_both_contribute(built); + built.assert_vertex([4.0, 1.0, 1.0]); + } +} diff --git a/main/opengeometry/tests/errors.rs b/main/opengeometry/tests/errors.rs index 6e359a54..a5ce3926 100644 --- a/main/opengeometry/tests/errors.rs +++ b/main/opengeometry/tests/errors.rs @@ -186,24 +186,36 @@ fn translate(world: &mut WorldGraph, og_id: &str, offset: [f64; 3]) { .unwrap(); } -#[test] -fn multi_tool_operate_failure_carries_handlers_tool_index_and_og_ids() { - let mut world = graph(); - unit_cuboid(&mut world, "target", 2.0); - unit_cuboid(&mut world, "tool-1", 2.0); - translate(&mut world, "tool-1", [1.0, 0.0, 0.0]); - unit_cuboid(&mut world, "tool-2", 1.0); - translate(&mut world, "tool-2", [1.5, 0.5, 0.0]); +fn tilted_cylinder(world: &mut WorldGraph, og_id: &str, offset: [f64; 3]) { + world + .create_primitive( + Primitive::Cylinder { + radius: 0.1, + height: 0.7, + }, + named(og_id), + ) + .unwrap(); world .transform( - "tool-2", + og_id, Transform::Rotate { - axis: [0.0, 1.0, 0.0], - degrees: 30.0, - pivot: Some([1.5, 0.5, 0.0]), + axis: [1.0, 0.0, 0.0], + degrees: 10.0, + pivot: Some([0.0, 0.35, 0.0]), }, ) .unwrap(); + translate(world, og_id, offset); +} + +#[test] +fn multi_tool_operate_failure_carries_handlers_tool_index_and_og_ids() { + let mut world = graph(); + unit_cuboid(&mut world, "target", 2.0); + unit_cuboid(&mut world, "tool-1", 2.0); + translate(&mut world, "tool-1", [1.0, 0.0, 0.0]); + tilted_cylinder(&mut world, "tool-2", [2.0, 1.6, 0.0]); let mut step_one = graph(); unit_cuboid(&mut step_one, "target", 2.0); unit_cuboid(&mut step_one, "tool-1", 2.0); @@ -238,9 +250,7 @@ fn multi_tool_operate_failure_carries_handlers_tool_index_and_og_ids() { "separate_boolean", "boolean_boxes", "separate_boolean", - "boolean_boxes", - "boolean_planar_extrusions", - "boolean_rectilinear", + "cylinder_box_boolean", "subtract_vertical_arc_extrusion", "generic_two_sided_cutter_band_subtraction", "generic_multiple_closed_loops_boolean", diff --git a/main/opengeometry/tests/step_oracle/fixtures.rs b/main/opengeometry/tests/step_oracle/fixtures.rs index b9c30868..df19b6c8 100644 --- a/main/opengeometry/tests/step_oracle/fixtures.rs +++ b/main/opengeometry/tests/step_oracle/fixtures.rs @@ -172,7 +172,7 @@ fn exported_matrix_rows( fn boolean_matrix_results_export_through_the_oracle_or_pin_an_error() { assert_eq!( exported_matrix_rows("boolean-matrix", 18, &[], &["cylinder-empty"]), - 15 + 17 ); } From fe49dfaa4cb74cd5bc6f0484be0f4e786f7601c2 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Thu, 12 Nov 2026 01:13:24 +0100 Subject: [PATCH 02/10] Build union and intersection of planar polyhedra The general planar handler takes the operation. Host pieces are kept outside the other body for union and subtraction and inside it for intersection; cutter pieces are kept outside for union and inside for the other two, and are turned inside out only for subtraction. Union and intersection run under the new handler name boolean_planar_polyhedra; subtraction keeps subtract_planar_polyhedra and its results. The report carries the real operation. The rotated-box union and intersection matrix rows now build; their stored results and golden keys are rewritten from this kernel, and the golden hashes of the handler-name sets gain the new name. --- .../modifying/boolean/dispatch/handler_id.rs | 2 + .../modifying/boolean/dispatch/table.rs | 51 ++-- .../modifying/boolean/handlers/mod.rs | 2 +- .../boolean/handlers/planar/facets.rs | 6 +- .../handlers/planar/layered_extrusions/mod.rs | 4 +- .../modifying/boolean/handlers/planar/mod.rs | 2 +- .../boolean/handlers/planar/polyhedra/keep.rs | 83 ++++++ .../boolean/handlers/planar/polyhedra/mod.rs | 157 ++++++----- .../operations/modifying/boolean/tests/mod.rs | 1 + .../boolean/tests/planar_polyhedra.rs | 257 ++++++++++++++++++ .../rotated-box-intersection.json | 2 +- .../boolean-matrix/rotated-box-union.json | 2 +- .../fixtures/golden/aarch64-apple-darwin.json | 8 +- 13 files changed, 476 insertions(+), 101 deletions(-) create mode 100644 main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/keep.rs create mode 100644 main/opengeometry/src/operations/modifying/boolean/tests/planar_polyhedra.rs diff --git a/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs b/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs index d8930d21..d98940bd 100644 --- a/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs +++ b/main/opengeometry/src/operations/modifying/boolean/dispatch/handler_id.rs @@ -20,6 +20,7 @@ pub(crate) enum HandlerId { BooleanRectilinear, SubtractLayeredExtrusions, SubtractPlanarPolyhedra, + BooleanPlanarPolyhedra, BooleanSpheres, SubtractVerticalArcExtrusion, GenericTwoSidedCutterBandSubtraction, @@ -55,6 +56,7 @@ impl HandlerId { Self::BooleanRectilinear => "boolean_rectilinear", Self::SubtractLayeredExtrusions => "subtract_layered_extrusions", Self::SubtractPlanarPolyhedra => "subtract_planar_polyhedra", + Self::BooleanPlanarPolyhedra => "boolean_planar_polyhedra", Self::BooleanSpheres => "boolean_spheres", Self::SubtractVerticalArcExtrusion => "subtract_vertical_arc_extrusion", Self::GenericTwoSidedCutterBandSubtraction => { diff --git a/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs b/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs index 72270b83..78d35198 100644 --- a/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs +++ b/main/opengeometry/src/operations/modifying/boolean/dispatch/table.rs @@ -2,15 +2,14 @@ use super::handler_id::HandlerId; use super::trace::{record_handler, record_optional, Trace}; use crate::brep::{BrepEnvelope, GeometryError, SurfaceGeometry}; use crate::operations::modifying::boolean::handlers::{ - boolean_boxes, boolean_cylinders, boolean_planar_extrusions, boolean_rectilinear, - boolean_spheres, conic_box_containment, conic_containment_boolean, cylinder_box_boolean, - cylinder_conic_containment, generic_multiple_closed_loops_boolean, + boolean_boxes, boolean_cylinders, boolean_planar_extrusions, boolean_planar_polyhedra, + boolean_rectilinear, boolean_spheres, conic_box_containment, conic_containment_boolean, + cylinder_box_boolean, cylinder_conic_containment, generic_multiple_closed_loops_boolean, generic_non_intersecting_boolean, generic_single_closed_loop_boolean, generic_two_sided_cutter_band_subtraction, prismatic_curved_boolean, sphere_box_boolean, sphere_conic_containment, sphere_cylinder_boolean, sphere_torus_containment, - subtract_layered_extrusions, subtract_planar_polyhedra, subtract_vertical_arc_extrusion, - torus_box_containment, torus_conic_containment, torus_containment_boolean, - torus_cylinder_containment, + subtract_layered_extrusions, subtract_vertical_arc_extrusion, torus_box_containment, + torus_conic_containment, torus_containment_boolean, torus_cylinder_containment, }; use crate::operations::modifying::boolean::operands::all_planar; use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; @@ -129,10 +128,18 @@ const FAMILY_HANDLERS: &[FamilyHandler] = &[ struct BoxHandler { name: HandlerId, - subtract_only: bool, + operations: &'static [BooleanOp], call: BooleanHandler, } +const EVERY_OPERATION: &[BooleanOp] = &[ + BooleanOp::Union, + BooleanOp::Intersection, + BooleanOp::Subtraction, +]; +const SUBTRACTION: &[BooleanOp] = &[BooleanOp::Subtraction]; +const UNION_AND_INTERSECTION: &[BooleanOp] = &[BooleanOp::Union, BooleanOp::Intersection]; + fn dispatch_boxes( a: &BrepEnvelope, b: &BrepEnvelope, @@ -160,40 +167,36 @@ fn dispatch_layered( subtract_layered_extrusions(a, b, id) } -fn dispatch_polyhedra( - a: &BrepEnvelope, - b: &BrepEnvelope, - _: BooleanOp, - id: String, -) -> Result { - subtract_planar_polyhedra(a, b, id) -} - const BOX_HANDLERS: &[BoxHandler] = &[ BoxHandler { name: HandlerId::BooleanBoxes, - subtract_only: false, + operations: EVERY_OPERATION, call: dispatch_boxes, }, BoxHandler { name: HandlerId::BooleanPlanarExtrusions, - subtract_only: false, + operations: EVERY_OPERATION, call: boolean_planar_extrusions, }, BoxHandler { name: HandlerId::BooleanRectilinear, - subtract_only: false, + operations: EVERY_OPERATION, call: dispatch_rectilinear, }, BoxHandler { name: HandlerId::SubtractLayeredExtrusions, - subtract_only: true, + operations: SUBTRACTION, call: dispatch_layered, }, BoxHandler { name: HandlerId::SubtractPlanarPolyhedra, - subtract_only: true, - call: dispatch_polyhedra, + operations: SUBTRACTION, + call: boolean_planar_polyhedra, + }, + BoxHandler { + name: HandlerId::BooleanPlanarPolyhedra, + operations: UNION_AND_INTERSECTION, + call: boolean_planar_polyhedra, }, ]; @@ -213,8 +216,8 @@ pub(super) fn specialized_boolean( if pair == [OperandFamily::Planar, OperandFamily::Planar] { let mut gap = None; for entry in BOX_HANDLERS { - if entry.subtract_only && operation != BooleanOp::Subtraction { - break; + if !entry.operations.contains(&operation) { + continue; } match record_handler(trace, entry.name, || { (entry.call)(a, b, operation, id.clone()) diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs index aa1a9eab..fa669a2d 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/mod.rs @@ -29,7 +29,7 @@ pub(super) use generic::{ generic_single_closed_loop_boolean, generic_two_sided_cutter_band_subtraction, prismatic_curved_boolean, }; -pub(super) use planar::{subtract_layered_extrusions, subtract_planar_polyhedra}; +pub(super) use planar::{boolean_planar_polyhedra, subtract_layered_extrusions}; pub(super) use planar_extrusions::boolean_planar_extrusions; pub(super) use rectilinear::boolean_rectilinear; pub(super) use sphere_box::sphere_box_boolean; diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/facets.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/facets.rs index 718e963a..b24e60e9 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/facets.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/facets.rs @@ -199,6 +199,7 @@ pub(super) fn finish( mut facets: PlanarFacets, a: &BrepEnvelope, b: &BrepEnvelope, + operation: BooleanOp, ) -> Result { let mut assigned = vec![false; facets.builder.brep.topology.faces.len()]; let mut outer_shells = Vec::new(); @@ -251,7 +252,7 @@ pub(super) fn finish( .cavity_shells .push(cavity_shell); } - finish_planar_result(facets.builder.brep, a, b) + finish_planar_result(facets.builder.brep, a, b, operation) } fn shell_volume(brep: &BrepEnvelope, faces: &[u32]) -> Result { @@ -344,6 +345,7 @@ fn finish_planar_result( mut out: BrepEnvelope, a: &BrepEnvelope, b: &BrepEnvelope, + operation: BooleanOp, ) -> Result { out.revision = a.revision.max(b.revision).checked_add(1).ok_or_else(|| { @@ -353,7 +355,7 @@ fn finish_planar_result( let face_mappings = analytic_face_mappings(&out, [a, b]); Ok(BooleanResult { report: BooleanReport { - operation: BooleanOp::Subtraction, + operation, quality: out.quality.clone(), contacts: Vec::new(), coincident: true, diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/layered_extrusions/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/layered_extrusions/mod.rs index 12a4102e..c149246b 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/layered_extrusions/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/layered_extrusions/mod.rs @@ -10,7 +10,7 @@ use crate::math::{cross, dot, scale, sub, Point3}; use crate::operations::modifying::boolean::operands::{ all_planar, brep_face_source, full_planar_extrusion, }; -use crate::operations::modifying::boolean::types::BooleanResult; +use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; use crate::query::face_contains_uv; use regions::{contours, layer_boundaries, layer_regions, LayerBoundaries}; @@ -97,7 +97,7 @@ pub(crate) fn subtract_layered_extrusions( &boundaries.vertices, &[(host, FaceRole::Split)], )?; - finish(facets, host, cutter) + finish(facets, host, cutter, BooleanOp::Subtraction) } fn check_layer_alignment(host: &BrepEnvelope, frame: Frame3) -> Result<(), GeometryError> { diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/mod.rs index bba4a9ff..0dc79bab 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/mod.rs @@ -3,4 +3,4 @@ mod layered_extrusions; mod polyhedra; pub(crate) use layered_extrusions::subtract_layered_extrusions; -pub(crate) use polyhedra::subtract_planar_polyhedra; +pub(crate) use polyhedra::boolean_planar_polyhedra; diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/keep.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/keep.rs new file mode 100644 index 00000000..4b51b996 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/keep.rs @@ -0,0 +1,83 @@ +use crate::brep::{FaceRole, Frame3}; +use crate::geom2d::{signed_area2, Pt2, RingRegion}; +use crate::math::scale; +use crate::operations::modifying::boolean::types::BooleanOp; +use crate::query::PointClassification; + +pub(super) fn host_keeps(operation: BooleanOp, place: PointClassification) -> Option { + match place { + PointClassification::Outside => Some(operation != BooleanOp::Intersection), + PointClassification::Inside => Some(operation == BooleanOp::Intersection), + PointClassification::Boundary | PointClassification::Unknown => None, + } +} + +pub(super) fn boundary_probe_side(operation: BooleanOp) -> f64 { + if operation == BooleanOp::Union { + -1.0 + } else { + 1.0 + } +} + +pub(super) fn cutter_keeps(operation: BooleanOp, place: PointClassification) -> Option { + match place { + PointClassification::Inside => Some(operation != BooleanOp::Union), + PointClassification::Outside => Some(operation == BooleanOp::Union), + PointClassification::Boundary => Some(operation == BooleanOp::Subtraction), + PointClassification::Unknown => None, + } +} + +pub(super) fn turns_cutter(operation: BooleanOp) -> bool { + operation == BooleanOp::Subtraction +} + +pub(super) fn cutter_frame(operation: BooleanOp, frame: Frame3) -> Frame3 { + if !turns_cutter(operation) { + return frame; + } + Frame3 { + origin: frame.origin, + x: frame.x, + y: scale(frame.y, -1.0), + z: scale(frame.z, -1.0), + } +} + +pub(super) fn piece_role( + operation: BooleanOp, + cutter: bool, + region: &RingRegion, + base: &[Vec], + tolerance: f64, +) -> FaceRole { + if operation == BooleanOp::Subtraction { + return if cutter { + FaceRole::Cut + } else { + FaceRole::Split + }; + } + let area = |rings: &[Vec]| { + rings + .iter() + .map(|ring| signed_area2(ring).abs()) + .sum::() + }; + let region_area = signed_area2(®ion.outer).abs() - area(®ion.holes); + let base_area = signed_area2(&base[0]).abs() - area(&base[1..]); + let perimeter = base + .iter() + .flat_map(|ring| { + ring.iter() + .zip(ring.iter().cycle().skip(1)) + .map(|(a, b)| (b.x - a.x).hypot(b.z - a.z)) + }) + .sum::(); + if base_area - region_area <= tolerance * perimeter { + FaceRole::Preserved + } else { + FaceRole::Split + } +} diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs index 60ab6255..d31bb49b 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs @@ -1,3 +1,4 @@ +mod keep; mod sections; use super::facets::{coverage, finish, PlanarFacets}; @@ -8,17 +9,19 @@ use crate::geom2d::{ }; use crate::math::{dot, scale, sub, Point3}; use crate::operations::modifying::boolean::operands::brep_face_source; -use crate::operations::modifying::boolean::types::BooleanResult; +use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; use crate::query::{Classifier, PointClassification}; +use keep::{boundary_probe_side, cutter_frame, cutter_keeps, host_keeps, piece_role, turns_cutter}; use sections::{ convex_faces, convex_section_polygon, planar_faces, planar_section_segments, ring_vertex_candidates, split_planar_ring, PlanarFace, PlanarFacet, }; use std::cell::RefCell; -pub(crate) fn subtract_planar_polyhedra( +pub(crate) fn boolean_planar_polyhedra( host: &BrepEnvelope, cutter: &BrepEnvelope, + operation: BooleanOp, id: String, ) -> Result { let accuracy = Accuracy::combined(host.accuracy, cutter.accuracy); @@ -28,37 +31,56 @@ pub(crate) fn subtract_planar_polyhedra( let Some(cutter_faces) = planar_faces(cutter, accuracy.intersection)? else { return Err(coverage()); }; - let convex_cutter_faces = convex_faces(cutter, accuracy.intersection)?; + let convex_cutter_faces = if operation == BooleanOp::Subtraction { + convex_faces(cutter, accuracy.intersection)? + } else { + None + }; check_not_nested(host, cutter)?; let mut pieces = Vec::new(); for face in &host_faces { - let mut base = vec![local_ring(&face.polygon, face.frame)]; - base.extend(face.holes.iter().map(|ring| local_ring(ring, face.frame))); + let base = face_rings(face); let regions = if let Some(convex_faces) = &convex_cutter_faces { convex_host_regions(cutter, convex_faces, face, &base, accuracy)? } else { - classified_host_regions(cutter, &cutter_faces, face, &base, accuracy)? + classified_host_regions(cutter, &cutter_faces, face, &base, accuracy, operation)? }; for region in regions { - pieces.push(host_piece(host, face, ®ion)); + let role = piece_role(operation, false, ®ion, &base, accuracy.geometric); + pieces.push(host_piece(host, face, ®ion, role)); } } let classifier = Classifier::new(host); for face in &cutter_faces { - let regions = cutter_face_regions(host, &classifier, &host_faces, face, accuracy)?; - let reversed = Frame3 { - origin: face.frame.origin, - x: face.frame.x, - y: scale(face.frame.y, -1.0), - z: scale(face.frame.z, -1.0), - }; + let base = face_rings(face); + let regions = cutter_face_regions( + host, + &classifier, + &host_faces, + face, + &base, + accuracy, + operation, + )?; for region in regions { - pieces.push(cutter_piece(cutter, face, reversed, ®ion)); + let role = piece_role(operation, true, ®ion, &base, accuracy.geometric); + pieces.push(cutter_piece(cutter, face, operation, ®ion, role)); } } if pieces.is_empty() { return Err(coverage()); } + let facets = welded_facets(pieces, id, accuracy)?; + let mut result = finish(facets, host, cutter, operation)?; + result.report.coincident = false; + Ok(result) +} + +fn welded_facets( + pieces: Vec, + id: String, + accuracy: Accuracy, +) -> Result { let vertices = pieces .iter() .flat_map(|piece| { @@ -87,9 +109,14 @@ pub(crate) fn subtract_planar_polyhedra( let holes = piece.holes.iter().map(|ring| split(ring)).collect(); facets.face(&piece.key, piece.frame, outer, holes, piece.provenance)?; } - let mut result = finish(facets, host, cutter)?; - result.report.coincident = false; - Ok(result) + Ok(facets) +} + +fn face_rings(face: &PlanarFace) -> Vec> { + std::iter::once(&face.polygon) + .chain(&face.holes) + .map(|ring| local_ring(ring, face.frame)) + .collect() } fn check_not_nested(host: &BrepEnvelope, cutter: &BrepEnvelope) -> Result<(), GeometryError> { @@ -147,6 +174,7 @@ fn classified_host_regions( face: &PlanarFace, base: &[Vec], accuracy: Accuracy, + operation: BooleanOp, ) -> Result, GeometryError> { let mut edges = planar_section_segments(cutter, cutter_faces, face.frame, accuracy.intersection)?; @@ -176,15 +204,16 @@ fn classified_host_regions( position, scale( face.frame.z, - accuracy.geometric.max(accuracy.intersection) * 4.0, + boundary_probe_side(operation) + * accuracy.geometric.max(accuracy.intersection) + * 4.0, ), )), other => other, }; - match classification { - Ok(PointClassification::Outside) => true, - Ok(PointClassification::Inside) => false, - Ok(PointClassification::Boundary) | Ok(PointClassification::Unknown) => { + match classification.map(|place| host_keeps(operation, place)) { + Ok(Some(keep)) => keep, + Ok(None) => { *failure.borrow_mut() = Some(coverage()); false } @@ -216,27 +245,24 @@ fn coplanar_rings(faces: &[PlanarFace], face: &PlanarFace, accuracy: Accuracy) - .collect::>() } -fn host_piece(host: &BrepEnvelope, face: &PlanarFace, region: &RingRegion) -> PlanarFacet { +fn host_piece( + host: &BrepEnvelope, + face: &PlanarFace, + region: &RingRegion, + role: FaceRole, +) -> PlanarFacet { PlanarFacet { key: format!("planar-host-{}", face.id), frame: face.frame, - outer: region - .outer - .iter() - .map(|point| face.frame.point([point.x, point.z, 0.0])) - .collect(), + outer: lifted_ring(®ion.outer, face.frame, false), holes: region .holes .iter() - .map(|ring| { - ring.iter() - .map(|point| face.frame.point([point.x, point.z, 0.0])) - .collect() - }) + .map(|ring| lifted_ring(ring, face.frame, false)) .collect(), provenance: FaceProvenance { sources: vec![brep_face_source(host, face.id)], - role: FaceRole::Split, + role, reversed: false, }, } @@ -247,12 +273,12 @@ fn cutter_face_regions( classifier: &Classifier, host_faces: &[PlanarFace], face: &PlanarFace, + cutter_region: &[Vec], accuracy: Accuracy, + operation: BooleanOp, ) -> Result, GeometryError> { let mut edges = planar_section_segments(host, host_faces, face.frame, accuracy.intersection)?; let coplanar = coplanar_rings(host_faces, face, accuracy); - let mut cutter_region = vec![local_ring(&face.polygon, face.frame)]; - cutter_region.extend(face.holes.iter().map(|ring| local_ring(ring, face.frame))); for ring in cutter_region.iter().chain(&coplanar) { edges.extend( ring.iter() @@ -275,10 +301,12 @@ fn cutter_face_regions( { return false; } - match classifier.classify(face.frame.point([point.x, point.z, 0.0])) { - Ok(PointClassification::Inside) | Ok(PointClassification::Boundary) => true, - Ok(PointClassification::Outside) => false, - Ok(PointClassification::Unknown) => { + match classifier + .classify(face.frame.point([point.x, point.z, 0.0])) + .map(|place| cutter_keeps(operation, place)) + { + Ok(Some(keep)) => keep, + Ok(None) => { *failure.borrow_mut() = Some(coverage()); false } @@ -297,36 +325,35 @@ fn cutter_face_regions( fn cutter_piece( cutter: &BrepEnvelope, face: &PlanarFace, - reversed: Frame3, + operation: BooleanOp, region: &RingRegion, + role: FaceRole, ) -> PlanarFacet { - let mut outer = region - .outer - .iter() - .map(|point| face.frame.point([point.x, point.z, 0.0])) - .collect::>(); - outer.reverse(); - let holes = region - .holes - .iter() - .map(|ring| { - let mut points = ring - .iter() - .map(|point| face.frame.point([point.x, point.z, 0.0])) - .collect::>(); - points.reverse(); - points - }) - .collect(); + let reversed = turns_cutter(operation); PlanarFacet { key: format!("planar-cut-{}", face.id), - frame: reversed, - outer, - holes, + frame: cutter_frame(operation, face.frame), + outer: lifted_ring(®ion.outer, face.frame, reversed), + holes: region + .holes + .iter() + .map(|ring| lifted_ring(ring, face.frame, reversed)) + .collect(), provenance: FaceProvenance { sources: vec![brep_face_source(cutter, face.id)], - role: FaceRole::Cut, - reversed: true, + role, + reversed, }, } } + +fn lifted_ring(ring: &[Pt2], frame: Frame3, reversed: bool) -> Vec { + let mut points = ring + .iter() + .map(|point| frame.point([point.x, point.z, 0.0])) + .collect::>(); + if reversed { + points.reverse(); + } + points +} diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs b/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs index ce775760..e1b7a650 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/mod.rs @@ -10,6 +10,7 @@ mod generic; mod planar_batch; mod planar_extrusions; mod planar_oblique; +mod planar_polyhedra; mod shell; mod shell_necks; mod sphere_cylinder; diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/planar_polyhedra.rs b/main/opengeometry/src/operations/modifying/boolean/tests/planar_polyhedra.rs new file mode 100644 index 00000000..2b1e6986 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/tests/planar_polyhedra.rs @@ -0,0 +1,257 @@ +use super::support::{extrusion_span, volume}; +use crate::brep::{Accuracy, BrepEnvelope, FaceRole, Frame3}; +use crate::operations::modifying::boolean::dispatch::boolean_brep_with_handlers; +use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; +use crate::primitives; +use crate::query::{classify_point, PointClassification}; + +const ACCURACY: Accuracy = Accuracy { + geometric: 1.0e-9, + intersection: 1.0e-10, + tessellation: 0.01, + exchange: 1.0e-5, +}; + +fn cube() -> BrepEnvelope { + primitives::cuboid( + "cube".into(), + Frame3 { + origin: [-1.0, -1.0, -1.0], + ..Frame3::IDENTITY + }, + [2.0, 2.0, 2.0], + ACCURACY, + ) + .unwrap() +} + +fn bar_turned_about_x() -> BrepEnvelope { + let half = std::f64::consts::FRAC_1_SQRT_2; + let (y, z) = ([0.0, half, half], [0.0, -half, half]); + primitives::cuboid( + "turned-bar".into(), + Frame3 { + origin: [-1.5, 0.0, -std::f64::consts::SQRT_2], + x: [1.0, 0.0, 0.0], + y, + z, + }, + [3.0, 2.0, 2.0], + ACCURACY, + ) + .unwrap() +} + +fn bar_in_cube() -> f64 { + 16.0 * (std::f64::consts::SQRT_2 - 1.0) +} + +fn operated(a: &BrepEnvelope, b: &BrepEnvelope, operation: BooleanOp) -> (BooleanResult, f64) { + let (result, handlers) = boolean_brep_with_handlers(a, b, operation, "planar".into()).unwrap(); + let expected = if operation == BooleanOp::Subtraction { + "subtract_planar_polyhedra" + } else { + "boolean_planar_polyhedra" + }; + assert_eq!(handlers, [expected], "{operation:?}"); + assert_eq!(result.report.operation, operation); + result.brep.validate().unwrap(); + assert!(result + .brep + .topology + .shells + .iter() + .all(|shell| shell.is_closed)); + let measured = volume(&result.brep); + assert!(measured > 0.0, "{operation:?} winds inward: {measured}"); + (result, measured) +} + +fn assert_identities(a: &BrepEnvelope, b: &BrepEnvelope, overlap: f64) -> [BooleanResult; 3] { + let (va, vb) = (volume(a), volume(b)); + let (union, united) = operated(a, b, BooleanOp::Union); + let (intersection, common) = operated(a, b, BooleanOp::Intersection); + let (subtraction, rest) = operated(a, b, BooleanOp::Subtraction); + for (measured, expected) in [ + (common, overlap), + (united, va + vb - overlap), + (rest, va - overlap), + ] { + assert!( + (measured - expected).abs() <= 1.0e-9 * va.max(vb), + "{measured} != {expected}" + ); + } + [union, intersection, subtraction] +} + +#[test] +fn turned_bar_through_a_cube_follows_the_volume_identities() { + let (a, b) = (cube(), bar_turned_about_x()); + let [union, intersection, subtraction] = assert_identities(&a, &b, bar_in_cube()); + assert_eq!(union.brep.solids.len(), 1); + assert_eq!(intersection.brep.solids.len(), 1); + assert_eq!(subtraction.brep.solids.len(), 4); + let tip = [0.0, 0.0, 1.3]; + assert_eq!( + classify_point(&union.brep, tip).unwrap(), + PointClassification::Inside + ); + assert_eq!( + classify_point(&intersection.brep, tip).unwrap(), + PointClassification::Outside + ); + assert_eq!( + classify_point(&subtraction.brep, [0.0, 0.95, 0.95]).unwrap(), + PointClassification::Inside + ); +} + +#[test] +fn union_and_intersection_keep_cutter_faces_unturned_and_mark_whole_faces_preserved() { + let (a, b) = (cube(), bar_turned_about_x()); + let [union, intersection, subtraction] = assert_identities(&a, &b, bar_in_cube()); + for result in [&union, &intersection] { + assert!(result + .brep + .topology + .faces + .iter() + .all(|face| { !face.provenance.reversed && face.provenance.role != FaceRole::Cut })); + } + let roles = |result: &BooleanResult, body: &str| { + result + .brep + .topology + .faces + .iter() + .filter(|face| face.provenance.sources[0].body == body) + .map(|face| face.provenance.role) + .collect::>() + }; + let bar_roles = roles(&union, &b.id); + assert_eq!( + bar_roles + .iter() + .filter(|role| **role == FaceRole::Preserved) + .count(), + 2 + ); + assert!(roles(&union, &a.id) + .iter() + .all(|role| *role == FaceRole::Split)); + assert!(roles(&intersection, &a.id) + .iter() + .chain(&roles(&intersection, &b.id)) + .all(|role| *role == FaceRole::Split)); + assert!(subtraction + .brep + .topology + .faces + .iter() + .any(|face| { face.provenance.reversed && face.provenance.role == FaceRole::Cut })); +} + +#[test] +fn pitched_bar_through_a_u_profile_intersects_in_two_solids() { + let host = extrusion_span( + "u-profile", + 0.0, + 1.0, + vec![ + [0.0, 0.0], + [3.0, 0.0], + [3.0, 3.0], + [2.0, 3.0], + [2.0, 1.0], + [1.0, 1.0], + [1.0, 3.0], + [0.0, 3.0], + ], + Vec::new(), + ); + let pitch = 5.0_f64.to_radians(); + let (x, z) = ( + [pitch.cos(), 0.0, pitch.sin()], + [-pitch.sin(), 0.0, pitch.cos()], + ); + let center = [1.5, 2.25, 0.5]; + let bar = primitives::cuboid( + "pitched-bar".into(), + Frame3 { + origin: std::array::from_fn(|i| { + center[i] - 2.5 * x[i] - 0.25 * z[i] - 0.25 * [0.0, 1.0, 0.0][i] + }), + x, + y: [0.0, 1.0, 0.0], + z, + }, + [5.0, 0.5, 0.5], + ACCURACY, + ) + .unwrap(); + let [union, intersection, subtraction] = assert_identities(&host, &bar, 0.5 / pitch.cos()); + assert_eq!(union.brep.solids.len(), 1); + assert_eq!(intersection.brep.solids.len(), 2); + assert_eq!(subtraction.brep.solids.len(), 1); +} + +fn corner_cube() -> BrepEnvelope { + primitives::cuboid("cube".into(), Frame3::IDENTITY, [2.0, 2.0, 2.0], ACCURACY).unwrap() +} + +fn corner_tetrahedron(id: &str, apex: f64, faces: &[Vec]) -> BrepEnvelope { + primitives::polyhedron( + id.into(), + &[ + [1.0, 1.0, 2.0], + [3.0, 1.0, 2.0], + [1.0, 3.0, 2.0], + [1.0, 1.0, apex], + ], + faces, + ACCURACY, + ) + .unwrap() +} + +#[test] +fn a_face_shared_in_the_same_direction_is_kept_once_by_union_and_intersection() { + let host = corner_cube(); + let pit = corner_tetrahedron( + "pit", + 0.5, + &[vec![0, 1, 2], vec![0, 3, 1], vec![0, 2, 3], vec![1, 3, 2]], + ); + let [union, intersection, subtraction] = assert_identities(&host, &pit, 0.75); + let inside_shared = [1.2, 1.2, 2.0 - 1.0e-3]; + assert_eq!( + classify_point(&union.brep, inside_shared).unwrap(), + PointClassification::Inside + ); + assert_eq!( + classify_point(&intersection.brep, inside_shared).unwrap(), + PointClassification::Inside + ); + assert_eq!( + classify_point(&subtraction.brep, inside_shared).unwrap(), + PointClassification::Outside + ); +} + +#[test] +fn union_drops_a_face_shared_back_to_back_from_both_bodies() { + let host = corner_cube(); + let cap = corner_tetrahedron( + "cap", + 3.5, + &[vec![0, 2, 1], vec![0, 1, 3], vec![0, 3, 2], vec![1, 2, 3]], + ); + let (union, united) = operated(&host, &cap, BooleanOp::Union); + assert!((united - 9.0).abs() <= 1.0e-9 * 9.0, "{united}"); + assert_eq!(union.brep.solids.len(), 1); + assert_eq!( + classify_point(&union.brep, [1.2, 1.2, 2.0]).unwrap(), + PointClassification::Inside + ); +} diff --git a/main/opengeometry/tests/fixtures/cases/boolean-matrix/rotated-box-intersection.json b/main/opengeometry/tests/fixtures/cases/boolean-matrix/rotated-box-intersection.json index 6947f538..5cbce7ca 100644 --- a/main/opengeometry/tests/fixtures/cases/boolean-matrix/rotated-box-intersection.json +++ b/main/opengeometry/tests/fixtures/cases/boolean-matrix/rotated-box-intersection.json @@ -1 +1 @@ -{"a":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,0.0,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,0.0,-3.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[0.0,0.0,-3.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.0,-3.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[0.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,3.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,3.0,-3.0]}],"intersections":[],"pcurves":[{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]}],"surfaces":[{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[1.0,-0.0,0.0],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,3.0,0.0],"x":[1.0,0.0,0.0],"y":[0.0,0.0,-1.0],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,1.0,0.0],"y":[0.0,0.0,-1.0],"z":[-1.0,-0.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[3.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[-0.0,1.0,0.0],"z":[1.0,0.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[1.0,0.0,0.0],"y":[-0.0,1.0,0.0],"z":[-0.0,-0.0,1.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,-3.0],"x":[0.0,1.0,0.0],"y":[1.0,-0.0,0.0],"z":[0.0,0.0,-1.0]},"kind":"Plane"}]},"id":"matrix-box-host","quality":{"kind":"Analytic"},"revision":0,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":3,"id":0,"tolerance":1e-8,"twin_halfedge":16},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":0,"id":1,"tolerance":1e-8,"twin_halfedge":11},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":8,"id":2,"tolerance":1e-8,"twin_halfedge":19},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":2,"id":3,"tolerance":1e-8,"twin_halfedge":12},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":15,"id":4,"tolerance":1e-8,"twin_halfedge":17},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":1,"id":5,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":10,"id":6,"tolerance":1e-8,"twin_halfedge":20},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":13,"id":7,"tolerance":1e-8,"twin_halfedge":22},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":4,"id":8,"tolerance":1e-8,"twin_halfedge":18},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":7,"id":9,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":5,"id":10,"tolerance":1e-8,"twin_halfedge":14},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":6,"id":11,"tolerance":1e-8,"twin_halfedge":21}],"faces":[{"id":0,"key":"bottom","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":1,"key":"top","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":2,"key":"left","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":3,"key":"right","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":4,"key":"front","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":5,"key":"back","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}}],"halfedges":[{"edge":1,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":2,"twin":11,"wire_ref":null},{"edge":5,"face":0,"from":2,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":3,"twin":23,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Reverse"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":1,"twin":12,"wire_ref":null},{"edge":0,"face":0,"from":1,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Reverse"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":16,"wire_ref":null},{"edge":8,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":5,"twin":18,"wire_ref":null},{"edge":10,"face":1,"from":5,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Forward"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":7,"twin":14,"wire_ref":null},{"edge":11,"face":1,"from":7,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Reverse"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":6,"twin":21,"wire_ref":null},{"edge":9,"face":1,"from":6,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Reverse"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":9,"wire_ref":null},{"edge":2,"face":2,"from":0,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Forward"},"id":8,"loop_ref":2,"next":9,"prev":11,"to":4,"twin":19,"wire_ref":null},{"edge":9,"face":2,"from":4,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Forward"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":6,"twin":7,"wire_ref":null},{"edge":6,"face":2,"from":6,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Reverse"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":2,"twin":20,"wire_ref":null},{"edge":1,"face":2,"from":2,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Reverse"},"id":11,"loop_ref":2,"next":8,"prev":10,"to":0,"twin":0,"wire_ref":null},{"edge":3,"face":3,"from":1,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":3,"next":13,"prev":15,"to":3,"twin":2,"wire_ref":null},{"edge":7,"face":3,"from":3,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Forward"},"id":13,"loop_ref":3,"next":14,"prev":12,"to":7,"twin":22,"wire_ref":null},{"edge":10,"face":3,"from":7,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Reverse"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":5,"twin":5,"wire_ref":null},{"edge":4,"face":3,"from":5,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Reverse"},"id":15,"loop_ref":3,"next":12,"prev":14,"to":1,"twin":17,"wire_ref":null},{"edge":0,"face":4,"from":0,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Forward"},"id":16,"loop_ref":4,"next":17,"prev":19,"to":1,"twin":3,"wire_ref":null},{"edge":4,"face":4,"from":1,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Forward"},"id":17,"loop_ref":4,"next":18,"prev":16,"to":5,"twin":15,"wire_ref":null},{"edge":8,"face":4,"from":5,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Reverse"},"id":18,"loop_ref":4,"next":19,"prev":17,"to":4,"twin":4,"wire_ref":null},{"edge":2,"face":4,"from":4,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Reverse"},"id":19,"loop_ref":4,"next":16,"prev":18,"to":0,"twin":8,"wire_ref":null},{"edge":6,"face":5,"from":2,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":5,"next":21,"prev":23,"to":6,"twin":10,"wire_ref":null},{"edge":11,"face":5,"from":6,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Forward"},"id":21,"loop_ref":5,"next":22,"prev":20,"to":7,"twin":6,"wire_ref":null},{"edge":7,"face":5,"from":7,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Reverse"},"id":22,"loop_ref":5,"next":23,"prev":21,"to":3,"twin":13,"wire_ref":null},{"edge":5,"face":5,"from":3,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":5,"next":20,"prev":22,"to":2,"twin":1,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":12},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":16},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":20}],"shells":[{"faces":[0,1,2,3,4,5],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[0.0,0.0,0.0],"tolerance":1e-8},{"id":1,"outgoing_halfedge":3,"position":[3.0,0.0,0.0],"tolerance":1e-8},{"id":2,"outgoing_halfedge":1,"position":[0.0,0.0,-3.0],"tolerance":1e-8},{"id":3,"outgoing_halfedge":2,"position":[3.0,0.0,-3.0],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[0.0,3.0,0.0],"tolerance":1e-8},{"id":5,"outgoing_halfedge":5,"position":[3.0,3.0,0.0],"tolerance":1e-8},{"id":6,"outgoing_halfedge":7,"position":[0.0,3.0,-3.0],"tolerance":1e-8},{"id":7,"outgoing_halfedge":6,"position":[3.0,3.0,-3.0],"tolerance":1e-8}],"wires":[]}},"b":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[2.7320508075688776,0.5,0.5000000000000002]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[2.7320508075688776,0.5,0.5000000000000002]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[2.0,0.5,-2.2320508075688776]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[2.0,0.5,-2.2320508075688776]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.7320508075688776,0.5,-1.2320508075688772]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[1.0,2.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[1.0,2.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[2.7320508075688776,2.5,0.5000000000000002]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[2.0,2.5,-2.2320508075688776]}],"intersections":[],"pcurves":[{"direction":[1.0,1.5165928555043604e-16],"kind":"Line2","origin":[0.0,0.0]},{"direction":[-1.5165928555043604e-16,1.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,1.5165928555043604e-16],"kind":"Line2","origin":[-1.9229626863835643e-16,2.0000000000000004]},{"direction":[-1.5165928555043604e-16,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,-1.487416814333745e-17],"kind":"Line2","origin":[0.0,0.0]},{"direction":[4.0636983087920364e-17,1.0000000000000002],"kind":"Line2","origin":[2.0000000000000004,8.127396617584078e-17]},{"direction":[1.0,-1.487416814333745e-17],"kind":"Line2","origin":[-2.220446049250313e-16,2.0000000000000004]},{"direction":[4.0636983087920364e-17,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0000000000000004]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0000000000000002,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0000000000000002,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[2.0000000000000004,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.000000000000001]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]}],"surfaces":[{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.4999999999999999,0.0,-0.8660254037844386],"y":[0.8660254037844387,-0.0,0.49999999999999994],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,2.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.0,1.0,0.0],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[-0.8660254037844387,-0.0,-0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[2.7320508075688776,0.5,0.5000000000000002],"x":[0.5000000000000001,0.0,-0.8660254037844386],"y":[-0.0,1.0,0.0],"z":[0.8660254037844387,0.0,0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[-0.0,1.0,0.0],"z":[-0.5000000000000001,-0.0,0.8660254037844387]},"kind":"Plane"},{"frame":{"origin":[2.0,0.5,-2.2320508075688776],"x":[0.0,1.0,0.0],"y":[0.8660254037844387,-0.0,0.5000000000000001],"z":[0.5000000000000001,0.0,-0.8660254037844387]},"kind":"Plane"}]},"id":"matrix-rotated-cutter","quality":{"kind":"Analytic"},"revision":0,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":3,"id":0,"tolerance":1e-8,"twin_halfedge":16},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":0,"id":1,"tolerance":1e-8,"twin_halfedge":11},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":8,"id":2,"tolerance":1e-8,"twin_halfedge":19},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":2,"id":3,"tolerance":1e-8,"twin_halfedge":12},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":15,"id":4,"tolerance":1e-8,"twin_halfedge":17},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":1,"id":5,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":10,"id":6,"tolerance":1e-8,"twin_halfedge":20},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":13,"id":7,"tolerance":1e-8,"twin_halfedge":22},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":4,"id":8,"tolerance":1e-8,"twin_halfedge":18},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":7,"id":9,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":5,"id":10,"tolerance":1e-8,"twin_halfedge":14},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":6,"id":11,"tolerance":1e-8,"twin_halfedge":21}],"faces":[{"id":0,"key":"bottom","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":2.00000001,"lo":-1.0000000192296269e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":1,"key":"top","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":2.0000000100000004,"lo":-1.0000000222044605e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":2,"key":"left","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":3,"key":"right","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":4,"key":"front","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":2.0000000100000004,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":5,"key":"back","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.000000010000001,"lo":-1e-8}]}}],"halfedges":[{"edge":1,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":2,"twin":11,"wire_ref":null},{"edge":5,"face":0,"from":2,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":3,"twin":23,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Reverse"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":1,"twin":12,"wire_ref":null},{"edge":0,"face":0,"from":1,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Reverse"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":16,"wire_ref":null},{"edge":8,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":5,"twin":18,"wire_ref":null},{"edge":10,"face":1,"from":5,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Forward"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":7,"twin":14,"wire_ref":null},{"edge":11,"face":1,"from":7,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Reverse"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":6,"twin":21,"wire_ref":null},{"edge":9,"face":1,"from":6,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Reverse"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":9,"wire_ref":null},{"edge":2,"face":2,"from":0,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Forward"},"id":8,"loop_ref":2,"next":9,"prev":11,"to":4,"twin":19,"wire_ref":null},{"edge":9,"face":2,"from":4,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Forward"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":6,"twin":7,"wire_ref":null},{"edge":6,"face":2,"from":6,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Reverse"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":2,"twin":20,"wire_ref":null},{"edge":1,"face":2,"from":2,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Reverse"},"id":11,"loop_ref":2,"next":8,"prev":10,"to":0,"twin":0,"wire_ref":null},{"edge":3,"face":3,"from":1,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":3,"next":13,"prev":15,"to":3,"twin":2,"wire_ref":null},{"edge":7,"face":3,"from":3,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Forward"},"id":13,"loop_ref":3,"next":14,"prev":12,"to":7,"twin":22,"wire_ref":null},{"edge":10,"face":3,"from":7,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Reverse"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":5,"twin":5,"wire_ref":null},{"edge":4,"face":3,"from":5,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Reverse"},"id":15,"loop_ref":3,"next":12,"prev":14,"to":1,"twin":17,"wire_ref":null},{"edge":0,"face":4,"from":0,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Forward"},"id":16,"loop_ref":4,"next":17,"prev":19,"to":1,"twin":3,"wire_ref":null},{"edge":4,"face":4,"from":1,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Forward"},"id":17,"loop_ref":4,"next":18,"prev":16,"to":5,"twin":15,"wire_ref":null},{"edge":8,"face":4,"from":5,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Reverse"},"id":18,"loop_ref":4,"next":19,"prev":17,"to":4,"twin":4,"wire_ref":null},{"edge":2,"face":4,"from":4,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Reverse"},"id":19,"loop_ref":4,"next":16,"prev":18,"to":0,"twin":8,"wire_ref":null},{"edge":6,"face":5,"from":2,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":5,"next":21,"prev":23,"to":6,"twin":10,"wire_ref":null},{"edge":11,"face":5,"from":6,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Forward"},"id":21,"loop_ref":5,"next":22,"prev":20,"to":7,"twin":6,"wire_ref":null},{"edge":7,"face":5,"from":7,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Reverse"},"id":22,"loop_ref":5,"next":23,"prev":21,"to":3,"twin":13,"wire_ref":null},{"edge":5,"face":5,"from":3,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":5,"next":20,"prev":22,"to":2,"twin":1,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":12},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":16},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":20}],"shells":[{"faces":[0,1,2,3,4,5],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[1.0,0.5,-0.5],"tolerance":1e-8},{"id":1,"outgoing_halfedge":3,"position":[2.7320508075688776,0.5,0.5000000000000002],"tolerance":1e-8},{"id":2,"outgoing_halfedge":1,"position":[2.0,0.5,-2.2320508075688776],"tolerance":1e-8},{"id":3,"outgoing_halfedge":2,"position":[3.7320508075688776,0.5,-1.2320508075688772],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[1.0,2.5,-0.5],"tolerance":1e-8},{"id":5,"outgoing_halfedge":5,"position":[2.7320508075688776,2.5,0.5000000000000002],"tolerance":1e-8},{"id":6,"outgoing_halfedge":7,"position":[2.0,2.5,-2.2320508075688776],"tolerance":1e-8},{"id":7,"outgoing_halfedge":6,"position":[3.7320508075688776,2.5,-1.2320508075688772],"tolerance":1e-8}],"wires":[]}},"handlers":[],"operation":"intersection","result":{"error":{"CoverageGap":{"families":["generic analytic solid","intersecting analytic solid"]}}}} \ No newline at end of file +{"a":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,0.0,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,0.0,-3.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[0.0,0.0,-3.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.0,-3.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[0.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,3.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,3.0,-3.0]}],"intersections":[],"pcurves":[{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]}],"surfaces":[{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[1.0,-0.0,0.0],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,3.0,0.0],"x":[1.0,0.0,0.0],"y":[0.0,0.0,-1.0],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,1.0,0.0],"y":[0.0,0.0,-1.0],"z":[-1.0,-0.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[3.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[-0.0,1.0,0.0],"z":[1.0,0.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[1.0,0.0,0.0],"y":[-0.0,1.0,0.0],"z":[-0.0,-0.0,1.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,-3.0],"x":[0.0,1.0,0.0],"y":[1.0,-0.0,0.0],"z":[0.0,0.0,-1.0]},"kind":"Plane"}]},"id":"matrix-box-host","quality":{"kind":"Analytic"},"revision":0,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":3,"id":0,"tolerance":1e-8,"twin_halfedge":16},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":0,"id":1,"tolerance":1e-8,"twin_halfedge":11},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":8,"id":2,"tolerance":1e-8,"twin_halfedge":19},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":2,"id":3,"tolerance":1e-8,"twin_halfedge":12},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":15,"id":4,"tolerance":1e-8,"twin_halfedge":17},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":1,"id":5,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":10,"id":6,"tolerance":1e-8,"twin_halfedge":20},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":13,"id":7,"tolerance":1e-8,"twin_halfedge":22},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":4,"id":8,"tolerance":1e-8,"twin_halfedge":18},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":7,"id":9,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":5,"id":10,"tolerance":1e-8,"twin_halfedge":14},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":6,"id":11,"tolerance":1e-8,"twin_halfedge":21}],"faces":[{"id":0,"key":"bottom","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":1,"key":"top","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":2,"key":"left","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":3,"key":"right","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":4,"key":"front","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":5,"key":"back","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}}],"halfedges":[{"edge":1,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":2,"twin":11,"wire_ref":null},{"edge":5,"face":0,"from":2,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":3,"twin":23,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Reverse"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":1,"twin":12,"wire_ref":null},{"edge":0,"face":0,"from":1,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Reverse"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":16,"wire_ref":null},{"edge":8,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":5,"twin":18,"wire_ref":null},{"edge":10,"face":1,"from":5,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Forward"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":7,"twin":14,"wire_ref":null},{"edge":11,"face":1,"from":7,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Reverse"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":6,"twin":21,"wire_ref":null},{"edge":9,"face":1,"from":6,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Reverse"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":9,"wire_ref":null},{"edge":2,"face":2,"from":0,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Forward"},"id":8,"loop_ref":2,"next":9,"prev":11,"to":4,"twin":19,"wire_ref":null},{"edge":9,"face":2,"from":4,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Forward"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":6,"twin":7,"wire_ref":null},{"edge":6,"face":2,"from":6,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Reverse"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":2,"twin":20,"wire_ref":null},{"edge":1,"face":2,"from":2,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Reverse"},"id":11,"loop_ref":2,"next":8,"prev":10,"to":0,"twin":0,"wire_ref":null},{"edge":3,"face":3,"from":1,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":3,"next":13,"prev":15,"to":3,"twin":2,"wire_ref":null},{"edge":7,"face":3,"from":3,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Forward"},"id":13,"loop_ref":3,"next":14,"prev":12,"to":7,"twin":22,"wire_ref":null},{"edge":10,"face":3,"from":7,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Reverse"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":5,"twin":5,"wire_ref":null},{"edge":4,"face":3,"from":5,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Reverse"},"id":15,"loop_ref":3,"next":12,"prev":14,"to":1,"twin":17,"wire_ref":null},{"edge":0,"face":4,"from":0,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Forward"},"id":16,"loop_ref":4,"next":17,"prev":19,"to":1,"twin":3,"wire_ref":null},{"edge":4,"face":4,"from":1,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Forward"},"id":17,"loop_ref":4,"next":18,"prev":16,"to":5,"twin":15,"wire_ref":null},{"edge":8,"face":4,"from":5,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Reverse"},"id":18,"loop_ref":4,"next":19,"prev":17,"to":4,"twin":4,"wire_ref":null},{"edge":2,"face":4,"from":4,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Reverse"},"id":19,"loop_ref":4,"next":16,"prev":18,"to":0,"twin":8,"wire_ref":null},{"edge":6,"face":5,"from":2,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":5,"next":21,"prev":23,"to":6,"twin":10,"wire_ref":null},{"edge":11,"face":5,"from":6,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Forward"},"id":21,"loop_ref":5,"next":22,"prev":20,"to":7,"twin":6,"wire_ref":null},{"edge":7,"face":5,"from":7,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Reverse"},"id":22,"loop_ref":5,"next":23,"prev":21,"to":3,"twin":13,"wire_ref":null},{"edge":5,"face":5,"from":3,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":5,"next":20,"prev":22,"to":2,"twin":1,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":12},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":16},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":20}],"shells":[{"faces":[0,1,2,3,4,5],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[0.0,0.0,0.0],"tolerance":1e-8},{"id":1,"outgoing_halfedge":3,"position":[3.0,0.0,0.0],"tolerance":1e-8},{"id":2,"outgoing_halfedge":1,"position":[0.0,0.0,-3.0],"tolerance":1e-8},{"id":3,"outgoing_halfedge":2,"position":[3.0,0.0,-3.0],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[0.0,3.0,0.0],"tolerance":1e-8},{"id":5,"outgoing_halfedge":5,"position":[3.0,3.0,0.0],"tolerance":1e-8},{"id":6,"outgoing_halfedge":7,"position":[0.0,3.0,-3.0],"tolerance":1e-8},{"id":7,"outgoing_halfedge":6,"position":[3.0,3.0,-3.0],"tolerance":1e-8}],"wires":[]}},"b":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[2.7320508075688776,0.5,0.5000000000000002]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[2.7320508075688776,0.5,0.5000000000000002]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[2.0,0.5,-2.2320508075688776]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[2.0,0.5,-2.2320508075688776]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.7320508075688776,0.5,-1.2320508075688772]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[1.0,2.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[1.0,2.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[2.7320508075688776,2.5,0.5000000000000002]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[2.0,2.5,-2.2320508075688776]}],"intersections":[],"pcurves":[{"direction":[1.0,1.5165928555043604e-16],"kind":"Line2","origin":[0.0,0.0]},{"direction":[-1.5165928555043604e-16,1.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,1.5165928555043604e-16],"kind":"Line2","origin":[-1.9229626863835643e-16,2.0000000000000004]},{"direction":[-1.5165928555043604e-16,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,-1.487416814333745e-17],"kind":"Line2","origin":[0.0,0.0]},{"direction":[4.0636983087920364e-17,1.0000000000000002],"kind":"Line2","origin":[2.0000000000000004,8.127396617584078e-17]},{"direction":[1.0,-1.487416814333745e-17],"kind":"Line2","origin":[-2.220446049250313e-16,2.0000000000000004]},{"direction":[4.0636983087920364e-17,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0000000000000004]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0000000000000002,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0000000000000002,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[2.0000000000000004,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.000000000000001]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]}],"surfaces":[{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.4999999999999999,0.0,-0.8660254037844386],"y":[0.8660254037844387,-0.0,0.49999999999999994],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,2.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.0,1.0,0.0],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[-0.8660254037844387,-0.0,-0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[2.7320508075688776,0.5,0.5000000000000002],"x":[0.5000000000000001,0.0,-0.8660254037844386],"y":[-0.0,1.0,0.0],"z":[0.8660254037844387,0.0,0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[-0.0,1.0,0.0],"z":[-0.5000000000000001,-0.0,0.8660254037844387]},"kind":"Plane"},{"frame":{"origin":[2.0,0.5,-2.2320508075688776],"x":[0.0,1.0,0.0],"y":[0.8660254037844387,-0.0,0.5000000000000001],"z":[0.5000000000000001,0.0,-0.8660254037844387]},"kind":"Plane"}]},"id":"matrix-rotated-cutter","quality":{"kind":"Analytic"},"revision":0,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":3,"id":0,"tolerance":1e-8,"twin_halfedge":16},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":0,"id":1,"tolerance":1e-8,"twin_halfedge":11},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":8,"id":2,"tolerance":1e-8,"twin_halfedge":19},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":2,"id":3,"tolerance":1e-8,"twin_halfedge":12},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":15,"id":4,"tolerance":1e-8,"twin_halfedge":17},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":1,"id":5,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":10,"id":6,"tolerance":1e-8,"twin_halfedge":20},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":13,"id":7,"tolerance":1e-8,"twin_halfedge":22},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":4,"id":8,"tolerance":1e-8,"twin_halfedge":18},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":7,"id":9,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":5,"id":10,"tolerance":1e-8,"twin_halfedge":14},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":6,"id":11,"tolerance":1e-8,"twin_halfedge":21}],"faces":[{"id":0,"key":"bottom","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":2.00000001,"lo":-1.0000000192296269e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":1,"key":"top","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":2.0000000100000004,"lo":-1.0000000222044605e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":2,"key":"left","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":3,"key":"right","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":4,"key":"front","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":2.0000000100000004,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":5,"key":"back","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.000000010000001,"lo":-1e-8}]}}],"halfedges":[{"edge":1,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":2,"twin":11,"wire_ref":null},{"edge":5,"face":0,"from":2,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":3,"twin":23,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Reverse"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":1,"twin":12,"wire_ref":null},{"edge":0,"face":0,"from":1,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Reverse"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":16,"wire_ref":null},{"edge":8,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":5,"twin":18,"wire_ref":null},{"edge":10,"face":1,"from":5,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Forward"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":7,"twin":14,"wire_ref":null},{"edge":11,"face":1,"from":7,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Reverse"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":6,"twin":21,"wire_ref":null},{"edge":9,"face":1,"from":6,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Reverse"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":9,"wire_ref":null},{"edge":2,"face":2,"from":0,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Forward"},"id":8,"loop_ref":2,"next":9,"prev":11,"to":4,"twin":19,"wire_ref":null},{"edge":9,"face":2,"from":4,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Forward"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":6,"twin":7,"wire_ref":null},{"edge":6,"face":2,"from":6,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Reverse"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":2,"twin":20,"wire_ref":null},{"edge":1,"face":2,"from":2,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Reverse"},"id":11,"loop_ref":2,"next":8,"prev":10,"to":0,"twin":0,"wire_ref":null},{"edge":3,"face":3,"from":1,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":3,"next":13,"prev":15,"to":3,"twin":2,"wire_ref":null},{"edge":7,"face":3,"from":3,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Forward"},"id":13,"loop_ref":3,"next":14,"prev":12,"to":7,"twin":22,"wire_ref":null},{"edge":10,"face":3,"from":7,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Reverse"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":5,"twin":5,"wire_ref":null},{"edge":4,"face":3,"from":5,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Reverse"},"id":15,"loop_ref":3,"next":12,"prev":14,"to":1,"twin":17,"wire_ref":null},{"edge":0,"face":4,"from":0,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Forward"},"id":16,"loop_ref":4,"next":17,"prev":19,"to":1,"twin":3,"wire_ref":null},{"edge":4,"face":4,"from":1,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Forward"},"id":17,"loop_ref":4,"next":18,"prev":16,"to":5,"twin":15,"wire_ref":null},{"edge":8,"face":4,"from":5,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Reverse"},"id":18,"loop_ref":4,"next":19,"prev":17,"to":4,"twin":4,"wire_ref":null},{"edge":2,"face":4,"from":4,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Reverse"},"id":19,"loop_ref":4,"next":16,"prev":18,"to":0,"twin":8,"wire_ref":null},{"edge":6,"face":5,"from":2,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":5,"next":21,"prev":23,"to":6,"twin":10,"wire_ref":null},{"edge":11,"face":5,"from":6,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Forward"},"id":21,"loop_ref":5,"next":22,"prev":20,"to":7,"twin":6,"wire_ref":null},{"edge":7,"face":5,"from":7,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Reverse"},"id":22,"loop_ref":5,"next":23,"prev":21,"to":3,"twin":13,"wire_ref":null},{"edge":5,"face":5,"from":3,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":5,"next":20,"prev":22,"to":2,"twin":1,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":12},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":16},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":20}],"shells":[{"faces":[0,1,2,3,4,5],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[1.0,0.5,-0.5],"tolerance":1e-8},{"id":1,"outgoing_halfedge":3,"position":[2.7320508075688776,0.5,0.5000000000000002],"tolerance":1e-8},{"id":2,"outgoing_halfedge":1,"position":[2.0,0.5,-2.2320508075688776],"tolerance":1e-8},{"id":3,"outgoing_halfedge":2,"position":[3.7320508075688776,0.5,-1.2320508075688772],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[1.0,2.5,-0.5],"tolerance":1e-8},{"id":5,"outgoing_halfedge":5,"position":[2.7320508075688776,2.5,0.5000000000000002],"tolerance":1e-8},{"id":6,"outgoing_halfedge":7,"position":[2.0,2.5,-2.2320508075688776],"tolerance":1e-8},{"id":7,"outgoing_halfedge":6,"position":[3.7320508075688776,2.5,-1.2320508075688772],"tolerance":1e-8}],"wires":[]}},"handlers":["boolean_planar_polyhedra"],"operation":"intersection","result":{"brep":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,0.5,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.5,-1.6547005383792517]},{"direction":[0.0,0.0,1.0],"kind":"Line","origin":[3.0,2.5,-1.6547005383792517]},{"direction":[0.0,-1.0,0.0],"kind":"Line","origin":[3.0,2.5,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[1.8660254037844386,0.5,0.0]},{"direction":[-1.0,0.0,0.0],"kind":"Line","origin":[3.0,2.5,0.0]},{"direction":[0.0,-1.0,0.0],"kind":"Line","origin":[1.8660254037844386,2.5,0.0]},{"direction":[-0.8660254037844386,0.0,-0.4999999999999999],"kind":"Line","origin":[1.8660254037844388,0.5,0.0]},{"direction":[0.49999999999999994,0.0,-0.8660254037844387],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.8660254037844387,0.0,0.4999999999999999],"kind":"Line","origin":[1.9999999999999998,0.5,-2.232050807568877]},{"direction":[-0.8660254037844387,0.0,-0.5],"kind":"Line","origin":[3.0000000000000004,2.5,-1.6547005383792521]},{"direction":[-0.4999999999999999,0.0,0.8660254037844388],"kind":"Line","origin":[2.0,2.5,-2.232050807568878]},{"direction":[0.8660254037844386,0.0,0.5000000000000001],"kind":"Line","origin":[1.0,2.5,-0.5]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.0,-1.0,0.0],"kind":"Line","origin":[2.0000000000000004,2.5,-2.2320508075688776]}],"intersections":[],"pcurves":[{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.5]},{"direction":[0.0,1.0],"kind":"Line2","origin":[1.6547005383792517,0.5]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[1.6547005383792517,2.5]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[0.0,2.5]},{"direction":[1.0,0.0],"kind":"Line2","origin":[1.8660254037844386,0.5]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[3.0,2.5]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[3.0,2.5]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[1.8660254037844386,2.5]},{"direction":[0.8660254037844386,-0.49999999999999994],"kind":"Line2","origin":[0.5669872981077805,1.9820508075688774]},{"direction":[0.4999999999999999,0.8660254037844387],"kind":"Line2","origin":[-9.614813431917819e-17,1.0]},{"direction":[-1.4874168143337463e-17,-0.9999999999999999],"kind":"Line2","origin":[1.487416814333744e-17,1.0000000000000002]},{"direction":[0.9999999999999999,7.437084071668725e-18],"kind":"Line2","origin":[0.0,0.0]},{"direction":[7.038531937459528e-17,1.0],"kind":"Line2","origin":[1.9999999999999996,2.97483362866749e-17]},{"direction":[0.5,-0.8660254037844387],"kind":"Line2","origin":[1.1547005383792515,2.0000000000000004]},{"direction":[-1.0,-9.61481343191782e-17],"kind":"Line2","origin":[1.1547005383792515,2.0000000000000013]},{"direction":[2.0717043678169384e-16,-1.0000000000000002],"kind":"Line2","origin":[-4.440892098500626e-16,2.000000000000001]},{"direction":[1.0,-7.038531937459529e-17],"kind":"Line2","origin":[0.0,0.0]},{"direction":[-0.8660254037844386,-0.5000000000000001],"kind":"Line2","origin":[1.9820508075688772,0.5669872981077808]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,-1.0000000000000002],"kind":"Line2","origin":[2.0,2.000000000000001]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[2.0,2.000000000000001]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[0.9999999999999999,2.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[-0.9999999999999999,0.0],"kind":"Line2","origin":[1.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,1.1547005383792517]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,2.974833628667495e-17]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[2.0,3.845925372767128e-16]},{"direction":[0.0,-1.0000000000000002],"kind":"Line2","origin":[2.0,1.154700538379252]}],"surfaces":[{"frame":{"origin":[3.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[-0.0,1.0,0.0],"z":[1.0,0.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[1.0,0.0,0.0],"y":[-0.0,1.0,0.0],"z":[-0.0,-0.0,1.0]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.4999999999999999,0.0,-0.8660254037844386],"y":[0.8660254037844387,-0.0,0.49999999999999994],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,2.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.0,1.0,0.0],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[-0.8660254037844387,-0.0,-0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[-0.0,1.0,0.0],"z":[-0.5000000000000001,-0.0,0.8660254037844387]},"kind":"Plane"},{"frame":{"origin":[2.0,0.5,-2.2320508075688776],"x":[0.0,1.0,0.0],"y":[0.8660254037844387,-0.0,0.5000000000000001],"z":[0.5000000000000001,0.0,-0.8660254037844387]},"kind":"Plane"}]},"id":"matrix-rotated-box-intersection","quality":{"kind":"Analytic"},"revision":1,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":1.6547005383792517,"lo":0.0}},"halfedge":0,"id":0,"tolerance":1e-8,"twin_halfedge":8},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":1,"id":1,"tolerance":1e-8,"twin_halfedge":26},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":1.6547005383792517,"lo":0.0}},"halfedge":2,"id":2,"tolerance":1e-8,"twin_halfedge":13},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":3,"id":3,"tolerance":1e-8,"twin_halfedge":5},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":1.1339745962155614,"lo":0.0}},"halfedge":4,"id":4,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":1.1339745962155614,"lo":0.0}},"halfedge":6,"id":5,"tolerance":1e-8,"twin_halfedge":17},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":7,"id":6,"tolerance":1e-8,"twin_halfedge":22},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":1.0000000000000002,"lo":0.0}},"halfedge":10,"id":7,"tolerance":1e-8,"twin_halfedge":25},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":1.9999999999999998,"lo":0.0}},"halfedge":11,"id":8,"tolerance":1e-8,"twin_halfedge":21},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":1.1547005383792517,"lo":0.0}},"halfedge":12,"id":9,"tolerance":1e-8,"twin_halfedge":27},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":1.154700538379252,"lo":0.0}},"halfedge":14,"id":10,"tolerance":1e-8,"twin_halfedge":29},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":2.0000000000000004,"lo":0.0}},"halfedge":15,"id":11,"tolerance":1e-8,"twin_halfedge":19},{"chart_seam":false,"geometry":{"curve":12,"kind":"Curve","range":{"hi":0.9999999999999998,"lo":0.0}},"halfedge":16,"id":12,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":13,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":18,"id":13,"tolerance":1e-8,"twin_halfedge":24},{"chart_seam":false,"geometry":{"curve":14,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":20,"id":14,"tolerance":1e-8,"twin_halfedge":28}],"faces":[{"id":0,"key":"planar-host-3-0","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":1.6547005483792516,"lo":-1e-8},{"hi":2.50000001,"lo":0.49999999}]}},{"id":1,"key":"planar-host-4-1","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":3.00000001,"lo":1.8660253937844387},{"hi":2.50000001,"lo":0.49999999}]}},{"id":2,"key":"planar-cut-0-2","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":2.0000000099999995,"lo":-1e-8},{"hi":1.9820508175688774,"lo":-1e-8}]}},{"id":3,"key":"planar-cut-1-3","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":1.9820508175688771,"lo":-1.000000044408921e-8},{"hi":2.0000000100000013,"lo":-1.000000007038532e-8}]}},{"id":4,"key":"planar-cut-2-4","provenance":{"reversed":false,"role":"Preserved","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.000000010000001,"lo":-1e-8}]}},{"id":5,"key":"planar-cut-4-5","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":1.00000001,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":6,"key":"planar-cut-5-6","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":6,"trim":{"chart":0,"holes":[],"outer":6,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":1.154700548379252,"lo":-1e-8}]}}],"halfedges":[{"edge":0,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":1,"twin":8,"wire_ref":null},{"edge":1,"face":0,"from":1,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":2,"twin":26,"wire_ref":null},{"edge":2,"face":0,"from":2,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Forward"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":3,"twin":13,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Forward"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":5,"wire_ref":null},{"edge":4,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":0,"twin":9,"wire_ref":null},{"edge":3,"face":1,"from":0,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Reverse"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":3,"twin":3,"wire_ref":null},{"edge":5,"face":1,"from":3,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Forward"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":5,"twin":17,"wire_ref":null},{"edge":6,"face":1,"from":5,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Forward"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":22,"wire_ref":null},{"edge":0,"face":2,"from":1,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Reverse"},"id":8,"loop_ref":2,"next":9,"prev":12,"to":0,"twin":0,"wire_ref":null},{"edge":4,"face":2,"from":0,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Reverse"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":4,"twin":4,"wire_ref":null},{"edge":7,"face":2,"from":4,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Forward"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":6,"twin":25,"wire_ref":null},{"edge":8,"face":2,"from":6,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Forward"},"id":11,"loop_ref":2,"next":12,"prev":10,"to":7,"twin":21,"wire_ref":null},{"edge":9,"face":2,"from":7,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":2,"next":8,"prev":11,"to":1,"twin":27,"wire_ref":null},{"edge":2,"face":3,"from":3,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Reverse"},"id":13,"loop_ref":3,"next":14,"prev":17,"to":2,"twin":2,"wire_ref":null},{"edge":10,"face":3,"from":2,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Forward"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":8,"twin":29,"wire_ref":null},{"edge":11,"face":3,"from":8,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Forward"},"id":15,"loop_ref":3,"next":16,"prev":14,"to":9,"twin":19,"wire_ref":null},{"edge":12,"face":3,"from":9,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Forward"},"id":16,"loop_ref":3,"next":17,"prev":15,"to":5,"twin":23,"wire_ref":null},{"edge":5,"face":3,"from":5,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Reverse"},"id":17,"loop_ref":3,"next":13,"prev":16,"to":3,"twin":6,"wire_ref":null},{"edge":13,"face":4,"from":6,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Forward"},"id":18,"loop_ref":4,"next":19,"prev":21,"to":9,"twin":24,"wire_ref":null},{"edge":11,"face":4,"from":9,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Reverse"},"id":19,"loop_ref":4,"next":20,"prev":18,"to":8,"twin":15,"wire_ref":null},{"edge":14,"face":4,"from":8,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":4,"next":21,"prev":19,"to":7,"twin":28,"wire_ref":null},{"edge":8,"face":4,"from":7,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Reverse"},"id":21,"loop_ref":4,"next":18,"prev":20,"to":6,"twin":11,"wire_ref":null},{"edge":6,"face":5,"from":4,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Reverse"},"id":22,"loop_ref":5,"next":23,"prev":25,"to":5,"twin":7,"wire_ref":null},{"edge":12,"face":5,"from":5,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":5,"next":24,"prev":22,"to":9,"twin":16,"wire_ref":null},{"edge":13,"face":5,"from":9,"geometry_use":{"pcurve":24,"periodic_lift":[0,0],"sense":"Reverse"},"id":24,"loop_ref":5,"next":25,"prev":23,"to":6,"twin":18,"wire_ref":null},{"edge":7,"face":5,"from":6,"geometry_use":{"pcurve":25,"periodic_lift":[0,0],"sense":"Reverse"},"id":25,"loop_ref":5,"next":22,"prev":24,"to":4,"twin":10,"wire_ref":null},{"edge":1,"face":6,"from":2,"geometry_use":{"pcurve":26,"periodic_lift":[0,0],"sense":"Reverse"},"id":26,"loop_ref":6,"next":27,"prev":29,"to":1,"twin":1,"wire_ref":null},{"edge":9,"face":6,"from":1,"geometry_use":{"pcurve":27,"periodic_lift":[0,0],"sense":"Reverse"},"id":27,"loop_ref":6,"next":28,"prev":26,"to":7,"twin":12,"wire_ref":null},{"edge":14,"face":6,"from":7,"geometry_use":{"pcurve":28,"periodic_lift":[0,0],"sense":"Reverse"},"id":28,"loop_ref":6,"next":29,"prev":27,"to":8,"twin":20,"wire_ref":null},{"edge":10,"face":6,"from":8,"geometry_use":{"pcurve":29,"periodic_lift":[0,0],"sense":"Reverse"},"id":29,"loop_ref":6,"next":26,"prev":28,"to":2,"twin":14,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":13},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":18},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":22},{"face_ref":6,"id":6,"is_hole":false,"start_halfedge":26}],"shells":[{"faces":[0,2,6,3,1,5,4],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[3.0,0.5,0.0],"tolerance":1e-8},{"id":1,"outgoing_halfedge":1,"position":[3.0,0.5,-1.6547005383792517],"tolerance":1e-8},{"id":2,"outgoing_halfedge":2,"position":[3.0,2.5,-1.6547005383792517],"tolerance":1e-8},{"id":3,"outgoing_halfedge":3,"position":[3.0,2.5,0.0],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[1.8660254037844386,0.5,0.0],"tolerance":1e-8},{"id":5,"outgoing_halfedge":7,"position":[1.8660254037844386,2.5,0.0],"tolerance":1e-8},{"id":6,"outgoing_halfedge":11,"position":[1.0,0.5,-0.5],"tolerance":1e-8},{"id":7,"outgoing_halfedge":12,"position":[1.9999999999999998,0.5,-2.232050807568877],"tolerance":1e-8},{"id":8,"outgoing_halfedge":15,"position":[2.0,2.5,-2.232050807568878],"tolerance":1e-8},{"id":9,"outgoing_halfedge":16,"position":[1.0,2.5,-0.5],"tolerance":1e-8}],"wires":[]}}}} \ No newline at end of file diff --git a/main/opengeometry/tests/fixtures/cases/boolean-matrix/rotated-box-union.json b/main/opengeometry/tests/fixtures/cases/boolean-matrix/rotated-box-union.json index ffd4e10a..e46a6d27 100644 --- a/main/opengeometry/tests/fixtures/cases/boolean-matrix/rotated-box-union.json +++ b/main/opengeometry/tests/fixtures/cases/boolean-matrix/rotated-box-union.json @@ -1 +1 @@ -{"a":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,0.0,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,0.0,-3.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[0.0,0.0,-3.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.0,-3.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[0.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,3.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,3.0,-3.0]}],"intersections":[],"pcurves":[{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]}],"surfaces":[{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[1.0,-0.0,0.0],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,3.0,0.0],"x":[1.0,0.0,0.0],"y":[0.0,0.0,-1.0],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,1.0,0.0],"y":[0.0,0.0,-1.0],"z":[-1.0,-0.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[3.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[-0.0,1.0,0.0],"z":[1.0,0.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[1.0,0.0,0.0],"y":[-0.0,1.0,0.0],"z":[-0.0,-0.0,1.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,-3.0],"x":[0.0,1.0,0.0],"y":[1.0,-0.0,0.0],"z":[0.0,0.0,-1.0]},"kind":"Plane"}]},"id":"matrix-box-host","quality":{"kind":"Analytic"},"revision":0,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":3,"id":0,"tolerance":1e-8,"twin_halfedge":16},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":0,"id":1,"tolerance":1e-8,"twin_halfedge":11},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":8,"id":2,"tolerance":1e-8,"twin_halfedge":19},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":2,"id":3,"tolerance":1e-8,"twin_halfedge":12},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":15,"id":4,"tolerance":1e-8,"twin_halfedge":17},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":1,"id":5,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":10,"id":6,"tolerance":1e-8,"twin_halfedge":20},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":13,"id":7,"tolerance":1e-8,"twin_halfedge":22},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":4,"id":8,"tolerance":1e-8,"twin_halfedge":18},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":7,"id":9,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":5,"id":10,"tolerance":1e-8,"twin_halfedge":14},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":6,"id":11,"tolerance":1e-8,"twin_halfedge":21}],"faces":[{"id":0,"key":"bottom","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":1,"key":"top","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":2,"key":"left","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":3,"key":"right","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":4,"key":"front","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":5,"key":"back","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}}],"halfedges":[{"edge":1,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":2,"twin":11,"wire_ref":null},{"edge":5,"face":0,"from":2,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":3,"twin":23,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Reverse"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":1,"twin":12,"wire_ref":null},{"edge":0,"face":0,"from":1,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Reverse"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":16,"wire_ref":null},{"edge":8,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":5,"twin":18,"wire_ref":null},{"edge":10,"face":1,"from":5,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Forward"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":7,"twin":14,"wire_ref":null},{"edge":11,"face":1,"from":7,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Reverse"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":6,"twin":21,"wire_ref":null},{"edge":9,"face":1,"from":6,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Reverse"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":9,"wire_ref":null},{"edge":2,"face":2,"from":0,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Forward"},"id":8,"loop_ref":2,"next":9,"prev":11,"to":4,"twin":19,"wire_ref":null},{"edge":9,"face":2,"from":4,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Forward"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":6,"twin":7,"wire_ref":null},{"edge":6,"face":2,"from":6,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Reverse"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":2,"twin":20,"wire_ref":null},{"edge":1,"face":2,"from":2,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Reverse"},"id":11,"loop_ref":2,"next":8,"prev":10,"to":0,"twin":0,"wire_ref":null},{"edge":3,"face":3,"from":1,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":3,"next":13,"prev":15,"to":3,"twin":2,"wire_ref":null},{"edge":7,"face":3,"from":3,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Forward"},"id":13,"loop_ref":3,"next":14,"prev":12,"to":7,"twin":22,"wire_ref":null},{"edge":10,"face":3,"from":7,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Reverse"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":5,"twin":5,"wire_ref":null},{"edge":4,"face":3,"from":5,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Reverse"},"id":15,"loop_ref":3,"next":12,"prev":14,"to":1,"twin":17,"wire_ref":null},{"edge":0,"face":4,"from":0,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Forward"},"id":16,"loop_ref":4,"next":17,"prev":19,"to":1,"twin":3,"wire_ref":null},{"edge":4,"face":4,"from":1,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Forward"},"id":17,"loop_ref":4,"next":18,"prev":16,"to":5,"twin":15,"wire_ref":null},{"edge":8,"face":4,"from":5,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Reverse"},"id":18,"loop_ref":4,"next":19,"prev":17,"to":4,"twin":4,"wire_ref":null},{"edge":2,"face":4,"from":4,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Reverse"},"id":19,"loop_ref":4,"next":16,"prev":18,"to":0,"twin":8,"wire_ref":null},{"edge":6,"face":5,"from":2,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":5,"next":21,"prev":23,"to":6,"twin":10,"wire_ref":null},{"edge":11,"face":5,"from":6,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Forward"},"id":21,"loop_ref":5,"next":22,"prev":20,"to":7,"twin":6,"wire_ref":null},{"edge":7,"face":5,"from":7,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Reverse"},"id":22,"loop_ref":5,"next":23,"prev":21,"to":3,"twin":13,"wire_ref":null},{"edge":5,"face":5,"from":3,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":5,"next":20,"prev":22,"to":2,"twin":1,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":12},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":16},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":20}],"shells":[{"faces":[0,1,2,3,4,5],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[0.0,0.0,0.0],"tolerance":1e-8},{"id":1,"outgoing_halfedge":3,"position":[3.0,0.0,0.0],"tolerance":1e-8},{"id":2,"outgoing_halfedge":1,"position":[0.0,0.0,-3.0],"tolerance":1e-8},{"id":3,"outgoing_halfedge":2,"position":[3.0,0.0,-3.0],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[0.0,3.0,0.0],"tolerance":1e-8},{"id":5,"outgoing_halfedge":5,"position":[3.0,3.0,0.0],"tolerance":1e-8},{"id":6,"outgoing_halfedge":7,"position":[0.0,3.0,-3.0],"tolerance":1e-8},{"id":7,"outgoing_halfedge":6,"position":[3.0,3.0,-3.0],"tolerance":1e-8}],"wires":[]}},"b":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[2.7320508075688776,0.5,0.5000000000000002]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[2.7320508075688776,0.5,0.5000000000000002]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[2.0,0.5,-2.2320508075688776]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[2.0,0.5,-2.2320508075688776]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.7320508075688776,0.5,-1.2320508075688772]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[1.0,2.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[1.0,2.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[2.7320508075688776,2.5,0.5000000000000002]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[2.0,2.5,-2.2320508075688776]}],"intersections":[],"pcurves":[{"direction":[1.0,1.5165928555043604e-16],"kind":"Line2","origin":[0.0,0.0]},{"direction":[-1.5165928555043604e-16,1.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,1.5165928555043604e-16],"kind":"Line2","origin":[-1.9229626863835643e-16,2.0000000000000004]},{"direction":[-1.5165928555043604e-16,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,-1.487416814333745e-17],"kind":"Line2","origin":[0.0,0.0]},{"direction":[4.0636983087920364e-17,1.0000000000000002],"kind":"Line2","origin":[2.0000000000000004,8.127396617584078e-17]},{"direction":[1.0,-1.487416814333745e-17],"kind":"Line2","origin":[-2.220446049250313e-16,2.0000000000000004]},{"direction":[4.0636983087920364e-17,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0000000000000004]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0000000000000002,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0000000000000002,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[2.0000000000000004,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.000000000000001]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]}],"surfaces":[{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.4999999999999999,0.0,-0.8660254037844386],"y":[0.8660254037844387,-0.0,0.49999999999999994],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,2.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.0,1.0,0.0],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[-0.8660254037844387,-0.0,-0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[2.7320508075688776,0.5,0.5000000000000002],"x":[0.5000000000000001,0.0,-0.8660254037844386],"y":[-0.0,1.0,0.0],"z":[0.8660254037844387,0.0,0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[-0.0,1.0,0.0],"z":[-0.5000000000000001,-0.0,0.8660254037844387]},"kind":"Plane"},{"frame":{"origin":[2.0,0.5,-2.2320508075688776],"x":[0.0,1.0,0.0],"y":[0.8660254037844387,-0.0,0.5000000000000001],"z":[0.5000000000000001,0.0,-0.8660254037844387]},"kind":"Plane"}]},"id":"matrix-rotated-cutter","quality":{"kind":"Analytic"},"revision":0,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":3,"id":0,"tolerance":1e-8,"twin_halfedge":16},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":0,"id":1,"tolerance":1e-8,"twin_halfedge":11},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":8,"id":2,"tolerance":1e-8,"twin_halfedge":19},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":2,"id":3,"tolerance":1e-8,"twin_halfedge":12},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":15,"id":4,"tolerance":1e-8,"twin_halfedge":17},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":1,"id":5,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":10,"id":6,"tolerance":1e-8,"twin_halfedge":20},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":13,"id":7,"tolerance":1e-8,"twin_halfedge":22},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":4,"id":8,"tolerance":1e-8,"twin_halfedge":18},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":7,"id":9,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":5,"id":10,"tolerance":1e-8,"twin_halfedge":14},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":6,"id":11,"tolerance":1e-8,"twin_halfedge":21}],"faces":[{"id":0,"key":"bottom","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":2.00000001,"lo":-1.0000000192296269e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":1,"key":"top","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":2.0000000100000004,"lo":-1.0000000222044605e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":2,"key":"left","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":3,"key":"right","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":4,"key":"front","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":2.0000000100000004,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":5,"key":"back","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.000000010000001,"lo":-1e-8}]}}],"halfedges":[{"edge":1,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":2,"twin":11,"wire_ref":null},{"edge":5,"face":0,"from":2,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":3,"twin":23,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Reverse"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":1,"twin":12,"wire_ref":null},{"edge":0,"face":0,"from":1,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Reverse"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":16,"wire_ref":null},{"edge":8,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":5,"twin":18,"wire_ref":null},{"edge":10,"face":1,"from":5,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Forward"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":7,"twin":14,"wire_ref":null},{"edge":11,"face":1,"from":7,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Reverse"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":6,"twin":21,"wire_ref":null},{"edge":9,"face":1,"from":6,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Reverse"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":9,"wire_ref":null},{"edge":2,"face":2,"from":0,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Forward"},"id":8,"loop_ref":2,"next":9,"prev":11,"to":4,"twin":19,"wire_ref":null},{"edge":9,"face":2,"from":4,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Forward"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":6,"twin":7,"wire_ref":null},{"edge":6,"face":2,"from":6,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Reverse"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":2,"twin":20,"wire_ref":null},{"edge":1,"face":2,"from":2,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Reverse"},"id":11,"loop_ref":2,"next":8,"prev":10,"to":0,"twin":0,"wire_ref":null},{"edge":3,"face":3,"from":1,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":3,"next":13,"prev":15,"to":3,"twin":2,"wire_ref":null},{"edge":7,"face":3,"from":3,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Forward"},"id":13,"loop_ref":3,"next":14,"prev":12,"to":7,"twin":22,"wire_ref":null},{"edge":10,"face":3,"from":7,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Reverse"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":5,"twin":5,"wire_ref":null},{"edge":4,"face":3,"from":5,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Reverse"},"id":15,"loop_ref":3,"next":12,"prev":14,"to":1,"twin":17,"wire_ref":null},{"edge":0,"face":4,"from":0,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Forward"},"id":16,"loop_ref":4,"next":17,"prev":19,"to":1,"twin":3,"wire_ref":null},{"edge":4,"face":4,"from":1,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Forward"},"id":17,"loop_ref":4,"next":18,"prev":16,"to":5,"twin":15,"wire_ref":null},{"edge":8,"face":4,"from":5,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Reverse"},"id":18,"loop_ref":4,"next":19,"prev":17,"to":4,"twin":4,"wire_ref":null},{"edge":2,"face":4,"from":4,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Reverse"},"id":19,"loop_ref":4,"next":16,"prev":18,"to":0,"twin":8,"wire_ref":null},{"edge":6,"face":5,"from":2,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":5,"next":21,"prev":23,"to":6,"twin":10,"wire_ref":null},{"edge":11,"face":5,"from":6,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Forward"},"id":21,"loop_ref":5,"next":22,"prev":20,"to":7,"twin":6,"wire_ref":null},{"edge":7,"face":5,"from":7,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Reverse"},"id":22,"loop_ref":5,"next":23,"prev":21,"to":3,"twin":13,"wire_ref":null},{"edge":5,"face":5,"from":3,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":5,"next":20,"prev":22,"to":2,"twin":1,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":12},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":16},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":20}],"shells":[{"faces":[0,1,2,3,4,5],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[1.0,0.5,-0.5],"tolerance":1e-8},{"id":1,"outgoing_halfedge":3,"position":[2.7320508075688776,0.5,0.5000000000000002],"tolerance":1e-8},{"id":2,"outgoing_halfedge":1,"position":[2.0,0.5,-2.2320508075688776],"tolerance":1e-8},{"id":3,"outgoing_halfedge":2,"position":[3.7320508075688776,0.5,-1.2320508075688772],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[1.0,2.5,-0.5],"tolerance":1e-8},{"id":5,"outgoing_halfedge":5,"position":[2.7320508075688776,2.5,0.5000000000000002],"tolerance":1e-8},{"id":6,"outgoing_halfedge":7,"position":[2.0,2.5,-2.2320508075688776],"tolerance":1e-8},{"id":7,"outgoing_halfedge":6,"position":[3.7320508075688776,2.5,-1.2320508075688772],"tolerance":1e-8}],"wires":[]}},"handlers":[],"operation":"union","result":{"error":{"CoverageGap":{"families":["generic analytic solid","intersecting analytic solid"]}}}} \ No newline at end of file +{"a":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,0.0,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,0.0,-3.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[0.0,0.0,-3.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.0,-3.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[0.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,3.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,3.0,-3.0]}],"intersections":[],"pcurves":[{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]}],"surfaces":[{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[1.0,-0.0,0.0],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,3.0,0.0],"x":[1.0,0.0,0.0],"y":[0.0,0.0,-1.0],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,1.0,0.0],"y":[0.0,0.0,-1.0],"z":[-1.0,-0.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[3.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[-0.0,1.0,0.0],"z":[1.0,0.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[1.0,0.0,0.0],"y":[-0.0,1.0,0.0],"z":[-0.0,-0.0,1.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,-3.0],"x":[0.0,1.0,0.0],"y":[1.0,-0.0,0.0],"z":[0.0,0.0,-1.0]},"kind":"Plane"}]},"id":"matrix-box-host","quality":{"kind":"Analytic"},"revision":0,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":3,"id":0,"tolerance":1e-8,"twin_halfedge":16},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":0,"id":1,"tolerance":1e-8,"twin_halfedge":11},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":8,"id":2,"tolerance":1e-8,"twin_halfedge":19},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":2,"id":3,"tolerance":1e-8,"twin_halfedge":12},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":15,"id":4,"tolerance":1e-8,"twin_halfedge":17},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":1,"id":5,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":10,"id":6,"tolerance":1e-8,"twin_halfedge":20},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":13,"id":7,"tolerance":1e-8,"twin_halfedge":22},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":4,"id":8,"tolerance":1e-8,"twin_halfedge":18},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":7,"id":9,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":5,"id":10,"tolerance":1e-8,"twin_halfedge":14},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":6,"id":11,"tolerance":1e-8,"twin_halfedge":21}],"faces":[{"id":0,"key":"bottom","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":1,"key":"top","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":2,"key":"left","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":3,"key":"right","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":4,"key":"front","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":5,"key":"back","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}}],"halfedges":[{"edge":1,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":2,"twin":11,"wire_ref":null},{"edge":5,"face":0,"from":2,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":3,"twin":23,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Reverse"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":1,"twin":12,"wire_ref":null},{"edge":0,"face":0,"from":1,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Reverse"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":16,"wire_ref":null},{"edge":8,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":5,"twin":18,"wire_ref":null},{"edge":10,"face":1,"from":5,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Forward"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":7,"twin":14,"wire_ref":null},{"edge":11,"face":1,"from":7,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Reverse"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":6,"twin":21,"wire_ref":null},{"edge":9,"face":1,"from":6,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Reverse"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":9,"wire_ref":null},{"edge":2,"face":2,"from":0,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Forward"},"id":8,"loop_ref":2,"next":9,"prev":11,"to":4,"twin":19,"wire_ref":null},{"edge":9,"face":2,"from":4,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Forward"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":6,"twin":7,"wire_ref":null},{"edge":6,"face":2,"from":6,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Reverse"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":2,"twin":20,"wire_ref":null},{"edge":1,"face":2,"from":2,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Reverse"},"id":11,"loop_ref":2,"next":8,"prev":10,"to":0,"twin":0,"wire_ref":null},{"edge":3,"face":3,"from":1,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":3,"next":13,"prev":15,"to":3,"twin":2,"wire_ref":null},{"edge":7,"face":3,"from":3,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Forward"},"id":13,"loop_ref":3,"next":14,"prev":12,"to":7,"twin":22,"wire_ref":null},{"edge":10,"face":3,"from":7,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Reverse"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":5,"twin":5,"wire_ref":null},{"edge":4,"face":3,"from":5,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Reverse"},"id":15,"loop_ref":3,"next":12,"prev":14,"to":1,"twin":17,"wire_ref":null},{"edge":0,"face":4,"from":0,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Forward"},"id":16,"loop_ref":4,"next":17,"prev":19,"to":1,"twin":3,"wire_ref":null},{"edge":4,"face":4,"from":1,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Forward"},"id":17,"loop_ref":4,"next":18,"prev":16,"to":5,"twin":15,"wire_ref":null},{"edge":8,"face":4,"from":5,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Reverse"},"id":18,"loop_ref":4,"next":19,"prev":17,"to":4,"twin":4,"wire_ref":null},{"edge":2,"face":4,"from":4,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Reverse"},"id":19,"loop_ref":4,"next":16,"prev":18,"to":0,"twin":8,"wire_ref":null},{"edge":6,"face":5,"from":2,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":5,"next":21,"prev":23,"to":6,"twin":10,"wire_ref":null},{"edge":11,"face":5,"from":6,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Forward"},"id":21,"loop_ref":5,"next":22,"prev":20,"to":7,"twin":6,"wire_ref":null},{"edge":7,"face":5,"from":7,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Reverse"},"id":22,"loop_ref":5,"next":23,"prev":21,"to":3,"twin":13,"wire_ref":null},{"edge":5,"face":5,"from":3,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":5,"next":20,"prev":22,"to":2,"twin":1,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":12},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":16},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":20}],"shells":[{"faces":[0,1,2,3,4,5],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[0.0,0.0,0.0],"tolerance":1e-8},{"id":1,"outgoing_halfedge":3,"position":[3.0,0.0,0.0],"tolerance":1e-8},{"id":2,"outgoing_halfedge":1,"position":[0.0,0.0,-3.0],"tolerance":1e-8},{"id":3,"outgoing_halfedge":2,"position":[3.0,0.0,-3.0],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[0.0,3.0,0.0],"tolerance":1e-8},{"id":5,"outgoing_halfedge":5,"position":[3.0,3.0,0.0],"tolerance":1e-8},{"id":6,"outgoing_halfedge":7,"position":[0.0,3.0,-3.0],"tolerance":1e-8},{"id":7,"outgoing_halfedge":6,"position":[3.0,3.0,-3.0],"tolerance":1e-8}],"wires":[]}},"b":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[1.0,0.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[2.7320508075688776,0.5,0.5000000000000002]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[2.7320508075688776,0.5,0.5000000000000002]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[2.0,0.5,-2.2320508075688776]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[2.0,0.5,-2.2320508075688776]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.7320508075688776,0.5,-1.2320508075688772]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[1.0,2.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[1.0,2.5,-0.5]},{"direction":[0.5000000000000001,0.0,-0.8660254037844387],"kind":"Line","origin":[2.7320508075688776,2.5,0.5000000000000002]},{"direction":[0.8660254037844387,0.0,0.5000000000000001],"kind":"Line","origin":[2.0,2.5,-2.2320508075688776]}],"intersections":[],"pcurves":[{"direction":[1.0,1.5165928555043604e-16],"kind":"Line2","origin":[0.0,0.0]},{"direction":[-1.5165928555043604e-16,1.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,1.5165928555043604e-16],"kind":"Line2","origin":[-1.9229626863835643e-16,2.0000000000000004]},{"direction":[-1.5165928555043604e-16,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,-1.487416814333745e-17],"kind":"Line2","origin":[0.0,0.0]},{"direction":[4.0636983087920364e-17,1.0000000000000002],"kind":"Line2","origin":[2.0000000000000004,8.127396617584078e-17]},{"direction":[1.0,-1.487416814333745e-17],"kind":"Line2","origin":[-2.220446049250313e-16,2.0000000000000004]},{"direction":[4.0636983087920364e-17,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0000000000000004]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0000000000000002,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0000000000000002,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[2.0000000000000004,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.000000000000001]},{"direction":[0.0,1.0000000000000002],"kind":"Line2","origin":[0.0,0.0]}],"surfaces":[{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.4999999999999999,0.0,-0.8660254037844386],"y":[0.8660254037844387,-0.0,0.49999999999999994],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,2.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.0,1.0,0.0],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[-0.8660254037844387,-0.0,-0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[2.7320508075688776,0.5,0.5000000000000002],"x":[0.5000000000000001,0.0,-0.8660254037844386],"y":[-0.0,1.0,0.0],"z":[0.8660254037844387,0.0,0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[-0.0,1.0,0.0],"z":[-0.5000000000000001,-0.0,0.8660254037844387]},"kind":"Plane"},{"frame":{"origin":[2.0,0.5,-2.2320508075688776],"x":[0.0,1.0,0.0],"y":[0.8660254037844387,-0.0,0.5000000000000001],"z":[0.5000000000000001,0.0,-0.8660254037844387]},"kind":"Plane"}]},"id":"matrix-rotated-cutter","quality":{"kind":"Analytic"},"revision":0,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":3,"id":0,"tolerance":1e-8,"twin_halfedge":16},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":0,"id":1,"tolerance":1e-8,"twin_halfedge":11},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":8,"id":2,"tolerance":1e-8,"twin_halfedge":19},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":2,"id":3,"tolerance":1e-8,"twin_halfedge":12},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":15,"id":4,"tolerance":1e-8,"twin_halfedge":17},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":1,"id":5,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":10,"id":6,"tolerance":1e-8,"twin_halfedge":20},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":13,"id":7,"tolerance":1e-8,"twin_halfedge":22},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":4,"id":8,"tolerance":1e-8,"twin_halfedge":18},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":7,"id":9,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":5,"id":10,"tolerance":1e-8,"twin_halfedge":14},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":6,"id":11,"tolerance":1e-8,"twin_halfedge":21}],"faces":[{"id":0,"key":"bottom","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":2.00000001,"lo":-1.0000000192296269e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":1,"key":"top","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":2.0000000100000004,"lo":-1.0000000222044605e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":2,"key":"left","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.0000000100000004,"lo":-1e-8}]}},{"id":3,"key":"right","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":4,"key":"front","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":2.0000000100000004,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":5,"key":"back","provenance":{"reversed":false,"role":"Authored","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.000000010000001,"lo":-1e-8}]}}],"halfedges":[{"edge":1,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":2,"twin":11,"wire_ref":null},{"edge":5,"face":0,"from":2,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":3,"twin":23,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Reverse"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":1,"twin":12,"wire_ref":null},{"edge":0,"face":0,"from":1,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Reverse"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":16,"wire_ref":null},{"edge":8,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":5,"twin":18,"wire_ref":null},{"edge":10,"face":1,"from":5,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Forward"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":7,"twin":14,"wire_ref":null},{"edge":11,"face":1,"from":7,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Reverse"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":6,"twin":21,"wire_ref":null},{"edge":9,"face":1,"from":6,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Reverse"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":9,"wire_ref":null},{"edge":2,"face":2,"from":0,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Forward"},"id":8,"loop_ref":2,"next":9,"prev":11,"to":4,"twin":19,"wire_ref":null},{"edge":9,"face":2,"from":4,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Forward"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":6,"twin":7,"wire_ref":null},{"edge":6,"face":2,"from":6,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Reverse"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":2,"twin":20,"wire_ref":null},{"edge":1,"face":2,"from":2,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Reverse"},"id":11,"loop_ref":2,"next":8,"prev":10,"to":0,"twin":0,"wire_ref":null},{"edge":3,"face":3,"from":1,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":3,"next":13,"prev":15,"to":3,"twin":2,"wire_ref":null},{"edge":7,"face":3,"from":3,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Forward"},"id":13,"loop_ref":3,"next":14,"prev":12,"to":7,"twin":22,"wire_ref":null},{"edge":10,"face":3,"from":7,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Reverse"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":5,"twin":5,"wire_ref":null},{"edge":4,"face":3,"from":5,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Reverse"},"id":15,"loop_ref":3,"next":12,"prev":14,"to":1,"twin":17,"wire_ref":null},{"edge":0,"face":4,"from":0,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Forward"},"id":16,"loop_ref":4,"next":17,"prev":19,"to":1,"twin":3,"wire_ref":null},{"edge":4,"face":4,"from":1,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Forward"},"id":17,"loop_ref":4,"next":18,"prev":16,"to":5,"twin":15,"wire_ref":null},{"edge":8,"face":4,"from":5,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Reverse"},"id":18,"loop_ref":4,"next":19,"prev":17,"to":4,"twin":4,"wire_ref":null},{"edge":2,"face":4,"from":4,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Reverse"},"id":19,"loop_ref":4,"next":16,"prev":18,"to":0,"twin":8,"wire_ref":null},{"edge":6,"face":5,"from":2,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":5,"next":21,"prev":23,"to":6,"twin":10,"wire_ref":null},{"edge":11,"face":5,"from":6,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Forward"},"id":21,"loop_ref":5,"next":22,"prev":20,"to":7,"twin":6,"wire_ref":null},{"edge":7,"face":5,"from":7,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Reverse"},"id":22,"loop_ref":5,"next":23,"prev":21,"to":3,"twin":13,"wire_ref":null},{"edge":5,"face":5,"from":3,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":5,"next":20,"prev":22,"to":2,"twin":1,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":12},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":16},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":20}],"shells":[{"faces":[0,1,2,3,4,5],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[1.0,0.5,-0.5],"tolerance":1e-8},{"id":1,"outgoing_halfedge":3,"position":[2.7320508075688776,0.5,0.5000000000000002],"tolerance":1e-8},{"id":2,"outgoing_halfedge":1,"position":[2.0,0.5,-2.2320508075688776],"tolerance":1e-8},{"id":3,"outgoing_halfedge":2,"position":[3.7320508075688776,0.5,-1.2320508075688772],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[1.0,2.5,-0.5],"tolerance":1e-8},{"id":5,"outgoing_halfedge":5,"position":[2.7320508075688776,2.5,0.5000000000000002],"tolerance":1e-8},{"id":6,"outgoing_halfedge":7,"position":[2.0,2.5,-2.2320508075688776],"tolerance":1e-8},{"id":7,"outgoing_halfedge":6,"position":[3.7320508075688776,2.5,-1.2320508075688772],"tolerance":1e-8}],"wires":[]}},"handlers":["boolean_planar_polyhedra"],"operation":"union","result":{"brep":{"accuracy":{"exchange":1e-6,"geometric":1e-8,"intersection":1e-9,"tessellation":0.01},"geometry":{"curves":[{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,0.0,-3.0]},{"direction":[0.0,0.0,1.0],"kind":"Line","origin":[3.0,0.0,-3.0]},{"direction":[-1.0,0.0,0.0],"kind":"Line","origin":[3.0,0.0,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[0.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,3.0,0.0]},{"direction":[-1.0,0.0,0.0],"kind":"Line","origin":[3.0,3.0,-3.0]},{"direction":[0.0,0.0,1.0],"kind":"Line","origin":[0.0,3.0,-3.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[0.0,0.0,0.0]},{"direction":[0.0,-1.0,0.0],"kind":"Line","origin":[0.0,3.0,-3.0]},{"direction":[0.0,0.0,1.0],"kind":"Line","origin":[3.0,0.5,-1.654700538379252]},{"direction":[0.0,-1.0,0.0],"kind":"Line","origin":[3.0,0.5,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.0,0.0,-3.0]},{"direction":[0.0,-1.0,0.0],"kind":"Line","origin":[3.0,3.0,0.0]},{"direction":[0.0,0.0,-1.0],"kind":"Line","origin":[3.0,2.5,0.0]},{"direction":[0.0,-1.0,0.0],"kind":"Line","origin":[3.0,2.5,-1.654700538379252]},{"direction":[-1.0,0.0,0.0],"kind":"Line","origin":[3.0,0.5,0.0]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[1.8660254037844386,0.5,0.0]},{"direction":[1.0,0.0,0.0],"kind":"Line","origin":[1.8660254037844386,2.5,0.0]},{"direction":[0.8660254037844387,0.0,0.4999999999999999],"kind":"Line","origin":[3.0,0.5,-1.6547005383792512]},{"direction":[-0.5000000000000001,0.0,0.8660254037844387],"kind":"Line","origin":[3.7320508075688776,0.5,-1.232050807568877]},{"direction":[-0.8660254037844387,0.0,-0.5],"kind":"Line","origin":[2.7320508075688776,0.5,0.5000000000000002]},{"direction":[0.8660254037844387,0.0,0.5],"kind":"Line","origin":[1.8660254037844386,2.5,-5.551115123125783e-17]},{"direction":[0.4999999999999999,0.0,-0.8660254037844388],"kind":"Line","origin":[2.7320508075688776,2.5,0.5000000000000002]},{"direction":[-0.8660254037844386,0.0,-0.5000000000000001],"kind":"Line","origin":[3.7320508075688776,2.5,-1.2320508075688779]},{"direction":[0.0,1.0,0.0],"kind":"Line","origin":[3.7320508075688776,0.5,-1.232050807568877]},{"direction":[0.0,-1.0,0.0],"kind":"Line","origin":[2.7320508075688776,2.5,0.5000000000000002]}],"intersections":[],"pcurves":[{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[3.0,3.0]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[3.0,3.0]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[3.0,3.0]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[3.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[1.654700538379252,0.5]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[0.0,0.5]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.5]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[1.654700538379252,2.5]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[3.0,0.5]},{"direction":[0.0,1.0],"kind":"Line2","origin":[1.8660254037844386,0.5]},{"direction":[1.0,0.0],"kind":"Line2","origin":[1.8660254037844386,2.5]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[3.0,3.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[3.0,0.5]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[3.0,0.0]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[3.0,3.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,3.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,0.0]},{"direction":[-0.8660254037844386,0.49999999999999994],"kind":"Line2","origin":[2.0,1.1547005383792515]},{"direction":[7.038531937459528e-17,1.0],"kind":"Line2","origin":[1.9999999999999996,1.154700538379252]},{"direction":[-1.0,-1.5165928555043604e-16],"kind":"Line2","origin":[1.9999999999999996,2.000000000000001]},{"direction":[4.0636983087920376e-17,-1.0],"kind":"Line2","origin":[-1.9229626863835643e-16,2.0000000000000004]},{"direction":[-0.4999999999999999,-0.8660254037844387],"kind":"Line2","origin":[0.5669872981077805,1.9820508075688774]},{"direction":[-0.5,0.8660254037844387],"kind":"Line2","origin":[1.9820508075688772,0.5669872981077808]},{"direction":[0.8660254037844386,0.5000000000000001],"kind":"Line2","origin":[0.9999999999999999,4.0636983087920364e-17]},{"direction":[1.0,9.61481343191782e-17],"kind":"Line2","origin":[0.9999999999999999,9.614813431917819e-17]},{"direction":[-2.0717043678169384e-16,1.0000000000000002],"kind":"Line2","origin":[2.0000000000000004,8.127396617584078e-17]},{"direction":[-1.0,7.038531937459529e-17],"kind":"Line2","origin":[1.9999999999999998,2.000000000000001]},{"direction":[-1.0000000000000002,0.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[2.0,0.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[0.0,2.0]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.9999999999999999,0.0]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[2.0000000000000004,0.0]},{"direction":[0.0,-1.0],"kind":"Line2","origin":[2.0000000000000004,2.0]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.9999999999999999,2.0]},{"direction":[-1.0,0.0],"kind":"Line2","origin":[2.0,1.1547005383792517]},{"direction":[0.0,-1.0000000000000002],"kind":"Line2","origin":[2.0,2.0000000000000004]},{"direction":[1.0,0.0],"kind":"Line2","origin":[0.0,2.000000000000001]},{"direction":[0.0,1.0],"kind":"Line2","origin":[0.0,1.154700538379252]}],"surfaces":[{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[1.0,-0.0,0.0],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,3.0,0.0],"x":[1.0,0.0,0.0],"y":[0.0,0.0,-1.0],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[0.0,1.0,0.0],"y":[0.0,0.0,-1.0],"z":[-1.0,-0.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[3.0,0.0,0.0],"x":[0.0,0.0,-1.0],"y":[-0.0,1.0,0.0],"z":[1.0,0.0,0.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,0.0],"x":[1.0,0.0,0.0],"y":[-0.0,1.0,0.0],"z":[-0.0,-0.0,1.0]},"kind":"Plane"},{"frame":{"origin":[0.0,0.0,-3.0],"x":[0.0,1.0,0.0],"y":[1.0,-0.0,0.0],"z":[0.0,0.0,-1.0]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.4999999999999999,0.0,-0.8660254037844386],"y":[0.8660254037844387,-0.0,0.49999999999999994],"z":[-0.0,-1.0,-0.0]},"kind":"Plane"},{"frame":{"origin":[1.0,2.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[0.5000000000000001,0.0,-0.8660254037844387],"z":[0.0,1.0,0.0]},"kind":"Plane"},{"frame":{"origin":[2.7320508075688776,0.5,0.5000000000000002],"x":[0.5000000000000001,0.0,-0.8660254037844386],"y":[-0.0,1.0,0.0],"z":[0.8660254037844387,0.0,0.5000000000000001]},"kind":"Plane"},{"frame":{"origin":[1.0,0.5,-0.5],"x":[0.8660254037844386,0.0,0.5],"y":[-0.0,1.0,0.0],"z":[-0.5000000000000001,-0.0,0.8660254037844387]},"kind":"Plane"},{"frame":{"origin":[2.0,0.5,-2.2320508075688776],"x":[0.0,1.0,0.0],"y":[0.8660254037844387,-0.0,0.5000000000000001],"z":[0.5000000000000001,0.0,-0.8660254037844387]},"kind":"Plane"}]},"id":"matrix-rotated-box-union","quality":{"kind":"Analytic"},"revision":1,"schema_version":2,"solids":[{"cavity_shells":[],"outer_shell":0}],"topology":{"edges":[{"chart_seam":false,"geometry":{"curve":0,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":0,"id":0,"tolerance":1e-8,"twin_halfedge":11},{"chart_seam":false,"geometry":{"curve":1,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":1,"id":1,"tolerance":1e-8,"twin_halfedge":31},{"chart_seam":false,"geometry":{"curve":2,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":2,"id":2,"tolerance":1e-8,"twin_halfedge":14},{"chart_seam":false,"geometry":{"curve":3,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":3,"id":3,"tolerance":1e-8,"twin_halfedge":26},{"chart_seam":false,"geometry":{"curve":4,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":4,"id":4,"tolerance":1e-8,"twin_halfedge":24},{"chart_seam":false,"geometry":{"curve":5,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":5,"id":5,"tolerance":1e-8,"twin_halfedge":16},{"chart_seam":false,"geometry":{"curve":6,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":6,"id":6,"tolerance":1e-8,"twin_halfedge":29},{"chart_seam":false,"geometry":{"curve":7,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":7,"id":7,"tolerance":1e-8,"twin_halfedge":9},{"chart_seam":false,"geometry":{"curve":8,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":8,"id":8,"tolerance":1e-8,"twin_halfedge":25},{"chart_seam":false,"geometry":{"curve":9,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":10,"id":9,"tolerance":1e-8,"twin_halfedge":28},{"chart_seam":false,"geometry":{"curve":10,"kind":"Curve","range":{"hi":1.654700538379252,"lo":0.0}},"halfedge":12,"id":10,"tolerance":1e-8,"twin_halfedge":32},{"chart_seam":false,"geometry":{"curve":11,"kind":"Curve","range":{"hi":0.5,"lo":0.0}},"halfedge":13,"id":11,"tolerance":1e-8,"twin_halfedge":27},{"chart_seam":false,"geometry":{"curve":12,"kind":"Curve","range":{"hi":3.0,"lo":0.0}},"halfedge":15,"id":12,"tolerance":1e-8,"twin_halfedge":30},{"chart_seam":false,"geometry":{"curve":13,"kind":"Curve","range":{"hi":0.5,"lo":0.0}},"halfedge":17,"id":13,"tolerance":1e-8,"twin_halfedge":23},{"chart_seam":false,"geometry":{"curve":14,"kind":"Curve","range":{"hi":1.654700538379252,"lo":0.0}},"halfedge":18,"id":14,"tolerance":1e-8,"twin_halfedge":37},{"chart_seam":false,"geometry":{"curve":15,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":19,"id":15,"tolerance":1e-8,"twin_halfedge":50},{"chart_seam":false,"geometry":{"curve":16,"kind":"Curve","range":{"hi":1.1339745962155614,"lo":0.0}},"halfedge":20,"id":16,"tolerance":1e-8,"twin_halfedge":36},{"chart_seam":false,"geometry":{"curve":17,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":21,"id":17,"tolerance":1e-8,"twin_halfedge":46},{"chart_seam":false,"geometry":{"curve":18,"kind":"Curve","range":{"hi":1.1339745962155614,"lo":0.0}},"halfedge":22,"id":18,"tolerance":1e-8,"twin_halfedge":38},{"chart_seam":false,"geometry":{"curve":19,"kind":"Curve","range":{"hi":0.8452994616207488,"lo":0.0}},"halfedge":33,"id":19,"tolerance":1e-8,"twin_halfedge":53},{"chart_seam":false,"geometry":{"curve":20,"kind":"Curve","range":{"hi":1.9999999999999998,"lo":0.0}},"halfedge":34,"id":20,"tolerance":1e-8,"twin_halfedge":42},{"chart_seam":false,"geometry":{"curve":21,"kind":"Curve","range":{"hi":1.0000000000000004,"lo":0.0}},"halfedge":35,"id":21,"tolerance":1e-8,"twin_halfedge":47},{"chart_seam":false,"geometry":{"curve":22,"kind":"Curve","range":{"hi":1.0000000000000004,"lo":0.0}},"halfedge":39,"id":22,"tolerance":1e-8,"twin_halfedge":49},{"chart_seam":false,"geometry":{"curve":23,"kind":"Curve","range":{"hi":2.0000000000000004,"lo":0.0}},"halfedge":40,"id":23,"tolerance":1e-8,"twin_halfedge":44},{"chart_seam":false,"geometry":{"curve":24,"kind":"Curve","range":{"hi":0.8452994616207484,"lo":0.0}},"halfedge":41,"id":24,"tolerance":1e-8,"twin_halfedge":51},{"chart_seam":false,"geometry":{"curve":25,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":43,"id":25,"tolerance":1e-8,"twin_halfedge":52},{"chart_seam":false,"geometry":{"curve":26,"kind":"Curve","range":{"hi":2.0,"lo":0.0}},"halfedge":45,"id":26,"tolerance":1e-8,"twin_halfedge":48}],"faces":[{"id":0,"key":"planar-host-0-0","provenance":{"reversed":false,"role":"Preserved","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":0,"trim":{"chart":0,"holes":[],"outer":0,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":1,"key":"planar-host-1-1","provenance":{"reversed":false,"role":"Preserved","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":1,"trim":{"chart":0,"holes":[],"outer":1,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":2,"key":"planar-host-2-2","provenance":{"reversed":false,"role":"Preserved","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":2,"key":"left"}]},"sense":"Forward","shell_ref":0,"surface":2,"trim":{"chart":0,"holes":[],"outer":2,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":3,"key":"planar-host-3-3","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":3,"trim":{"chart":0,"holes":[],"outer":3,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":4,"key":"planar-host-4-4","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":4,"trim":{"chart":0,"holes":[],"outer":4,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":5,"key":"planar-host-5-5","provenance":{"reversed":false,"role":"Preserved","sources":[{"body":"matrix-box-host","entity":"matrix-box-host","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":5,"trim":{"chart":0,"holes":[],"outer":5,"uv_bounds":[{"hi":3.00000001,"lo":-1e-8},{"hi":3.00000001,"lo":-1e-8}]}},{"id":6,"key":"planar-cut-0-6","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":0,"key":"bottom"}]},"sense":"Forward","shell_ref":0,"surface":6,"trim":{"chart":0,"holes":[],"outer":6,"uv_bounds":[{"hi":2.0000000099999995,"lo":-1.0000000192296269e-8},{"hi":2.000000010000001,"lo":0.99999999}]}},{"id":7,"key":"planar-cut-1-7","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":1,"key":"top"}]},"sense":"Forward","shell_ref":0,"surface":7,"trim":{"chart":0,"holes":[],"outer":7,"uv_bounds":[{"hi":2.0000000100000004,"lo":0.9999999899999998},{"hi":2.0000000100000013,"lo":-9.999999918726034e-9}]}},{"id":8,"key":"planar-cut-3-8","provenance":{"reversed":false,"role":"Preserved","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":3,"key":"right"}]},"sense":"Forward","shell_ref":0,"surface":8,"trim":{"chart":0,"holes":[],"outer":8,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.00000001,"lo":-1e-8}]}},{"id":9,"key":"planar-cut-4-9","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":4,"key":"front"}]},"sense":"Forward","shell_ref":0,"surface":9,"trim":{"chart":0,"holes":[],"outer":9,"uv_bounds":[{"hi":2.0000000100000004,"lo":0.9999999899999996},{"hi":2.00000001,"lo":-1e-8}]}},{"id":10,"key":"planar-cut-5-10","provenance":{"reversed":false,"role":"Split","sources":[{"body":"matrix-rotated-cutter","entity":"matrix-rotated-cutter","face":5,"key":"back"}]},"sense":"Forward","shell_ref":0,"surface":10,"trim":{"chart":0,"holes":[],"outer":10,"uv_bounds":[{"hi":2.00000001,"lo":-1e-8},{"hi":2.0000000100000013,"lo":1.1547005283792517}]}}],"halfedges":[{"edge":0,"face":0,"from":0,"geometry_use":{"pcurve":0,"periodic_lift":[0,0],"sense":"Forward"},"id":0,"loop_ref":0,"next":1,"prev":3,"to":1,"twin":11,"wire_ref":null},{"edge":1,"face":0,"from":1,"geometry_use":{"pcurve":1,"periodic_lift":[0,0],"sense":"Forward"},"id":1,"loop_ref":0,"next":2,"prev":0,"to":2,"twin":31,"wire_ref":null},{"edge":2,"face":0,"from":2,"geometry_use":{"pcurve":2,"periodic_lift":[0,0],"sense":"Forward"},"id":2,"loop_ref":0,"next":3,"prev":1,"to":3,"twin":14,"wire_ref":null},{"edge":3,"face":0,"from":3,"geometry_use":{"pcurve":3,"periodic_lift":[0,0],"sense":"Forward"},"id":3,"loop_ref":0,"next":0,"prev":2,"to":0,"twin":26,"wire_ref":null},{"edge":4,"face":1,"from":4,"geometry_use":{"pcurve":4,"periodic_lift":[0,0],"sense":"Forward"},"id":4,"loop_ref":1,"next":5,"prev":7,"to":5,"twin":24,"wire_ref":null},{"edge":5,"face":1,"from":5,"geometry_use":{"pcurve":5,"periodic_lift":[0,0],"sense":"Forward"},"id":5,"loop_ref":1,"next":6,"prev":4,"to":6,"twin":16,"wire_ref":null},{"edge":6,"face":1,"from":6,"geometry_use":{"pcurve":6,"periodic_lift":[0,0],"sense":"Forward"},"id":6,"loop_ref":1,"next":7,"prev":5,"to":7,"twin":29,"wire_ref":null},{"edge":7,"face":1,"from":7,"geometry_use":{"pcurve":7,"periodic_lift":[0,0],"sense":"Forward"},"id":7,"loop_ref":1,"next":4,"prev":6,"to":4,"twin":9,"wire_ref":null},{"edge":8,"face":2,"from":0,"geometry_use":{"pcurve":8,"periodic_lift":[0,0],"sense":"Forward"},"id":8,"loop_ref":2,"next":9,"prev":11,"to":4,"twin":25,"wire_ref":null},{"edge":7,"face":2,"from":4,"geometry_use":{"pcurve":9,"periodic_lift":[0,0],"sense":"Reverse"},"id":9,"loop_ref":2,"next":10,"prev":8,"to":7,"twin":7,"wire_ref":null},{"edge":9,"face":2,"from":7,"geometry_use":{"pcurve":10,"periodic_lift":[0,0],"sense":"Forward"},"id":10,"loop_ref":2,"next":11,"prev":9,"to":1,"twin":28,"wire_ref":null},{"edge":0,"face":2,"from":1,"geometry_use":{"pcurve":11,"periodic_lift":[0,0],"sense":"Reverse"},"id":11,"loop_ref":2,"next":8,"prev":10,"to":0,"twin":0,"wire_ref":null},{"edge":10,"face":3,"from":8,"geometry_use":{"pcurve":12,"periodic_lift":[0,0],"sense":"Forward"},"id":12,"loop_ref":3,"next":13,"prev":19,"to":9,"twin":32,"wire_ref":null},{"edge":11,"face":3,"from":9,"geometry_use":{"pcurve":13,"periodic_lift":[0,0],"sense":"Forward"},"id":13,"loop_ref":3,"next":14,"prev":12,"to":3,"twin":27,"wire_ref":null},{"edge":2,"face":3,"from":3,"geometry_use":{"pcurve":14,"periodic_lift":[0,0],"sense":"Reverse"},"id":14,"loop_ref":3,"next":15,"prev":13,"to":2,"twin":2,"wire_ref":null},{"edge":12,"face":3,"from":2,"geometry_use":{"pcurve":15,"periodic_lift":[0,0],"sense":"Forward"},"id":15,"loop_ref":3,"next":16,"prev":14,"to":6,"twin":30,"wire_ref":null},{"edge":5,"face":3,"from":6,"geometry_use":{"pcurve":16,"periodic_lift":[0,0],"sense":"Reverse"},"id":16,"loop_ref":3,"next":17,"prev":15,"to":5,"twin":5,"wire_ref":null},{"edge":13,"face":3,"from":5,"geometry_use":{"pcurve":17,"periodic_lift":[0,0],"sense":"Forward"},"id":17,"loop_ref":3,"next":18,"prev":16,"to":10,"twin":23,"wire_ref":null},{"edge":14,"face":3,"from":10,"geometry_use":{"pcurve":18,"periodic_lift":[0,0],"sense":"Forward"},"id":18,"loop_ref":3,"next":19,"prev":17,"to":11,"twin":37,"wire_ref":null},{"edge":15,"face":3,"from":11,"geometry_use":{"pcurve":19,"periodic_lift":[0,0],"sense":"Forward"},"id":19,"loop_ref":3,"next":12,"prev":18,"to":8,"twin":50,"wire_ref":null},{"edge":16,"face":4,"from":9,"geometry_use":{"pcurve":20,"periodic_lift":[0,0],"sense":"Forward"},"id":20,"loop_ref":4,"next":21,"prev":27,"to":12,"twin":36,"wire_ref":null},{"edge":17,"face":4,"from":12,"geometry_use":{"pcurve":21,"periodic_lift":[0,0],"sense":"Forward"},"id":21,"loop_ref":4,"next":22,"prev":20,"to":13,"twin":46,"wire_ref":null},{"edge":18,"face":4,"from":13,"geometry_use":{"pcurve":22,"periodic_lift":[0,0],"sense":"Forward"},"id":22,"loop_ref":4,"next":23,"prev":21,"to":10,"twin":38,"wire_ref":null},{"edge":13,"face":4,"from":10,"geometry_use":{"pcurve":23,"periodic_lift":[0,0],"sense":"Reverse"},"id":23,"loop_ref":4,"next":24,"prev":22,"to":5,"twin":17,"wire_ref":null},{"edge":4,"face":4,"from":5,"geometry_use":{"pcurve":24,"periodic_lift":[0,0],"sense":"Reverse"},"id":24,"loop_ref":4,"next":25,"prev":23,"to":4,"twin":4,"wire_ref":null},{"edge":8,"face":4,"from":4,"geometry_use":{"pcurve":25,"periodic_lift":[0,0],"sense":"Reverse"},"id":25,"loop_ref":4,"next":26,"prev":24,"to":0,"twin":8,"wire_ref":null},{"edge":3,"face":4,"from":0,"geometry_use":{"pcurve":26,"periodic_lift":[0,0],"sense":"Reverse"},"id":26,"loop_ref":4,"next":27,"prev":25,"to":3,"twin":3,"wire_ref":null},{"edge":11,"face":4,"from":3,"geometry_use":{"pcurve":27,"periodic_lift":[0,0],"sense":"Reverse"},"id":27,"loop_ref":4,"next":20,"prev":26,"to":9,"twin":13,"wire_ref":null},{"edge":9,"face":5,"from":1,"geometry_use":{"pcurve":28,"periodic_lift":[0,0],"sense":"Reverse"},"id":28,"loop_ref":5,"next":29,"prev":31,"to":7,"twin":10,"wire_ref":null},{"edge":6,"face":5,"from":7,"geometry_use":{"pcurve":29,"periodic_lift":[0,0],"sense":"Reverse"},"id":29,"loop_ref":5,"next":30,"prev":28,"to":6,"twin":6,"wire_ref":null},{"edge":12,"face":5,"from":6,"geometry_use":{"pcurve":30,"periodic_lift":[0,0],"sense":"Reverse"},"id":30,"loop_ref":5,"next":31,"prev":29,"to":2,"twin":15,"wire_ref":null},{"edge":1,"face":5,"from":2,"geometry_use":{"pcurve":31,"periodic_lift":[0,0],"sense":"Reverse"},"id":31,"loop_ref":5,"next":28,"prev":30,"to":1,"twin":1,"wire_ref":null},{"edge":10,"face":6,"from":9,"geometry_use":{"pcurve":32,"periodic_lift":[0,0],"sense":"Reverse"},"id":32,"loop_ref":6,"next":33,"prev":36,"to":8,"twin":12,"wire_ref":null},{"edge":19,"face":6,"from":8,"geometry_use":{"pcurve":33,"periodic_lift":[0,0],"sense":"Forward"},"id":33,"loop_ref":6,"next":34,"prev":32,"to":14,"twin":53,"wire_ref":null},{"edge":20,"face":6,"from":14,"geometry_use":{"pcurve":34,"periodic_lift":[0,0],"sense":"Forward"},"id":34,"loop_ref":6,"next":35,"prev":33,"to":15,"twin":42,"wire_ref":null},{"edge":21,"face":6,"from":15,"geometry_use":{"pcurve":35,"periodic_lift":[0,0],"sense":"Forward"},"id":35,"loop_ref":6,"next":36,"prev":34,"to":12,"twin":47,"wire_ref":null},{"edge":16,"face":6,"from":12,"geometry_use":{"pcurve":36,"periodic_lift":[0,0],"sense":"Reverse"},"id":36,"loop_ref":6,"next":32,"prev":35,"to":9,"twin":20,"wire_ref":null},{"edge":14,"face":7,"from":11,"geometry_use":{"pcurve":37,"periodic_lift":[0,0],"sense":"Reverse"},"id":37,"loop_ref":7,"next":38,"prev":41,"to":10,"twin":18,"wire_ref":null},{"edge":18,"face":7,"from":10,"geometry_use":{"pcurve":38,"periodic_lift":[0,0],"sense":"Reverse"},"id":38,"loop_ref":7,"next":39,"prev":37,"to":13,"twin":22,"wire_ref":null},{"edge":22,"face":7,"from":13,"geometry_use":{"pcurve":39,"periodic_lift":[0,0],"sense":"Forward"},"id":39,"loop_ref":7,"next":40,"prev":38,"to":16,"twin":49,"wire_ref":null},{"edge":23,"face":7,"from":16,"geometry_use":{"pcurve":40,"periodic_lift":[0,0],"sense":"Forward"},"id":40,"loop_ref":7,"next":41,"prev":39,"to":17,"twin":44,"wire_ref":null},{"edge":24,"face":7,"from":17,"geometry_use":{"pcurve":41,"periodic_lift":[0,0],"sense":"Forward"},"id":41,"loop_ref":7,"next":37,"prev":40,"to":11,"twin":51,"wire_ref":null},{"edge":20,"face":8,"from":15,"geometry_use":{"pcurve":42,"periodic_lift":[0,0],"sense":"Reverse"},"id":42,"loop_ref":8,"next":43,"prev":45,"to":14,"twin":34,"wire_ref":null},{"edge":25,"face":8,"from":14,"geometry_use":{"pcurve":43,"periodic_lift":[0,0],"sense":"Forward"},"id":43,"loop_ref":8,"next":44,"prev":42,"to":17,"twin":52,"wire_ref":null},{"edge":23,"face":8,"from":17,"geometry_use":{"pcurve":44,"periodic_lift":[0,0],"sense":"Reverse"},"id":44,"loop_ref":8,"next":45,"prev":43,"to":16,"twin":40,"wire_ref":null},{"edge":26,"face":8,"from":16,"geometry_use":{"pcurve":45,"periodic_lift":[0,0],"sense":"Forward"},"id":45,"loop_ref":8,"next":42,"prev":44,"to":15,"twin":48,"wire_ref":null},{"edge":17,"face":9,"from":13,"geometry_use":{"pcurve":46,"periodic_lift":[0,0],"sense":"Reverse"},"id":46,"loop_ref":9,"next":47,"prev":49,"to":12,"twin":21,"wire_ref":null},{"edge":21,"face":9,"from":12,"geometry_use":{"pcurve":47,"periodic_lift":[0,0],"sense":"Reverse"},"id":47,"loop_ref":9,"next":48,"prev":46,"to":15,"twin":35,"wire_ref":null},{"edge":26,"face":9,"from":15,"geometry_use":{"pcurve":48,"periodic_lift":[0,0],"sense":"Reverse"},"id":48,"loop_ref":9,"next":49,"prev":47,"to":16,"twin":45,"wire_ref":null},{"edge":22,"face":9,"from":16,"geometry_use":{"pcurve":49,"periodic_lift":[0,0],"sense":"Reverse"},"id":49,"loop_ref":9,"next":46,"prev":48,"to":13,"twin":39,"wire_ref":null},{"edge":15,"face":10,"from":8,"geometry_use":{"pcurve":50,"periodic_lift":[0,0],"sense":"Reverse"},"id":50,"loop_ref":10,"next":51,"prev":53,"to":11,"twin":19,"wire_ref":null},{"edge":24,"face":10,"from":11,"geometry_use":{"pcurve":51,"periodic_lift":[0,0],"sense":"Reverse"},"id":51,"loop_ref":10,"next":52,"prev":50,"to":17,"twin":41,"wire_ref":null},{"edge":25,"face":10,"from":17,"geometry_use":{"pcurve":52,"periodic_lift":[0,0],"sense":"Reverse"},"id":52,"loop_ref":10,"next":53,"prev":51,"to":14,"twin":43,"wire_ref":null},{"edge":19,"face":10,"from":14,"geometry_use":{"pcurve":53,"periodic_lift":[0,0],"sense":"Reverse"},"id":53,"loop_ref":10,"next":50,"prev":52,"to":8,"twin":33,"wire_ref":null}],"loops":[{"face_ref":0,"id":0,"is_hole":false,"start_halfedge":0},{"face_ref":1,"id":1,"is_hole":false,"start_halfedge":4},{"face_ref":2,"id":2,"is_hole":false,"start_halfedge":8},{"face_ref":3,"id":3,"is_hole":false,"start_halfedge":12},{"face_ref":4,"id":4,"is_hole":false,"start_halfedge":20},{"face_ref":5,"id":5,"is_hole":false,"start_halfedge":28},{"face_ref":6,"id":6,"is_hole":false,"start_halfedge":32},{"face_ref":7,"id":7,"is_hole":false,"start_halfedge":37},{"face_ref":8,"id":8,"is_hole":false,"start_halfedge":42},{"face_ref":9,"id":9,"is_hole":false,"start_halfedge":46},{"face_ref":10,"id":10,"is_hole":false,"start_halfedge":50}],"shells":[{"faces":[0,2,5,3,4,1,6,7,10,9,8],"id":0,"is_closed":true}],"vertices":[{"id":0,"outgoing_halfedge":0,"position":[0.0,0.0,0.0],"tolerance":1e-8},{"id":1,"outgoing_halfedge":1,"position":[0.0,0.0,-3.0],"tolerance":1e-8},{"id":2,"outgoing_halfedge":2,"position":[3.0,0.0,-3.0],"tolerance":1e-8},{"id":3,"outgoing_halfedge":3,"position":[3.0,0.0,0.0],"tolerance":1e-8},{"id":4,"outgoing_halfedge":4,"position":[0.0,3.0,0.0],"tolerance":1e-8},{"id":5,"outgoing_halfedge":5,"position":[3.0,3.0,0.0],"tolerance":1e-8},{"id":6,"outgoing_halfedge":6,"position":[3.0,3.0,-3.0],"tolerance":1e-8},{"id":7,"outgoing_halfedge":7,"position":[0.0,3.0,-3.0],"tolerance":1e-8},{"id":8,"outgoing_halfedge":12,"position":[3.0,0.5,-1.654700538379252],"tolerance":1e-8},{"id":9,"outgoing_halfedge":13,"position":[3.0,0.5,0.0],"tolerance":1e-8},{"id":10,"outgoing_halfedge":18,"position":[3.0,2.5,0.0],"tolerance":1e-8},{"id":11,"outgoing_halfedge":19,"position":[3.0,2.5,-1.654700538379252],"tolerance":1e-8},{"id":12,"outgoing_halfedge":21,"position":[1.8660254037844386,0.5,0.0],"tolerance":1e-8},{"id":13,"outgoing_halfedge":22,"position":[1.8660254037844386,2.5,0.0],"tolerance":1e-8},{"id":14,"outgoing_halfedge":34,"position":[3.7320508075688776,0.5,-1.232050807568877],"tolerance":1e-8},{"id":15,"outgoing_halfedge":35,"position":[2.7320508075688776,0.5,0.5000000000000002],"tolerance":1e-8},{"id":16,"outgoing_halfedge":40,"position":[2.7320508075688776,2.5,0.5000000000000002],"tolerance":1e-8},{"id":17,"outgoing_halfedge":41,"position":[3.7320508075688776,2.5,-1.2320508075688779],"tolerance":1e-8}],"wires":[]}}}} \ No newline at end of file diff --git a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json index 1f59b51f..fc949e06 100644 --- a/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json +++ b/main/opengeometry/tests/fixtures/golden/aarch64-apple-darwin.json @@ -222,8 +222,8 @@ "booleans.matrix.cylinder-offset": "6857b607947dc823d23280ac2a7ce9fb014e939ce37785a40f05ff51a26ca243", "booleans.matrix.cylinder-sliced": "87d109122c1c383c90421c8fbcce2d31cbf3f2f25654460cbdea041fafc3d175", "booleans.matrix.planar-extrusions": "5e7d3466e930688f454d33f75575856633104232e4d768a91cd46df3c0838332", - "booleans.matrix.rotated-box-intersection": "8ff89e8fb45061169615439444fd8bcba05fcce79b9321ec752ddd1b8deee21f", - "booleans.matrix.rotated-box-union": "4515ec1c8edc2d0bd509d91839f33f955015ef9107e2718682fe2c57000e3489", + "booleans.matrix.rotated-box-intersection": "f8f7865294a54262b5185690a1402cf71cd9d11f9edc830dccdf1726b55faca3", + "booleans.matrix.rotated-box-union": "5c67020529c1e871b6c5a69bd21c3e290b6eb968ac0aa561efb9d8c272812750", "booleans.matrix.sphere-contained": "da881786860c019848c83cfcc54ac2171f3c8cbe4b3e063ba4e1f565e9a54776", "booleans.matrix.torus-contained": "1ab0f5f8c7d85f09ae0f41699ab6a8d2f577618d0e2b6a25549f335c0ce6976d", "booleans.matrix.wall-rectilinear": "ecf494cd48f3687d5ebdf8405bca1770e31694bf6adfb67d83ff694c6496272a", @@ -549,8 +549,8 @@ "graph.scenario.reparenting": "a98d8485874040b931f50155a2c61ab0f08b11a17b5cdc017d233d099c426921", "graph.scenario.shared-rebuild": "b780cbc13d3a6cd62a50c332b04368fd8d5e74d577bc9f24845740474fa52273", "graph.scenario.subtree-dispose": "3393656291d922a7f7206b51849264b6e68092170b8a6f67849135ea6179f00b", - "handlers-covered": "786eb944d6f4ae8bb198fa31a8ed05290b2d8940aff4acaaeed14f2408010868", - "handlers-expected": "9c692231727b9c7acfc2c0303b66ccb7ec4a3b3c3d014ad848c8fc2a64ee83c5", + "handlers-covered": "3b879c7ba799f678660ebfab0d91220039d0f534a9afc0efe6ad0aa012e529d2", + "handlers-expected": "d2a2a8fdbac778a10faba5007ca1da75d1f63e07231c5ba4f5f44620cb57804e", "handlers-uncovered": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "level.step.metre.y": "3bfe75db34f9ee266e502be4b3b641abd5d26b3a51c1bf8657f41252ee614f49", "level.step.metre.z": "6108f7b562c88424cd12180dcb82584d60810117a73b36b6ac410bfcdbabb0c2", From 8a394fbe1498434b1f37ca8cba5c2fc793805c5a Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Thu, 12 Nov 2026 02:13:02 +0100 Subject: [PATCH 03/10] Build planar booleans of bodies that touch Coplanar faces of the two bodies that face the same way become one coincident face naming the input faces of both bodies; faces of one body in one plane stay apart. Bodies that touch only at edges or corners, or dip into each other by less than the tolerance, are contact: their union is left to the non-intersecting route as before, and their intersection is the empty body that route builds for separate bodies. A body standing on a face unites into one solid, and subtracting it leaves the host whole: the convex shortcut makes no cut on a face the cutter lies wholly outside of. An arrangement that cannot close a cell below the classification tolerance is a coverage gap for union and intersection, so the generic route runs; subtraction keeps its error. --- .../boolean/handlers/planar/polyhedra/keep.rs | 60 ++++- .../handlers/planar/polyhedra/merge.rs | 139 ++++++++++ .../boolean/handlers/planar/polyhedra/mod.rs | 66 +++-- .../handlers/planar/polyhedra/sections.rs | 10 + .../boolean/tests/planar_polyhedra.rs | 248 +++++++++++++++++- .../creating/polyhedron_boolean_checks.rs | 12 +- .../creating/polyhedron_boolean_contacts.rs | 43 +++ 7 files changed, 547 insertions(+), 31 deletions(-) create mode 100644 main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/merge.rs diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/keep.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/keep.rs index 4b51b996..ca20aa61 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/keep.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/keep.rs @@ -1,10 +1,50 @@ -use crate::brep::{FaceRole, Frame3}; +use crate::brep::{FaceRole, Frame3, GeometryError}; use crate::geom2d::{signed_area2, Pt2, RingRegion}; use crate::math::scale; +use crate::operations::modifying::boolean::handlers::planar::facets::coverage; use crate::operations::modifying::boolean::types::BooleanOp; use crate::query::PointClassification; +use std::cell::Cell; -pub(super) fn host_keeps(operation: BooleanOp, place: PointClassification) -> Option { +pub(super) struct Keeper { + operation: BooleanOp, + shared: Cell, +} + +impl Keeper { + pub(super) fn new(operation: BooleanOp) -> Self { + Self { + operation, + shared: Cell::new(false), + } + } + + pub(super) fn probe_side(&self) -> f64 { + boundary_probe_side(self.operation) + } + + pub(super) fn host(&self, place: PointClassification) -> Option { + self.note(place); + host_keeps(self.operation, place) + } + + pub(super) fn cutter(&self, place: PointClassification) -> Option { + self.note(place); + cutter_keeps(self.operation, place) + } + + pub(super) fn shares_volume_or_face(&self) -> bool { + self.shared.get() + } + + fn note(&self, place: PointClassification) { + if place == PointClassification::Inside { + self.shared.set(true); + } + } +} + +fn host_keeps(operation: BooleanOp, place: PointClassification) -> Option { match place { PointClassification::Outside => Some(operation != BooleanOp::Intersection), PointClassification::Inside => Some(operation == BooleanOp::Intersection), @@ -12,7 +52,7 @@ pub(super) fn host_keeps(operation: BooleanOp, place: PointClassification) -> Op } } -pub(super) fn boundary_probe_side(operation: BooleanOp) -> f64 { +fn boundary_probe_side(operation: BooleanOp) -> f64 { if operation == BooleanOp::Union { -1.0 } else { @@ -20,7 +60,7 @@ pub(super) fn boundary_probe_side(operation: BooleanOp) -> f64 { } } -pub(super) fn cutter_keeps(operation: BooleanOp, place: PointClassification) -> Option { +fn cutter_keeps(operation: BooleanOp, place: PointClassification) -> Option { match place { PointClassification::Inside => Some(operation != BooleanOp::Union), PointClassification::Outside => Some(operation == BooleanOp::Union), @@ -81,3 +121,15 @@ pub(super) fn piece_role( FaceRole::Split } } + +pub(super) fn open_boundary_gap(operation: BooleanOp, error: GeometryError) -> GeometryError { + match error { + GeometryError::InvalidTopology(message) + if operation != BooleanOp::Subtraction + && message == "layered planar Boolean has an open boundary" => + { + coverage() + } + other => other, + } +} diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/merge.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/merge.rs new file mode 100644 index 00000000..b2905886 --- /dev/null +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/merge.rs @@ -0,0 +1,139 @@ +use super::sections::{local_ring, PlanarFace, PlanarFacet}; +use crate::brep::{Accuracy, BrepEnvelope, FaceProvenance, FaceRole, Frame3, GeometryError}; +use crate::geom2d::{boolean_oriented_regions, PlanarBooleanOp, Pt2}; +use crate::math::{dot, sub, Point3}; +use crate::operations::modifying::boolean::operands::brep_face_source; + +type Inputs<'a> = [(&'a BrepEnvelope, &'a [PlanarFace]); 2]; + +struct Merge { + members: Vec, + facet: PlanarFacet, +} + +pub(super) fn merged_coplanar( + pieces: Vec, + inputs: Inputs<'_>, + accuracy: Accuracy, +) -> Result, GeometryError> { + let mut grouped = vec![false; pieces.len()]; + let mut merges = Vec::new(); + for start in 0..pieces.len() { + if grouped[start] { + continue; + } + let frame = pieces[start].frame; + let group = (start..pieces.len()) + .filter(|&index| !grouped[index] && same_plane(frame, pieces[index].frame, accuracy)) + .collect::>(); + for &index in &group { + grouped[index] = true; + } + merges.extend(coincident_faces(&pieces, &group, inputs, accuracy)?); + } + let mut slots = pieces.into_iter().map(Some).collect::>(); + for merge in merges { + for &member in &merge.members { + slots[member] = None; + } + slots[merge.members[0]] = Some(merge.facet); + } + Ok(slots.into_iter().flatten().collect()) +} + +fn coincident_faces( + pieces: &[PlanarFacet], + group: &[usize], + inputs: Inputs<'_>, + accuracy: Accuracy, +) -> Result, GeometryError> { + let frame = pieces[group[0]].frame; + let candidates = inputs + .iter() + .enumerate() + .flat_map(|(input, &(body, faces))| { + faces + .iter() + .filter(move |face| same_plane(frame, face.frame, accuracy)) + .map(move |face| (input, body, face)) + }) + .collect::>(); + if (0..2).any(|input| candidates.iter().all(|candidate| candidate.0 != input)) { + return Ok(Vec::new()); + } + let local = |outer: &[Point3], holes: &[Vec]| { + std::iter::once(outer) + .chain(holes.iter().map(Vec::as_slice)) + .map(|ring| local_ring(ring, frame)) + .collect::>() + }; + let member_rings = group + .iter() + .map(|&index| local(&pieces[index].outer, &pieces[index].holes)) + .collect::>(); + let union = boolean_oriented_regions( + &member_rings.concat(), + &[], + PlanarBooleanOp::Union, + accuracy.intersection, + )?; + let mut merges = Vec::new(); + for region in union { + let rings = std::iter::once(region.outer) + .chain(region.holes) + .collect::>(); + let (mut sources, mut from) = (Vec::new(), [false; 2]); + for &(input, body, face) in &candidates { + if overlaps(&rings, &local(&face.polygon, &face.holes), accuracy)? { + sources.push(brep_face_source(body, face.id)); + from[input] = true; + } + } + let mut members = Vec::new(); + for (&index, member) in group.iter().zip(&member_rings) { + if overlaps(&rings, member, accuracy)? { + members.push(index); + } + } + if from.contains(&false) || members.is_empty() { + continue; + } + let lifted = lifted_rings(&rings, frame); + let facet = PlanarFacet { + key: pieces[members[0]].key.clone(), + frame, + outer: lifted[0].clone(), + holes: lifted[1..].to_vec(), + provenance: FaceProvenance { + sources, + role: FaceRole::Coincident, + reversed: false, + }, + }; + merges.push(Merge { members, facet }); + } + Ok(merges) +} + +fn lifted_rings(rings: &[Vec], frame: Frame3) -> Vec> { + rings + .iter() + .map(|ring| { + ring.iter() + .map(|point| frame.point([point.x, point.z, 0.0])) + .collect() + }) + .collect() +} + +fn overlaps(a: &[Vec], b: &[Vec], accuracy: Accuracy) -> Result { + Ok( + !boolean_oriented_regions(a, b, PlanarBooleanOp::Intersection, accuracy.intersection)? + .is_empty(), + ) +} + +fn same_plane(frame: Frame3, other: Frame3, accuracy: Accuracy) -> bool { + dot(frame.z, other.z) >= 1.0 - 1.0e-10 + && dot(frame.z, sub(other.origin, frame.origin)).abs() <= accuracy.intersection +} diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs index d31bb49b..b38f7a6b 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs @@ -1,4 +1,5 @@ mod keep; +mod merge; mod sections; use super::facets::{coverage, finish, PlanarFacets}; @@ -8,12 +9,14 @@ use crate::geom2d::{ RingRegion, }; use crate::math::{dot, scale, sub, Point3}; +use crate::operations::modifying::boolean::handlers::disjoint::separate_boolean; use crate::operations::modifying::boolean::operands::brep_face_source; use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; use crate::query::{Classifier, PointClassification}; -use keep::{boundary_probe_side, cutter_frame, cutter_keeps, host_keeps, piece_role, turns_cutter}; +use keep::{cutter_frame, open_boundary_gap, piece_role, turns_cutter, Keeper}; +use merge::merged_coplanar; use sections::{ - convex_faces, convex_section_polygon, planar_faces, planar_section_segments, + convex_faces, convex_section_polygon, local_ring, planar_faces, planar_section_segments, ring_vertex_candidates, split_planar_ring, PlanarFace, PlanarFacet, }; use std::cell::RefCell; @@ -23,6 +26,16 @@ pub(crate) fn boolean_planar_polyhedra( cutter: &BrepEnvelope, operation: BooleanOp, id: String, +) -> Result { + planar_polyhedra(host, cutter, operation, id) + .map_err(|error| open_boundary_gap(operation, error)) +} + +fn planar_polyhedra( + host: &BrepEnvelope, + cutter: &BrepEnvelope, + operation: BooleanOp, + id: String, ) -> Result { let accuracy = Accuracy::combined(host.accuracy, cutter.accuracy); let Some(host_faces) = planar_faces(host, accuracy.intersection)? else { @@ -37,13 +50,14 @@ pub(crate) fn boolean_planar_polyhedra( None }; check_not_nested(host, cutter)?; + let keeper = Keeper::new(operation); let mut pieces = Vec::new(); for face in &host_faces { let base = face_rings(face); let regions = if let Some(convex_faces) = &convex_cutter_faces { convex_host_regions(cutter, convex_faces, face, &base, accuracy)? } else { - classified_host_regions(cutter, &cutter_faces, face, &base, accuracy, operation)? + classified_host_regions(cutter, &cutter_faces, face, &base, accuracy, &keeper)? }; for region in regions { let role = piece_role(operation, false, ®ion, &base, accuracy.geometric); @@ -60,16 +74,29 @@ pub(crate) fn boolean_planar_polyhedra( face, &base, accuracy, - operation, + &keeper, )?; for region in regions { let role = piece_role(operation, true, ®ion, &base, accuracy.geometric); pieces.push(cutter_piece(cutter, face, operation, ®ion, role)); } } + if operation != BooleanOp::Subtraction && !keeper.shares_volume_or_face() { + return match operation { + BooleanOp::Intersection => separate_boolean(host, cutter, operation, id, Vec::new()), + _ => Err(coverage()), + }; + } if pieces.is_empty() { return Err(coverage()); } + if operation != BooleanOp::Subtraction { + pieces = merged_coplanar( + pieces, + [(host, &host_faces), (cutter, &cutter_faces)], + accuracy, + )?; + } let facets = welded_facets(pieces, id, accuracy)?; let mut result = finish(facets, host, cutter, operation)?; result.report.coincident = false; @@ -137,16 +164,6 @@ fn check_not_nested(host: &BrepEnvelope, cutter: &BrepEnvelope) -> Result<(), Ge Ok(()) } -fn local_ring(points: &[Point3], frame: Frame3) -> Vec { - points - .iter() - .map(|point| { - let local = frame.local(*point); - Pt2::new(local[0], local[1]) - }) - .collect::>() -} - fn convex_host_regions( cutter: &BrepEnvelope, convex_faces: &[PlanarFace], @@ -154,7 +171,14 @@ fn convex_host_regions( base: &[Vec], accuracy: Accuracy, ) -> Result, GeometryError> { - let section = convex_section_polygon(cutter, convex_faces, face.frame, accuracy.intersection); + let touches_from_outside = cutter.topology.vertices.iter().all(|vertex| { + dot(face.frame.z, sub(vertex.position, face.frame.origin)) >= -accuracy.intersection + }); + let section = if touches_from_outside { + Vec::new() + } else { + convex_section_polygon(cutter, convex_faces, face.frame, accuracy.intersection) + }; let cut = if section.is_empty() { Vec::new() } else { @@ -174,7 +198,7 @@ fn classified_host_regions( face: &PlanarFace, base: &[Vec], accuracy: Accuracy, - operation: BooleanOp, + keeper: &Keeper, ) -> Result, GeometryError> { let mut edges = planar_section_segments(cutter, cutter_faces, face.frame, accuracy.intersection)?; @@ -204,14 +228,12 @@ fn classified_host_regions( position, scale( face.frame.z, - boundary_probe_side(operation) - * accuracy.geometric.max(accuracy.intersection) - * 4.0, + keeper.probe_side() * accuracy.geometric.max(accuracy.intersection) * 4.0, ), )), other => other, }; - match classification.map(|place| host_keeps(operation, place)) { + match classification.map(|place| keeper.host(place)) { Ok(Some(keep)) => keep, Ok(None) => { *failure.borrow_mut() = Some(coverage()); @@ -275,7 +297,7 @@ fn cutter_face_regions( face: &PlanarFace, cutter_region: &[Vec], accuracy: Accuracy, - operation: BooleanOp, + keeper: &Keeper, ) -> Result, GeometryError> { let mut edges = planar_section_segments(host, host_faces, face.frame, accuracy.intersection)?; let coplanar = coplanar_rings(host_faces, face, accuracy); @@ -303,7 +325,7 @@ fn cutter_face_regions( } match classifier .classify(face.frame.point([point.x, point.z, 0.0])) - .map(|place| cutter_keeps(operation, place)) + .map(|place| keeper.cutter(place)) { Ok(Some(keep)) => keep, Ok(None) => { diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/sections.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/sections.rs index 8f7ccea3..d5c96467 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/sections.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/sections.rs @@ -406,3 +406,13 @@ pub(super) fn split_planar_ring( } split } + +pub(super) fn local_ring(points: &[Point3], frame: Frame3) -> Vec { + points + .iter() + .map(|point| { + let local = frame.local(*point); + Pt2::new(local[0], local[1]) + }) + .collect::>() +} diff --git a/main/opengeometry/src/operations/modifying/boolean/tests/planar_polyhedra.rs b/main/opengeometry/src/operations/modifying/boolean/tests/planar_polyhedra.rs index 2b1e6986..4e3b0082 100644 --- a/main/opengeometry/src/operations/modifying/boolean/tests/planar_polyhedra.rs +++ b/main/opengeometry/src/operations/modifying/boolean/tests/planar_polyhedra.rs @@ -1,5 +1,6 @@ use super::support::{extrusion_span, volume}; -use crate::brep::{Accuracy, BrepEnvelope, FaceRole, Frame3}; +use crate::brep::{Accuracy, BrepEnvelope, Face, FaceRole, Frame3, GeometryError}; +use crate::math::Point3; use crate::operations::modifying::boolean::dispatch::boolean_brep_with_handlers; use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; use crate::primitives; @@ -255,3 +256,248 @@ fn union_drops_a_face_shared_back_to_back_from_both_bodies() { PointClassification::Inside ); } + +fn faces_on(result: &BooleanResult, height: f64, facing: f64) -> Vec<&Face> { + result + .brep + .topology + .faces + .iter() + .filter(|face| { + let origin = result.brep.geometry.surfaces[face.surface as usize] + .frame() + .origin; + let normal = result.brep.face_normal_at(face.id, origin).unwrap(); + (origin[2] - height).abs() < 1.0e-9 && normal[2] * facing > 1.0 - 1.0e-9 + }) + .collect() +} + +#[test] +fn faces_sharing_a_plane_and_a_facing_merge_into_one_coincident_face() { + let host = corner_cube(); + let tent = primitives::polyhedron( + "tent".into(), + &[ + [1.0, 1.0, 0.0], + [3.0, 1.0, 0.0], + [1.0, 3.0, 0.0], + [1.0, 1.0, 1.5], + ], + &[vec![0, 2, 1], vec![0, 1, 3], vec![0, 3, 2], vec![1, 2, 3]], + ACCURACY, + ) + .unwrap(); + let [union, intersection, _] = assert_identities(&host, &tent, 0.75); + for result in [&union, &intersection] { + let ground = faces_on(result, 0.0, -1.0); + assert_eq!(ground.len(), 1); + assert_eq!(ground[0].provenance.role, FaceRole::Coincident); + let bodies = ground[0] + .provenance + .sources + .iter() + .map(|source| source.body.as_str()) + .collect::>(); + assert_eq!(bodies, [host.id.as_str(), tent.id.as_str()]); + } +} + +#[test] +fn bodies_touching_only_along_an_edge_unite_apart_and_intersect_empty() { + let host = corner_cube(); + let half = std::f64::consts::FRAC_1_SQRT_2; + let ridge = primitives::cuboid( + "ridge".into(), + Frame3 { + origin: [-0.5, 2.0, 2.0], + x: [1.0, 0.0, 0.0], + y: [0.0, half, half], + z: [0.0, -half, half], + }, + [3.0, 0.5, 0.5], + ACCURACY, + ) + .unwrap(); + for (operation, solids, handler) in [ + (BooleanOp::Union, 2, "generic_non_intersecting_boolean"), + (BooleanOp::Intersection, 0, "boolean_planar_polyhedra"), + ] { + let (result, handlers) = + boolean_brep_with_handlers(&host, &ridge, operation, "edge".into()).unwrap(); + assert_eq!(handlers, [handler]); + assert_eq!(result.brep.solids.len(), solids); + } +} + +#[test] +fn subtracting_a_body_standing_on_a_face_leaves_the_host_whole() { + let host = corner_cube(); + let standing = corner_tetrahedron( + "standing", + 3.5, + &[vec![0, 2, 1], vec![0, 1, 3], vec![0, 3, 2], vec![1, 2, 3]], + ); + let (result, measured) = operated(&host, &standing, BooleanOp::Subtraction); + assert!((measured - 8.0).abs() <= 1.0e-9 * 8.0, "{measured}"); + assert_eq!(result.brep.solids.len(), 1); +} + +const WORLD: Accuracy = Accuracy { + geometric: 1.0e-8, + intersection: 1.0e-9, + tessellation: 0.01, + exchange: 1.0e-6, +}; + +fn cube_with_top(id: &str, top: &[Vec]) -> BrepEnvelope { + let mut faces = vec![ + vec![3, 2, 1, 0], + vec![0, 1, 5, 4], + vec![1, 2, 6, 5], + vec![2, 3, 7, 6], + vec![3, 0, 4, 7], + ]; + faces.extend_from_slice(top); + primitives::polyhedron( + id.into(), + &[ + [0.0, 0.0, 0.0], + [2.0, 0.0, 0.0], + [2.0, 2.0, 0.0], + [0.0, 2.0, 0.0], + [0.0, 0.0, 2.0], + [2.0, 0.0, 2.0], + [2.0, 2.0, 2.0], + [0.0, 2.0, 2.0], + ], + &faces, + WORLD, + ) + .unwrap() +} + +fn spike(id: &str, apex: Point3, base: [Point3; 3]) -> BrepEnvelope { + primitives::polyhedron( + id.into(), + &[apex, base[0], base[1], base[2]], + &[vec![0, 2, 1], vec![0, 3, 2], vec![0, 1, 3], vec![1, 2, 3]], + WORLD, + ) + .unwrap() +} + +#[test] +fn a_dip_within_the_tolerance_band_is_contact_for_union_and_intersection() { + let cube = cube_with_top("cube", &[vec![4, 5, 6, 7]]); + for depth in [5.0e-9, 1.0e-8, 2.0e-8] { + let dip = spike( + "dip", + [1.0, 1.0, 2.0 - depth], + [[0.5, 0.5, 3.0], [1.5, 0.5, 3.0], [1.0, 1.5, 3.0]], + ); + let (union, handlers) = + boolean_brep_with_handlers(&cube, &dip, BooleanOp::Union, "dip".into()).unwrap(); + assert_eq!(handlers, ["generic_non_intersecting_boolean"], "{depth}"); + assert_eq!(union.brep.solids.len(), 2); + assert!((volume(&union.brep) - volume(&cube) - volume(&dip)).abs() <= 1.0e-9); + let (intersection, handlers) = + boolean_brep_with_handlers(&cube, &dip, BooleanOp::Intersection, "dip".into()).unwrap(); + assert_eq!(handlers, ["boolean_planar_polyhedra"], "{depth}"); + assert!(intersection.brep.solids.is_empty(), "{depth}"); + assert!(intersection.brep.topology.faces.is_empty(), "{depth}"); + } +} + +#[test] +fn a_dip_deeper_than_the_tolerance_intersects_in_a_solid() { + let cube = cube_with_top("cube", &[vec![4, 5, 6, 7]]); + let dip = spike( + "dip", + [1.0, 1.0, 2.0 - 1.0e-4], + [[0.5, 0.5, 3.0], [1.5, 0.5, 3.0], [1.0, 1.5, 3.0]], + ); + let (intersection, handlers) = + boolean_brep_with_handlers(&cube, &dip, BooleanOp::Intersection, "dip".into()).unwrap(); + assert_eq!(handlers, ["boolean_planar_polyhedra"]); + assert_eq!(intersection.brep.solids.len(), 1); + assert!(volume(&intersection.brep) > 0.0); +} + +#[test] +fn coplanar_faces_of_one_body_stay_apart_where_the_other_body_is_absent() { + let cube = cube_with_top("split-top", &[vec![4, 5, 6], vec![4, 6, 7]]); + let side = spike( + "side", + [1.5, 1.0, 1.0], + [[3.0, 0.5, 0.5], [3.0, 1.5, 0.5], [3.0, 1.0, 1.5]], + ); + let (union, _) = operated(&cube, &side, BooleanOp::Union); + let top = faces_on(&union, 2.0, 1.0); + assert_eq!(top.len(), 2); + for face in top { + assert_eq!(face.provenance.sources.len(), 1); + assert_ne!(face.provenance.role, FaceRole::Coincident); + } +} + +fn turned_slab(overlap: f64) -> BrepEnvelope { + let turn = 30.0_f64.to_radians(); + let corner = |u: f64, v: f64| { + [ + 1.0 + 0.5 * (u * turn.cos() - v * turn.sin()), + 1.0 + 0.5 * (u * turn.sin() + v * turn.cos()), + ] + }; + let ring = [ + corner(-1.0, -1.0), + corner(1.0, -1.0), + corner(1.0, 1.0), + corner(-1.0, 1.0), + ]; + let mut points = ring.map(|[x, y]| [x, y, 2.0 - overlap]).to_vec(); + points.extend(ring.map(|[x, y]| [x, y, 3.0])); + primitives::polyhedron( + "slab".into(), + &points, + &[ + vec![3, 2, 1, 0], + vec![4, 5, 6, 7], + vec![0, 1, 5, 4], + vec![1, 2, 6, 5], + vec![2, 3, 7, 6], + vec![3, 0, 4, 7], + ], + WORLD, + ) + .unwrap() +} + +#[test] +fn a_slab_overlapping_within_the_tolerance_band_never_breaks_topology() { + let cube = cube_with_top("cube", &[vec![4, 5, 6, 7]]); + for overlap in [1.1e-8, 1.5e-8, 2.0e-8] { + for operation in [BooleanOp::Union, BooleanOp::Intersection] { + let result = + boolean_brep_with_handlers(&cube, &turned_slab(overlap), operation, "slab".into()); + assert!( + !matches!(result, Err(GeometryError::InvalidTopology(_))), + "{overlap} {operation:?}: {:?}", + result.err() + ); + } + } + for overlap in [2.5e-8, 1.0e-6] { + let (intersection, _) = boolean_brep_with_handlers( + &cube, + &turned_slab(overlap), + BooleanOp::Intersection, + "slab".into(), + ) + .unwrap(); + assert!( + (volume(&intersection.brep) - overlap).abs() <= 1.0e-12, + "{overlap}" + ); + } +} diff --git a/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs b/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs index 6fe4c2f9..d51c74ec 100644 --- a/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs +++ b/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs @@ -103,11 +103,18 @@ impl Pair { let union = self.built(BooleanOp::Union, 2); assert_eq!(union.handlers, ["generic_non_intersecting_boolean"]); union.assert_volume(a.value + b.value); + self.assert_empty_intersection(); + let difference = self.built(BooleanOp::Subtraction, 1); + assert_eq!(difference.handlers, ["subtract_planar_polyhedra"]); + difference.assert_volume(a.value); + } + + pub(super) fn assert_empty_intersection(&self) { let operation = BooleanOp::Intersection; let (result, handlers) = boolean_brep_outcome_with_handlers(&self.a, &self.b, operation, "touching".into()); let result = result.unwrap_or_else(|error| panic!("{operation:?}: {error:?}")); - assert_eq!(handlers, ["generic_non_intersecting_boolean"]); + assert_eq!(handlers, ["boolean_planar_polyhedra"]); result.brep.validate().unwrap(); let topology = &result.brep.topology; assert!(topology.vertices.is_empty() && topology.edges.is_empty()); @@ -117,9 +124,6 @@ impl Pair { assert_eq!(value["topology"]["shells"], json!([])); let report = serde_json::to_value(&result.report).unwrap(); assert_eq!(report["operation"], json!(operation)); - let difference = self.built(BooleanOp::Subtraction, 1); - assert_eq!(difference.handlers, ["subtract_planar_polyhedra"]); - difference.assert_volume(a.value); } pub(super) fn assert_identities( diff --git a/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs b/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs index 18320966..13f0cc0c 100644 --- a/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs +++ b/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs @@ -157,3 +157,46 @@ fn box_standing_on_a_turned_cuboid_subtracts_to_the_unchanged_host() { ]); pair.built(BooleanOp::Subtraction, 1).assert_volume(1.0); } + +fn eave_slab(plate: bool, shift: f64) -> Primitive { + let (ridge, eave, sole) = (5.3006226146291295, 2.8498962308951454, 2.6498962308951453); + let inner = if plate { 5.100622614629129 } else { sole }; + let points = [ + [-0.5, ridge, 3.0], + [8.5, ridge, 3.0], + [8.5, eave, -0.5], + [-0.5, eave, -0.5], + [-0.5, sole, -0.5], + [8.5, sole, -0.5], + [8.5, inner, 3.0], + [-0.5, inner, 3.0], + [-0.5, eave, 6.5], + [8.5, eave, 6.5], + [8.5, sole, 6.5], + [-0.5, sole, 6.5], + ] + .map(|[x, y, z]| [x, y, z + shift]); + let faces: [&[u32]; 10] = [ + &[0, 1, 2, 3], + &[4, 5, 6, 7], + &[1, 0, 8, 9], + &[10, 11, 7, 6], + &[2, 1, 6, 5], + &[3, 2, 5, 4], + &[0, 3, 4, 7], + &[8, 0, 7, 11], + &[9, 8, 11, 10], + &[1, 9, 10, 6], + ]; + polyhedron(&points, &faces) +} + +#[test] +fn mass_roofs_touching_along_an_eave_intersect_empty() { + Pair::new(eave_slab(false, 0.0), eave_slab(false, 7.0)).assert_empty_intersection(); +} + +#[test] +fn plate_roofs_touching_along_an_eave_intersect_empty() { + Pair::new(eave_slab(true, 0.0), eave_slab(true, 7.0)).assert_empty_intersection(); +} From 844a6fa0a9367e7e39b1a0e0c27c52476ea06cdc Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Thu, 12 Nov 2026 02:13:02 +0100 Subject: [PATCH 04/10] Document and test planar union and intersection The migration guide gains an Unreleased entry: union and intersection of planar solids now build, how touching bodies behave, and the changed families of a planar refusal. Node tests check a crossing gable pair and a turned cube through the SDK. --- MIGRATION.md | 20 ++++++ .../tests/node/planar-booleans.test.mjs | 70 +++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 main/opengeometry-three/tests/node/planar-booleans.test.mjs diff --git a/MIGRATION.md b/MIGRATION.md index cfa55af4..b61f7a1c 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -12,6 +12,26 @@ It is written for people and for coding assistants alike: each section says what 4. For a name the table does not list, look in "Partly replaced". Anything listed under "Removed" has no replacement: take that code out, or stay on the older version. 5. Finish with its "Check your result" steps. +## Unreleased + +### What changed + +- `operate` now builds the union and the intersection of two planar solids that are not both axis-aligned boxes: a cuboid turned against the other body's axes, a polyhedron with sloped faces such as a roof, or a polyhedron that is not convex. 3.0 threw an error for these (`CoverageGap`, for some pairs `UnresolvedIntersection`). The result is one closed solid, or several when the intersection falls apart into pieces. The report's `handlers` is `['boolean_planar_polyhedra']`. + +### Different behaviour + +- Where faces of both bodies lie in one plane and face the same way, the connected region they cover becomes one face. Faces of one body alone in a plane stay as they are. +- Some pairs that 3.0 already built through the generic route, for example a body inside the other that touches it along an edge, are now built by the planar handler with the same volume and number of solids. The report's `handlers` becomes `['boolean_planar_polyhedra']`. +- Two of these solids that touch on a face, one standing on the other, now unite into one solid. 3.0 threw. +- Two planar solids that touch only along an edge or at a corner give the same result as in 3.0: the union keeps both solids, and the intersection throws `EmptyResult`, as for two separate solids. +- An intersection of two planar solids that touch only on a face now throws `EmptyResult` too, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. +- Subtraction: pairs that built in 3.0 build the same bodies, and the report's `handlers` is still `['subtract_planar_polyhedra']`. Subtracting a body that only stands on a face of the host now returns the host. 3.0 threw `InvalidTopology`. +- A union or intersection of two planar solids that is still refused throws `CoverageGap` with `details.families` equal to `['layered planar profile extrusion', 'layered planar profile extrusion']`. 3.0 gave `['canonical aligned cuboid', 'canonical aligned cuboid']`. Code that matches the old pair must match the new one. + +### Check your result + +1. A union of a cuboid of width 2, height 1 and depth 2 with a cuboid of width 1, height 1 and depth 1, turned 30 degrees about `[0, 1, 0]` and moved by `[1, 0, 0]`, does not throw. `validity()` gives `{ valid: true, closed: true, bodyType: 'Solid' }`, `measure().volume` is within `errorBound` of 4.5, and the report's `handlers` is `['boolean_planar_polyhedra']`. The intersection of the same pair has a volume of 0.5. + ## 2.5 to 3.0 2.5 was not published. From 2.0, follow 2.0 to 2.5, then 2.5 to 3.0. diff --git a/main/opengeometry-three/tests/node/planar-booleans.test.mjs b/main/opengeometry-three/tests/node/planar-booleans.test.mjs new file mode 100644 index 00000000..e31613b1 --- /dev/null +++ b/main/opengeometry-three/tests/node/planar-booleans.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + Solid, OG_OPERATION_INTERSECT, OG_OPERATION_SUBTRACT, OG_OPERATION_UNION, OG_PRIMITIVE_CUBOID, + OG_PRIMITIVE_POLYHEDRON, OG_TRANSFORM_ROTATE, OG_TRANSFORM_TRANSLATE, +} from '../../../dist/index.js'; +import { boot } from './support.mjs'; + +const GABLE_FACES = [[0, 1, 2, 3], [1, 0, 4, 5], [2, 1, 5], [3, 2, 5, 4], [0, 3, 4]]; +const CROSS_FACES = [[0, 1, 2, 3], [1, 0, 4], [3, 2, 5], [0, 3, 5, 4], [2, 1, 4, 5]]; + +function gableRoof(ogId) { + const points = [[0, 0, 0], [4, 0, 0], [4, 0, 2], [0, 0, 2], [0, 1, 1], [4, 1, 1]]; + return new Solid(OG_PRIMITIVE_POLYHEDRON, { points, faces: GABLE_FACES }, { ogId }); +} + +function crossGable(ogId) { + const points = [[1.5, -0.5, -1], [2.5, -0.5, -1], [2.5, -0.5, 3], [1.5, -0.5, 3], [2, 1.5, -1], [2, 1.5, 3]]; + return new Solid(OG_PRIMITIVE_POLYHEDRON, { points, faces: CROSS_FACES }, { ogId }); +} + +function turnedCube(ogId) { + const cube = new Solid(OG_PRIMITIVE_CUBOID, { width: 1, height: 1, depth: 1 }, { ogId }); + cube.transform(OG_TRANSFORM_ROTATE, { axis: [0, 1, 0], degrees: 30, pivot: [0, 0, 0] }); + cube.transform(OG_TRANSFORM_TRANSLATE, { offset: [1, 0, 0] }); + return cube; +} + +function operated(host, operation, tool, expected) { + try { + host.operate(operation, { tools: [tool] }); + assert.deepEqual(host.validity(), { valid: true, closed: true, bodyType: 'Solid' }); + const { volume, errorBound } = host.measure(); + assert( + Math.abs(volume - expected) <= errorBound, + `${host.ogId}: ${volume} versus ${expected} with bound ${errorBound}`, + ); + return host.getReport()?.handlers; + } finally { + tool.dispose(); + host.dispose(); + } +} + +test('a gable roof and a crossing gable sunk below its eaves union into one closed solid', async () => { + await boot(); + const handlers = operated(gableRoof('union-roof'), OG_OPERATION_UNION, crossGable('union-cross'), 89 / 12); + assert.deepEqual(handlers, ['boolean_planar_polyhedra']); +}); + +test('a gable roof and a crossing gable sunk below its eaves intersect into one closed solid', async () => { + await boot(); + const handlers = operated(gableRoof('meet-roof'), OG_OPERATION_INTERSECT, crossGable('meet-cross'), 7 / 12); + assert.deepEqual(handlers, ['boolean_planar_polyhedra']); +}); + +test('a gable roof minus a crossing gable still goes through the planar polyhedra subtraction', async () => { + await boot(); + const handlers = operated(gableRoof('cut-roof'), OG_OPERATION_SUBTRACT, crossGable('cut-cross'), 41 / 12); + assert.deepEqual(handlers, ['subtract_planar_polyhedra']); +}); + +test('a cuboid and a cube turned 30 degrees about its height union and intersect', async () => { + await boot(); + const slab = (ogId) => new Solid(OG_PRIMITIVE_CUBOID, { width: 2, height: 1, depth: 2 }, { ogId }); + const union = operated(slab('turned-union-slab'), OG_OPERATION_UNION, turnedCube('turned-union-cube'), 4.5); + assert.deepEqual(union, ['boolean_planar_polyhedra']); + const meet = operated(slab('turned-meet-slab'), OG_OPERATION_INTERSECT, turnedCube('turned-meet-cube'), 0.5); + assert.deepEqual(meet, ['boolean_planar_polyhedra']); +}); From ef4b3b771213d65a0f63dd75ad8b5f065618959b Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Thu, 12 Nov 2026 14:56:41 +0100 Subject: [PATCH 05/10] Subtract a planar host that lies wholly inside the tool to nothing When the host lies inside the tool and touches it on a face, an edge or a corner, every host piece is removed and the planar handler threw CoverageGap. The result is empty. --- MIGRATION.md | 2 +- .../tests/node/planar-booleans.test.mjs | 19 ++++- .../boolean/handlers/planar/polyhedra/mod.rs | 8 ++- .../creating/polyhedron_boolean_checks.rs | 9 ++- .../creating/polyhedron_boolean_contacts.rs | 69 +++++++++++++++++++ 5 files changed, 101 insertions(+), 6 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index b61f7a1c..cabc92af 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -25,7 +25,7 @@ It is written for people and for coding assistants alike: each section says what - Two of these solids that touch on a face, one standing on the other, now unite into one solid. 3.0 threw. - Two planar solids that touch only along an edge or at a corner give the same result as in 3.0: the union keeps both solids, and the intersection throws `EmptyResult`, as for two separate solids. - An intersection of two planar solids that touch only on a face now throws `EmptyResult` too, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. -- Subtraction: pairs that built in 3.0 build the same bodies, and the report's `handlers` is still `['subtract_planar_polyhedra']`. Subtracting a body that only stands on a face of the host now returns the host. 3.0 threw `InvalidTopology`. +- Subtraction: pairs that built in 3.0 build the same bodies, and the report's `handlers` is still `['subtract_planar_polyhedra']`. Subtracting a body that only stands on a face of the host now returns the host. 3.0 threw `InvalidTopology`. When the host lies wholly inside the tool and touches it, on a face, along an edge or at a corner, the subtraction now throws `EmptyResult`, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. - A union or intersection of two planar solids that is still refused throws `CoverageGap` with `details.families` equal to `['layered planar profile extrusion', 'layered planar profile extrusion']`. 3.0 gave `['canonical aligned cuboid', 'canonical aligned cuboid']`. Code that matches the old pair must match the new one. ### Check your result diff --git a/main/opengeometry-three/tests/node/planar-booleans.test.mjs b/main/opengeometry-three/tests/node/planar-booleans.test.mjs index e31613b1..8791e56d 100644 --- a/main/opengeometry-three/tests/node/planar-booleans.test.mjs +++ b/main/opengeometry-three/tests/node/planar-booleans.test.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { - Solid, OG_OPERATION_INTERSECT, OG_OPERATION_SUBTRACT, OG_OPERATION_UNION, OG_PRIMITIVE_CUBOID, + OGError, Solid, OG_OPERATION_INTERSECT, OG_OPERATION_SUBTRACT, OG_OPERATION_UNION, OG_PRIMITIVE_CUBOID, OG_PRIMITIVE_POLYHEDRON, OG_TRANSFORM_ROTATE, OG_TRANSFORM_TRANSLATE, } from '../../../dist/index.js'; import { boot } from './support.mjs'; @@ -68,3 +68,20 @@ test('a cuboid and a cube turned 30 degrees about its height union and intersect const meet = operated(slab('turned-meet-slab'), OG_OPERATION_INTERSECT, turnedCube('turned-meet-cube'), 0.5); assert.deepEqual(meet, ['boolean_planar_polyhedra']); }); + +test('a block standing on the floor wholly inside a gable roof subtracts to nothing', async () => { + await boot(); + const block = new Solid(OG_PRIMITIVE_CUBOID, { width: 0.4, height: 0.3, depth: 0.4 }, { ogId: 'inside-block' }); + block.transform(OG_TRANSFORM_TRANSLATE, { offset: [2, 0, 1] }); + const roof = gableRoof('inside-roof'); + try { + assert.throws( + () => block.operate(OG_OPERATION_SUBTRACT, { tools: [roof] }), + (error) => error instanceof OGError && error.code === 'EmptyResult', + ); + assert(Math.abs(block.measure().volume - 0.048) <= block.measure().errorBound); + } finally { + roof.dispose(); + block.dispose(); + } +}); diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs index b38f7a6b..c9706e98 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/planar/polyhedra/mod.rs @@ -9,6 +9,7 @@ use crate::geom2d::{ RingRegion, }; use crate::math::{dot, scale, sub, Point3}; +use crate::operations::modifying::boolean::handlers::containment::analytic_containment_boolean; use crate::operations::modifying::boolean::handlers::disjoint::separate_boolean; use crate::operations::modifying::boolean::operands::brep_face_source; use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; @@ -88,7 +89,12 @@ fn planar_polyhedra( }; } if pieces.is_empty() { - return Err(coverage()); + return match operation { + BooleanOp::Subtraction => { + analytic_containment_boolean(host, cutter, false, operation, id) + } + _ => Err(coverage()), + }; } if operation != BooleanOp::Subtraction { pieces = merged_coplanar( diff --git a/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs b/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs index d51c74ec..2b1b0ef9 100644 --- a/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs +++ b/main/opengeometry/tests/creating/polyhedron_boolean_checks.rs @@ -110,11 +110,14 @@ impl Pair { } pub(super) fn assert_empty_intersection(&self) { - let operation = BooleanOp::Intersection; + self.assert_empty(BooleanOp::Intersection, "boolean_planar_polyhedra"); + } + + pub(super) fn assert_empty(&self, operation: BooleanOp, handler: &str) { let (result, handlers) = - boolean_brep_outcome_with_handlers(&self.a, &self.b, operation, "touching".into()); + boolean_brep_outcome_with_handlers(&self.a, &self.b, operation, "empty".into()); let result = result.unwrap_or_else(|error| panic!("{operation:?}: {error:?}")); - assert_eq!(handlers, ["boolean_planar_polyhedra"]); + assert_eq!(handlers, [handler]); result.brep.validate().unwrap(); let topology = &result.brep.topology; assert!(topology.vertices.is_empty() && topology.edges.is_empty()); diff --git a/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs b/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs index 13f0cc0c..09951db0 100644 --- a/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs +++ b/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs @@ -200,3 +200,72 @@ fn mass_roofs_touching_along_an_eave_intersect_empty() { fn plate_roofs_touching_along_an_eave_intersect_empty() { Pair::new(eave_slab(true, 0.0), eave_slab(true, 7.0)).assert_empty_intersection(); } + +fn gable_on_a_wall_top(length: f64, span: f64, ridge: f64) -> Primitive { + let points = [ + [0.0, ridge, span / 2.0], + [length, ridge, span / 2.0], + [length, 3.2, 0.0], + [0.0, 3.2, 0.0], + [0.0, 3.0, 0.0], + [length, 3.0, 0.0], + [length, 3.0, span / 2.0], + [0.0, 3.0, span / 2.0], + [0.0, 3.2, span], + [length, 3.2, span], + [length, 3.0, span], + [0.0, 3.0, span], + ]; + let faces: [&[u32]; 10] = [ + &[0, 1, 2, 3], + &[4, 5, 6, 7], + &[1, 0, 8, 9], + &[10, 11, 7, 6], + &[2, 1, 6, 5], + &[3, 2, 5, 4], + &[0, 3, 4, 7], + &[8, 0, 7, 11], + &[9, 8, 11, 10], + &[1, 9, 10, 6], + ]; + polyhedron(&points, &faces) +} + +#[test] +fn roof_wholly_inside_a_roof_on_the_same_wall_top_subtracts_to_nothing() { + let (main_ridge, wing_ridge) = (5.3006226146291295, 3.9002075382097097); + let main = 8.0 * (6.0 * 0.2 + 3.0 * (main_ridge - 3.2)); + let wing = 3.0 * (2.0 * 0.2 + (wing_ridge - 3.2)); + let wing_place = [ + turned(90.0, [0.0, 0.0, 0.0]), + Transform::Translate { + offset: [3.0, 0.0, 4.5], + }, + ]; + let main_roof = || gable_on_a_wall_top(8.0, 6.0, main_ridge); + let wing_roof = || gable_on_a_wall_top(3.0, 2.0, wing_ridge); + let pair = Pair::placed([(wing_roof(), &wing_place), (main_roof(), &[])]); + pair.assert_empty(BooleanOp::Subtraction, "subtract_planar_polyhedra"); + pair.built(BooleanOp::Union, 1).assert_volume(main); + pair.built(BooleanOp::Intersection, 1).assert_volume(wing); + Pair::placed([(main_roof(), &[]), (wing_roof(), &wing_place)]) + .built(BooleanOp::Subtraction, 1) + .assert_volume(main - wing); +} + +#[test] +fn box_on_the_floor_inside_an_l_roof_subtracts_to_nothing() { + let block = || { + hexahedron( + [0.8, 0.0, 0.8], + [[0.4, 0.0, 0.0], [0.0, 0.5, 0.0], [0.0, 0.0, 0.4]], + ) + }; + let pair = Pair::new(block(), l_roof()); + pair.assert_empty(BooleanOp::Subtraction, "subtract_planar_polyhedra"); + pair.built(BooleanOp::Union, 1).assert_volume(16.0 / 3.0); + pair.built(BooleanOp::Intersection, 1).assert_volume(0.08); + Pair::new(l_roof(), block()) + .built(BooleanOp::Subtraction, 1) + .assert_volume(16.0 / 3.0 - 0.08); +} From 8cffd268a57c64f6702b39a3f71dd9eec2f9647f Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Thu, 12 Nov 2026 15:52:06 +0100 Subject: [PATCH 06/10] Treat planes within an angular resolution as parallel Faces of a polyhedron whose points are turned about a slanted axis can be parallel except for a normal cross product of about 1e-16. The plane intersection either failed with a singular system or returned a line far outside both faces, so a solid wholly inside another threw UnresolvedIntersection. Planes whose normals differ by a sine of at most 1e-11 are now parallel: coincident or apart by their distance. --- MIGRATION.md | 1 + .../src/intersection/ssi/planar.rs | 4 +- .../src/intersection/ssi/tests/closed_form.rs | 25 +++++++++ .../creating/polyhedron_boolean_contacts.rs | 53 +++++++++++++++++++ 4 files changed, 82 insertions(+), 1 deletion(-) diff --git a/MIGRATION.md b/MIGRATION.md index cabc92af..8c11ec9f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -26,6 +26,7 @@ It is written for people and for coding assistants alike: each section says what - Two planar solids that touch only along an edge or at a corner give the same result as in 3.0: the union keeps both solids, and the intersection throws `EmptyResult`, as for two separate solids. - An intersection of two planar solids that touch only on a face now throws `EmptyResult` too, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. - Subtraction: pairs that built in 3.0 build the same bodies, and the report's `handlers` is still `['subtract_planar_polyhedra']`. Subtracting a body that only stands on a face of the host now returns the host. 3.0 threw `InvalidTopology`. When the host lies wholly inside the tool and touches it, on a face, along an edge or at a corner, the subtraction now throws `EmptyResult`, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. +- Two flat faces whose normals differ by an angle of at most 1e-11 radians are now treated as parallel. Rounding gives such faces when a polyhedron's points are turned about a slanted axis. Pairs that were refused only because of such faces now build. For example, a box wholly inside a gable roof, not touching it, both turned that way, gives the roof for the union, the box for the intersection, the roof with a cavity for the roof minus the box, and `EmptyResult` for the box minus the roof. 3.0 threw `UnresolvedIntersection` for many such turns, with `plane intersection: SingularSystem` or `intersection curve misses support`. Some pairs that are still refused now throw `CoverageGap` instead. - A union or intersection of two planar solids that is still refused throws `CoverageGap` with `details.families` equal to `['layered planar profile extrusion', 'layered planar profile extrusion']`. 3.0 gave `['canonical aligned cuboid', 'canonical aligned cuboid']`. Code that matches the old pair must match the new one. ### Check your result diff --git a/main/opengeometry/src/intersection/ssi/planar.rs b/main/opengeometry/src/intersection/ssi/planar.rs index f17dadbe..5bce8e0e 100644 --- a/main/opengeometry/src/intersection/ssi/planar.rs +++ b/main/opengeometry/src/intersection/ssi/planar.rs @@ -6,6 +6,8 @@ use crate::math::{ Interval, Sign, }; +const PARALLEL_SINE: f64 = 1.0e-11; + pub(super) fn plane_plane( a: Frame3, b: Frame3, @@ -14,7 +16,7 @@ pub(super) fn plane_plane( let tangent = cross(a.z, b.z); let length = norm(tangent); let delta = sub(b.origin, a.origin); - if length == 0.0 { + if length <= PARALLEL_SINE { return Ok(if dot(a.z, delta).abs() <= accuracy.intersection { GeometryResult::Coincident } else { diff --git a/main/opengeometry/src/intersection/ssi/tests/closed_form.rs b/main/opengeometry/src/intersection/ssi/tests/closed_form.rs index 58ed3f25..b7274703 100644 --- a/main/opengeometry/src/intersection/ssi/tests/closed_form.rs +++ b/main/opengeometry/src/intersection/ssi/tests/closed_form.rs @@ -382,3 +382,28 @@ fn canonical_fast_dispatch_covers_all_fifteen_unordered_family_pairs() { } assert_eq!(count, 15); } + +#[test] +fn planes_parallel_up_to_rounding_are_apart_or_coincident() { + let intersect = |gap: f64, tilt: f64| { + let mut store = GeometryStore::new(); + store.surfaces = vec![ + SurfaceGeometry::Plane { + frame: Frame3::IDENTITY, + }, + SurfaceGeometry::Plane { + frame: Frame3::from_axis([0.0, 0.0, gap], [tilt, 0.0, 1.0], [1.0, 0.0, 0.0]) + .unwrap(), + }, + ]; + intersect_surfaces(&mut store, 0, 1, fine_accuracy()).unwrap() + }; + for tilt in [1.0e-16, 1.0e-12] { + let apart = intersect(0.1, tilt); + assert!(apart.curves.is_empty() && apart.contacts.is_empty() && !apart.coincident); + assert!(intersect(0.0, tilt).coincident); + } + let crossing = intersect(0.0, 1.0e-9); + assert_eq!(crossing.curves.len(), 1); + assert!(!crossing.coincident); +} diff --git a/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs b/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs index 09951db0..26428c9f 100644 --- a/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs +++ b/main/opengeometry/tests/creating/polyhedron_boolean_contacts.rs @@ -269,3 +269,56 @@ fn box_on_the_floor_inside_an_l_roof_subtracts_to_nothing() { .built(BooleanOp::Subtraction, 1) .assert_volume(16.0 / 3.0 - 0.08); } + +fn turned_points(primitive: Primitive, (axis, degrees): (Point3, f64)) -> Primitive { + let Primitive::Polyhedron { points, faces } = primitive else { + panic!("not a polyhedron"); + }; + let length = axis.iter().map(|value| value * value).sum::().sqrt(); + let axis = axis.map(|value| value / length); + let (sin, cos) = degrees.to_radians().sin_cos(); + let points = points + .into_iter() + .map(|point: Point3| { + let along = (0..3).map(|i| axis[i] * point[i]).sum::(); + let across = [ + axis[1] * point[2] - axis[2] * point[1], + axis[2] * point[0] - axis[0] * point[2], + axis[0] * point[1] - axis[1] * point[0], + ]; + std::array::from_fn(|i| { + point[i] * cos + across[i] * sin + axis[i] * along * (1.0 - cos) + }) + }) + .collect(); + Primitive::Polyhedron { points, faces } +} + +#[test] +fn box_strictly_inside_a_gable_roof_with_turned_points_subtracts_to_nothing() { + for turn in [ + ([1.0, 2.0, 3.0], 37.0), + ( + [-0.5252771006644055, 0.9494761331925581, 0.9087649454590425], + 207.2570567402658, + ), + ] { + let block = || { + turned_points( + hexahedron( + [1.0, 0.1, 0.8], + [[1.0, 0.0, 0.0], [0.0, 0.2, 0.0], [0.0, 0.0, 0.4]], + ), + turn, + ) + }; + let roof = || turned_points(gable_roof(), turn); + let pair = Pair::new(block(), roof()); + pair.assert_empty(BooleanOp::Subtraction, "generic_non_intersecting_boolean"); + pair.built(BooleanOp::Union, 1).assert_volume(4.0); + pair.built(BooleanOp::Intersection, 1).assert_volume(0.08); + Pair::new(roof(), block()) + .built(BooleanOp::Subtraction, 1) + .assert_volume(3.92); + } +} From ab668b1a8f394d0bed81d6965384368212b71230 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Thu, 12 Nov 2026 18:04:22 +0100 Subject: [PATCH 07/10] Decide containment from a boundary point of each body The generic route for bodies whose boundaries do not cross tested a point moved inward from one face of each body. When the other body lay closer to that face than the step, the point fell inside it and both bodies looked inside each other. A point on one body's boundary, away from any contact, is inside or outside the other body, and that decides the containment. --- MIGRATION.md | 1 + .../boolean/handlers/containment/core.rs | 39 ++++++------------- .../boolean/handlers/containment/mod.rs | 2 +- .../handlers/generic/non_intersecting.rs | 8 ++-- .../creating/polyhedron_boolean_contacts.rs | 29 ++++++++++++++ 5 files changed, 47 insertions(+), 32 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 8c11ec9f..88d9b21f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -27,6 +27,7 @@ It is written for people and for coding assistants alike: each section says what - An intersection of two planar solids that touch only on a face now throws `EmptyResult` too, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. - Subtraction: pairs that built in 3.0 build the same bodies, and the report's `handlers` is still `['subtract_planar_polyhedra']`. Subtracting a body that only stands on a face of the host now returns the host. 3.0 threw `InvalidTopology`. When the host lies wholly inside the tool and touches it, on a face, along an edge or at a corner, the subtraction now throws `EmptyResult`, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. - Two flat faces whose normals differ by an angle of at most 1e-11 radians are now treated as parallel. Rounding gives such faces when a polyhedron's points are turned about a slanted axis. Pairs that were refused only because of such faces now build. For example, a box wholly inside a gable roof, not touching it, both turned that way, gives the roof for the union, the box for the intersection, the roof with a cavity for the roof minus the box, and `EmptyResult` for the box minus the roof. 3.0 threw `UnresolvedIntersection` for many such turns, with `plane intersection: SingularSystem` or `intersection curve misses support`. Some pairs that are still refused now throw `CoverageGap` instead. +- A solid wholly inside another and not touching it now gives the same results when it lies very close to one of the other's faces, closer than about a millionth of the model's size. 3.0 threw `UnresolvedIntersection` with `mutual containment without a coincident boundary is unresolved`. - A union or intersection of two planar solids that is still refused throws `CoverageGap` with `details.families` equal to `['layered planar profile extrusion', 'layered planar profile extrusion']`. 3.0 gave `['canonical aligned cuboid', 'canonical aligned cuboid']`. Code that matches the old pair must match the new one. ### Check your result diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs index 1ad9e1ac..e7117d3d 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs @@ -1,5 +1,4 @@ -use crate::brep::{unit, Accuracy, BrepEnvelope, FaceRole, GeometryError, Surface}; -use crate::math::{scale, sub, Point3}; +use crate::brep::{Accuracy, BrepEnvelope, FaceRole, GeometryError, Surface}; use crate::operations::modifying::boolean::assembly::{ append_analytic_input, finish_analytic_result, reverse_face, }; @@ -44,21 +43,10 @@ pub(crate) fn analytic_containment_boolean( finish_analytic_result(out, a, b, true, operation, false) } -pub(crate) fn interior_sample(brep: &BrepEnvelope) -> Result { - let bounds = brep.bounds()?.ok_or_else(|| { - GeometryError::InvalidTopology("cannot classify an empty solid boundary".into()) - })?; - let model_scale = bounds - .axes - .iter() - .map(|axis| axis.width()) - .fold(brep.accuracy.geometric, f64::max); - let offsets = [ - (model_scale * 1.0e-6).max(32.0 * brep.accuracy.geometric), - 16.0 * brep.accuracy.geometric, - 8.0 * brep.accuracy.geometric, - 4.0 * brep.accuracy.geometric, - ]; +pub(crate) fn boundary_side( + brep: &BrepEnvelope, + other: &BrepEnvelope, +) -> Result { let samples = [0.5, 0.25, 0.75, 0.125, 0.875]; for face in &brep.topology.faces { let surface = brep.geometry.surface(face.surface)?; @@ -71,18 +59,15 @@ pub(crate) fn interior_sample(brep: &BrepEnvelope) -> Result Primitive { + let Primitive::Polyhedron { points, faces } = primitive else { + panic!("not a polyhedron"); + }; + let points = points + .into_iter() + .map(|point: Point3| point.map(|value| value * factor)) + .collect(); + Primitive::Polyhedron { points, faces } +} + +#[test] +fn box_just_above_the_floor_inside_a_large_gable_roof_subtracts_to_nothing() { + let block = || { + hexahedron( + [150.0, 2.0e-4, 80.0], + [[100.0, 0.0, 0.0], [0.0, 30.0, 0.0], [0.0, 0.0, 40.0]], + ) + }; + let roof = || scaled(gable_roof(), 100.0); + let pair = Pair::new(block(), roof()); + pair.assert_empty(BooleanOp::Subtraction, "generic_non_intersecting_boolean"); + pair.built(BooleanOp::Union, 1).assert_volume(4.0e6); + pair.built(BooleanOp::Intersection, 1).assert_volume(1.2e5); + Pair::new(roof(), block()) + .built(BooleanOp::Subtraction, 1) + .assert_volume(3.88e6); +} From 030de743637b0ab1fec25cdf7a7a98e900727995 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Thu, 12 Nov 2026 18:43:29 +0100 Subject: [PATCH 08/10] Decide containment only when every shell of a body agrees One boundary point decides containment only for a body with one shell. A body with a cavity, or with several separate solids, can have shells on both sides of the other body, and the generic route then built a wrong result. Each shell now gives its own side; when they differ the route refuses, and a later handler may still build the pair. --- MIGRATION.md | 3 +- .../boolean/handlers/containment/core.rs | 20 +++++- .../tests/booleans/point_classification.rs | 70 ++++++++++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 88d9b21f..e8fb3f5f 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -27,7 +27,8 @@ It is written for people and for coding assistants alike: each section says what - An intersection of two planar solids that touch only on a face now throws `EmptyResult` too, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. - Subtraction: pairs that built in 3.0 build the same bodies, and the report's `handlers` is still `['subtract_planar_polyhedra']`. Subtracting a body that only stands on a face of the host now returns the host. 3.0 threw `InvalidTopology`. When the host lies wholly inside the tool and touches it, on a face, along an edge or at a corner, the subtraction now throws `EmptyResult`, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. - Two flat faces whose normals differ by an angle of at most 1e-11 radians are now treated as parallel. Rounding gives such faces when a polyhedron's points are turned about a slanted axis. Pairs that were refused only because of such faces now build. For example, a box wholly inside a gable roof, not touching it, both turned that way, gives the roof for the union, the box for the intersection, the roof with a cavity for the roof minus the box, and `EmptyResult` for the box minus the roof. 3.0 threw `UnresolvedIntersection` for many such turns, with `plane intersection: SingularSystem` or `intersection curve misses support`. Some pairs that are still refused now throw `CoverageGap` instead. -- A solid wholly inside another and not touching it now gives the same results when it lies very close to one of the other's faces, closer than about a millionth of the model's size. 3.0 threw `UnresolvedIntersection` with `mutual containment without a coincident boundary is unresolved`. +- When the boundaries of two solids do not cross, whether one lies inside the other is now decided from points on their surfaces, not from a point stepped inward from one face. Pairs where the inner solid lay very close to the face that was stepped from, or touched it there, now build. 3.0 threw `UnresolvedIntersection` with `mutual containment without a coincident boundary is unresolved`. Some pairs of a curved solid very close to a flat face, which 3.0 built through `prismatic_curved_boolean`, now report `['generic_non_intersecting_boolean']`, with the same volume, `coincident` false and fewer edges. +- When a solid has a cavity or several separate parts, and its parts lie on different sides of the other solid, the operation now throws `UnresolvedIntersection` with `generic boolean containment classification is unresolved`. 3.0 built a wrong result for some of these pairs, for example a sphere in the wall of a hollow cube that encloses the cavity. - A union or intersection of two planar solids that is still refused throws `CoverageGap` with `details.families` equal to `['layered planar profile extrusion', 'layered planar profile extrusion']`. 3.0 gave `['canonical aligned cuboid', 'canonical aligned cuboid']`. Code that matches the old pair must match the new one. ### Check your result diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs index e7117d3d..94220eb1 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs @@ -46,9 +46,27 @@ pub(crate) fn analytic_containment_boolean( pub(crate) fn boundary_side( brep: &BrepEnvelope, other: &BrepEnvelope, +) -> Result { + let mut sides = Vec::new(); + for shell in &brep.topology.shells { + sides.push(shell_side(brep, &shell.faces, other)?); + } + Ok(match sides.split_first() { + Some((first, rest)) if rest.iter().all(|side| side == first) => *first, + _ => PointClassification::Unknown, + }) +} + +fn shell_side( + brep: &BrepEnvelope, + faces: &[u32], + other: &BrepEnvelope, ) -> Result { let samples = [0.5, 0.25, 0.75, 0.125, 0.875]; - for face in &brep.topology.faces { + for face in faces + .iter() + .map(|&face| &brep.topology.faces[face as usize]) + { let surface = brep.geometry.surface(face.surface)?; for u in samples { for v in samples { diff --git a/main/opengeometry/tests/booleans/point_classification.rs b/main/opengeometry/tests/booleans/point_classification.rs index cbd4fba6..63ba739f 100644 --- a/main/opengeometry/tests/booleans/point_classification.rs +++ b/main/opengeometry/tests/booleans/point_classification.rs @@ -1,4 +1,4 @@ -use opengeometry::brep::{BrepEnvelope, Frame3}; +use opengeometry::brep::{BrepEnvelope, Frame3, GeometryError}; use opengeometry::operations::modifying::boolean::{boolean_brep, BooleanOp}; use opengeometry::primitives; use opengeometry::query::{classify_point, PointClassification}; @@ -153,3 +153,71 @@ fn each_solid_of_a_body_cut_in_two_classifies_its_own_points() { ); } } + +fn ball(id: &str, origin: [f64; 3], radius: f64) -> BrepEnvelope { + let frame = Frame3 { + origin, + ..Frame3::IDENTITY + }; + primitives::sphere(id.into(), frame, radius, accuracy(1e-5)).unwrap() +} + +fn cube(id: &str, origin: [f64; 3], size: f64) -> BrepEnvelope { + let frame = Frame3 { + origin, + ..Frame3::IDENTITY + }; + primitives::cuboid(id.into(), frame, [size; 3], accuracy(1e-5)).unwrap() +} + +fn assert_refused(a: &BrepEnvelope, b: &BrepEnvelope) { + for (first, second) in [(a, b), (b, a)] { + for operation in [ + BooleanOp::Union, + BooleanOp::Intersection, + BooleanOp::Subtraction, + ] { + let Err(error) = boolean_brep(first, second, operation, "refused".into()) else { + panic!("{} {operation:?} {} built", first.id, second.id); + }; + assert!( + matches!( + &error, + GeometryError::UnresolvedIntersection(message) + if message == "generic boolean containment classification is unresolved" + ), + "{} {operation:?} {}: {error:?}", + first.id, + second.id + ); + } + } +} + +#[test] +fn ball_around_the_cavity_of_a_hollow_cube_is_refused_not_measured_wrong() { + let outer = cube("outer", [0.0; 3], 4.0); + let hollow = boolean_brep( + &outer, + &cube("cavity", [1.0; 3], 2.0), + BooleanOp::Subtraction, + "hollow".into(), + ) + .unwrap() + .brep; + assert_refused(&hollow, &ball("ball", [2.0; 3], 1.9)); +} + +#[test] +fn two_balls_in_one_body_against_a_cube_holding_one_are_refused_not_measured_wrong() { + let pair = boolean_brep( + &ball("near", [2.0; 3], 0.5), + &ball("far", [12.0, 2.0, 2.0], 0.5), + BooleanOp::Union, + "balls".into(), + ) + .unwrap() + .brep; + assert_eq!(solid_count(&pair), 2); + assert_refused(&pair, &cube("cube", [0.0; 3], 4.0)); +} From 4f41ca4c3ad4201826c548fbc4737404d2914306 Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Thu, 12 Nov 2026 19:30:51 +0100 Subject: [PATCH 09/10] Place each contained solid as a cavity of the solid that holds it The containment route sampled every shell in the topology, open sheets included, so a sheet could pass as a solid and a later subtraction indexed past the end of the solids. Shells now come from the solids only. Subtracting a contained body made a cavity from its last solid only and put it into the first solid of the host. Every solid of the contained body now becomes a cavity of the host solid that holds it; a contained body with a cavity of its own is refused. --- MIGRATION.md | 3 +- .../boolean/handlers/containment/core.rs | 96 ++++++++++++++--- .../tests/booleans/point_classification.rs | 101 ++++++++++++++++++ 3 files changed, 182 insertions(+), 18 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index e8fb3f5f..365cd6f3 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -28,7 +28,8 @@ It is written for people and for coding assistants alike: each section says what - Subtraction: pairs that built in 3.0 build the same bodies, and the report's `handlers` is still `['subtract_planar_polyhedra']`. Subtracting a body that only stands on a face of the host now returns the host. 3.0 threw `InvalidTopology`. When the host lies wholly inside the tool and touches it, on a face, along an edge or at a corner, the subtraction now throws `EmptyResult`, because nothing is left. 3.0 threw `CoverageGap` or `UnresolvedIntersection`. - Two flat faces whose normals differ by an angle of at most 1e-11 radians are now treated as parallel. Rounding gives such faces when a polyhedron's points are turned about a slanted axis. Pairs that were refused only because of such faces now build. For example, a box wholly inside a gable roof, not touching it, both turned that way, gives the roof for the union, the box for the intersection, the roof with a cavity for the roof minus the box, and `EmptyResult` for the box minus the roof. 3.0 threw `UnresolvedIntersection` for many such turns, with `plane intersection: SingularSystem` or `intersection curve misses support`. Some pairs that are still refused now throw `CoverageGap` instead. - When the boundaries of two solids do not cross, whether one lies inside the other is now decided from points on their surfaces, not from a point stepped inward from one face. Pairs where the inner solid lay very close to the face that was stepped from, or touched it there, now build. 3.0 threw `UnresolvedIntersection` with `mutual containment without a coincident boundary is unresolved`. Some pairs of a curved solid very close to a flat face, which 3.0 built through `prismatic_curved_boolean`, now report `['generic_non_intersecting_boolean']`, with the same volume, `coincident` false and fewer edges. -- When a solid has a cavity or several separate parts, and its parts lie on different sides of the other solid, the operation now throws `UnresolvedIntersection` with `generic boolean containment classification is unresolved`. 3.0 built a wrong result for some of these pairs, for example a sphere in the wall of a hollow cube that encloses the cavity. +- When a body has a cavity or several separate solids, and its shells lie on different sides of the other body, the operation throws `UnresolvedIntersection` with `generic boolean containment classification is unresolved`, unless another handler builds it. 3.0 built a wrong result for some of these pairs, for example a sphere in the wall of a hollow cube that encloses the cavity. +- Subtracting a body that lies wholly inside another now puts a cavity for each of its solids into the solid that holds it. 3.0 made a cavity for only one of them, kept the others as solids turned inside out, and always put the cavity into the first solid, even when the hole lay in another. Subtracting a body that has a cavity of its own and lies wholly inside another now throws `UnresolvedIntersection` with `containment cavity of a hollow cutter is unresolved`. 3.0 threw `InvalidTopology` with `closed shell has no solid membership`. - A union or intersection of two planar solids that is still refused throws `CoverageGap` with `details.families` equal to `['layered planar profile extrusion', 'layered planar profile extrusion']`. 3.0 gave `['canonical aligned cuboid', 'canonical aligned cuboid']`. Code that matches the old pair must match the new one. ### Check your result diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs index 94220eb1..27d3bd71 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs @@ -1,9 +1,12 @@ -use crate::brep::{Accuracy, BrepEnvelope, FaceRole, GeometryError, Surface}; +use crate::brep::{Accuracy, BrepEnvelope, FaceRole, GeometryError, SolidRegion, Surface}; +use crate::math::Point3; use crate::operations::modifying::boolean::assembly::{ append_analytic_input, finish_analytic_result, reverse_face, }; use crate::operations::modifying::boolean::types::{BooleanOp, BooleanResult}; -use crate::query::{classify_point, face_contains_uv, PointClassification}; +use crate::query::{ + classify_point, classify_point_in_shell, face_contains_uv, PointClassification, +}; pub(crate) fn analytic_containment_boolean( a: &BrepEnvelope, @@ -25,13 +28,19 @@ pub(crate) fn analytic_containment_boolean( BooleanOp::Subtraction if b_inside_a => { append_analytic_input(&mut out, a)?; let face_offset = out.topology.faces.len() as u32; + let solids = out.solids.len(); append_analytic_input(&mut out, b)?; - let cavity = out - .solids - .pop() - .ok_or_else(|| GeometryError::InvalidTopology("missing cutter solid".into()))? - .outer_shell; - out.solids[0].cavity_shells.push(cavity); + let cavities = out.solids.split_off(solids); + if cavities.is_empty() || cavities.iter().any(|solid| !solid.cavity_shells.is_empty()) { + return Err(GeometryError::UnresolvedIntersection( + "containment cavity of a hollow cutter is unresolved".into(), + )); + } + for (cavity, solid) in cavities.into_iter().zip(&b.solids) { + let faces = &b.topology.shells[solid.outer_shell as usize].faces; + let home = enclosing_solid(a, b, faces)?; + out.solids[home].cavity_shells.push(cavity.outer_shell); + } for face in face_offset..out.topology.faces.len() as u32 { reverse_face(&mut out, face); out.topology.faces[face as usize].provenance.role = FaceRole::Cut; @@ -48,8 +57,11 @@ pub(crate) fn boundary_side( other: &BrepEnvelope, ) -> Result { let mut sides = Vec::new(); - for shell in &brep.topology.shells { - sides.push(shell_side(brep, &shell.faces, other)?); + for solid in &brep.solids { + for shell in std::iter::once(&solid.outer_shell).chain(&solid.cavity_shells) { + let faces = &brep.topology.shells[*shell as usize].faces; + sides.push(shell_side(brep, faces, other)?); + } } Ok(match sides.split_first() { Some((first, rest)) if rest.iter().all(|side| side == first) => *first, @@ -62,6 +74,60 @@ fn shell_side( faces: &[u32], other: &BrepEnvelope, ) -> Result { + let side = first_sample(brep, faces, |point| { + let place = classify_point(other, point)?; + Ok(matches!( + place, + PointClassification::Inside | PointClassification::Outside + ) + .then_some(place)) + })?; + Ok(side.unwrap_or(PointClassification::Unknown)) +} + +fn enclosing_solid( + a: &BrepEnvelope, + b: &BrepEnvelope, + faces: &[u32], +) -> Result { + let home = first_sample(b, faces, |point| { + for (index, solid) in a.solids.iter().enumerate() { + if solid_place(a, solid, point)? == PointClassification::Inside { + return Ok(Some(index)); + } + } + Ok(None) + })?; + home.ok_or_else(|| { + GeometryError::UnresolvedIntersection("containment cavity has no enclosing solid".into()) + }) +} + +fn solid_place( + brep: &BrepEnvelope, + solid: &SolidRegion, + point: Point3, +) -> Result { + let faces = |shell: u32| &brep.topology.shells[shell as usize].faces; + let outer = classify_point_in_shell(brep, faces(solid.outer_shell), point)?; + if outer != PointClassification::Inside { + return Ok(outer); + } + for cavity in &solid.cavity_shells { + match classify_point_in_shell(brep, faces(*cavity), point)? { + PointClassification::Outside => {} + PointClassification::Inside => return Ok(PointClassification::Outside), + other => return Ok(other), + } + } + Ok(PointClassification::Inside) +} + +fn first_sample( + brep: &BrepEnvelope, + faces: &[u32], + mut found: impl FnMut(Point3) -> Result, GeometryError>, +) -> Result, GeometryError> { let samples = [0.5, 0.25, 0.75, 0.125, 0.875]; for face in faces .iter() @@ -77,15 +143,11 @@ fn shell_side( if face_contains_uv(brep, face, uv)? != Some(true) { continue; } - let place = classify_point(other, surface.point_at(uv)?)?; - if matches!( - place, - PointClassification::Inside | PointClassification::Outside - ) { - return Ok(place); + if let Some(value) = found(surface.point_at(uv)?)? { + return Ok(Some(value)); } } } } - Ok(PointClassification::Unknown) + Ok(None) } diff --git a/main/opengeometry/tests/booleans/point_classification.rs b/main/opengeometry/tests/booleans/point_classification.rs index 63ba739f..2ad49f4c 100644 --- a/main/opengeometry/tests/booleans/point_classification.rs +++ b/main/opengeometry/tests/booleans/point_classification.rs @@ -2,7 +2,10 @@ use opengeometry::brep::{BrepEnvelope, Frame3, GeometryError}; use opengeometry::operations::modifying::boolean::{boolean_brep, BooleanOp}; use opengeometry::primitives; use opengeometry::query::{classify_point, PointClassification}; +use opengeometry::world_graph::{CreatingOperation, Primitive}; use opengeometry_test_support::accuracy::accuracy; +use opengeometry_test_support::volume; +use opengeometry_test_support::world_graph::{graph, named}; #[test] fn analytic_ray_classification_covers_all_surface_families() { @@ -221,3 +224,101 @@ fn two_balls_in_one_body_against_a_cube_holding_one_are_refused_not_measured_wro assert_eq!(solid_count(&pair), 2); assert_refused(&pair, &cube("cube", [0.0; 3], 4.0)); } + +#[test] +fn cube_minus_two_balls_inside_it_keeps_both_balls_as_cavities() { + let pair = boolean_brep( + &ball("left", [2.0, 4.0, 4.0], 0.5), + &ball("right", [6.0, 4.0, 4.0], 0.5), + BooleanOp::Union, + "balls".into(), + ) + .unwrap() + .brep; + let cube = cube("cube", [0.0; 3], 8.0); + let result = boolean_brep(&cube, &pair, BooleanOp::Subtraction, "hollowed".into()) + .unwrap() + .brep; + assert_eq!(solid_count(&result), 1); + for (point, place) in [ + ([2.0, 4.0, 4.0], PointClassification::Outside), + ([6.0, 4.0, 4.0], PointClassification::Outside), + ([4.0, 4.0, 4.0], PointClassification::Inside), + ] { + assert_eq!(classify_point(&result, point).unwrap(), place, "{point:?}"); + } + let measured = volume::estimate(&result, 0.001); + let expected = 512.0 - 2.0 * std::f64::consts::PI / 6.0; + assert!((measured.value - expected).abs() <= measured.error_bound); +} + +#[test] +fn open_tube_inside_a_cube_is_refused() { + let mut world = graph(); + world + .create_primitive(Primitive::Circle { radius: 0.3 }, named("profile")) + .unwrap(); + world + .create_primitive( + Primitive::Polyline { + points: vec![[0.0; 3], [0.0, 1.0, 0.0]], + closed: false, + }, + named("path"), + ) + .unwrap(); + world + .create_operation( + CreatingOperation::Sweep { + profile: "profile".into(), + path: "path".into(), + caps: Some(false), + }, + named("tube"), + ) + .unwrap(); + let tube = world.placed_brep("tube").unwrap().as_ref().clone(); + let cube = cube("cube", [-2.0, -1.5, -2.0], 4.0); + for (first, second) in [(&cube, &tube), (&tube, &cube)] { + for operation in [ + BooleanOp::Union, + BooleanOp::Intersection, + BooleanOp::Subtraction, + ] { + assert!( + boolean_brep(first, second, operation, "sheet".into()).is_err(), + "{} {operation:?} {}", + first.id, + second.id + ); + } + } +} + +#[test] +fn ball_inside_the_second_of_two_cubes_becomes_that_cubes_cavity() { + let cubes = boolean_brep( + &cube("first", [0.0; 3], 4.0), + &cube("second", [10.0, 0.0, 0.0], 4.0), + BooleanOp::Union, + "cubes".into(), + ) + .unwrap() + .brep; + assert_eq!(solid_count(&cubes), 2); + let ball = ball("ball", [12.0, 2.0, 2.0], 1.0); + let result = boolean_brep(&cubes, &ball, BooleanOp::Subtraction, "hollowed".into()) + .unwrap() + .brep; + result.validate().unwrap(); + let solids = serde_json::to_value(&result).unwrap()["solids"].clone(); + assert_eq!(solids[0]["cavity_shells"].as_array().unwrap().len(), 0); + assert_eq!(solids[1]["cavity_shells"].as_array().unwrap().len(), 1); + assert_eq!( + classify_point(&result, [12.0, 2.0, 2.0]).unwrap(), + PointClassification::Outside + ); + let measured = volume::estimate(&result, 0.001); + let expected = 128.0 - 4.0 / 3.0 * std::f64::consts::PI; + assert!((measured.value - expected).abs() <= measured.error_bound); +} From c18e8a36089fc7b85e8605a1d6bb1ea2870b9fbe Mon Sep 17 00:00:00 2001 From: Vishwajeet Date: Sat, 14 Nov 2026 13:33:20 +0100 Subject: [PATCH 10/10] Subtract a hollow cutter that lies wholly inside the host The material left inside each cavity of the cutter becomes its own solid, and each cavity goes to the innermost solid that holds it. --- MIGRATION.md | 2 +- .../boolean/handlers/containment/core.rs | 104 +++++++++++------- .../tests/booleans/point_classification.rs | 85 ++++++++++++++ 3 files changed, 148 insertions(+), 43 deletions(-) diff --git a/MIGRATION.md b/MIGRATION.md index 365cd6f3..dc3b3cca 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -29,7 +29,7 @@ It is written for people and for coding assistants alike: each section says what - Two flat faces whose normals differ by an angle of at most 1e-11 radians are now treated as parallel. Rounding gives such faces when a polyhedron's points are turned about a slanted axis. Pairs that were refused only because of such faces now build. For example, a box wholly inside a gable roof, not touching it, both turned that way, gives the roof for the union, the box for the intersection, the roof with a cavity for the roof minus the box, and `EmptyResult` for the box minus the roof. 3.0 threw `UnresolvedIntersection` for many such turns, with `plane intersection: SingularSystem` or `intersection curve misses support`. Some pairs that are still refused now throw `CoverageGap` instead. - When the boundaries of two solids do not cross, whether one lies inside the other is now decided from points on their surfaces, not from a point stepped inward from one face. Pairs where the inner solid lay very close to the face that was stepped from, or touched it there, now build. 3.0 threw `UnresolvedIntersection` with `mutual containment without a coincident boundary is unresolved`. Some pairs of a curved solid very close to a flat face, which 3.0 built through `prismatic_curved_boolean`, now report `['generic_non_intersecting_boolean']`, with the same volume, `coincident` false and fewer edges. - When a body has a cavity or several separate solids, and its shells lie on different sides of the other body, the operation throws `UnresolvedIntersection` with `generic boolean containment classification is unresolved`, unless another handler builds it. 3.0 built a wrong result for some of these pairs, for example a sphere in the wall of a hollow cube that encloses the cavity. -- Subtracting a body that lies wholly inside another now puts a cavity for each of its solids into the solid that holds it. 3.0 made a cavity for only one of them, kept the others as solids turned inside out, and always put the cavity into the first solid, even when the hole lay in another. Subtracting a body that has a cavity of its own and lies wholly inside another now throws `UnresolvedIntersection` with `containment cavity of a hollow cutter is unresolved`. 3.0 threw `InvalidTopology` with `closed shell has no solid membership`. +- Subtracting a body that lies wholly inside another now puts a cavity for each of its solids into the solid that holds it. 3.0 made a cavity for only one of them, kept the others as solids turned inside out, and always put the cavity into the first solid, even when the hole lay in another. Subtracting a body that has a cavity of its own and lies wholly inside another now builds: the material left inside that cavity becomes a separate solid, and a cavity of the host or a solid of the cutter that lies inside it goes with that solid. 3.0 threw `InvalidTopology` with `closed shell has no solid membership` for a hollow ball inside a cube. Some of these pairs with a prism-shaped cutter, which 3.0 built through `prismatic_curved_boolean`, now report `['generic_non_intersecting_boolean']`, with the same volume and other faces and edges. - A union or intersection of two planar solids that is still refused throws `CoverageGap` with `details.families` equal to `['layered planar profile extrusion', 'layered planar profile extrusion']`. 3.0 gave `['canonical aligned cuboid', 'canonical aligned cuboid']`. Code that matches the old pair must match the new one. ### Check your result diff --git a/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs b/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs index 27d3bd71..f19bfc9d 100644 --- a/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs +++ b/main/opengeometry/src/operations/modifying/boolean/handlers/containment/core.rs @@ -30,22 +30,25 @@ pub(crate) fn analytic_containment_boolean( let face_offset = out.topology.faces.len() as u32; let solids = out.solids.len(); append_analytic_input(&mut out, b)?; - let cavities = out.solids.split_off(solids); - if cavities.is_empty() || cavities.iter().any(|solid| !solid.cavity_shells.is_empty()) { - return Err(GeometryError::UnresolvedIntersection( - "containment cavity of a hollow cutter is unresolved".into(), - )); - } - for (cavity, solid) in cavities.into_iter().zip(&b.solids) { - let faces = &b.topology.shells[solid.outer_shell as usize].faces; - let home = enclosing_solid(a, b, faces)?; - out.solids[home].cavity_shells.push(cavity.outer_shell); - } for face in face_offset..out.topology.faces.len() as u32 { reverse_face(&mut out, face); out.topology.faces[face as usize].provenance.role = FaceRole::Cut; out.topology.faces[face as usize].provenance.reversed = true; } + let cutters = out.solids.split_off(solids); + let outers = out + .solids + .iter() + .map(|solid| solid.outer_shell) + .chain(cutters.iter().flat_map(|solid| solid.cavity_shells.clone())) + .collect::>(); + let holes = out + .solids + .iter() + .flat_map(|solid| solid.cavity_shells.clone()) + .chain(cutters.iter().map(|solid| solid.outer_shell)) + .collect::>(); + out.solids = nest_shells(&out, &outers, holes)?; } BooleanOp::Subtraction => {} } @@ -85,42 +88,59 @@ fn shell_side( Ok(side.unwrap_or(PointClassification::Unknown)) } -fn enclosing_solid( - a: &BrepEnvelope, - b: &BrepEnvelope, - faces: &[u32], -) -> Result { - let home = first_sample(b, faces, |point| { - for (index, solid) in a.solids.iter().enumerate() { - if solid_place(a, solid, point)? == PointClassification::Inside { - return Ok(Some(index)); - } - } - Ok(None) - })?; - home.ok_or_else(|| { - GeometryError::UnresolvedIntersection("containment cavity has no enclosing solid".into()) - }) +fn nest_shells( + brep: &BrepEnvelope, + outers: &[u32], + holes: Vec, +) -> Result, GeometryError> { + let depths = outers + .iter() + .map(|&shell| Ok(enclosing_outers(brep, outers, shell)?.len())) + .collect::, GeometryError>>()?; + let mut solids = outers + .iter() + .map(|&outer_shell| SolidRegion { + outer_shell, + cavity_shells: Vec::new(), + }) + .collect::>(); + for hole in holes { + let home = enclosing_outers(brep, outers, hole)? + .into_iter() + .max_by_key(|&index| depths[index]) + .ok_or_else(|| { + GeometryError::UnresolvedIntersection( + "containment cavity has no enclosing solid".into(), + ) + })?; + solids[home].cavity_shells.push(hole); + } + Ok(solids) } -fn solid_place( +fn enclosing_outers( brep: &BrepEnvelope, - solid: &SolidRegion, - point: Point3, -) -> Result { + outers: &[u32], + shell: u32, +) -> Result, GeometryError> { let faces = |shell: u32| &brep.topology.shells[shell as usize].faces; - let outer = classify_point_in_shell(brep, faces(solid.outer_shell), point)?; - if outer != PointClassification::Inside { - return Ok(outer); - } - for cavity in &solid.cavity_shells { - match classify_point_in_shell(brep, faces(*cavity), point)? { - PointClassification::Outside => {} - PointClassification::Inside => return Ok(PointClassification::Outside), - other => return Ok(other), + let found = first_sample(brep, faces(shell), |point| { + let mut enclosing = Vec::new(); + for (index, &outer) in outers.iter().enumerate() { + if outer == shell { + continue; + } + match classify_point_in_shell(brep, faces(outer), point)? { + PointClassification::Inside => enclosing.push(index), + PointClassification::Outside => {} + _ => return Ok(None), + } } - } - Ok(PointClassification::Inside) + Ok(Some(enclosing)) + })?; + found.ok_or_else(|| { + GeometryError::UnresolvedIntersection("containment shell nesting is unresolved".into()) + }) } fn first_sample( diff --git a/main/opengeometry/tests/booleans/point_classification.rs b/main/opengeometry/tests/booleans/point_classification.rs index 2ad49f4c..f177f403 100644 --- a/main/opengeometry/tests/booleans/point_classification.rs +++ b/main/opengeometry/tests/booleans/point_classification.rs @@ -322,3 +322,88 @@ fn ball_inside_the_second_of_two_cubes_becomes_that_cubes_cavity() { let expected = 128.0 - 4.0 / 3.0 * std::f64::consts::PI; assert!((measured.value - expected).abs() <= measured.error_bound); } + +fn hollow_ball(id: &str) -> BrepEnvelope { + boolean_brep( + &ball("outer", [4.0; 3], 2.0), + &ball("inner", [4.0; 3], 1.0), + BooleanOp::Subtraction, + id.into(), + ) + .unwrap() + .brep +} + +fn assert_cube_minus(cutter: &BrepEnvelope, host: &BrepEnvelope, places: &[([f64; 3], bool)]) { + let result = boolean_brep(host, cutter, BooleanOp::Subtraction, "hollowed".into()) + .unwrap() + .brep; + result.validate().unwrap(); + assert_eq!(solid_count(&result), 2); + for (point, inside) in places { + let place = if *inside { + PointClassification::Inside + } else { + PointClassification::Outside + }; + assert_eq!(classify_point(&result, *point).unwrap(), place, "{point:?}"); + } + let [measured, host, cutter] = + [&result, host, cutter].map(|body| volume::estimate(body, 0.001)); + let bound = measured.error_bound + host.error_bound + cutter.error_bound; + assert!((measured.value - (host.value - cutter.value)).abs() <= bound); +} + +#[test] +fn cube_minus_a_hollow_ball_inside_it_keeps_the_core_as_a_second_solid() { + assert_cube_minus( + &hollow_ball("hollow"), + &cube("cube", [0.0; 3], 8.0), + &[([4.0; 3], true), ([4.0, 4.0, 5.5], false), ([1.0; 3], true)], + ); +} + +#[test] +fn hollow_ball_around_the_cavity_of_a_cube_leaves_that_cavity_in_the_core() { + let host = boolean_brep( + &cube("cube", [0.0; 3], 8.0), + &ball("cavity", [4.0; 3], 0.5), + BooleanOp::Subtraction, + "host".into(), + ) + .unwrap() + .brep; + assert_cube_minus( + &hollow_ball("hollow"), + &host, + &[ + ([4.0; 3], false), + ([4.0, 4.0, 4.75], true), + ([4.0, 4.0, 5.5], false), + ([1.0; 3], true), + ], + ); +} + +#[test] +fn ball_in_the_hollow_of_a_cutter_becomes_a_cavity_of_the_core() { + let cutter = boolean_brep( + &hollow_ball("hollow"), + &ball("seed", [4.0; 3], 0.5), + BooleanOp::Union, + "cutter".into(), + ) + .unwrap() + .brep; + assert_eq!(solid_count(&cutter), 2); + assert_cube_minus( + &cutter, + &cube("cube", [0.0; 3], 8.0), + &[ + ([4.0; 3], false), + ([4.0, 4.0, 4.75], true), + ([4.0, 4.0, 5.5], false), + ([1.0; 3], true), + ], + ); +}