Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions MIGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,30 @@ 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`. 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 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 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

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.
Expand Down
87 changes: 87 additions & 0 deletions main/opengeometry-three/tests/node/planar-booleans.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import assert from 'node:assert/strict';
import { test } from 'node:test';
import {
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';

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']);
});

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();
}
});
4 changes: 3 additions & 1 deletion main/opengeometry/src/intersection/ssi/planar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 {
Expand Down
25 changes: 25 additions & 0 deletions main/opengeometry/src/intersection/ssi/tests/closed_form.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ pub(crate) enum HandlerId {
BooleanRectilinear,
SubtractLayeredExtrusions,
SubtractPlanarPolyhedra,
BooleanPlanarPolyhedra,
BooleanSpheres,
SubtractVerticalArcExtrusion,
GenericTwoSidedCutterBandSubtraction,
Expand Down Expand Up @@ -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 => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -160,40 +167,36 @@ fn dispatch_layered(
subtract_layered_extrusions(a, b, id)
}

fn dispatch_polyhedra(
a: &BrepEnvelope,
b: &BrepEnvelope,
_: BooleanOp,
id: String,
) -> Result<BooleanResult, GeometryError> {
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,
},
];

Expand All @@ -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())
Expand Down
Loading
Loading