diff --git a/README.md b/README.md
index 8f8ac37..24bc3d6 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@
OpenGeometry is an **open-source, browser-native CAD kernel**. The geometry engine is written in **Rust**, compiled to **WebAssembly**, and paired with a **Three.js-friendly TypeScript layer** so you can build real CAD tools that run in the browser.
-OpenGeometry is best suited for **browser-based CAD, AEC/BIM, configurators, and geometry-heavy web tools**. Whether you're building a parametric modeler, a wall/opening workflow, a BIM viewer, or a custom Three.js modeling tool, OpenGeometry gives you deterministic, kernel-backed primitives and operations without leaving JavaScript.
+OpenGeometry is best suited for **browser-based CAD, configurators, and geometry-heavy web tools**. Whether you're building a parametric modeler, a solid modeling workflow, a geometry viewer, or a custom Three.js modeling tool, OpenGeometry gives you deterministic, kernel-backed primitives and operations without leaving JavaScript.
It is the geometry engine layer, not a full CAD application. OpenPlans is a downstream application/toolkit built on top of OpenGeometry for AEC workflows. In this repository, OpenGeometry is the primary SDK and engine.
@@ -41,7 +41,7 @@ It is the geometry engine layer, not a full CAD application. OpenPlans is a down
Use OpenGeometry when you need:
- browser-based parametric modeling with Rust + WebAssembly performance
-- wall/opening subtraction and other solid boolean workflows
+- cutout subtraction and other solid boolean workflows
- polygon extrusion into solids for CAD or AEC modeling
- IFC, STEP, STL, and PDF-style export/projection in web apps
- a Three.js-friendly CAD kernel instead of ad hoc mesh math
@@ -61,7 +61,7 @@ Good examples include:
**Good fit**
-- browser CAD, AEC/BIM, Mechanical, and geometry-heavy web applications
+- browser CAD, mechanical design, and geometry-heavy web applications
- Three.js-based modeling tools that need a real kernel behind them
- AI first CAD frontends that need deterministic geometry execution in the browser
@@ -186,7 +186,7 @@ npm test
## Who is this for?
-- Teams building **browser-based CAD/BIM/geometry tools**
+- Teams building **browser-based CAD and geometry tools**
- Developers evaluating **WebAssembly-powered 3D** for the web
- Contributors interested in the **Rust → WASM geometry pipeline**
- Anyone exploring **open-source CAD kernel internals**
diff --git a/developer.md b/developer.md
index 1be5224..27803f6 100644
--- a/developer.md
+++ b/developer.md
@@ -24,8 +24,10 @@ npm run build # Full pipeline: Rust → WASM → TS bundle → dist
npm test # Cargo unit + integration tests (no TypeScript tests yet)
```
-`npm run build` runs `build-core` (wasm-pack + cargo release), `build-three` (Rollup),
-and `prepare-dist` in order. Running them out of order produces stale `pkg/` and bundle
+`npm run build` runs `build-core` (wasm-pack + cargo release), `build-three`
+(clean `dist/`, then Rollup), and `prepare-dist` (copy WASM and package metadata) in
+order. The clean step removes declarations for deleted sources before Rollup emits
+new ones. Running the stages out of order produces stale `pkg/` and bundle
mismatches — see `.claude/skills/wasm-build-flow.md` if you hit that.
## Running the example app
diff --git a/docs/api/shapes/analytic-solid.mdx b/docs/api/shapes/analytic-solid.mdx
index ea124a3..0327c5d 100644
--- a/docs/api/shapes/analytic-solid.mdx
+++ b/docs/api/shapes/analytic-solid.mdx
@@ -3,7 +3,7 @@ title: "Analytic solid"
description: "Render authoritative analytic primitives with chord-error tessellation."
---
-`AnalyticSolid` constructs line-profile extrusions, cuboids, cylinders, spheres, cones, frusta, ring tori, circular-wall segments, coaxial circle lofts, and rectangular revolutions from analytic surfaces. Its required surface and curve geometry uses BRep schema version 2. Render triangles are generated from that geometry.
+`AnalyticSolid` constructs line-profile extrusions, cuboids, cylinders, spheres, cones, frusta, ring tori, annular sector extrusions, coaxial circle lofts, and rectangular revolutions from analytic surfaces. Its required surface and curve geometry uses BRep schema version 2. Render triangles are generated from that geometry.
The public boolean helpers accept only `AnalyticSolid` operands and use the in-house analytic engine.
Schema-v1 and faceted operands are rejected; there is no mesh fallback.
@@ -45,31 +45,31 @@ const profile = new AnalyticSolid({
Dimensions must be finite and positive. A frustum may have a zero upper radius. Ring tori require `majorRadius > minorRadius > 0`.
-Use `kind: "circularWall"` with centerline `radius`, `thickness`, `height`, `startAngle`, and signed `sweepAngle` in radians. The frame origin is the arc center at the wall's base. Radius must exceed half thickness; sweep must be nonzero and smaller than a full turn. Negative sweep retains the authored start/end identity.
+Use `kind: "annularSectorExtrusion"` with reference `radius`, `thickness`, `height`, `startAngle`, and signed `sweepAngle` in radians. The frame origin is the arc center at the extrusion base. Radius must exceed half thickness; sweep must be nonzero and smaller than a full turn. Negative sweep retains the authored start/end identity.
-`kind: "circularWallWithOpenings"` adds a required `openings` array. Each opening supplies `id`, center `angle`, tangent `width`, `bottom`, and `height`. Interior openings retain exact inner/outer cylinder trim holes plus planar sill, header, and jamb faces with cut ancestry. Set `bottom: 0` for a door: the cylinder trims follow the bottom notch, the remaining bottom regions are split exactly, and the cut contains a header and two jambs without an inset. Openings must remain strictly inside the authored wall ends and leave resolved material above the head.
+`kind: "annularSectorExtrusionWithOpenings"` adds a required `openings` array. Each opening supplies `id`, center `angle`, tangent `width`, `bottom`, and `height`. Interior openings retain exact inner/outer cylinder trim holes plus planar lower, upper, and lateral cut faces with source ancestry. Set `bottom: 0` for a bottom-reaching opening: the cylinder trims follow the bottom notch, the remaining bottom regions are split exactly, and the cut contains an upper face and two lateral faces without an inset. Openings must remain strictly inside the authored sweep ends and leave resolved material above the head.
```ts
-const wall = new AnalyticSolid({
- kind: "circularWallWithOpenings",
+const sector = new AnalyticSolid({
+ kind: "annularSectorExtrusionWithOpenings",
radius: 3,
thickness: 0.3,
height: 3,
startAngle: 0,
sweepAngle: Math.PI / 2,
- openings: [{ id: "window-1", angle: 0.8, width: 0.9, bottom: 0.9, height: 1.2 }],
+ openings: [{ id: "aperture-1", angle: 0.8, width: 0.9, bottom: 0.9, height: 1.2 }],
});
```
-Use `kind: "circularWallWithArchedOpening"` for one arched opening on a circular wall.
+Use `kind: "annularSectorExtrusionWithArchedOpening"` for one arched opening on an annular sector extrusion.
Its `opening` has the same fields. `height` is the total opening height and must exceed
-`width / 2`. The lower jamb region is exact circular-wall trimming; the header is a cylindrical
-face bounded by corrected intersection curves on the inner and outer wall supports.
+`width / 2`. The lower cut region uses exact annular-sector trimming; the arch cap is a cylindrical
+face bounded by corrected intersection curves on the inner and outer cylindrical supports.
-Use `kind: "straightWallWithArchedOpening"` with wall `width`, `depth`, and `height`, plus
+Use `kind: "boxWithArchedOpening"` with box `width`, `depth`, and `height`, plus
`opening: { id, station, width, bottom, height }`. It constructs the opening directly with two
-shared semicircle edges and a cylindrical header face. The opening must remain strictly inside the
-wall ends and top. These arched forms avoid overlapping cutter booleans and remain authoritative
+shared semicircle edges and a cylindrical arch-cap face. The opening must remain strictly inside the
+box ends and top. These arched forms avoid overlapping cutter booleans and remain authoritative
when retessellated.
`solid.shell(thickness, options?)` offsets inward and returns a closed analytic shell for canonical cuboids, cylinders, spheres, cones, frusta, ring tori, and straight-edge line-profile extrusions. It builds an oriented cavity shell, retains the source face mapping, and includes the solid's complete world placement. Cone offsets move the cavity apex by the true normal offset; frustum offsets trim both caps and retain the authored semi-angle. Profile offsets intersect neighboring offset planes at exact miter vertices and expand authored holes. Thickness must leave a resolved positive interior in every offset dimension; collapsing, reversing, or self-intersecting offsets return `UnresolvedIntersection`. Already-composite solids return `CoverageGap`.
@@ -106,7 +106,7 @@ Call `solid.dispose()` when you remove it permanently.
## STEP export and diagnostics
-`solid.exportStep("metre")` returns `{ text, report }` directly from v2 surfaces, shared curves and oriented edge uses. It supports all five target surface families, frusta, circular walls and the current sphere and aligned-cuboid boolean results, including cavities. It does not require a viewport mesh or facet regrouping.
+`solid.exportStep("metre")` returns `{ text, report }` directly from v2 surfaces, shared curves and oriented edge uses. It supports all five target surface families, frusta, annular sector extrusions and the current sphere and aligned-cuboid boolean results, including cavities. It does not require a viewport mesh or facet regrouping.
Authored coordinates are interpreted as metres. Pass `"millimetre"` to convert coordinates and dimensions once and emit the corresponding unit context. Export applies the complete Three.js world placement once, including positive uniform scale. `getWorldBrepSerialized()` returns the corresponding world model; `getBrepSerialized()` retains local geometry. Retessellation does not alter export text.
diff --git a/main/opengeometry-three/examples-vite/operations/analytic-step-export.html b/main/opengeometry-three/examples-vite/operations/analytic-step-export.html
index 57d60e9..801323e 100644
--- a/main/opengeometry-three/examples-vite/operations/analytic-step-export.html
+++ b/main/opengeometry-three/examples-vite/operations/analytic-step-export.html
@@ -13,7 +13,7 @@
Export authored geometry
Shared analytic surfaces, curves and oriented edge uses go directly to STEP. Display deflection never changes the exported model. Coordinates are authored in metres.
Body Cuboid Cylinder Sphere Cone Frustum Ring torus Circular wall Sphere subtraction Sphere cavity
+ class="og-control-row">Body Cuboid Cylinder Sphere Cone Frustum Ring torus Circular wall Sphere subtraction Sphere cavity
STEP length unit Metre Millimetre
Export authored geometry
minorRadius: 0.4,
frame: frame(0.4),
},
- circularWall: {
- kind: "circularWall",
+ annularSectorExtrusion: {
+ kind: "annularSectorExtrusion",
radius: 2,
thickness: 0.3,
height: 1.5,
diff --git a/main/opengeometry-three/examples-vite/operations/polygon-boolean-operations.html b/main/opengeometry-three/examples-vite/operations/polygon-boolean-operations.html
index 03b178e..55b5a6d 100644
--- a/main/opengeometry-three/examples-vite/operations/polygon-boolean-operations.html
+++ b/main/opengeometry-three/examples-vite/operations/polygon-boolean-operations.html
@@ -1,7 +1,7 @@
- OpenGeometry Planar Profile Solid Boolean Example
+ OpenGeometry Polygon Boolean Operations
+
Back
-
Planar Profile Solid Booleans
+
Polygon Boolean Operations
- Profile solid boolean
+ Planar polygon Boolean
- Two thin, authoritative line-profile extrusions are shown on the left. Their strict-v2
- boolean result is shown on the right with source-face ancestry.
+ Two flat polygons are shown on the left. Their exact 2D union, intersection, or
+ subtraction is shown on the right. This operation produces planar regions, not solids.
@@ -57,11 +70,10 @@ Profile solid boolean
import Stats from "three/examples/jsm/libs/stats.module.js";
import wasmUrl from "../../../opengeometry/pkg/opengeometry_bg.wasm?url";
import {
- booleanIntersection,
- booleanSubtraction,
- booleanUnion,
+ booleanRegions2D,
OpenGeometry,
- AnalyticSolid,
+ Polygon,
+ Vector3,
} from "opengeometry";
const app = document.getElementById("app");
@@ -142,80 +154,79 @@ Profile solid boolean
function disposeObject(object) {
object.traverse((child) => {
- if (child instanceof AnalyticSolid) {
+ if (child instanceof Polygon) {
child.dispose();
- return;
+ child.polygon.free();
}
- child.geometry?.dispose?.();
- if (Array.isArray(child.material)) {
- child.material.forEach((material) => material?.dispose?.());
- return;
- }
- child.material?.dispose?.();
});
}
- function getExecutor() {
- switch (config.operation) {
- case "union":
- return booleanUnion;
- case "intersection":
- return booleanIntersection;
- default:
- return booleanSubtraction;
- }
+ // booleanRegions2D accepts clockwise outer rings in the XZ plane.
+ function rectangle(xMin, zMin, xMax, zMax) {
+ return {
+ outer: [
+ { x: xMin, y: 0, z: zMax },
+ { x: xMax, y: 0, z: zMax },
+ { x: xMax, y: 0, z: zMin },
+ { x: xMin, y: 0, z: zMin },
+ ],
+ holes: [],
+ };
+ }
+
+ function ringArea(ring) {
+ return Math.abs(ring.reduce((sum, point, index) => {
+ const next = ring[(index + 1) % ring.length];
+ return sum + point.x * next.z - next.x * point.z;
+ }, 0)) / 2;
+ }
+
+ function regionArea(region) {
+ return ringArea(region.outer) - region.holes.reduce((sum, hole) => sum + ringArea(hole), 0);
+ }
+
+ function addPlanarPolygon(group, region, color, xOffset, yOffset = 0, opacity = 1) {
+ const toVector = (point) => new Vector3(point.x, point.y, point.z);
+ const polygon = new Polygon({
+ vertices: region.outer.map(toVector),
+ holes: region.holes.map((hole) => hole.map(toVector)),
+ color,
+ });
+ polygon.position.set(xOffset, yOffset, 0);
+ polygon.outline = true;
+ polygon.material.transparent = opacity < 1;
+ polygon.material.opacity = opacity;
+ polygon.material.depthWrite = opacity === 1;
+ group.add(polygon);
}
function rebuildExample() {
- if (!scene) {
- return;
- }
+ if (!scene) return;
const nextGroup = new THREE.Group();
try {
+ const base = rectangle(-1.8, -1, 0.6, 0.8);
+ const tool = rectangle(-0.5 + config.offsetX, -0.7, 1.1 + config.offsetX, 0.4);
+ const regions = booleanRegions2D([base], [tool], config.operation);
+
+ addPlanarPolygon(nextGroup, base, 0x60a5fa, -4, 0, 0.65);
+ addPlanarPolygon(nextGroup, tool, 0xf97316, -4, 0.005, 0.65);
+ regions.forEach((region) => addPlanarPolygon(nextGroup, region, 0x22c55e, 4));
- const base = new AnalyticSolid({
- kind: "linearExtrusion",
- outer: [[-1.8, 0.8], [0.6, 0.8], [0.6, -1], [-1.8, -1]],
- holes: [],
- height: 0.12,
- color: 0x60a5fa,
- deflection: 0.01,
- });
- nextGroup.add(base);
- base.outline = true;
-
- const tool = new AnalyticSolid({
- kind: "linearExtrusion",
- outer: [[-0.5, 0.4], [1.1, 0.4], [1.1, -0.7], [-0.5, -0.7]],
- holes: [],
- height: 0.12,
- color: 0xf97316,
- deflection: 0.01,
- });
- nextGroup.add(tool);
- tool.outline = true;
- tool.position.x = config.offsetX;
-
- const result = getExecutor()(base, tool, {
- outline: true,
- });
-
- base.position.x -= 4;
- tool.position.x -= 4;
- result.position.x += 4;
-
- nextGroup.add(result);
- if (currentGroup) { scene.remove(currentGroup); disposeObject(currentGroup); }
+ if (currentGroup) {
+ scene.remove(currentGroup);
+ disposeObject(currentGroup);
+ }
currentGroup = nextGroup;
scene.add(currentGroup);
- readout.textContent = JSON.stringify(result.report, null, 2);
+ readout.textContent = JSON.stringify({
+ operation: config.operation,
+ geometry: "2D regions",
+ regionCount: regions.length,
+ area: Number(regions.reduce((sum, region) => sum + regionArea(region), 0).toFixed(6)),
+ holes: regions.reduce((sum, region) => sum + region.holes.length, 0),
+ }, null, 2);
errorReadout.textContent = "";
-
- console.groupCollapsed("[boolean-polygon] rebuilt");
- console.log("Config", { ...config });
- console.log("Report", result.report);
- console.groupEnd();
} catch (failure) {
disposeObject(nextGroup);
errorReadout.textContent = `Previous preview retained. ${String(failure)}`;
@@ -226,26 +237,32 @@ Profile solid boolean
scene = new THREE.Scene();
scene.background = new THREE.Color(0xeef2ff);
- const camera = new THREE.PerspectiveCamera(
- 55,
- window.innerWidth / window.innerHeight,
- 0.1,
- 100
- );
- camera.position.set(9.5, 5.4, 10.5);
+ const camera = new THREE.OrthographicCamera(-7.5, 7.5, 5, -5, 0.1, 100);
+ camera.position.set(0, 10, 12);
+ function resizeViewer() {
+ const aspect = app.clientWidth / app.clientHeight;
+ camera.left = -7.5;
+ camera.right = 7.5;
+ camera.top = 7.5 / aspect;
+ camera.bottom = -camera.top;
+ camera.updateProjectionMatrix();
+ renderer.setSize(app.clientWidth, app.clientHeight);
+ }
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
- renderer.setSize(window.innerWidth, window.innerHeight);
app.replaceChildren(renderer.domElement);
+ resizeViewer();
const stats = mountStats();
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
- controls.target.set(0, 0.2, 0);
+ controls.target.set(0, 0, 0);
controls.update();
- scene.add(new THREE.GridHelper(30, 30, 0x4460ff, 0xd5ddff));
+ const grid = new THREE.GridHelper(30, 30, 0x4460ff, 0xd5ddff);
+ grid.position.y = -0.03;
+ scene.add(grid);
scene.add(new THREE.AmbientLight(0xffffff, 0.82));
const light = new THREE.DirectionalLight(0xffffff, 1.15);
@@ -271,11 +288,7 @@ Profile solid boolean
renderer.render(scene, camera);
});
- window.addEventListener("resize", () => {
- camera.aspect = window.innerWidth / window.innerHeight;
- camera.updateProjectionMatrix();
- renderer.setSize(window.innerWidth, window.innerHeight);
- });
+ window.addEventListener("resize", resizeViewer);
}
void main().catch(failure => { errorReadout.textContent = String(failure); });
diff --git a/main/opengeometry-three/examples-vite/shapes/analytic-solids.html b/main/opengeometry-three/examples-vite/shapes/analytic-solids.html
index 23a0d1c..bf4d490 100644
--- a/main/opengeometry-three/examples-vite/shapes/analytic-solids.html
+++ b/main/opengeometry-three/examples-vite/shapes/analytic-solids.html
@@ -61,7 +61,7 @@ Analytic solids
{ kind: "sphere", radius: 0.8, color: 0x639bd0 },
{ kind: "frustum", lowerRadius: 0.8, upperRadius: 0.45, height: 1.8, color: 0x61b0c1 },
{ kind: "torus", majorRadius: 0.75, minorRadius: 0.25, color: 0x7cba83 },
- { kind: "circularWallWithOpenings", radius: 2.4, thickness: 0.3, height: 1.4, startAngle: 0.15, sweepAngle: 1.8, openings: [{ id: "gallery-window", angle: 1.0, width: 0.7, bottom: 0.35, height: 0.7 }], color: 0xd89a88 },
+ { kind: "annularSectorExtrusionWithOpenings", radius: 2.4, thickness: 0.3, height: 1.4, startAngle: 0.15, sweepAngle: 1.8, openings: [{ id: "gallery-window", angle: 1.0, width: 0.7, bottom: 0.35, height: 0.7 }], color: 0xd89a88 },
{ kind: "linearExtrusion", outer: [[-1, -0.75], [1, -0.75], [1, 0.75], [-1, 0.75]], holes: [[[-0.45, -0.3], [-0.45, 0.3], [0.45, 0.3], [0.45, -0.3]]], height: 1.5, color: 0x4d9b86 },
{ kind: "coaxialCircleLoft", lowerRadius: 0.8, upperRadius: 0.25, height: 1.6, color: 0xd97757 },
{ kind: "revolvedRectangle", innerRadius: 0.45, outerRadius: 0.85, height: 1.5, color: 0x6763c8 },
diff --git a/main/opengeometry-three/examples-vite/shapes/circular-wall-arched-opening.html b/main/opengeometry-three/examples-vite/shapes/circular-wall-arched-opening.html
index d7fc098..1e86fe4 100644
--- a/main/opengeometry-three/examples-vite/shapes/circular-wall-arched-opening.html
+++ b/main/opengeometry-three/examples-vite/shapes/circular-wall-arched-opening.html
@@ -97,7 +97,7 @@ Circular Wall Arched Opening
function createSolid() {
return new AnalyticSolid({
- kind: "circularWallWithArchedOpening",
+ kind: "annularSectorExtrusionWithArchedOpening",
radius: value("radius"),
thickness: value("thickness"),
height: value("height"),
diff --git a/main/opengeometry-three/examples-vite/shapes/circular-wall.html b/main/opengeometry-three/examples-vite/shapes/circular-wall.html
index 3846919..87a6349 100644
--- a/main/opengeometry-three/examples-vite/shapes/circular-wall.html
+++ b/main/opengeometry-three/examples-vite/shapes/circular-wall.html
@@ -97,7 +97,7 @@ Analytic Circular Wall
function createSolid() {
return new AnalyticSolid({
- kind: "circularWallWithOpenings",
+ kind: "annularSectorExtrusionWithOpenings",
radius: value("radius"),
thickness: value("thickness"),
height: value("height"),
diff --git a/main/opengeometry-three/examples-vite/shapes/straight-wall-arched-opening.html b/main/opengeometry-three/examples-vite/shapes/straight-wall-arched-opening.html
index e325a09..a16f1cc 100644
--- a/main/opengeometry-three/examples-vite/shapes/straight-wall-arched-opening.html
+++ b/main/opengeometry-three/examples-vite/shapes/straight-wall-arched-opening.html
@@ -87,7 +87,7 @@ Straight Wall Arched Opening
function createSolid() {
return new AnalyticSolid({
- kind: "straightWallWithArchedOpening",
+ kind: "boxWithArchedOpening",
width: value("width"),
depth: value("depth"),
height: value("height"),
diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts
index a08f63a..8d4e19d 100644
--- a/main/opengeometry-three/index.ts
+++ b/main/opengeometry-three/index.ts
@@ -119,7 +119,7 @@ export { AnalyticSolid } from "./src/shapes/analytic-solid";
export { AnalyticPattern, circularPattern, linearPattern, rectangularPattern } from "./src/shapes/analytic-pattern";
export type { AnalyticPatternHit, AnalyticPatternInstance } from "./src/shapes/analytic-pattern";
export type {
- AnalyticBooleanOp, AnalyticBooleanReport, AnalyticBrepOptions, AnalyticFrame, AnalyticAccuracy, AnalyticCircularWallOpening, AnalyticPolygonLoftAlignment, AnalyticProjectionCamera, AnalyticProjectionHlr, AnalyticPrimitiveOptions, AnalyticTessellationStats, AnalyticTessellationOptions, AnalyticStepExport, AnalyticStlExport,
+ AnalyticBooleanOp, AnalyticBooleanReport, AnalyticBrepOptions, AnalyticFrame, AnalyticAccuracy, AnalyticAnnularSectorOpening, AnalyticProfileEdge, AnalyticPolygonLoftAlignment, AnalyticProjectionCamera, AnalyticProjectionHlr, AnalyticPrimitiveOptions, AnalyticTessellationStats, AnalyticTessellationOptions, AnalyticStepExport, AnalyticStlExport,
} from "./src/shapes/analytic-solid";
/**
diff --git a/main/opengeometry-three/package.json b/main/opengeometry-three/package.json
index 1bf5213..f59be68 100644
--- a/main/opengeometry-three/package.json
+++ b/main/opengeometry-three/package.json
@@ -23,8 +23,6 @@
"geometry",
"webassembly",
"wasm",
- "aec",
- "bim",
"ifc",
"step",
"stl",
diff --git a/main/opengeometry-three/src/examples/booleans.ts b/main/opengeometry-three/src/examples/booleans.ts
index 207c1f4..cc98daf 100644
--- a/main/opengeometry-three/src/examples/booleans.ts
+++ b/main/opengeometry-three/src/examples/booleans.ts
@@ -176,7 +176,7 @@ function createExtrudedPreset(
) {
const extrudedOffset = options.extrudedOffset ?? new Vector3(0.0, 0.0, 0.0);
- const wall = new AnalyticSolid({
+ const host = new AnalyticSolid({
kind: "linearExtrusion",
outer: [[-2.2, -0.18], [2.2, -0.18], [2.2, 0.18], [-2.2, 0.18]],
holes: [],
@@ -184,7 +184,7 @@ function createExtrudedPreset(
color: 0x60a5fa,
deflection: 0.01,
});
- const opening = new AnalyticSolid({
+ const cutter = new AnalyticSolid({
kind: "linearExtrusion",
outer: [[-0.7, -0.34], [0.9, -0.34], [0.9, 0.34], [-0.7, 0.34]],
holes: [],
@@ -192,19 +192,19 @@ function createExtrudedPreset(
color: 0xf97316,
deflection: 0.01,
});
- opening.position.set(extrudedOffset.x, 0, extrudedOffset.z);
+ cutter.position.set(extrudedOffset.x, 0, extrudedOffset.z);
- wall.outline = options.outline ?? true;
- opening.outline = options.outline ?? true;
+ host.outline = options.outline ?? true;
+ cutter.outline = options.outline ?? true;
return {
title: `${capitalize(operation)} Extruded Solid`,
description:
"Two coextensive authoritative line-profile extrusions use the in-house planar arrangement.",
- lhsOperand: wall,
- rhsOperand: opening,
- lhsVisual: wall,
- rhsVisual: opening,
+ lhsOperand: host,
+ rhsOperand: cutter,
+ lhsVisual: host,
+ rhsVisual: cutter,
};
}
diff --git a/main/opengeometry-three/src/examples/index.ts b/main/opengeometry-three/src/examples/index.ts
index fec7d48..4978f70 100644
--- a/main/opengeometry-three/src/examples/index.ts
+++ b/main/opengeometry-three/src/examples/index.ts
@@ -6,6 +6,6 @@ export * from './shapes';
export * from './sweep';
export * from './offset';
export * from './offset-ring-variable';
-export * from './wall-from-offsets';
+export * from './ribbon-from-offsets';
export * from './booleans';
export * from './editor-modes';
diff --git a/main/opengeometry-three/src/examples/wall-from-offsets.ts b/main/opengeometry-three/src/examples/ribbon-from-offsets.ts
similarity index 74%
rename from main/opengeometry-three/src/examples/wall-from-offsets.ts
rename to main/opengeometry-three/src/examples/ribbon-from-offsets.ts
index 30221f7..165b22f 100644
--- a/main/opengeometry-three/src/examples/wall-from-offsets.ts
+++ b/main/opengeometry-three/src/examples/ribbon-from-offsets.ts
@@ -13,7 +13,7 @@ function areClose(a: Vector3, b: Vector3): boolean {
return (dx * dx + dy * dy + dz * dz) <= EPSILON * EPSILON;
}
-function buildWallOutline(left: Vector3[], right: Vector3[]): Vector3[] {
+function buildRibbonOutline(left: Vector3[], right: Vector3[]): Vector3[] {
if (left.length === 0 || right.length === 0) {
return [];
}
@@ -29,13 +29,13 @@ function buildWallOutline(left: Vector3[], right: Vector3[]): Vector3[] {
return outline;
}
-export interface WallFromOffsetsOptions {
+export interface RibbonFromOffsetsOptions {
/** Subtract one exact coextensive through slot. Default `false`. */
cutThroughSlot?: boolean;
/** Width of the through slot along its centerline segment. Default `0.6`. */
slotWidth?: number;
/**
- * Wall extrusion height in meters. Default `2.6` to match the example HTML.
+ * Ribbon extrusion height in meters. Default `2.6` to match the example HTML.
*/
height?: number;
}
@@ -46,7 +46,7 @@ function midpoint(a: Vector3, b: Vector3): Vector3 {
function buildThroughSlot(
centerlinePoints: Vector3[],
- wallThickness: number,
+ ribbonThickness: number,
height: number,
width: number,
): AnalyticSolid {
@@ -57,7 +57,7 @@ function buildThroughSlot(
const tangent = [(b.x - a.x) / length, (b.z - a.z) / length];
const normal = [-tangent[1], tangent[0]];
const halfWidth = width * 0.5;
- const halfDepth = wallThickness * 0.5 + 0.1;
+ const halfDepth = ribbonThickness * 0.5 + 0.1;
const point = (along: number, across: number): [number, number] => [
mid.x + tangent[0] * along + normal[0] * across,
-(mid.z + tangent[1] * along + normal[1] * across),
@@ -82,16 +82,16 @@ function buildThroughSlot(
* other thrown value). Used by the example HTML to surface kernel failures
* in a status panel without losing the structured payload.
*/
-export function describeWallSubtractError(error: unknown): string {
+export function describeRibbonSubtractError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
-export function createWallFromOffsetsExample(
+export function createRibbonFromOffsetsExample(
scene: THREE.Scene,
- options: WallFromOffsetsOptions = {}
+ options: RibbonFromOffsetsOptions = {}
) {
const centerline = new Polyline({
points: [
@@ -105,11 +105,11 @@ export function createWallFromOffsetsExample(
color: 0x1f2937,
});
- const wallThickness = 0.45;
- const half = wallThickness * 0.5;
+ const ribbonThickness = 0.45;
+ const half = ribbonThickness * 0.5;
const acuteThreshold = 35.0;
const bevel = true;
- const wallHeight = options.height ?? 2.6;
+ const ribbonHeight = options.height ?? 2.6;
const leftOffset = centerline.getOffset(half, acuteThreshold, bevel);
const rightOffset = centerline.getOffset(-half, acuteThreshold, bevel);
@@ -124,32 +124,32 @@ export function createWallFromOffsetsExample(
color: 0xf97316,
});
- const wallOutline = buildWallOutline(leftOffset.points, rightOffset.points);
- if (wallOutline.length < 3) {
- throw new Error("Failed to create wall polygon from offsets.");
+ const ribbonOutline = buildRibbonOutline(leftOffset.points, rightOffset.points);
+ if (ribbonOutline.length < 3) {
+ throw new Error("Failed to create ribbon polygon from offsets.");
}
- const wallPolygon = new Polygon({
- vertices: wallOutline,
+ const ribbonPolygon = new Polygon({
+ vertices: ribbonOutline,
color: 0x3b82f6,
});
- wallPolygon.position.y = 0.01;
+ ribbonPolygon.position.y = 0.01;
scene.add(centerline);
scene.add(leftOffsetPolyline);
scene.add(rightOffsetPolyline);
- scene.add(wallPolygon);
+ scene.add(ribbonPolygon);
- let cutResult: { wall: THREE.Object3D; cutters: AnalyticSolid[] } | null = null;
+ let cutResult: { ribbon: THREE.Object3D; cutters: AnalyticSolid[] } | null = null;
let cutError: unknown = null;
if (options.cutThroughSlot ?? false) {
- const wallSolid = new AnalyticSolid({
+ const ribbonSolid = new AnalyticSolid({
kind: "linearExtrusion",
- outer: wallOutline.map((point) => [point.x, -point.z]),
+ outer: ribbonOutline.map((point) => [point.x, -point.z]),
holes: [],
- height: wallHeight,
+ height: ribbonHeight,
color: 0x3b82f6,
deflection: 0.01,
});
@@ -162,20 +162,20 @@ export function createWallFromOffsetsExample(
new Vector3(0.1, 0.0, 1.0),
new Vector3(2.6, 0.0, 2.0),
],
- wallThickness,
- wallHeight,
+ ribbonThickness,
+ ribbonHeight,
options.slotWidth ?? 0.6,
);
const cutters = [cutter];
try {
- const cutWall = wallSolid.subtract(cutters, { color: 0x3b82f6 });
- scene.add(cutWall);
- cutResult = { wall: cutWall, cutters };
+ const cutRibbon = ribbonSolid.subtract(cutters, { color: 0x3b82f6 });
+ scene.add(cutRibbon);
+ cutResult = { ribbon: cutRibbon, cutters };
} catch (error) {
cutError = error;
- scene.add(wallSolid);
- cutResult = { wall: wallSolid, cutters };
+ scene.add(ribbonSolid);
+ cutResult = { ribbon: ribbonSolid, cutters };
}
}
@@ -183,7 +183,7 @@ export function createWallFromOffsetsExample(
centerline,
leftOffsetPolyline,
rightOffsetPolyline,
- wallPolygon,
+ ribbonPolygon,
leftOffset,
rightOffset,
cutResult,
diff --git a/main/opengeometry-three/src/operations/boolean-regions.ts b/main/opengeometry-three/src/operations/boolean-regions.ts
new file mode 100644
index 0000000..fe26642
--- /dev/null
+++ b/main/opengeometry-three/src/operations/boolean-regions.ts
@@ -0,0 +1,61 @@
+import * as OGKernel from "../../../opengeometry/pkg/opengeometry";
+import type { OffsetRegion, OffsetRegionPoint } from "./offset-regions";
+
+export type PlanarBooleanOperation = "union" | "intersection" | "subtraction";
+
+export type CurvedRegionEdge =
+ | { kind: "line"; from: [number, number]; to: [number, number] }
+ | { kind: "arc"; center: [number, number]; radius: number; start_angle: number; sweep_angle: number };
+
+export interface CurvedRegion2D {
+ outer: CurvedRegionEdge[];
+ holes: CurvedRegionEdge[][];
+}
+
+type KernelRegionsResult = { regionsSerialized: string };
+type KernelBooleanRegions = (
+ aJson: string,
+ bJson: string,
+ operation: PlanarBooleanOperation,
+) => KernelRegionsResult;
+
+export function booleanRegions2D(
+ a: readonly OffsetRegion[],
+ b: readonly OffsetRegion[],
+ operation: PlanarBooleanOperation,
+): OffsetRegion[] {
+ const kernelExport = (OGKernel as Record).booleanRegions2D;
+ if (typeof kernelExport !== "function") {
+ throw new Error("booleanRegions2D is unavailable in the loaded WASM package");
+ }
+ const point = (value: OffsetRegionPoint): [number, number, number] => [
+ value.x,
+ value.y,
+ value.z,
+ ];
+ const encode = (regions: readonly OffsetRegion[]) =>
+ JSON.stringify(
+ regions.map((region) => ({
+ outer: region.outer.map(point),
+ holes: region.holes.map((hole) => hole.map(point)),
+ })),
+ );
+ const result = (kernelExport as KernelBooleanRegions)(encode(a), encode(b), operation);
+ return JSON.parse(result.regionsSerialized) as OffsetRegion[];
+}
+
+/** Analytic line/arc region Boolean; output arcs retain their exact circles. */
+export function booleanCurvedRegions2D(
+ a: readonly CurvedRegion2D[],
+ b: readonly CurvedRegion2D[],
+ operation: PlanarBooleanOperation,
+): CurvedRegion2D[] {
+ const kernelExport = (OGKernel as Record).booleanCurvedRegions2D;
+ if (typeof kernelExport !== "function") {
+ throw new Error("booleanCurvedRegions2D is unavailable in the loaded WASM package");
+ }
+ const result = (kernelExport as (a: string, b: string, operation: PlanarBooleanOperation) => string)(
+ JSON.stringify(a), JSON.stringify(b), operation,
+ );
+ return JSON.parse(result) as CurvedRegion2D[];
+}
diff --git a/main/opengeometry-three/src/operations/index.ts b/main/opengeometry-three/src/operations/index.ts
index 510c354..99e2985 100644
--- a/main/opengeometry-three/src/operations/index.ts
+++ b/main/opengeometry-three/src/operations/index.ts
@@ -2,6 +2,7 @@
* Kernel-backed boolean helpers and renderable result mesh.
*/
export * from "./boolean";
+export * from "./boolean-regions";
export * from "./analytic-errors";
export * from "./extrude";
export * from "./loft";
diff --git a/main/opengeometry-three/src/operations/offset-regions.ts b/main/opengeometry-three/src/operations/offset-regions.ts
index e540685..86b4335 100644
--- a/main/opengeometry-three/src/operations/offset-regions.ts
+++ b/main/opengeometry-three/src/operations/offset-regions.ts
@@ -32,7 +32,7 @@ type KernelOffsetPolylineRegions = (
* trims, and nonzero-winding resolution of reflex / closed loops. NO boolean union
* (so it is deterministic, unlike the 3D-mesh CSG path). A simple centreline returns
* one region; a self-crossing closed centreline (e.g. a figure-8) returns one per
- * simple sub-loop. Returns an EMPTY array for a genuinely unbuildable input (does
+ * simple sub-loop. Returns an EMPTY array for a collapsed input (does
* not throw, so it never looks like a kernel crash). Rings are CW-outer / CCW-holes.
*
* @param centrelineFlat centreline points as `[x,y,z, …]`.
@@ -63,9 +63,8 @@ type KernelOffsetRingVariable = (
holesJson: string,
) => KernelOffsetRegionsResult;
-/** An excluded interior ring of a variable inset (an easement island, a
- * protected tree pit): its closed ring `[x,y,z, …]` plus one clearance
- * distance per hole edge. The inset GROWS the hole by its distances. */
+/** An excluded interior ring of a variable inset. Its closed ring has one
+ * clearance distance per edge; the inset grows the hole by those distances. */
export interface OffsetHole {
ring: number[] | Float64Array;
distances: number[] | Float64Array;
@@ -74,17 +73,17 @@ export interface OffsetHole {
/**
* Inset a CLOSED ring inward with one distance per edge (edge i =
* `ring[i] → ring[i+1]`; an explicit closing duplicate point is accepted and
- * stripped, the distance count stays one per unique edge). Built for setback
- * envelopes: the result is the CLEARANCE-EXACT buildable region — the ring's
- * interior minus every point within `distances[i]` of edge i's SEGMENT, so
- * distant lot lines are honoured across notches and corners where distances
- * differ become circumscribed clearance arcs (conservative). There is no
+ * stripped, the distance count stays one per unique edge). The result is a
+ * clearance-exact inset region: the ring's interior minus every point within
+ * `distances[i]` of edge i's segment. Distant boundary segments remain
+ * effective across notches; different distances produce circumscribed
+ * clearance arcs. There is no
* `miterLimit` parameter, deliberately: miters and bevels only approximate the
* clearance arc, and a bevel would claim points inside the required distance.
*
* `holes` are excluded interior rings, each grown outward by its own per-edge
* distances — the region keeps clear of a hole's edges exactly as it keeps
- * clear of the lot lines, so a hole can split the region or swallow it.
+ * clear of the outer boundary, so a hole can split the region or swallow it.
*
* Returns CW-outer / CCW-hole regions (canonical start vertex), possibly
* SEVERAL when a deep inset (or a hole) splits a waisted ring into disjoint
@@ -96,7 +95,7 @@ export interface OffsetHole {
*
* @param ring closed ring points as `[x,y,z, …]` (Y is carried through).
* @param distances inward inset distance per edge, metres, each >= 0
- * (0 = the edge stays in place, e.g. lot-line construction).
+ * (0 keeps that boundary edge in place).
* @param holes excluded interior rings with their own per-edge distances.
*/
export function offsetRingVariable(
@@ -133,7 +132,7 @@ type KernelOffsetPolylineGroupRegions = (polylinesJson: string) => KernelOffsetR
* Merge a GROUP of separate polylines (a crossing T / X / L overlap) into one clean
* region by nonzero-winding union of their mitered bands — the overlapping strokes
* merge into a single region with mitered/bevelled corners, no internal edges.
- * Returns CW-outer / CCW-hole regions (one or more); empty if nothing is buildable.
+ * Returns CW-outer / CCW-hole regions (one or more); empty if no region remains.
* No CSG. Used to render/extrude an overlapping crossing as one joined mass.
*/
export function offsetPolylineGroupRegions(polylines: OffsetPolyline[]): OffsetRegion[] {
diff --git a/main/opengeometry-three/src/shapes/analytic-solid.ts b/main/opengeometry-three/src/shapes/analytic-solid.ts
index 0c8c94c..e34197f 100644
--- a/main/opengeometry-three/src/shapes/analytic-solid.ts
+++ b/main/opengeometry-three/src/shapes/analytic-solid.ts
@@ -20,7 +20,7 @@ export interface AnalyticAccuracy {
exchange: number;
}
-export interface AnalyticCircularWallOpening {
+export interface AnalyticAnnularSectorOpening {
id: string;
angle: number;
width: number;
@@ -28,6 +28,10 @@ export interface AnalyticCircularWallOpening {
height: number;
}
+export type AnalyticProfileEdge =
+ | { kind: "line"; from: [number, number]; to: [number, number] }
+ | { kind: "arc"; center: [number, number]; radius: number; startAngle: number; sweepAngle: number };
+
export type AnalyticPolygonLoftAlignment = "auto" | {
upperStartIndex: number;
reverseUpper?: boolean;
@@ -56,17 +60,18 @@ interface AnalyticOptions {
export type AnalyticPrimitiveOptions = AnalyticOptions & (
| { kind: "cuboid"; width: number; depth: number; height: number }
| { kind: "linearExtrusion"; outer: [number, number][]; holes: [number, number][][]; height: number }
+ | { kind: "arcEdgedExtrusion"; outer: AnalyticProfileEdge[]; holes?: AnalyticProfileEdge[][]; height: number }
| { kind: "polygonLoft"; lower: [number, number, number][]; upper: [number, number, number][]; alignment?: AnalyticPolygonLoftAlignment }
- | { kind: "straightWallWithArchedOpening"; width: number; depth: number; height: number; opening: { id: string; station: number; width: number; bottom: number; height: number } }
+ | { kind: "boxWithArchedOpening"; width: number; depth: number; height: number; opening: { id: string; station: number; width: number; bottom: number; height: number } }
| { kind: "planarPolyhedron"; vertices: [number, number, number][]; faces: number[][] }
| { kind: "cylinder" | "cone"; radius: number; height: number }
| { kind: "cylinderSector"; radius: number; height: number; startAngle: number; sweepAngle: number }
| { kind: "sphere"; radius: number }
| { kind: "frustum"; lowerRadius: number; upperRadius: number; height: number }
| { kind: "torus"; majorRadius: number; minorRadius: number }
- | { kind: "circularWall"; radius: number; thickness: number; height: number; startAngle: number; sweepAngle: number }
- | { kind: "circularWallWithOpenings"; radius: number; thickness: number; height: number; startAngle: number; sweepAngle: number; openings: AnalyticCircularWallOpening[] }
- | { kind: "circularWallWithArchedOpening"; radius: number; thickness: number; height: number; startAngle: number; sweepAngle: number; opening: AnalyticCircularWallOpening }
+ | { kind: "annularSectorExtrusion"; radius: number; thickness: number; height: number; startAngle: number; sweepAngle: number }
+ | { kind: "annularSectorExtrusionWithOpenings"; radius: number; thickness: number; height: number; startAngle: number; sweepAngle: number; openings: AnalyticAnnularSectorOpening[] }
+ | { kind: "annularSectorExtrusionWithArchedOpening"; radius: number; thickness: number; height: number; startAngle: number; sweepAngle: number; opening: AnalyticAnnularSectorOpening }
| { kind: "coaxialCircleLoft"; lowerRadius: number; upperRadius: number; height: number }
| { kind: "revolvedRectangle"; innerRadius: number; outerRadius: number; height: number }
| { kind: "chamferedCuboid"; width: number; depth: number; height: number; chamfer: number }
@@ -233,13 +238,49 @@ export class AnalyticSolid extends THREE.Group {
if (!Array.isArray(cutters) || cutters.length === 0 || cutters.some((cutter) => !(cutter instanceof AnalyticSolid))) {
throw new Error("Analytic subtraction requires a nonempty AnalyticSolid array");
}
- let result = this.boolean(cutters[0], "subtraction", options);
- for (const cutter of cutters.slice(1)) {
- const next = result.boolean(cutter, "subtraction", options);
- result.dispose();
- result = next;
+ if (cutters.length > 1) {
+ const host = this.worldKernel();
+ const cutterKernels: OGAnalyticBrep[] = [];
+ let result: OGAnalyticBrep | undefined;
+ try {
+ for (const cutter of cutters) cutterKernels.push(cutter.worldKernel());
+ const payload = `[${cutterKernels.map((cutter) => cutter.get_brep_serialized()).join(",")}]`;
+ result = host.subtract_planar_cutters(payload, getUUID());
+ const report = result.get_boolean_report_serialized();
+ const solid = AnalyticSolid.fromBrep(result.get_brep_serialized(), {
+ deflection: options.deflection ?? this.lastDeflection,
+ color: options.color ?? this.surface.material.color,
+ });
+ solid.booleanReport = report ? JSON.parse(report) as AnalyticBooleanReport : undefined;
+ solid.name = this.name;
+ solid.userData = { ...this.userData };
+ solid.outline = this.outline;
+ return solid;
+ } catch (error) {
+ const parsed = parseAnalyticGeometryError(error);
+ // The native mixed-cutter path already chose a safe cutter order and
+ // checked spatial overlap. A serial fallback may drop an earlier
+ // cylindrical void or accept an invalid arched profile.
+ if (parsed.code !== "coverage_gap"
+ || parsed.families?.[0]?.includes("mixed cutter batch")) throw parsed;
+ } finally {
+ result?.free();
+ cutterKernels.forEach((cutter) => cutter.free());
+ host.free();
+ }
+ }
+ let result: AnalyticSolid | undefined;
+ try {
+ for (const cutter of cutters) {
+ const next = (result ?? this).boolean(cutter, "subtraction", options);
+ result?.dispose();
+ result = next;
+ }
+ return result!;
+ } catch (error) {
+ result?.dispose();
+ throw error;
}
- return result;
}
shell(thickness: number, options: { deflection?: number; color?: THREE.ColorRepresentation } = {}): AnalyticSolid {
@@ -604,6 +645,10 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string):
? [options.outer, ...options.holes].reduce((extent, loop) => (
loop.reduce((loopExtent, point) => Math.max(loopExtent, Math.abs(point[0]), Math.abs(point[1])), extent)
), 0)
+ : options.kind === "arcEdgedExtrusion"
+ ? [options.outer, ...(options.holes ?? [])].reduce((extent, ring) => ring.reduce((loopExtent, edge) => edge.kind === "line"
+ ? Math.max(loopExtent, ...edge.from.map(Math.abs), ...edge.to.map(Math.abs))
+ : Math.max(loopExtent, Math.abs(edge.center[0]) + edge.radius, Math.abs(edge.center[1]) + edge.radius), extent), 0)
: 0;
const loftExtent = options.kind === "polygonLoft"
? [...options.lower, ...options.upper].reduce((extent, point) => Math.max(extent, ...point.map(Math.abs)), 0)
@@ -619,17 +664,17 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string):
? Math.max(2 * options.outerRadius, options.height)
: options.kind === "chamferedCuboid"
? Math.max(options.width, options.depth, options.height)
- : options.kind === "linearExtrusion"
+ : options.kind === "linearExtrusion" || options.kind === "arcEdgedExtrusion"
? Math.max(options.height, profileExtent)
: options.kind === "polygonLoft"
? loftExtent
- : options.kind === "straightWallWithArchedOpening"
+ : options.kind === "boxWithArchedOpening"
? Math.max(options.width, options.depth, options.height)
: options.kind === "planarPolyhedron"
? polyhedronExtent
: options.kind === "frustum"
? Math.max(2 * options.lowerRadius, 2 * options.upperRadius, options.height)
- : options.kind === "circularWall" || options.kind === "circularWallWithOpenings" || options.kind === "circularWallWithArchedOpening"
+ : options.kind === "annularSectorExtrusion" || options.kind === "annularSectorExtrusionWithOpenings" || options.kind === "annularSectorExtrusionWithArchedOpening"
? Math.max(2 * options.radius + options.thickness, options.height)
: options.kind === "cuboid"
? Math.max(options.width, options.depth, options.height)
@@ -653,6 +698,14 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string):
? { radius: options.radius, height: options.height, fillet_radius: options.filletRadius }
: options.kind === "linearExtrusion"
? { outer: options.outer, holes: options.holes, height: options.height }
+ : options.kind === "arcEdgedExtrusion"
+ ? { outer: options.outer.map((edge) => edge.kind === "line" ? edge : {
+ kind: "arc", center: edge.center, radius: edge.radius,
+ start_angle: edge.startAngle, sweep_angle: edge.sweepAngle,
+ }), holes: (options.holes ?? []).map((ring) => ring.map((edge) => edge.kind === "line" ? edge : {
+ kind: "arc", center: edge.center, radius: edge.radius,
+ start_angle: edge.startAngle, sweep_angle: edge.sweepAngle,
+ })), height: options.height }
: options.kind === "polygonLoft"
? {
lower: options.lower,
@@ -665,14 +718,14 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string):
reverse_upper: options.alignment.reverseUpper ?? false,
},
}
- : options.kind === "straightWallWithArchedOpening"
+ : options.kind === "boxWithArchedOpening"
? { width: options.width, depth: options.depth, height: options.height, opening: options.opening }
: options.kind === "planarPolyhedron"
? { vertices: options.vertices, faces: options.faces }
: options.kind === "frustum"
? { lower_radius: options.lowerRadius, upper_radius: options.upperRadius, height: options.height }
- : options.kind === "circularWall" || options.kind === "circularWallWithOpenings" || options.kind === "circularWallWithArchedOpening"
- ? { radius: options.radius, thickness: options.thickness, height: options.height, start_angle: options.startAngle, sweep_angle: options.sweepAngle, ...(options.kind === "circularWallWithOpenings" ? { openings: options.openings.map((opening) => ({ id: opening.id, angle: opening.angle, width: opening.width, bottom: opening.bottom, height: opening.height })) } : options.kind === "circularWallWithArchedOpening" ? { opening: { id: options.opening.id, angle: options.opening.angle, width: options.opening.width, bottom: options.opening.bottom, height: options.opening.height } } : {}) }
+ : options.kind === "annularSectorExtrusion" || options.kind === "annularSectorExtrusionWithOpenings" || options.kind === "annularSectorExtrusionWithArchedOpening"
+ ? { radius: options.radius, thickness: options.thickness, height: options.height, start_angle: options.startAngle, sweep_angle: options.sweepAngle, ...(options.kind === "annularSectorExtrusionWithOpenings" ? { openings: options.openings.map((opening) => ({ id: opening.id, angle: opening.angle, width: opening.width, bottom: opening.bottom, height: opening.height })) } : options.kind === "annularSectorExtrusionWithArchedOpening" ? { opening: { id: options.opening.id, angle: options.opening.angle, width: options.opening.width, bottom: options.opening.bottom, height: options.opening.height } } : {}) }
: options.kind === "cylinderSector"
? { radius: options.radius, height: options.height, start_angle: options.startAngle, sweep_angle: options.sweepAngle }
: options.kind === "cuboid"
@@ -681,12 +734,12 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string):
? { radius: options.radius }
: { radius: options.radius, height: options.height };
return OGAnalyticBrep.from_primitive(JSON.stringify({
- kind: options.kind === "circularWall"
- ? "circular_wall"
- : options.kind === "circularWallWithOpenings"
- ? "circular_wall_with_openings"
- : options.kind === "circularWallWithArchedOpening"
- ? "circular_wall_with_arched_opening"
+ kind: options.kind === "annularSectorExtrusion"
+ ? "annular_sector_extrusion"
+ : options.kind === "annularSectorExtrusionWithOpenings"
+ ? "annular_sector_extrusion_with_openings"
+ : options.kind === "annularSectorExtrusionWithArchedOpening"
+ ? "annular_sector_extrusion_with_arched_opening"
: options.kind === "coaxialCircleLoft"
? "coaxial_circle_loft"
: options.kind === "revolvedRectangle"
@@ -697,10 +750,12 @@ function createPrimitiveKernel(options: AnalyticPrimitiveOptions, ogid: string):
? "filleted_cylinder"
: options.kind === "linearExtrusion"
? "linear_extrusion"
+ : options.kind === "arcEdgedExtrusion"
+ ? "arc_edged_extrusion"
: options.kind === "polygonLoft"
? "polygon_loft"
- : options.kind === "straightWallWithArchedOpening"
- ? "straight_wall_with_arched_opening"
+ : options.kind === "boxWithArchedOpening"
+ ? "box_with_arched_opening"
: options.kind === "planarPolyhedron"
? "planar_polyhedron"
: options.kind === "cylinderSector"
diff --git a/main/opengeometry/examples/analytic_exchange_fixtures.rs b/main/opengeometry/examples/analytic_exchange_fixtures.rs
index ae5b3bb..f9d8c4e 100644
--- a/main/opengeometry/examples/analytic_exchange_fixtures.rs
+++ b/main/opengeometry/examples/analytic_exchange_fixtures.rs
@@ -61,7 +61,16 @@ fn main() -> Result<(), Box> {
primitives::cone("cone".into(), frame, 1.0, 2.0, accuracy)?,
primitives::frustum("frustum".into(), frame, 1.0, 0.4, 2.0, accuracy)?,
primitives::torus("torus".into(), frame, 2.0, 0.5, accuracy)?,
- primitives::circular_wall("wall".into(), frame, 2.4, 0.3, 1.4, 0.15, -1.8, accuracy)?,
+ primitives::annular_sector_extrusion(
+ "wall".into(),
+ frame,
+ 2.4,
+ 0.3,
+ 1.4,
+ 0.15,
+ -1.8,
+ accuracy,
+ )?,
boolean_brep(&host, &cutter, BooleanOp::Subtraction, "cut".into())?.brep,
boolean_brep(&host, &inner, BooleanOp::Subtraction, "cavity".into())?.brep,
boolean_brep(&box_host, &box_cutter, BooleanOp::Union, "box-union".into())?.brep,
diff --git a/main/opengeometry/src/analytic/booleans.rs b/main/opengeometry/src/analytic/booleans.rs
index 2e142e2..a57be1b 100644
--- a/main/opengeometry/src/analytic/booleans.rs
+++ b/main/opengeometry/src/analytic/booleans.rs
@@ -883,7 +883,10 @@ fn separate_boolean(
})
}
-fn analytic_face_mappings(out: &BrepEnvelope, inputs: [&BrepEnvelope; 2]) -> Vec {
+pub(super) fn analytic_face_mappings<'a>(
+ out: &BrepEnvelope,
+ inputs: impl IntoIterator- ,
+) -> Vec
{
inputs
.into_iter()
.flat_map(|input| {
@@ -924,11 +927,11 @@ struct PrismaticSide {
source: FaceSource,
}
-struct PrismaticInput<'a> {
- brep: &'a BrepEnvelope,
- frame: Frame3,
- height: f64,
- contours: Vec>,
+pub(super) struct PrismaticInput<'a> {
+ pub(super) brep: &'a BrepEnvelope,
+ pub(super) frame: Frame3,
+ pub(super) height: f64,
+ pub(super) contours: Vec>,
sides: Vec,
}
@@ -941,7 +944,7 @@ fn prismatic_gap() -> GeometryError {
}
}
-fn brep_face_source(brep: &BrepEnvelope, face: u32) -> FaceSource {
+pub(super) fn brep_face_source(brep: &BrepEnvelope, face: u32) -> FaceSource {
FaceSource {
entity: brep.id.clone(),
body: brep.id.clone(),
@@ -1118,7 +1121,9 @@ fn planar_extrusion_topology_matches(
Ok(comparable(&normalized)? == comparable(expected)?)
}
-fn full_planar_extrusion(brep: &BrepEnvelope) -> Result, GeometryError> {
+pub(super) fn full_planar_extrusion(
+ brep: &BrepEnvelope,
+) -> Result, GeometryError> {
brep.validate()?;
if !matches!(brep.quality, GeometryQuality::Analytic)
|| brep.topology.faces.len() < 5
@@ -1354,7 +1359,7 @@ fn projected_segment_contains(
})
}
-fn prismatic_profile_sources(
+pub(super) fn prismatic_profile_sources(
input: &PrismaticInput<'_>,
common_frame: Frame3,
from: Pt2,
@@ -1933,6 +1938,65 @@ fn remap_closed_loop_pcurve(
})
}
+fn exact_closed_circle_pcurve(
+ source_geometry: &GeometryStore,
+ target_geometry: &GeometryStore,
+ source_pcurve: u32,
+ circle: &CurveGeometry,
+ surface: u32,
+ tolerance: f64,
+) -> Result, GeometryError> {
+ if !matches!(
+ source_geometry.pcurves.get(source_pcurve as usize),
+ Some(PcurveGeometry::ProjectedCurve { .. })
+ ) {
+ return Ok(None);
+ }
+ let CurveGeometry::Circle {
+ frame: circle,
+ radius,
+ } = circle
+ else {
+ return Ok(None);
+ };
+ let support = target_geometry.surface(surface)?;
+ let frame = support.frame();
+ let centre = frame.local(circle.origin);
+ let axis_x = [dot(circle.x, frame.x), dot(circle.x, frame.y)];
+ let axis_y = [dot(circle.y, frame.x), dot(circle.y, frame.y)];
+ match support {
+ SurfaceGeometry::Plane { .. }
+ if centre[2].abs() <= tolerance
+ && dot(circle.x, frame.z).abs() * radius <= tolerance
+ && dot(circle.y, frame.z).abs() * radius <= tolerance =>
+ {
+ Ok(Some(PcurveGeometry::Conic2 {
+ origin: [centre[0], centre[1]],
+ axis_a: axis_x.map(|value| value * radius),
+ axis_b: axis_y.map(|value| value * radius),
+ }))
+ }
+ SurfaceGeometry::Cylinder {
+ radius: support_radius,
+ ..
+ } if centre[0].hypot(centre[1]) <= tolerance
+ && (radius - support_radius).abs() <= tolerance
+ && (dot(circle.z, frame.z).abs() - 1.0).abs() <= 1.0e-10 =>
+ {
+ let rate = dot(cross(circle.x, circle.y), frame.z).signum();
+ let phase = axis_x[1].atan2(axis_x[0]);
+ let original = source_geometry.pcurve_at(source_pcurve, 0.0)?;
+ let lifted_phase = phase
+ + ((original[0] - phase) / std::f64::consts::TAU).round() * std::f64::consts::TAU;
+ Ok(Some(PcurveGeometry::Line2 {
+ origin: [lifted_phase, centre[2]],
+ direction: [rate, 0.0],
+ }))
+ }
+ _ => Ok(None),
+ }
+}
+
fn pcurve_winding(
geometry: &GeometryStore,
surface: u32,
@@ -2308,9 +2372,19 @@ fn generic_single_closed_loop_boolean(
})?
}
curve_geometry => {
- out.geometry.curves.push(curve_geometry);
+ out.geometry.curves.push(curve_geometry.clone());
[0, 1]
.map(|side| {
+ if let Some(exact) = exact_closed_circle_pcurve(
+ &graph.geometry,
+ &out.geometry,
+ branch.pcurves[side],
+ &curve_geometry,
+ selected_surfaces[side].unwrap(),
+ out.accuracy.geometric,
+ )? {
+ return Ok(exact);
+ }
remap_closed_loop_pcurve(
&graph.geometry.pcurves[branch.pcurves[side] as usize],
curve,
@@ -2700,6 +2774,7 @@ fn generic_two_sided_cutter_band_subtraction(
{
return Ok(None);
}
+ let mut closed_branches = Vec::with_capacity(2);
for pair in &body_graph.pairs {
if pair.graph.coincident
|| !pair.graph.contacts.is_empty()
@@ -2708,20 +2783,46 @@ fn generic_two_sided_cutter_band_subtraction(
return Ok(None);
}
let branch = &pair.graph.branches[0];
- if norm(sub(branch.endpoints[0], branch.endpoints[1]))
- > host.accuracy.intersection.max(cutter.accuracy.intersection)
+ let closure = norm(sub(branch.endpoints[0], branch.endpoints[1]));
+ let intersection = host.accuracy.intersection.max(cutter.accuracy.intersection);
+ let geometric = host.accuracy.geometric.max(cutter.accuracy.geometric);
+ let closed = if closure <= intersection {
+ super::face_intersection::IntersectionBranch {
+ curve: branch.curve,
+ pcurves: branch.pcurves,
+ range: branch.range,
+ endpoints: branch.endpoints,
+ }
+ } else if let Some(CurveGeometry::Circle { frame, radius }) =
+ pair.graph.geometry.curves.get(branch.curve as usize)
{
+ let tau = std::f64::consts::TAU;
+ if closure > 4.0 * geometric
+ || branch.range.lo.abs() * radius > 4.0 * geometric
+ || (branch.range.hi - tau).abs() * radius > 4.0 * geometric
+ {
+ return Ok(None);
+ }
+ let point = add(frame.origin, scale(frame.x, *radius));
+ super::face_intersection::IntersectionBranch {
+ curve: branch.curve,
+ pcurves: branch.pcurves,
+ range: Interval::new(0.0, tau)?,
+ endpoints: [point, point],
+ }
+ } else {
return Ok(None);
- }
+ };
if pcurve_winding(
&pair.graph.geometry,
- pcurve_support_surface(&pair.graph.geometry, branch.pcurves[0])?,
- branch.pcurves[0],
- branch.range,
+ pcurve_support_surface(&pair.graph.geometry, closed.pcurves[0])?,
+ closed.pcurves[0],
+ closed.range,
)? != [0, 0]
{
return Ok(None);
}
+ closed_branches.push(closed);
}
let accuracy = Accuracy {
@@ -2739,10 +2840,9 @@ fn generic_two_sided_cutter_band_subtraction(
let cut_face = out.topology.faces.len() as u32;
let mut cut_loops = Vec::with_capacity(2);
let mut cut_bounds: Option<[Interval; 2]> = None;
- for pair in &body_graph.pairs {
+ for (pair, branch) in body_graph.pairs.iter().zip(&closed_branches) {
let host_face = pair.faces[0];
let host_surface = out.topology.faces[host_face as usize].surface;
- let branch = &pair.graph.branches[0];
let (edge, vertex, pcurves) = append_closed_branch_geometry(
&mut out,
&pair.graph,
@@ -2901,9 +3001,19 @@ fn append_closed_branch_geometry(
})?
}
curve_geometry => {
- out.geometry.curves.push(curve_geometry);
+ out.geometry.curves.push(curve_geometry.clone());
[0, 1]
.map(|side| {
+ if let Some(exact) = exact_closed_circle_pcurve(
+ &graph.geometry,
+ &out.geometry,
+ branch.pcurves[side],
+ &curve_geometry,
+ selected_surfaces[side],
+ out.accuracy.geometric,
+ )? {
+ return Ok(exact);
+ }
remap_closed_loop_pcurve(
&graph.geometry.pcurves[branch.pcurves[side] as usize],
curve,
@@ -8219,7 +8329,39 @@ pub fn boolean_brep(
return match super::box_booleans::boolean_boxes(a, b, operation, id.clone()) {
Ok(result) => Ok(result),
Err(GeometryError::CoverageGap { .. }) => {
- boolean_planar_extrusions(a, b, operation, id.clone())
+ match boolean_planar_extrusions(a, b, operation, id.clone()) {
+ Ok(result) => Ok(result),
+ Err(GeometryError::CoverageGap { .. }) => {
+ match super::box_booleans::boolean_rectilinear(
+ a,
+ b,
+ operation,
+ id.clone(),
+ ) {
+ Ok(result) => Ok(result),
+ Err(GeometryError::CoverageGap { .. })
+ if operation == BooleanOp::Subtraction =>
+ {
+ match super::planar_booleans::subtract_layered_extrusions(
+ a,
+ b,
+ id.clone(),
+ ) {
+ Err(GeometryError::CoverageGap { .. }) => {
+ super::planar_booleans::subtract_planar_polyhedra(
+ a,
+ b,
+ id.clone(),
+ )
+ }
+ result => result,
+ }
+ }
+ Err(error) => Err(error),
+ }
+ }
+ Err(error) => Err(error),
+ }
}
Err(error) => Err(error),
};
@@ -8228,6 +8370,26 @@ pub fn boolean_brep(
})();
match specialized {
Err(GeometryError::CoverageGap { .. }) => {
+ if operation == BooleanOp::Subtraction
+ && b.geometry
+ .surfaces
+ .iter()
+ .all(|surface| matches!(surface, SurfaceGeometry::Plane { .. }))
+ && a.geometry
+ .surfaces
+ .iter()
+ .any(|surface| matches!(surface, SurfaceGeometry::Cylinder { .. }))
+ {
+ match super::curved_layered_boolean::subtract_vertical_arc_extrusion(
+ a,
+ b,
+ id.clone(),
+ ) {
+ Ok(result) => return Ok(result),
+ Err(GeometryError::CoverageGap { .. }) => {}
+ Err(error) => return Err(error),
+ }
+ }
if let Some(result) =
generic_two_sided_cutter_band_subtraction(a, b, operation, id.clone())?
{
@@ -8256,6 +8418,502 @@ pub fn boolean_brep(
}
}
+/// Subtract through-depth planar and circular profiles from a rectangular
+/// prism in one analytic arrangement. This avoids making a later circle cut
+/// through the coplanar face tiles created by an earlier rectangular cut.
+fn subtract_prismatic_profile_batch(
+ host: &BrepEnvelope,
+ cutters: &[BrepEnvelope],
+ id: String,
+) -> Result , GeometryError> {
+ use crate::geometry::curved_boolean2d::{boolean_curved_regions, CurveEdge2, CurveRegion2};
+
+ let Ok(prism) = full_planar_extrusion(host) else {
+ return Ok(None);
+ };
+ if prism.contours.len() != 1
+ || prism.contours[0].len() != 4
+ || host.topology.vertices.len() != 8
+ {
+ return Ok(None);
+ }
+ let first_axis = cutters.iter().find_map(|cutter| {
+ full_planar_extrusion(cutter)
+ .ok()
+ .map(|input| input.frame.z)
+ .or_else(|| full_cylinder(cutter).ok().map(|input| input.frame.z))
+ });
+ let Some(across) = first_axis else {
+ return Ok(None);
+ };
+ let up = prism.frame.z;
+ if dot(up, across).abs() > 1.0e-10 {
+ return Ok(None);
+ }
+ let along = unit(cross(up, across))?;
+ let basis = [along, up, across];
+ let mut ranges = [[f64::INFINITY, f64::NEG_INFINITY]; 3];
+ for vertex in &host.topology.vertices {
+ for axis in 0..3 {
+ let value = dot(vertex.position, basis[axis]);
+ ranges[axis][0] = ranges[axis][0].min(value);
+ ranges[axis][1] = ranges[axis][1].max(value);
+ }
+ }
+ let geometric = std::iter::once(host)
+ .chain(cutters.iter())
+ .map(|brep| brep.accuracy.geometric)
+ .fold(0.0_f64, f64::max);
+ let dimensions = ranges.map(|range| range[1] - range[0]);
+ if dimensions.iter().any(|value| *value <= 4.0 * geometric)
+ || host.topology.vertices.iter().any(|vertex| {
+ (0..3).any(|axis| {
+ let value = dot(vertex.position, basis[axis]);
+ (value - ranges[axis][0]).abs() > geometric
+ && (value - ranges[axis][1]).abs() > geometric
+ })
+ })
+ {
+ return Ok(None);
+ }
+ let frame = Frame3 {
+ origin: add(
+ add(scale(along, ranges[0][0]), scale(up, ranges[1][0])),
+ scale(across, ranges[2][0]),
+ ),
+ x: along,
+ y: up,
+ z: across,
+ };
+ frame.validate()?;
+ let line_ring = |points: &[[f64; 2]]| -> Vec {
+ points
+ .iter()
+ .enumerate()
+ .map(|(index, point)| CurveEdge2::Line {
+ from: *point,
+ to: points[(index + 1) % points.len()],
+ })
+ .collect()
+ };
+ let reverse_ring = |ring: &mut Vec| {
+ *ring = ring.iter().rev().map(CurveEdge2::reverse).collect();
+ };
+ let winding_ring = |mut ring: Vec, positive: bool| {
+ let area = ring.iter().map(CurveEdge2::twice_area).sum::();
+ if (area > 0.0) != positive {
+ reverse_ring(&mut ring);
+ }
+ ring
+ };
+ let host_region = CurveRegion2 {
+ outer: line_ring(&[
+ [0.0, 0.0],
+ [0.0, dimensions[1]],
+ [dimensions[0], dimensions[1]],
+ [dimensions[0], 0.0],
+ ]),
+ holes: Vec::new(),
+ };
+ let mut cut_regions = Vec::with_capacity(cutters.len());
+ for cutter in cutters {
+ let vertices_cover_depth = || {
+ let mut lo = f64::INFINITY;
+ let mut hi = f64::NEG_INFINITY;
+ for vertex in &cutter.topology.vertices {
+ let value = frame.local(vertex.position)[2];
+ lo = lo.min(value);
+ hi = hi.max(value);
+ }
+ lo <= geometric && hi >= dimensions[2] - geometric
+ };
+ if !vertices_cover_depth() {
+ return Ok(None);
+ }
+ if let Ok(input) = full_planar_extrusion(cutter) {
+ if dot(input.frame.z, across).abs() < 1.0 - 1.0e-10 {
+ return Ok(None);
+ }
+ let mut rings = input.contours.iter().map(|contour| {
+ line_ring(
+ &contour
+ .iter()
+ .map(|point| {
+ let local = frame.local(*point);
+ [local[0], local[1]]
+ })
+ .collect::>(),
+ )
+ });
+ let outer = winding_ring(
+ rings.next().ok_or_else(|| {
+ GeometryError::InvalidTopology("profile cutter has no outer ring".into())
+ })?,
+ false,
+ );
+ let holes = rings.map(|ring| winding_ring(ring, true)).collect();
+ cut_regions.push(CurveRegion2 { outer, holes });
+ } else if let Ok(input) = full_cylinder(cutter) {
+ if dot(input.frame.z, across).abs() < 1.0 - 1.0e-10 {
+ return Ok(None);
+ }
+ let center = frame.local(input.frame.origin);
+ cut_regions.push(CurveRegion2 {
+ outer: vec![
+ CurveEdge2::Arc {
+ center: [center[0], center[1]],
+ radius: input.radius,
+ start_angle: 0.0,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ CurveEdge2::Arc {
+ center: [center[0], center[1]],
+ radius: input.radius,
+ start_angle: -std::f64::consts::PI,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ ],
+ holes: Vec::new(),
+ });
+ } else {
+ return Ok(None);
+ }
+ }
+ let regions = boolean_curved_regions(
+ &[host_region],
+ &cut_regions,
+ PlanarBooleanOp::Subtraction,
+ geometric,
+ )
+ .map_err(|reason| {
+ GeometryError::UnresolvedIntersection(format!("mixed profile batch arrangement: {reason}"))
+ })?;
+ if regions.is_empty() {
+ return Ok(None);
+ }
+ let convert = |ring: Vec| -> Vec {
+ ring.into_iter()
+ .map(|edge| match edge {
+ CurveEdge2::Line { from, to } => primitives::ProfileEdge::Line { from, to },
+ CurveEdge2::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => primitives::ProfileEdge::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ },
+ })
+ .collect()
+ };
+ let accuracy = Accuracy {
+ geometric,
+ intersection: std::iter::once(host)
+ .chain(cutters.iter())
+ .map(|brep| brep.accuracy.intersection)
+ .fold(0.0_f64, f64::max),
+ tessellation: std::iter::once(host)
+ .chain(cutters.iter())
+ .map(|brep| brep.accuracy.tessellation)
+ .fold(0.0_f64, f64::max),
+ exchange: std::iter::once(host)
+ .chain(cutters.iter())
+ .map(|brep| brep.accuracy.exchange)
+ .fold(0.0_f64, f64::max),
+ };
+ let single_region = regions.len() == 1;
+ let mut parts = Vec::with_capacity(regions.len());
+ for (index, region) in regions.into_iter().enumerate() {
+ parts.push(primitives::arc_edged_extrusion_with_holes(
+ if single_region {
+ id.clone()
+ } else {
+ format!("{id}:part:{index}")
+ },
+ frame,
+ convert(region.outer),
+ region.holes.into_iter().map(convert).collect(),
+ dimensions[2],
+ accuracy,
+ )?);
+ }
+ let mut out = if single_region {
+ parts.pop().ok_or_else(|| {
+ GeometryError::InvalidTopology("profile batch produced no material region".into())
+ })?
+ } else {
+ let mut compound = BrepEnvelope::new(id, accuracy)?;
+ for part in &parts {
+ append_analytic_input(&mut compound, part)?;
+ }
+ compound
+ };
+ let inputs = std::iter::once(host)
+ .chain(cutters.iter())
+ .collect::>();
+ for result_face in &mut out.topology.faces {
+ let surface = out.geometry.surface(result_face.surface)?;
+ let mut sources = Vec::new();
+ let cap = matches!(surface, SurfaceGeometry::Plane { frame: plane }
+ if dot(plane.z, across).abs() > 1.0 - 1.0e-10);
+ if cap {
+ let position = dot(surface.frame().origin, across);
+ for face in &host.topology.faces {
+ let SurfaceGeometry::Plane {
+ frame: source_frame,
+ } = host.geometry.surface(face.surface)?
+ else {
+ continue;
+ };
+ if dot(source_frame.z, across).abs() > 1.0 - 1.0e-10
+ && (dot(source_frame.origin, across) - position).abs() <= geometric
+ {
+ sources.push(brep_face_source(host, face.id));
+ }
+ }
+ } else {
+ let uv = result_face.trim.uv_bounds.map(Interval::midpoint);
+ let sample = surface.point_at(uv)?;
+ for input in &inputs {
+ for face in &input.topology.faces {
+ let source_surface = input.geometry.surface(face.surface)?;
+ let on = match source_surface {
+ SurfaceGeometry::Plane {
+ frame: source_frame,
+ } => {
+ source_frame.local(sample)[2].abs() <= geometric
+ && dot(source_frame.z, across).abs() < 1.0 - 1.0e-10
+ }
+ SurfaceGeometry::Cylinder {
+ frame: source_frame,
+ radius,
+ } => {
+ let local = source_frame.local(sample);
+ (local[0].hypot(local[1]) - radius).abs() <= geometric
+ }
+ _ => false,
+ };
+ if !on {
+ continue;
+ }
+ let hint = face.trim.uv_bounds.map(Interval::midpoint);
+ let source_uv = source_surface.project(sample, Some(hint))?;
+ if face_contains_uv(input, face, source_uv)? == Some(true) {
+ sources.push(brep_face_source(input, face.id));
+ }
+ }
+ }
+ }
+ if sources.is_empty() {
+ return Err(GeometryError::InvalidTopology(format!(
+ "profile batch output face {} has no source",
+ result_face.key
+ )));
+ }
+ let cut = sources
+ .iter()
+ .any(|source| cutters.iter().any(|cutter| cutter.id == source.entity));
+ result_face.provenance = FaceProvenance {
+ sources: unique_sources(sources),
+ role: if cut { FaceRole::Cut } else { FaceRole::Split },
+ reversed: cut,
+ };
+ }
+ out.revision = std::iter::once(host)
+ .chain(cutters.iter())
+ .map(|brep| brep.revision)
+ .max()
+ .unwrap_or(0)
+ .checked_add(1)
+ .ok_or_else(|| GeometryError::LimitExceeded("boolean revision overflow".into()))?;
+ out.validate()?;
+ Ok(Some(BooleanResult {
+ report: BooleanReport {
+ operation: BooleanOp::Subtraction,
+ quality: GeometryQuality::Analytic,
+ contacts: Vec::new(),
+ coincident: false,
+ face_mappings: analytic_face_mappings(&out, inputs),
+ },
+ brep: out,
+ }))
+}
+
+pub fn subtract_planar_cutters(
+ host: &BrepEnvelope,
+ cutters: &[BrepEnvelope],
+ id: String,
+) -> Result {
+ if cutters.is_empty() || cutters.len() > 100 {
+ return Err(GeometryError::InvalidGeometry(
+ "planar batch subtraction requires between one and 100 cutters".into(),
+ ));
+ }
+ if cutters.len() == 1 {
+ return boolean_brep(host, &cutters[0], BooleanOp::Subtraction, id);
+ }
+ let mut accuracy = host.accuracy;
+ let mut bounds = Vec::with_capacity(cutters.len());
+ for cutter in cutters {
+ cutter.validate()?;
+ accuracy.geometric = accuracy.geometric.max(cutter.accuracy.geometric);
+ accuracy.intersection = accuracy.intersection.max(cutter.accuracy.intersection);
+ accuracy.tessellation = accuracy.tessellation.max(cutter.accuracy.tessellation);
+ accuracy.exchange = accuracy.exchange.max(cutter.accuracy.exchange);
+ bounds.push(
+ cutter.bounds()?.ok_or_else(|| {
+ GeometryError::InvalidGeometry("planar cutter has no bounds".into())
+ })?,
+ );
+ }
+ let planar = cutters
+ .iter()
+ .filter(|cutter| {
+ cutter
+ .geometry
+ .surfaces
+ .iter()
+ .all(|surface| matches!(surface, SurfaceGeometry::Plane { .. }))
+ })
+ .collect::>();
+ let cylinders = if planar.len() == cutters.len() {
+ Vec::new()
+ } else {
+ cutters
+ .iter()
+ .filter(|cutter| full_cylinder(cutter).is_ok())
+ .collect::>()
+ };
+ if !cylinders.is_empty() && planar.len() + cylinders.len() == cutters.len() {
+ if let Some(result) = subtract_prismatic_profile_batch(host, cutters, id.clone())? {
+ return Ok(result);
+ }
+ }
+ if !planar.is_empty()
+ && !cylinders.is_empty()
+ && planar.len() + cylinders.len() == cutters.len()
+ {
+ // Reconstruct planar voids first, then subtract transverse cylinders.
+ // Cutters may overlap outside the host, so their world bounds cannot
+ // decide whether the host cut is valid. Each chained Boolean must
+ // validate its exact B-rep or return a coverage error.
+ let planar_cutters = planar.into_iter().cloned().collect::>();
+ let mut result = subtract_planar_cutters(host, &planar_cutters, format!("{id}:planar"))
+ .map_err(|error| match error {
+ GeometryError::CoverageGap { .. } => GeometryError::CoverageGap {
+ families: ["mixed cutter batch".into(), "planar stage".into()],
+ },
+ other => other,
+ })?;
+ let count = cylinders.len();
+ for (index, cylinder) in cylinders.into_iter().enumerate() {
+ let prior = result;
+ let mut next = boolean_brep(
+ &prior.brep,
+ cylinder,
+ BooleanOp::Subtraction,
+ if index + 1 == count {
+ id.clone()
+ } else {
+ format!("{id}:round-{index}")
+ },
+ )
+ .map_err(|error| match error {
+ GeometryError::CoverageGap { .. } => GeometryError::CoverageGap {
+ families: ["mixed cutter batch".into(), "cylindrical stage".into()],
+ },
+ other => other,
+ })?;
+ // The generic two-sided builder records its immediate input as
+ // the source of every copied face. Replace that intermediate
+ // reference with the face's original ancestry so a mixed batch
+ // still identifies the authored host and planar cutter.
+ for face in &mut next.brep.topology.faces {
+ let mut lineage = Vec::new();
+ for source in std::mem::take(&mut face.provenance.sources) {
+ if source.entity == prior.brep.id && source.body == prior.brep.id {
+ let previous = prior
+ .brep
+ .topology
+ .faces
+ .get(source.face as usize)
+ .ok_or_else(|| {
+ GeometryError::InvalidTopology(
+ "mixed cut source face is missing".into(),
+ )
+ })?;
+ if previous.provenance.sources.is_empty() {
+ lineage.push(source);
+ } else {
+ lineage.extend(previous.provenance.sources.iter().cloned());
+ }
+ } else {
+ lineage.push(source);
+ }
+ }
+ face.provenance.sources = unique_sources(lineage);
+ }
+ next.brep.validate()?;
+ result = next;
+ }
+ result.report.face_mappings =
+ analytic_face_mappings(&result.brep, std::iter::once(host).chain(cutters.iter()));
+ return Ok(result);
+ }
+ if host
+ .geometry
+ .surfaces
+ .iter()
+ .any(|surface| matches!(surface, SurfaceGeometry::Cylinder { .. }))
+ && cutters.iter().all(|cutter| {
+ cutter
+ .geometry
+ .surfaces
+ .iter()
+ .all(|surface| matches!(surface, SurfaceGeometry::Plane { .. }))
+ })
+ {
+ match super::curved_layered_boolean::subtract_vertical_arc_extrusion_batch(
+ host,
+ &cutters.iter().collect::>(),
+ id.clone(),
+ ) {
+ Ok(result) => return Ok(result),
+ Err(GeometryError::CoverageGap { .. }) => {}
+ Err(error) => return Err(error),
+ }
+ }
+ for first in 0..cutters.len() {
+ for second in first + 1..cutters.len() {
+ if (0..3).all(|axis| {
+ bounds[first].axes[axis]
+ .hi
+ .min(bounds[second].axes[axis].hi)
+ - bounds[first].axes[axis]
+ .lo
+ .max(bounds[second].axes[axis].lo)
+ > 4.0 * accuracy.geometric
+ }) {
+ return Err(GeometryError::CoverageGap {
+ families: [
+ "overlapping planar batch cutters".into(),
+ "overlapping planar batch cutters".into(),
+ ],
+ });
+ }
+ }
+ }
+ let mut combined = BrepEnvelope::new(format!("{id}:cutters"), accuracy)?;
+ for cutter in cutters {
+ append_analytic_input(&mut combined, cutter)?;
+ }
+ combined.validate()?;
+ super::box_booleans::boolean_rectilinear(host, &combined, BooleanOp::Subtraction, id)
+}
+
pub fn boolean_spheres(
a: &BrepEnvelope,
b: &BrepEnvelope,
@@ -8527,7 +9185,10 @@ mod tests {
.unwrap()
}
fn volume(brep: &BrepEnvelope) -> f64 {
- let mesh = tessellate(brep, 0.02, 2_000_000).unwrap();
+ volume_at_deflection(brep, 0.02)
+ }
+ fn volume_at_deflection(brep: &BrepEnvelope, deflection: f64) -> f64 {
+ let mesh = tessellate(brep, deflection, 2_000_000).unwrap();
mesh.indices
.chunks_exact(3)
.map(|ids| {
@@ -10462,19 +11123,443 @@ mod tests {
}
#[test]
- fn coextensive_profile_extrusions_use_exact_planar_arrangements() {
- let a = extrusion(
- "a",
- vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]],
+ fn planar_extrusion_round_through_cutter_keeps_analytic_brep() {
+ let host = extrusion(
+ "round-host",
+ vec![[0.0, 0.0], [4.0, 0.0], [4.0, 0.3], [0.0, 0.3]],
Vec::new(),
);
- let b = extrusion(
- "b",
- vec![[2.0, -1.0], [5.0, -1.0], [5.0, 2.0], [2.0, 2.0]],
+ let cutter = primitives::cylinder(
+ "round-opening".into(),
+ Frame3 {
+ origin: [2.0, -0.5, 1.5],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ },
+ 0.5,
+ 1.3,
+ host.accuracy,
+ )
+ .unwrap();
+ let result = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ "round-host-cut".into(),
+ )
+ .unwrap();
+ assert_eq!(result.brep.solids.len(), 1);
+ result.brep.validate().unwrap();
+ assert_eq!(
+ classify_point(&result.brep, [2.0, 0.15, 1.5]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [2.0, 0.15, 2.2]).unwrap(),
+ PointClassification::Inside
+ );
+ let cut_face = result
+ .brep
+ .topology
+ .faces
+ .iter()
+ .find(|face| face.provenance.role == FaceRole::Cut)
+ .unwrap();
+ assert_eq!(
+ face_contains_uv(&result.brep, cut_face, [0.0, 0.65]).unwrap(),
+ Some(true)
+ );
+ tessellate(&result.brep, 0.01, 2_000_000).unwrap();
+ crate::analytic::exchange::export_step(&result.brep, "metre").unwrap();
+ }
+
+ #[test]
+ fn rectangular_cut_after_round_cut_never_loses_the_cylindrical_cap() {
+ let host = extrusion(
+ "arched-host",
+ vec![[0.0, 0.0], [4.0, 0.0], [4.0, 0.3], [0.0, 0.3]],
Vec::new(),
);
- for (operation, expected_volume) in [
- (BooleanOp::Union, 63.0),
+ let round = primitives::cylinder(
+ "arched-cap".into(),
+ Frame3 {
+ origin: [2.0, -0.5, 2.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ },
+ 0.5,
+ 1.3,
+ host.accuracy,
+ )
+ .unwrap();
+ let lower = primitives::linear_extrusion(
+ "arched-lower".into(),
+ Frame3 {
+ origin: [0.0, 0.8, 0.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, 1.0],
+ z: [0.0, -1.0, 0.0],
+ },
+ vec![[1.5, 0.5], [2.5, 0.5], [2.5, 2.0], [1.5, 2.0]],
+ Vec::new(),
+ 1.3,
+ host.accuracy,
+ )
+ .unwrap();
+ let batch = subtract_planar_cutters(
+ &host,
+ &[round.clone(), lower.clone()],
+ "arched-batch".into(),
+ )
+ .unwrap();
+ batch.brep.validate().unwrap();
+ assert_eq!(
+ classify_point(&batch.brep, [2.0, 0.15, 2.2]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&batch.brep, [2.0, 0.15, 1.0]).unwrap(),
+ PointClassification::Outside
+ );
+ assert!(batch.brep.topology.faces.iter().any(|face| {
+ matches!(
+ batch.brep.geometry.surfaces[face.surface as usize],
+ SurfaceGeometry::Cylinder { .. }
+ )
+ }));
+ for source in ["arched-cap", "arched-lower"] {
+ assert!(batch.report.face_mappings.iter().any(|mapping| {
+ mapping.source.entity == source && !mapping.result_faces.is_empty()
+ }));
+ }
+ let expected = 4.0 * 0.3 * 3.0 - (1.0 * 1.5 + std::f64::consts::PI * 0.5 * 0.5 / 2.0) * 0.3;
+ assert!((volume_at_deflection(&batch.brep, 0.0001).abs() - expected).abs() < 1.0e-4);
+ crate::analytic::exchange::export_step(&batch.brep, "metre").unwrap();
+ let cap = boolean_brep(&host, &round, BooleanOp::Subtraction, "cap".into()).unwrap();
+ match boolean_brep(&cap.brep, &lower, BooleanOp::Subtraction, "arch".into()) {
+ Ok(result) => {
+ result.brep.validate().unwrap();
+ assert!(result.brep.topology.faces.iter().any(|face| {
+ matches!(
+ result.brep.geometry.surfaces[face.surface as usize],
+ SurfaceGeometry::Cylinder { .. }
+ )
+ }));
+ assert_eq!(
+ classify_point(&result.brep, [2.0, 0.15, 2.2]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [2.0, 0.15, 1.0]).unwrap(),
+ PointClassification::Outside
+ );
+ }
+ Err(GeometryError::CoverageGap { .. }) => {}
+ Err(error) => panic!("unexpected chained arch failure: {error:?}"),
+ }
+ }
+
+ #[test]
+ fn disjoint_mixed_cutter_batch_preserves_both_openings_in_any_order() {
+ let host = extrusion(
+ "mixed-host",
+ vec![[0.0, 0.0], [6.0, 0.0], [6.0, 0.3], [0.0, 0.3]],
+ Vec::new(),
+ );
+ let round = primitives::cylinder(
+ "round".into(),
+ Frame3 {
+ origin: [1.5, -0.5, 1.5],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ },
+ 0.4,
+ 1.3,
+ host.accuracy,
+ )
+ .unwrap();
+ let rect = primitives::linear_extrusion(
+ "rectangle".into(),
+ Frame3 {
+ origin: [0.0, 0.8, 0.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, 1.0],
+ z: [0.0, -1.0, 0.0],
+ },
+ vec![[3.5, 0.7], [4.5, 0.7], [4.5, 2.2], [3.5, 2.2]],
+ Vec::new(),
+ 1.3,
+ host.accuracy,
+ )
+ .unwrap();
+ for cutters in [[round.clone(), rect.clone()], [rect.clone(), round.clone()]] {
+ let result = subtract_planar_cutters(&host, &cutters, "mixed".into()).unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(
+ classify_point(&result.brep, [1.5, 0.15, 1.5]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [4.0, 0.15, 1.5]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [2.5, 0.15, 1.5]).unwrap(),
+ PointClassification::Inside
+ );
+ let expected_volume =
+ 6.0 * 0.3 * 3.0 - 1.0 * 0.3 * 1.5 - std::f64::consts::PI * 0.4 * 0.4 * 0.3;
+ let measured_volume = volume_at_deflection(&result.brep, 0.0001).abs();
+ assert!(
+ (measured_volume - expected_volume).abs() < 1.0e-4,
+ "mixed volume {measured_volume} differs from expected {expected_volume}",
+ );
+ assert!(result.brep.topology.faces.iter().any(|face| {
+ matches!(
+ result.brep.geometry.surfaces[face.surface as usize],
+ SurfaceGeometry::Cylinder { .. }
+ )
+ }));
+ for source in ["round", "rectangle"] {
+ assert!(
+ result.brep.topology.faces.iter().any(|face| {
+ face.provenance
+ .sources
+ .iter()
+ .any(|origin| origin.entity == source)
+ }),
+ "missing source {source}: {:?}",
+ result
+ .brep
+ .topology
+ .faces
+ .iter()
+ .flat_map(|face| face
+ .provenance
+ .sources
+ .iter()
+ .map(|origin| &origin.entity))
+ .collect::>()
+ );
+ assert!(result.report.face_mappings.iter().any(|mapping| {
+ mapping.source.entity == source && !mapping.result_faces.is_empty()
+ }));
+ }
+ crate::analytic::exchange::export_step(&result.brep, "metre").unwrap();
+ }
+ }
+
+ #[test]
+ fn two_round_cutter_batch_keeps_both_analytic_voids() {
+ let host = extrusion(
+ "two-round-host",
+ vec![[0.0, 0.0], [6.0, 0.0], [6.0, 0.3], [0.0, 0.3]],
+ Vec::new(),
+ );
+ let make_round = |name: &str, station: f64| {
+ primitives::cylinder(
+ name.into(),
+ Frame3 {
+ origin: [station, 0.0, 1.5],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ },
+ 0.4,
+ 0.3,
+ host.accuracy,
+ )
+ .unwrap()
+ };
+ let first = make_round("round-a", 1.5);
+ let second = make_round("round-b", 4.5);
+ for cutters in [
+ [first.clone(), second.clone()],
+ [second.clone(), first.clone()],
+ ] {
+ let result = subtract_planar_cutters(&host, &cutters, "two-round".into()).unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 1);
+ for station in [1.5, 4.5] {
+ assert_eq!(
+ classify_point(&result.brep, [station, 0.15, 1.5]).unwrap(),
+ PointClassification::Outside
+ );
+ }
+ assert_eq!(
+ classify_point(&result.brep, [3.0, 0.15, 1.5]).unwrap(),
+ PointClassification::Inside
+ );
+ let expected = 6.0 * 0.3 * 3.0 - 2.0 * std::f64::consts::PI * 0.4 * 0.4 * 0.3;
+ assert!((volume_at_deflection(&result.brep, 0.0001).abs() - expected).abs() < 1.0e-4);
+ for source in ["round-a", "round-b"] {
+ assert!(result
+ .report
+ .face_mappings
+ .iter()
+ .any(|mapping| mapping.source.entity == source
+ && !mapping.result_faces.is_empty()));
+ }
+ let (_, report) =
+ crate::analytic::exchange::export_step(&result.brep, "metre").unwrap();
+ assert_eq!(report.solids, 1);
+ }
+ }
+
+ #[test]
+ fn mixed_profile_batch_preserves_disconnected_full_height_host_parts() {
+ let host = extrusion(
+ "split-mixed-host",
+ vec![[0.0, 0.0], [6.0, 0.0], [6.0, 0.3], [0.0, 0.3]],
+ Vec::new(),
+ );
+ let round = primitives::cylinder(
+ "split-round".into(),
+ Frame3 {
+ origin: [1.5, -0.5, 1.5],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ },
+ 0.4,
+ 1.3,
+ host.accuracy,
+ )
+ .unwrap();
+ let through = primitives::linear_extrusion(
+ "full-height".into(),
+ Frame3 {
+ origin: [0.0, 0.3, 0.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, 1.0],
+ z: [0.0, -1.0, 0.0],
+ },
+ vec![[2.5, 0.0], [3.5, 0.0], [3.5, 3.0], [2.5, 3.0]],
+ Vec::new(),
+ 0.3,
+ host.accuracy,
+ )
+ .unwrap();
+ for cutters in [
+ [round.clone(), through.clone()],
+ [through.clone(), round.clone()],
+ ] {
+ let result = subtract_planar_cutters(&host, &cutters, "split-mixed".into()).unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 2);
+ for point in [[1.5, 0.15, 1.5], [3.0, 0.15, 1.5]] {
+ assert_eq!(
+ classify_point(&result.brep, point).unwrap(),
+ PointClassification::Outside
+ );
+ }
+ for point in [[0.5, 0.15, 1.5], [5.0, 0.15, 1.5]] {
+ assert_eq!(
+ classify_point(&result.brep, point).unwrap(),
+ PointClassification::Inside
+ );
+ }
+ let expected =
+ 6.0 * 0.3 * 3.0 - 1.0 * 0.3 * 3.0 - std::f64::consts::PI * 0.4 * 0.4 * 0.3;
+ assert!((volume_at_deflection(&result.brep, 0.0001).abs() - expected).abs() < 1.0e-4);
+ for source in ["split-round", "full-height"] {
+ assert!(result
+ .report
+ .face_mappings
+ .iter()
+ .any(|mapping| mapping.source.entity == source
+ && !mapping.result_faces.is_empty()));
+ }
+ let (_, report) =
+ crate::analytic::exchange::export_step(&result.brep, "metre").unwrap();
+ assert_eq!(report.solids, 2);
+ }
+ }
+
+ #[test]
+ fn flush_mixed_cutters_cross_a_prior_planar_face_split_without_losing_the_round_hole() {
+ let host = extrusion(
+ "flush-mixed-host",
+ vec![[0.0, 0.0], [6.0, 0.0], [6.0, 0.3], [0.0, 0.3]],
+ Vec::new(),
+ );
+ let round = primitives::cylinder(
+ "flush-round".into(),
+ Frame3 {
+ origin: [1.5, 0.0, 1.5],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ },
+ 0.4,
+ 0.3,
+ host.accuracy,
+ )
+ .unwrap();
+ let rectangle = primitives::linear_extrusion(
+ "flush-rectangle".into(),
+ Frame3 {
+ origin: [0.0, 0.3, 0.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, 1.0],
+ z: [0.0, -1.0, 0.0],
+ },
+ vec![[3.5, 0.7], [4.5, 0.7], [4.5, 1.5], [3.5, 1.5]],
+ Vec::new(),
+ 0.3,
+ host.accuracy,
+ )
+ .unwrap();
+ for cutters in [
+ [round.clone(), rectangle.clone()],
+ [rectangle.clone(), round.clone()],
+ ] {
+ let result = subtract_planar_cutters(&host, &cutters, "flush-mixed".into()).unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(
+ classify_point(&result.brep, [1.5, 0.15, 1.5]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [4.0, 0.15, 1.4]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [2.5, 0.15, 1.5]).unwrap(),
+ PointClassification::Inside
+ );
+ assert!(result.brep.topology.faces.iter().any(|face| {
+ matches!(
+ result.brep.geometry.surfaces[face.surface as usize],
+ SurfaceGeometry::Cylinder { .. }
+ )
+ }));
+ for source in ["flush-round", "flush-rectangle"] {
+ assert!(result.report.face_mappings.iter().any(|mapping| {
+ mapping.source.entity == source && !mapping.result_faces.is_empty()
+ }));
+ }
+ crate::analytic::exchange::export_step(&result.brep, "metre").unwrap();
+ }
+ }
+
+ #[test]
+ fn coextensive_profile_extrusions_use_exact_planar_arrangements() {
+ let a = extrusion(
+ "a",
+ vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]],
+ Vec::new(),
+ );
+ let b = extrusion(
+ "b",
+ vec![[2.0, -1.0], [5.0, -1.0], [5.0, 2.0], [2.0, 2.0]],
+ Vec::new(),
+ );
+ for (operation, expected_volume) in [
+ (BooleanOp::Union, 63.0),
(BooleanOp::Intersection, 12.0),
(BooleanOp::Subtraction, 36.0),
] {
@@ -10577,63 +11662,800 @@ mod tests {
}
#[test]
- fn coaxial_conic_containment_builds_exact_cavities_and_ownership() {
- let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
- let host =
- primitives::frustum("host".into(), Frame3::IDENTITY, 3.0, 4.0, 4.0, accuracy).unwrap();
- let cutter = primitives::frustum(
- "cutter".into(),
- Frame3 {
- origin: [0.0, 0.0, 1.0],
- ..Frame3::IDENTITY
- },
- 1.0,
- 1.5,
- 1.0,
- accuracy,
+ fn different_height_planar_openings_support_chained_flush_cuts() {
+ let host = extrusion(
+ "host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.0, 0.3]],
+ Vec::new(),
+ );
+ let first = extrusion_span(
+ "lower_cutout",
+ 0.0,
+ 2.1,
+ vec![[2.0, 0.0], [3.0, 0.0], [3.0, 0.3], [2.0, 0.3]],
+ Vec::new(),
+ );
+ let cut = boolean_brep(
+ &host,
+ &first,
+ BooleanOp::Subtraction,
+ "one-lower_cutout".into(),
)
.unwrap();
- let host_volume = std::f64::consts::PI * 4.0 * (9.0 + 12.0 + 16.0) / 3.0;
- let cutter_volume = std::f64::consts::PI * (1.0 + 1.5 + 2.25) / 3.0;
- for (operation, expected_volume, expected_faces) in [
- (BooleanOp::Union, host_volume, 3),
- (BooleanOp::Intersection, cutter_volume, 3),
- (BooleanOp::Subtraction, host_volume - cutter_volume, 6),
- ] {
- let result =
- boolean_brep(&host, &cutter, operation, format!("conic-{operation:?}")).unwrap();
- result.brep.validate().unwrap();
- assert_eq!(result.brep.topology.faces.len(), expected_faces);
- assert_eq!(result.report.face_mappings.len(), 6);
- let measured = volume(&result.brep).abs();
- assert!(
- (measured - expected_volume).abs() < 0.75,
- "{operation:?}: measured {measured}, expected {expected_volume}"
- );
- if operation == BooleanOp::Subtraction {
- assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
- assert_eq!(
- result
- .brep
- .topology
- .faces
- .iter()
- .filter(|face| face.provenance.role == FaceRole::Cut
- && face.provenance.reversed)
- .count(),
- 3
- );
- }
- }
+ cut.brep.validate().unwrap();
+ assert!((volume(&cut.brep).abs() - 8.37).abs() < 1.0e-6);
+ assert_eq!(
+ super::super::query::classify_point(&cut.brep, [2.5, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&cut.brep, [2.5, 0.15, 2.5]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ let second = extrusion_span(
+ "raised_cutout",
+ 0.8,
+ 1.2,
+ vec![[6.0, 0.0], [7.0, 0.0], [7.0, 0.3], [6.0, 0.3]],
+ Vec::new(),
+ );
+ let chained = boolean_brep(
+ &cut.brep,
+ &second,
+ BooleanOp::Subtraction,
+ "lower_cutout-raised_cutout".into(),
+ )
+ .unwrap();
+ chained.brep.validate().unwrap();
+ assert!((volume(&chained.brep).abs() - 8.01).abs() < 1.0e-6);
+ assert_eq!(
+ super::super::query::classify_point(&chained.brep, [6.5, 0.15, 1.2]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ }
- let touching =
- primitives::frustum("touching".into(), Frame3::IDENTITY, 1.0, 1.5, 1.0, accuracy)
- .unwrap();
- assert!(matches!(
- boolean_brep(
- &host,
- &touching,
- BooleanOp::Subtraction,
+ #[test]
+ fn chained_mitered_host_openings_keep_top_cap_provenance() {
+ let host = extrusion_span(
+ "mitered-host",
+ 0.0,
+ 3.8,
+ vec![
+ [-0.16, -0.14],
+ [0.16, -0.46],
+ [13.84, -0.46],
+ [14.16, -0.14],
+ ],
+ Vec::new(),
+ );
+ let lower_cutout = extrusion_span(
+ "lower_cutout",
+ 0.0,
+ 2.45,
+ vec![[1.2, -0.14], [2.8, -0.14], [2.8, -0.46], [1.2, -0.46]],
+ Vec::new(),
+ );
+ let raised_cutout = extrusion_span(
+ "raised_cutout",
+ 0.3,
+ 2.8,
+ vec![
+ [3.625, -0.14],
+ [5.175, -0.14],
+ [5.175, -0.46],
+ [3.625, -0.46],
+ ],
+ Vec::new(),
+ );
+ let first = boolean_brep(
+ &host,
+ &lower_cutout,
+ BooleanOp::Subtraction,
+ "lower_cutout-cut".into(),
+ )
+ .unwrap();
+ let second = boolean_brep(
+ &first.brep,
+ &raised_cutout,
+ BooleanOp::Subtraction,
+ "raised_cutout-cut".into(),
+ )
+ .unwrap();
+ second.brep.validate().unwrap();
+ assert_eq!(
+ super::super::query::classify_point(&second.brep, [2.0, -0.3, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside,
+ );
+ assert_eq!(
+ super::super::query::classify_point(&second.brep, [4.4, -0.3, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside,
+ );
+ }
+
+ #[test]
+ fn ten_flush_planar_cutters_use_one_rectilinear_arrangement() {
+ let host = extrusion(
+ "host",
+ vec![[0.0, 0.0], [20.0, 0.0], [20.0, 0.3], [0.0, 0.3]],
+ Vec::new(),
+ );
+ let cutters = (0..10)
+ .map(|index| {
+ let left = 1.0 + index as f64 * 1.8;
+ extrusion_span(
+ &format!("opening-{index}"),
+ 0.0,
+ 2.1,
+ vec![
+ [left, 0.0],
+ [left + 0.5, 0.0],
+ [left + 0.5, 0.3],
+ [left, 0.3],
+ ],
+ Vec::new(),
+ )
+ })
+ .collect::>();
+ let result = subtract_planar_cutters(&host, &cutters, "batch".into()).unwrap();
+ result.brep.validate().unwrap();
+ assert!((volume(&result.brep).abs() - 14.85).abs() < 1.0e-6);
+ for index in 0..10 {
+ let station = 1.25 + index as f64 * 1.8;
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [station, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside,
+ );
+ }
+ assert!(result
+ .report
+ .face_mappings
+ .iter()
+ .any(|mapping| mapping.source.entity == "opening-9"));
+ }
+
+ #[test]
+ fn angled_host_cap_accepts_flush_lower_cutout_cutter() {
+ let host = extrusion(
+ "angled-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.3, 0.3], [0.0, 0.1]],
+ Vec::new(),
+ );
+ let cutter = extrusion_span(
+ "lower_cutout",
+ 0.0,
+ 2.1,
+ vec![[2.0, 0.0], [3.0, 0.0], [3.0, 0.3], [2.0, 0.3]],
+ Vec::new(),
+ );
+ let result =
+ boolean_brep(&host, &cutter, BooleanOp::Subtraction, "angled-cut".into()).unwrap();
+ result.brep.validate().unwrap();
+ assert!((volume(&result.brep).abs() - (volume(&host).abs() - 0.63)).abs() < 1.0e-6);
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [2.5, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ let raised_cutout = extrusion_span(
+ "raised_cutout",
+ 0.8,
+ 1.2,
+ vec![[6.0, 0.0], [7.0, 0.0], [7.0, 0.3], [6.0, 0.3]],
+ Vec::new(),
+ );
+ let chained = boolean_brep(
+ &result.brep,
+ &raised_cutout,
+ BooleanOp::Subtraction,
+ "angled-chain".into(),
+ )
+ .unwrap();
+ chained.brep.validate().unwrap();
+ assert!((volume(&chained.brep).abs() - (volume(&host).abs() - 0.99)).abs() < 1.0e-6);
+ assert_eq!(
+ super::super::query::classify_point(&chained.brep, [6.5, 0.15, 1.2]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&chained.brep, [6.5, 0.15, 2.5]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ }
+
+ #[test]
+ fn full_height_planar_opening_splits_angled_host_into_two_solids() {
+ let host = extrusion(
+ "angled-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.3, 0.3], [0.0, 0.1]],
+ Vec::new(),
+ );
+ let cutter = extrusion(
+ "through-lower_cutout",
+ vec![[4.0, 0.0], [5.0, 0.0], [5.0, 0.3], [4.0, 0.3]],
+ Vec::new(),
+ );
+ let result = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ "split-angled".into(),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 2);
+ assert!((volume(&result.brep).abs() - (volume(&host).abs() - 0.9)).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn overlapping_flush_openings_on_angled_host_remove_the_union_once() {
+ let host = extrusion(
+ "angled-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.3, 0.3], [0.0, 0.1]],
+ Vec::new(),
+ );
+ let first = extrusion_span(
+ "first",
+ 0.0,
+ 2.1,
+ vec![[2.0, 0.0], [3.0, 0.0], [3.0, 0.3], [2.0, 0.3]],
+ Vec::new(),
+ );
+ let second = extrusion_span(
+ "second",
+ 0.0,
+ 2.1,
+ vec![[2.5, 0.0], [3.5, 0.0], [3.5, 0.3], [2.5, 0.3]],
+ Vec::new(),
+ );
+ let once = boolean_brep(&host, &first, BooleanOp::Subtraction, "once".into()).unwrap();
+ let twice =
+ boolean_brep(&once.brep, &second, BooleanOp::Subtraction, "twice".into()).unwrap();
+ twice.brep.validate().unwrap();
+ assert!((volume(&twice.brep).abs() - (volume(&host).abs() - 0.945)).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn layered_planar_cut_supports_an_internal_cavity_shell() {
+ let host = extrusion(
+ "angled-host",
+ vec![
+ [0.0, 0.0],
+ [10.0, 0.0],
+ [10.0, 10.0],
+ [0.3, 10.0],
+ [0.0, 9.8],
+ ],
+ Vec::new(),
+ );
+ let cutter = extrusion_span(
+ "cavity",
+ 1.0,
+ 1.0,
+ vec![[4.0, 4.0], [6.0, 4.0], [6.0, 6.0], [4.0, 6.0]],
+ Vec::new(),
+ );
+ let result = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ "cavity-result".into(),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 1);
+ assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
+ assert!((volume(&result.brep).abs() - (volume(&host).abs() - 4.0)).abs() < 1.0e-6);
+ }
+
+ #[test]
+ fn angled_host_accepts_rotated_planar_cutter_frame() {
+ let host = extrusion(
+ "angled-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.3, 0.3], [0.0, 0.1]],
+ Vec::new(),
+ );
+ let angle = std::f64::consts::FRAC_PI_6;
+ let cutter = primitives::linear_extrusion(
+ "rotated-cutter".into(),
+ Frame3 {
+ origin: [2.5, 0.15, 0.0],
+ x: [angle.cos(), angle.sin(), 0.0],
+ y: [-angle.sin(), angle.cos(), 0.0],
+ z: [0.0, 0.0, 1.0],
+ },
+ vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]],
+ Vec::new(),
+ 2.1,
+ host.accuracy,
+ )
+ .unwrap();
+ let result =
+ boolean_brep(&host, &cutter, BooleanOp::Subtraction, "rotated-cut".into()).unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [2.5, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [2.5, 0.15, 2.5]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ assert!(volume(&result.brep).abs() < volume(&host).abs());
+ }
+
+ #[test]
+ fn oblique_planar_cutter_crosses_a_host_without_tessellating_the_boolean() {
+ let host = extrusion(
+ "oblique-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.3, 0.3], [0.0, 0.1]],
+ Vec::new(),
+ );
+ let angle = std::f64::consts::PI / 12.0;
+ let cutter = primitives::linear_extrusion(
+ "oblique-cutter".into(),
+ Frame3 {
+ origin: [2.5, 0.15, 0.0],
+ x: [angle.cos(), 0.0, -angle.sin()],
+ y: [0.0, 1.0, 0.0],
+ z: [angle.sin(), 0.0, angle.cos()],
+ },
+ vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]],
+ Vec::new(),
+ 2.1,
+ host.accuracy,
+ )
+ .unwrap();
+ let result =
+ boolean_brep(&host, &cutter, BooleanOp::Subtraction, "oblique-cut".into()).unwrap();
+ result.brep.validate().unwrap();
+ assert!(matches!(result.report.quality, GeometryQuality::Analytic));
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [2.8, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [2.8, 0.15, 2.5]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ assert!(volume(&result.brep).abs() < volume(&host).abs());
+ }
+
+ #[test]
+ fn chained_oblique_planar_cuts_keep_both_voids_and_analytic_faces() {
+ let host = extrusion(
+ "chained-oblique-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.3, 0.3], [0.0, 0.1]],
+ Vec::new(),
+ );
+ let angle = std::f64::consts::PI / 12.0;
+ let cutter = |name: &str, station: f64, sign: f64| {
+ primitives::linear_extrusion(
+ name.into(),
+ Frame3 {
+ origin: [station, 0.15, 0.0],
+ x: [angle.cos(), 0.0, -sign * angle.sin()],
+ y: [0.0, 1.0, 0.0],
+ z: [sign * angle.sin(), 0.0, angle.cos()],
+ },
+ vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]],
+ Vec::new(),
+ 2.1,
+ host.accuracy,
+ )
+ .unwrap()
+ };
+ let first = cutter("first-oblique", 2.5, 1.0);
+ let second = cutter("second-oblique", 5.0, -1.0);
+ let once = boolean_brep(&host, &first, BooleanOp::Subtraction, "once".into()).unwrap();
+ let twice =
+ boolean_brep(&once.brep, &second, BooleanOp::Subtraction, "twice".into()).unwrap();
+ twice.brep.validate().unwrap();
+ assert!(matches!(twice.report.quality, GeometryQuality::Analytic));
+ for point in [[2.8, 0.15, 1.0], [4.7, 0.15, 1.0]] {
+ assert_eq!(
+ super::super::query::classify_point(&twice.brep, point).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ }
+ assert_eq!(
+ super::super::query::classify_point(&twice.brep, [3.8, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ assert!(volume(&twice.brep).abs() < volume(&once.brep).abs());
+ }
+
+ #[test]
+ fn oblique_cut_accepts_a_disconnected_planar_host() {
+ let host = extrusion(
+ "split-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.0, 0.3]],
+ Vec::new(),
+ );
+ let through = extrusion_span(
+ "through",
+ -1.0,
+ 5.0,
+ vec![[3.0, -1.0], [4.0, -1.0], [4.0, 1.0], [3.0, 1.0]],
+ Vec::new(),
+ );
+ let split = boolean_brep(&host, &through, BooleanOp::Subtraction, "split".into()).unwrap();
+ assert_eq!(split.brep.solids.len(), 2);
+ let angle = std::f64::consts::PI / 12.0;
+ let oblique = primitives::linear_extrusion(
+ "split-oblique".into(),
+ Frame3 {
+ origin: [6.0, 0.15, 0.0],
+ x: [angle.cos(), 0.0, -angle.sin()],
+ y: [0.0, 1.0, 0.0],
+ z: [angle.sin(), 0.0, angle.cos()],
+ },
+ vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]],
+ Vec::new(),
+ 2.1,
+ host.accuracy,
+ )
+ .unwrap();
+ let result = boolean_brep(
+ &split.brep,
+ &oblique,
+ BooleanOp::Subtraction,
+ "split-oblique-result".into(),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 2);
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [6.3, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [1.0, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ }
+
+ #[test]
+ fn planar_cutter_tilts_about_both_host_axes_keep_exact_occupancy() {
+ let host = extrusion(
+ "tilt-sweep-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.0, 0.3]],
+ Vec::new(),
+ );
+ for axis in 0..2 {
+ for degrees in [-25.0_f64, -15.0, -5.0, 5.0, 15.0, 25.0] {
+ let angle = degrees.to_radians();
+ let frame = if axis == 0 {
+ Frame3 {
+ origin: [2.5, 0.15, 0.0],
+ x: [angle.cos(), 0.0, -angle.sin()],
+ y: [0.0, 1.0, 0.0],
+ z: [angle.sin(), 0.0, angle.cos()],
+ }
+ } else {
+ Frame3 {
+ origin: [2.5, 0.15, 0.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, angle.cos(), -angle.sin()],
+ z: [0.0, angle.sin(), angle.cos()],
+ }
+ };
+ let cutter = primitives::linear_extrusion(
+ format!("tilt-{axis}-{degrees}"),
+ frame,
+ vec![[-0.8, -0.8], [0.8, -0.8], [0.8, 0.8], [-0.8, 0.8]],
+ Vec::new(),
+ 2.1,
+ host.accuracy,
+ )
+ .unwrap();
+ let result = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ format!("tilt-result-{axis}-{degrees}"),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [2.5, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside,
+ "axis={axis}, degrees={degrees}"
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [8.0, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Inside,
+ "axis={axis}, degrees={degrees}"
+ );
+ assert!(volume(&result.brep).abs() < volume(&host).abs());
+ }
+ }
+ }
+
+ #[test]
+ fn oblique_cut_preserves_a_planar_host_with_profile_hole_loops() {
+ let host = extrusion(
+ "profile-hole-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 3.0], [0.0, 3.0]],
+ vec![vec![[2.0, 1.0], [3.0, 1.0], [3.0, 2.0], [2.0, 2.0]]],
+ );
+ assert!(host
+ .topology
+ .faces
+ .iter()
+ .any(|face| !face.trim.holes.is_empty()));
+ let angle = std::f64::consts::PI / 12.0;
+ let oblique = primitives::linear_extrusion(
+ "pocket-oblique".into(),
+ Frame3 {
+ origin: [6.0, 1.5, 0.0],
+ x: [angle.cos(), 0.0, -angle.sin()],
+ y: [0.0, 1.0, 0.0],
+ z: [angle.sin(), 0.0, angle.cos()],
+ },
+ vec![[-0.5, -0.5], [0.5, -0.5], [0.5, 0.5], [-0.5, 0.5]],
+ Vec::new(),
+ 2.1,
+ host.accuracy,
+ )
+ .unwrap();
+ let result =
+ boolean_brep(&host, &oblique, BooleanOp::Subtraction, "two-voids".into()).unwrap();
+ result.brep.validate().unwrap();
+ for point in [[2.5, 1.5, 1.5], [6.3, 1.5, 1.0]] {
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, point).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ }
+ for point in [[4.0, 1.5, 1.0], [6.3, 1.5, 2.5]] {
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, point).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ }
+ }
+
+ #[test]
+ fn oblique_nonconvex_planar_cutter_keeps_exact_host_material() {
+ let host = extrusion(
+ "nonconvex-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 0.3], [0.0, 0.3]],
+ Vec::new(),
+ );
+ let angle = std::f64::consts::PI / 12.0;
+ let cutter = primitives::linear_extrusion(
+ "nonconvex-cutter".into(),
+ Frame3 {
+ origin: [5.0, 0.15, 0.0],
+ x: [angle.cos(), 0.0, -angle.sin()],
+ y: [0.0, 1.0, 0.0],
+ z: [angle.sin(), 0.0, angle.cos()],
+ },
+ vec![
+ [-1.0, -1.0],
+ [1.0, -1.0],
+ [1.0, -0.05],
+ [0.0, -0.05],
+ [0.0, 1.0],
+ [-1.0, 1.0],
+ ],
+ Vec::new(),
+ 2.1,
+ host.accuracy,
+ )
+ .unwrap();
+ let result = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ "nonconvex-oblique-cut".into(),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ assert!(matches!(result.report.quality, GeometryQuality::Analytic));
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [4.5, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [5.5, 0.15, 1.0]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ }
+
+ #[test]
+ fn oblique_planar_cut_preserves_an_existing_internal_cavity_shell() {
+ let host = extrusion(
+ "cavity-host",
+ vec![[0.0, 0.0], [10.0, 0.0], [10.0, 3.0], [0.0, 3.0]],
+ Vec::new(),
+ );
+ let cavity = extrusion_span(
+ "internal-cavity",
+ 1.0,
+ 1.0,
+ vec![[2.0, 1.0], [3.0, 1.0], [3.0, 2.0], [2.0, 2.0]],
+ Vec::new(),
+ );
+ let host = boolean_brep(&host, &cavity, BooleanOp::Subtraction, "cavity-host".into())
+ .unwrap()
+ .brep;
+ assert_eq!(host.solids[0].cavity_shells.len(), 1);
+ let angle = std::f64::consts::PI / 12.0;
+ let cutter = primitives::linear_extrusion(
+ "oblique-after-cavity".into(),
+ Frame3 {
+ origin: [6.0, 1.5, 0.0],
+ x: [angle.cos(), 0.0, -angle.sin()],
+ y: [0.0, 1.0, 0.0],
+ z: [angle.sin(), 0.0, angle.cos()],
+ },
+ vec![[-0.5, -2.0], [0.5, -2.0], [0.5, 2.0], [-0.5, 2.0]],
+ Vec::new(),
+ 2.1,
+ host.accuracy,
+ )
+ .unwrap();
+ let result = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ "oblique-cavity-cut".into(),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 1);
+ assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [2.5, 1.5, 1.5]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [6.2, 1.5, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [6.2, 1.5, 2.6]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ }
+
+ #[test]
+ fn oblique_cut_assigns_a_remote_cavity_to_its_original_material_component() {
+ let left = extrusion(
+ "left-cavity-host",
+ vec![[0.0, 0.0], [4.0, 0.0], [4.0, 3.0], [0.0, 3.0]],
+ Vec::new(),
+ );
+ let cavity = extrusion_span(
+ "left-cavity",
+ 1.0,
+ 1.0,
+ vec![[1.0, 1.0], [2.0, 1.0], [2.0, 2.0], [1.0, 2.0]],
+ Vec::new(),
+ );
+ let left_with_cavity = boolean_brep(
+ &left,
+ &cavity,
+ BooleanOp::Subtraction,
+ "left-with-cavity".into(),
+ )
+ .unwrap()
+ .brep;
+ let right = extrusion(
+ "right-host",
+ vec![[6.0, 0.0], [10.0, 0.0], [10.0, 3.0], [6.0, 3.0]],
+ Vec::new(),
+ );
+ let host = boolean_brep(
+ &left_with_cavity,
+ &right,
+ BooleanOp::Union,
+ "two-components".into(),
+ )
+ .unwrap()
+ .brep;
+ assert_eq!(host.solids.len(), 2);
+ let angle = std::f64::consts::PI / 12.0;
+ let cutter = primitives::linear_extrusion(
+ "right-oblique-cutter".into(),
+ Frame3 {
+ origin: [8.5, 1.5, 0.0],
+ x: [angle.cos(), 0.0, -angle.sin()],
+ y: [0.0, 1.0, 0.0],
+ z: [angle.sin(), 0.0, angle.cos()],
+ },
+ vec![[-0.5, -2.0], [0.5, -2.0], [0.5, 2.0], [-0.5, 2.0]],
+ Vec::new(),
+ 2.1,
+ host.accuracy,
+ )
+ .unwrap();
+ let result = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ "remote-cavity-cut".into(),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 2);
+ assert_eq!(
+ result
+ .brep
+ .solids
+ .iter()
+ .map(|solid| solid.cavity_shells.len())
+ .sum::(),
+ 1
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [1.5, 1.5, 1.5]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [8.5, 1.5, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ }
+
+ #[test]
+ fn coaxial_conic_containment_builds_exact_cavities_and_ownership() {
+ let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
+ let host =
+ primitives::frustum("host".into(), Frame3::IDENTITY, 3.0, 4.0, 4.0, accuracy).unwrap();
+ let cutter = primitives::frustum(
+ "cutter".into(),
+ Frame3 {
+ origin: [0.0, 0.0, 1.0],
+ ..Frame3::IDENTITY
+ },
+ 1.0,
+ 1.5,
+ 1.0,
+ accuracy,
+ )
+ .unwrap();
+ let host_volume = std::f64::consts::PI * 4.0 * (9.0 + 12.0 + 16.0) / 3.0;
+ let cutter_volume = std::f64::consts::PI * (1.0 + 1.5 + 2.25) / 3.0;
+ for (operation, expected_volume, expected_faces) in [
+ (BooleanOp::Union, host_volume, 3),
+ (BooleanOp::Intersection, cutter_volume, 3),
+ (BooleanOp::Subtraction, host_volume - cutter_volume, 6),
+ ] {
+ let result =
+ boolean_brep(&host, &cutter, operation, format!("conic-{operation:?}")).unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.topology.faces.len(), expected_faces);
+ assert_eq!(result.report.face_mappings.len(), 6);
+ let measured = volume(&result.brep).abs();
+ assert!(
+ (measured - expected_volume).abs() < 0.75,
+ "{operation:?}: measured {measured}, expected {expected_volume}"
+ );
+ if operation == BooleanOp::Subtraction {
+ assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
+ assert_eq!(
+ result
+ .brep
+ .topology
+ .faces
+ .iter()
+ .filter(|face| face.provenance.role == FaceRole::Cut
+ && face.provenance.reversed)
+ .count(),
+ 3
+ );
+ }
+ }
+
+ let touching =
+ primitives::frustum("touching".into(), Frame3::IDENTITY, 1.0, 1.5, 1.0, accuracy)
+ .unwrap();
+ assert!(matches!(
+ boolean_brep(
+ &host,
+ &touching,
+ BooleanOp::Subtraction,
"touching-result".into()
),
Err(GeometryError::UnresolvedIntersection(_))
@@ -10641,364 +12463,1004 @@ mod tests {
}
#[test]
- fn mixed_conic_containment_preserves_all_analytic_supports() {
+ fn mixed_conic_containment_preserves_all_analytic_supports() {
+ let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
+ let conic =
+ primitives::frustum("frustum".into(), Frame3::IDENTITY, 3.0, 4.0, 4.0, accuracy)
+ .unwrap();
+ let inner_sphere = primitives::sphere(
+ "inner-sphere".into(),
+ Frame3 {
+ origin: [0.0, 0.0, 2.0],
+ ..Frame3::IDENTITY
+ },
+ 0.5,
+ accuracy,
+ )
+ .unwrap();
+ let outer_sphere = primitives::sphere(
+ "outer-sphere".into(),
+ Frame3 {
+ origin: [0.0, 0.0, 2.0],
+ ..Frame3::IDENTITY
+ },
+ 6.0,
+ accuracy,
+ )
+ .unwrap();
+ let inner_cylinder = primitives::cylinder(
+ "inner-cylinder".into(),
+ Frame3 {
+ origin: [0.0, 0.0, 1.0],
+ ..Frame3::IDENTITY
+ },
+ 0.5,
+ 1.0,
+ accuracy,
+ )
+ .unwrap();
+ let outer_cylinder = primitives::cylinder(
+ "outer-cylinder".into(),
+ Frame3 {
+ origin: [0.0, 0.0, -1.0],
+ ..Frame3::IDENTITY
+ },
+ 5.0,
+ 6.0,
+ accuracy,
+ )
+ .unwrap();
+ let inner_box = primitives::cuboid(
+ "inner-box".into(),
+ Frame3 {
+ origin: [-0.25, -0.25, 1.75],
+ ..Frame3::IDENTITY
+ },
+ [0.5, 0.5, 0.5],
+ accuracy,
+ )
+ .unwrap();
+ let outer_box = primitives::cuboid(
+ "outer-box".into(),
+ Frame3 {
+ origin: [-5.0, -5.0, -1.0],
+ ..Frame3::IDENTITY
+ },
+ [10.0, 10.0, 6.0],
+ accuracy,
+ )
+ .unwrap();
+
+ for (host, cutter, expected_faces) in [
+ (&conic, &inner_sphere, 4),
+ (&outer_sphere, &conic, 4),
+ (&conic, &inner_cylinder, 6),
+ (&outer_cylinder, &conic, 6),
+ (&conic, &inner_box, 9),
+ (&outer_box, &conic, 9),
+ ] {
+ let result = boolean_brep(
+ host,
+ cutter,
+ BooleanOp::Subtraction,
+ format!("{}-minus-{}", host.id, cutter.id),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 1);
+ assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
+ assert_eq!(result.brep.topology.faces.len(), expected_faces);
+ assert_eq!(
+ result
+ .brep
+ .topology
+ .faces
+ .iter()
+ .filter(|face| face.provenance.role == FaceRole::Cut
+ && face.provenance.reversed)
+ .count(),
+ cutter.topology.faces.len()
+ );
+ tessellate(&result.brep, 0.02, 2_000_000).unwrap();
+ }
+ }
+
+ #[test]
+ fn generic_closed_loop_imprints_an_off_axis_sphere_cone_intersection() {
+ let accuracy = cylinder("accuracy", 0.0, 1.0, false).accuracy;
+ let cone = primitives::cone("cone".into(), Frame3::IDENTITY, 2.0, 2.0, accuracy).unwrap();
+ let sphere = primitives::sphere(
+ "sphere".into(),
+ Frame3 {
+ origin: [1.42, 0.0, 1.0],
+ ..Frame3::IDENTITY
+ },
+ 0.35,
+ accuracy,
+ )
+ .unwrap();
+ for operation in [
+ BooleanOp::Union,
+ BooleanOp::Intersection,
+ BooleanOp::Subtraction,
+ ] {
+ let result = boolean_brep(
+ &cone,
+ &sphere,
+ operation,
+ format!("sphere-cone-{operation:?}"),
+ )
+ .unwrap_or_else(|error| panic!("{operation:?}: {error}"));
+ assert_eq!(result.brep.solids.len(), 1);
+ assert!(result
+ .brep
+ .geometry
+ .curves
+ .iter()
+ .any(|curve| matches!(curve, CurveGeometry::Intersection { .. })));
+ assert!(result
+ .brep
+ .topology
+ .faces
+ .iter()
+ .all(|face| !face.provenance.sources.is_empty()));
+ result.brep.validate().unwrap();
+ tessellate(&result.brep, 0.01, 2_000_000)
+ .unwrap_or_else(|error| panic!("{operation:?} tessellation: {error}"));
+ }
+ }
+
+ #[test]
+ fn generic_closed_loop_handles_periodic_winding() {
+ let accuracy = cylinder("accuracy", 0.0, 1.0, false).accuracy;
+ let cone = primitives::cone("cone".into(), Frame3::IDENTITY, 2.0, 2.0, accuracy).unwrap();
+ let sphere = primitives::sphere(
+ "sphere".into(),
+ Frame3 {
+ origin: [1.1, 0.0, 0.8],
+ ..Frame3::IDENTITY
+ },
+ 0.35,
+ accuracy,
+ )
+ .unwrap();
+ let result =
+ boolean_brep(&cone, &sphere, BooleanOp::Intersection, "winding".into()).unwrap();
+ result.brep.validate().unwrap();
+ tessellate(&result.brep, 0.01, 2_000_000).unwrap();
+ }
+
+ #[test]
+ fn ring_torus_containment_uses_exact_host_support_bounds() {
+ let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
+ let torus = primitives::torus(
+ "torus".into(),
+ Frame3 {
+ origin: [0.0, 0.0, 2.0],
+ ..Frame3::IDENTITY
+ },
+ 2.0,
+ 0.5,
+ accuracy,
+ )
+ .unwrap();
+ let cylinder =
+ primitives::cylinder("cylinder".into(), Frame3::IDENTITY, 4.0, 4.0, accuracy).unwrap();
+ let box_ = primitives::cuboid(
+ "box".into(),
+ Frame3 {
+ origin: [-4.0, -4.0, -1.0],
+ ..Frame3::IDENTITY
+ },
+ [8.0, 8.0, 6.0],
+ accuracy,
+ )
+ .unwrap();
+ let conic =
+ primitives::frustum("frustum".into(), Frame3::IDENTITY, 4.0, 5.0, 4.0, accuracy)
+ .unwrap();
+ for host in [&cylinder, &box_, &conic] {
+ let result = boolean_brep(
+ host,
+ &torus,
+ BooleanOp::Subtraction,
+ format!("{}-minus-torus", host.id),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 1);
+ assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
+ assert!(result.brep.topology.faces.iter().any(|face| {
+ matches!(
+ result.brep.geometry.surfaces[face.surface as usize],
+ SurfaceGeometry::Torus { .. }
+ ) && face.provenance.role == FaceRole::Cut
+ && face.provenance.reversed
+ }));
+ tessellate(&result.brep, 0.02, 2_000_000).unwrap();
+ }
+ }
+
+ #[test]
+ fn restricted_shell_offsets_supported_closed_families() {
+ let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
+ let inputs = [
+ (
+ primitives::cuboid("box".into(), Frame3::IDENTITY, [2.0; 3], accuracy).unwrap(),
+ 12,
+ ),
+ (
+ primitives::cylinder("cylinder".into(), Frame3::IDENTITY, 1.0, 2.0, accuracy)
+ .unwrap(),
+ 6,
+ ),
+ (
+ primitives::sphere("sphere".into(), Frame3::IDENTITY, 1.0, accuracy).unwrap(),
+ 2,
+ ),
+ (
+ primitives::torus("torus".into(), Frame3::IDENTITY, 3.0, 1.0, accuracy).unwrap(),
+ 2,
+ ),
+ (
+ primitives::cone("cone".into(), Frame3::IDENTITY, 2.0, 3.0, accuracy).unwrap(),
+ 4,
+ ),
+ (
+ primitives::frustum("frustum".into(), Frame3::IDENTITY, 2.0, 1.0, 3.0, accuracy)
+ .unwrap(),
+ 6,
+ ),
+ (
+ extrusion(
+ "profile-shell",
+ vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]],
+ vec![vec![[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [3.0, 1.0]]],
+ ),
+ 20,
+ ),
+ ];
+ for (input, expected_faces) in &inputs {
+ let result = shell_brep(&input, 0.2, format!("{}-shell", input.id)).unwrap();
+ result.brep.validate().unwrap();
+ assert_eq!(result.brep.solids.len(), 1);
+ assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
+ assert_eq!(result.brep.topology.faces.len(), *expected_faces);
+ assert_eq!(
+ result.report.face_mappings.len(),
+ input.topology.faces.len()
+ );
+ assert!(result.brep.topology.faces.iter().any(|face| {
+ face.provenance.role == FaceRole::Cut
+ && face.provenance.reversed
+ && face
+ .provenance
+ .sources
+ .iter()
+ .all(|source| source.entity == input.id)
+ }));
+ tessellate(&result.brep, 0.02, 2_000_000).unwrap();
+ }
+ assert!(matches!(
+ shell_brep(&inputs[2].0, 1.0, "invalid".into()),
+ Err(GeometryError::UnresolvedIntersection(_))
+ ));
+ }
+
+ #[test]
+ fn identical_general_analytic_body_uses_coincident_ownership() {
+ let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
+ let host = primitives::annular_sector_extrusion(
+ "host".into(),
+ Frame3::IDENTITY,
+ 3.0,
+ 0.4,
+ 2.0,
+ 0.2,
+ 1.4,
+ accuracy,
+ )
+ .unwrap();
+ let union = boolean_brep(&host, &host, BooleanOp::Union, "same".into()).unwrap();
+ assert!(union.report.coincident);
+ assert_eq!(union.brep.topology.faces.len(), host.topology.faces.len());
+ assert!(union
+ .brep
+ .topology
+ .faces
+ .iter()
+ .all(|face| face.provenance.role == FaceRole::Coincident));
+ let empty = boolean_brep(&host, &host, BooleanOp::Subtraction, "empty".into()).unwrap();
+ assert!(empty.brep.solids.is_empty());
+ }
+
+ #[test]
+ fn generic_pipeline_classifies_noncanonical_planar_solid_containment() {
+ let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
+ let tetrahedron = primitives::planar_polyhedron(
+ "tetrahedron".into(),
+ vec![
+ [-0.2, -0.2, 0.8],
+ [0.2, -0.2, 0.8],
+ [0.0, 0.2, 0.8],
+ [0.0, 0.0, 1.2],
+ ],
+ vec![vec![0, 2, 1], vec![0, 1, 3], vec![0, 3, 2], vec![1, 2, 3]],
+ accuracy,
+ )
+ .unwrap();
+ let cylinder =
+ primitives::cylinder("cylinder".into(), Frame3::IDENTITY, 2.0, 2.0, accuracy).unwrap();
+
+ let union =
+ boolean_brep(&cylinder, &tetrahedron, BooleanOp::Union, "union".into()).unwrap();
+ assert_eq!(
+ union.brep.topology.faces.len(),
+ cylinder.topology.faces.len()
+ );
+
+ let intersection = boolean_brep(
+ &cylinder,
+ &tetrahedron,
+ BooleanOp::Intersection,
+ "intersection".into(),
+ )
+ .unwrap();
+ assert_eq!(
+ intersection.brep.topology.faces.len(),
+ tetrahedron.topology.faces.len()
+ );
+
+ let subtraction = boolean_brep(
+ &cylinder,
+ &tetrahedron,
+ BooleanOp::Subtraction,
+ "subtraction".into(),
+ )
+ .unwrap();
+ assert_eq!(subtraction.brep.solids.len(), 1);
+ assert_eq!(subtraction.brep.solids[0].cavity_shells.len(), 1);
+ subtraction.brep.validate().unwrap();
+ tessellate(&subtraction.brep, 0.01, 2_000_000).unwrap();
+ }
+
+ #[test]
+ fn annular_sector_accepts_an_exact_vertical_sector_opening() {
let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
- let conic =
- primitives::frustum("frustum".into(), Frame3::IDENTITY, 3.0, 4.0, 4.0, accuracy)
- .unwrap();
- let inner_sphere = primitives::sphere(
- "inner-sphere".into(),
- Frame3 {
- origin: [0.0, 0.0, 2.0],
- ..Frame3::IDENTITY
- },
- 0.5,
+ let host = primitives::annular_cylinder(
+ "ring-host".into(),
+ Frame3::IDENTITY,
+ 1.9,
+ 2.1,
+ 3.0,
accuracy,
)
.unwrap();
- let outer_sphere = primitives::sphere(
- "outer-sphere".into(),
+ let reach = 2.3;
+ let cutter = primitives::linear_extrusion(
+ "ring-opening".into(),
Frame3 {
- origin: [0.0, 0.0, 2.0],
+ origin: [0.0, 0.0, 0.5],
..Frame3::IDENTITY
},
- 6.0,
+ vec![
+ [0.0, 0.0],
+ [reach * (-0.1_f64).cos(), reach * (-0.1_f64).sin()],
+ [reach * 0.1_f64.cos(), reach * 0.1_f64.sin()],
+ ],
+ vec![],
+ 2.0,
accuracy,
)
.unwrap();
- let inner_cylinder = primitives::cylinder(
- "inner-cylinder".into(),
+ let cut = boolean_brep(&host, &cutter, BooleanOp::Subtraction, "ring-cut".into()).unwrap();
+ cut.brep.validate().unwrap();
+ tessellate(&cut.brep, 0.01, 2_000_000).unwrap();
+ assert_eq!(
+ super::super::query::classify_point(&cut.brep, [2.0, 0.0, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&cut.brep, [0.0, 2.0, 1.0]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&cut.brep, [0.0, 0.0, 1.0]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ let second = primitives::linear_extrusion(
+ "second-ring-opening".into(),
Frame3 {
- origin: [0.0, 0.0, 1.0],
+ origin: [0.0, 0.0, 0.8],
..Frame3::IDENTITY
},
- 0.5,
- 1.0,
+ vec![
+ [0.0, 0.0],
+ [
+ reach * (std::f64::consts::FRAC_PI_2 - 0.1).cos(),
+ reach * (std::f64::consts::FRAC_PI_2 - 0.1).sin(),
+ ],
+ [
+ reach * (std::f64::consts::FRAC_PI_2 + 0.1).cos(),
+ reach * (std::f64::consts::FRAC_PI_2 + 0.1).sin(),
+ ],
+ ],
+ vec![],
+ 1.3,
accuracy,
)
.unwrap();
- let outer_cylinder = primitives::cylinder(
- "outer-cylinder".into(),
+ let chained = boolean_brep(
+ &cut.brep,
+ &second,
+ BooleanOp::Subtraction,
+ "ring-cut-chained".into(),
+ )
+ .unwrap();
+ chained.brep.validate().unwrap();
+ tessellate(&chained.brep, 0.01, 2_000_000).unwrap();
+ for point in [[2.0, 0.0, 1.0], [0.0, 2.0, 1.0]] {
+ assert_eq!(
+ super::super::query::classify_point(&chained.brep, point).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ }
+ assert_eq!(
+ super::super::query::classify_point(&chained.brep, [-2.0, 0.0, 1.0]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+ }
+
+ #[test]
+ fn arc_edged_host_accepts_a_vertical_rectangular_opening_cut() {
+ let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
+ let quarter = std::f64::consts::FRAC_PI_2;
+ let host = primitives::arc_edged_extrusion(
+ "arc-host".into(),
+ Frame3::IDENTITY,
+ vec![
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: 0.0,
+ sweep_angle: quarter,
+ },
+ primitives::ProfileEdge::Line {
+ from: [0.0, 2.0],
+ to: [0.0, 1.5],
+ },
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.5,
+ start_angle: quarter,
+ sweep_angle: -quarter,
+ },
+ primitives::ProfileEdge::Line {
+ from: [1.5, 0.0],
+ to: [2.0, 0.0],
+ },
+ ],
+ 3.0,
+ accuracy,
+ )
+ .unwrap();
+ let cutter = primitives::cuboid(
+ "opening".into(),
Frame3 {
- origin: [0.0, 0.0, -1.0],
+ origin: [1.1, 0.9, 0.5],
..Frame3::IDENTITY
},
- 5.0,
- 6.0,
+ [1.0, 0.35, 1.5],
accuracy,
)
.unwrap();
- let inner_box = primitives::cuboid(
- "inner-box".into(),
+ let assert_curved_normals = |brep: &BrepEnvelope| {
+ for (radius, expected) in [(2.0, Orientation::Forward), (1.5, Orientation::Reverse)] {
+ let faces = brep.topology.faces.iter().filter(|face| {
+ matches!(
+ brep.geometry.surfaces[face.surface as usize],
+ SurfaceGeometry::Cylinder { radius: value, .. } if (value - radius).abs() < 1e-9
+ )
+ }).collect::>();
+ assert!(!faces.is_empty(), "missing curved face at radius {radius}");
+ assert!(
+ faces.iter().all(|face| face.sense == expected),
+ "reconstructed curved face at radius {radius} points into material: {:?}",
+ faces
+ .iter()
+ .map(|face| (&face.key, face.sense))
+ .collect::>()
+ );
+ }
+ };
+ let cut = boolean_brep(&host, &cutter, BooleanOp::Subtraction, "arc-cut".into()).unwrap();
+ cut.brep.validate().unwrap();
+ assert_curved_normals(&cut.brep);
+ tessellate(&cut.brep, 0.01, 2_000_000).unwrap();
+ assert_eq!(
+ super::super::query::classify_point(&cut.brep, [1.5, 1.1, 1.2]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ let second_cutter = primitives::cuboid(
+ "second-opening".into(),
Frame3 {
- origin: [-0.25, -0.25, 1.75],
+ origin: [0.6, 1.3, 0.8],
..Frame3::IDENTITY
},
- [0.5, 0.5, 0.5],
+ [0.45, 0.8, 1.2],
accuracy,
)
.unwrap();
- let outer_box = primitives::cuboid(
- "outer-box".into(),
+ let batch = subtract_planar_cutters(
+ &host,
+ &[cutter, second_cutter.clone()],
+ "arc-cut-batch".into(),
+ )
+ .unwrap();
+ batch.brep.validate().unwrap();
+ tessellate(&batch.brep, 0.01, 2_000_000).unwrap();
+ assert_curved_normals(&batch.brep);
+ for point in [[1.5, 1.1, 1.2], [0.9, 1.65, 1.2]] {
+ assert_eq!(
+ super::super::query::classify_point(&batch.brep, point).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ }
+ let chained = boolean_brep(
+ &cut.brep,
+ &second_cutter,
+ BooleanOp::Subtraction,
+ "arc-cut-chained".into(),
+ )
+ .unwrap();
+ chained.brep.validate().unwrap();
+ assert_curved_normals(&chained.brep);
+ for point in [[1.5, 1.1, 1.2], [0.9, 1.65, 1.2]] {
+ assert_eq!(
+ super::super::query::classify_point(&chained.brep, point).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ }
+ let overlapping = primitives::cuboid(
+ "overlapping-opening".into(),
Frame3 {
- origin: [-5.0, -5.0, -1.0],
+ origin: [1.4, 0.95, 0.2],
..Frame3::IDENTITY
},
- [10.0, 10.0, 6.0],
+ [0.5, 0.4, 2.2],
accuracy,
)
.unwrap();
-
- for (host, cutter, expected_faces) in [
- (&conic, &inner_sphere, 4),
- (&outer_sphere, &conic, 4),
- (&conic, &inner_cylinder, 6),
- (&outer_cylinder, &conic, 6),
- (&conic, &inner_box, 9),
- (&outer_box, &conic, 9),
+ let third = boolean_brep(
+ &chained.brep,
+ &overlapping,
+ BooleanOp::Subtraction,
+ "arc-cut-third".into(),
+ )
+ .unwrap();
+ third.brep.validate().unwrap();
+ tessellate(&third.brep, 0.01, 2_000_000).unwrap();
+ assert_curved_normals(&third.brep);
+ for point in [
+ [1.5, 1.1, 1.2],
+ [0.9, 1.65, 1.2],
+ [1.45, 1.2, 0.3],
+ [1.45, 1.2, 2.2],
] {
- let result = boolean_brep(
- host,
- cutter,
- BooleanOp::Subtraction,
- format!("{}-minus-{}", host.id, cutter.id),
- )
- .unwrap();
- result.brep.validate().unwrap();
- assert_eq!(result.brep.solids.len(), 1);
- assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
- assert_eq!(result.brep.topology.faces.len(), expected_faces);
assert_eq!(
- result
- .brep
- .topology
- .faces
- .iter()
- .filter(|face| face.provenance.role == FaceRole::Cut
- && face.provenance.reversed)
- .count(),
- cutter.topology.faces.len()
+ super::super::query::classify_point(&third.brep, point).unwrap(),
+ super::super::query::PointClassification::Outside
);
- tessellate(&result.brep, 0.02, 2_000_000).unwrap();
}
}
#[test]
- fn generic_closed_loop_imprints_an_off_axis_sphere_cone_intersection() {
- let accuracy = cylinder("accuracy", 0.0, 1.0, false).accuracy;
- let cone = primitives::cone("cone".into(), Frame3::IDENTITY, 2.0, 2.0, accuracy).unwrap();
- let sphere = primitives::sphere(
- "sphere".into(),
+ fn curved_internal_cut_creates_one_cavity_shell_in_its_host_solid() {
+ let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
+ let quarter = std::f64::consts::FRAC_PI_2;
+ let host = primitives::arc_edged_extrusion(
+ "curved-cavity-host".into(),
+ Frame3::IDENTITY,
+ vec![
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: 0.0,
+ sweep_angle: quarter,
+ },
+ primitives::ProfileEdge::Line {
+ from: [0.0, 2.0],
+ to: [0.0, 1.0],
+ },
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.0,
+ start_angle: quarter,
+ sweep_angle: -quarter,
+ },
+ primitives::ProfileEdge::Line {
+ from: [1.0, 0.0],
+ to: [2.0, 0.0],
+ },
+ ],
+ 3.0,
+ accuracy,
+ )
+ .unwrap();
+ let cutter = primitives::linear_extrusion(
+ "curved-interior-cutter".into(),
Frame3 {
- origin: [1.42, 0.0, 1.0],
+ origin: [0.0, 0.0, 1.0],
..Frame3::IDENTITY
},
- 0.35,
+ vec![[1.0, 1.0], [1.2, 1.0], [1.2, 1.2], [1.0, 1.2]],
+ vec![],
+ 1.0,
accuracy,
)
.unwrap();
- for operation in [
- BooleanOp::Union,
- BooleanOp::Intersection,
+ let result = boolean_brep(
+ &host,
+ &cutter,
BooleanOp::Subtraction,
- ] {
- let result = boolean_brep(
- &cone,
- &sphere,
- operation,
- format!("sphere-cone-{operation:?}"),
- )
- .unwrap_or_else(|error| panic!("{operation:?}: {error}"));
- assert_eq!(result.brep.solids.len(), 1);
- assert!(result
- .brep
- .geometry
- .curves
- .iter()
- .any(|curve| matches!(curve, CurveGeometry::Intersection { .. })));
- assert!(result
- .brep
- .topology
- .faces
- .iter()
- .all(|face| !face.provenance.sources.is_empty()));
- result.brep.validate().unwrap();
- tessellate(&result.brep, 0.01, 2_000_000)
- .unwrap_or_else(|error| panic!("{operation:?} tessellation: {error}"));
- }
- }
+ "curved-cavity".into(),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ tessellate(&result.brep, 0.01, 2_000_000).unwrap();
+ assert_eq!(result.brep.solids.len(), 1);
+ assert_eq!(result.brep.topology.shells.len(), 2);
+ assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [1.1, 1.1, 1.5]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ assert_eq!(
+ super::super::query::classify_point(&result.brep, [1.5, 0.5, 1.5]).unwrap(),
+ super::super::query::PointClassification::Inside
+ );
+
+ let split_sector = primitives::linear_extrusion(
+ "curved-split-sector".into(),
+ Frame3 {
+ origin: [0.0, 0.0, -1.0],
+ ..Frame3::IDENTITY
+ },
+ vec![
+ [0.0, 0.0],
+ [3.0 * 0.2_f64.cos(), 3.0 * 0.2_f64.sin()],
+ [3.0 * 0.3_f64.cos(), 3.0 * 0.3_f64.sin()],
+ ],
+ vec![],
+ 5.0,
+ accuracy,
+ )
+ .unwrap();
+ let split = boolean_brep(
+ &host,
+ &split_sector,
+ BooleanOp::Subtraction,
+ "curved-split".into(),
+ )
+ .unwrap();
+ assert_eq!(split.brep.solids.len(), 2);
+ let split_with_cavity = boolean_brep(
+ &split.brep,
+ &cutter,
+ BooleanOp::Subtraction,
+ "curved-split-with-cavity".into(),
+ )
+ .unwrap();
+ split_with_cavity.brep.validate().unwrap();
+ assert_eq!(split_with_cavity.brep.solids.len(), 2);
+ assert_eq!(split_with_cavity.brep.topology.shells.len(), 3);
+ assert_eq!(
+ split_with_cavity
+ .brep
+ .solids
+ .iter()
+ .map(|solid| solid.cavity_shells.len())
+ .sum::(),
+ 1
+ );
+ assert_eq!(
+ super::super::query::classify_point(&split_with_cavity.brep, [1.1, 1.1, 1.5]).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
- #[test]
- fn generic_closed_loop_handles_periodic_winding() {
- let accuracy = cylinder("accuracy", 0.0, 1.0, false).accuracy;
- let cone = primitives::cone("cone".into(), Frame3::IDENTITY, 2.0, 2.0, accuracy).unwrap();
- let sphere = primitives::sphere(
- "sphere".into(),
+ let second_cutter = primitives::linear_extrusion(
+ "curved-other-component-cutter".into(),
Frame3 {
- origin: [1.1, 0.0, 0.8],
+ origin: [0.0, 0.0, 1.1],
..Frame3::IDENTITY
},
- 0.35,
+ vec![[1.55, 0.1], [1.67, 0.1], [1.67, 0.22], [1.55, 0.22]],
+ vec![],
+ 0.8,
accuracy,
)
.unwrap();
- let result =
- boolean_brep(&cone, &sphere, BooleanOp::Intersection, "winding".into()).unwrap();
- result.brep.validate().unwrap();
- tessellate(&result.brep, 0.01, 2_000_000).unwrap();
+ let same_component_cavities = boolean_brep(
+ &result.brep,
+ &second_cutter,
+ BooleanOp::Subtraction,
+ "curved-same-component-cavities".into(),
+ )
+ .unwrap();
+ same_component_cavities.brep.validate().unwrap();
+ assert_eq!(same_component_cavities.brep.solids.len(), 1);
+ assert_eq!(same_component_cavities.brep.topology.shells.len(), 3);
+ assert_eq!(
+ same_component_cavities.brep.solids[0].cavity_shells.len(),
+ 2
+ );
+ let two_cavities = boolean_brep(
+ &split_with_cavity.brep,
+ &second_cutter,
+ BooleanOp::Subtraction,
+ "curved-two-cavities".into(),
+ )
+ .unwrap();
+ two_cavities.brep.validate().unwrap();
+ assert_eq!(two_cavities.brep.solids.len(), 2);
+ assert_eq!(two_cavities.brep.topology.shells.len(), 4);
+ assert!(two_cavities
+ .brep
+ .solids
+ .iter()
+ .all(|solid| solid.cavity_shells.len() == 1));
+ for point in [[1.1, 1.1, 1.5], [1.6, 0.16, 1.5]] {
+ assert_eq!(
+ super::super::query::classify_point(&two_cavities.brep, point).unwrap(),
+ super::super::query::PointClassification::Outside
+ );
+ }
}
#[test]
- fn ring_torus_containment_uses_exact_host_support_bounds() {
+ fn curved_cut_provenance_selects_the_trimmed_coaxial_source_face() {
let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
- let torus = primitives::torus(
- "torus".into(),
- Frame3 {
- origin: [0.0, 0.0, 2.0],
- ..Frame3::IDENTITY
- },
- 2.0,
- 0.5,
+ let quarter = std::f64::consts::FRAC_PI_2;
+ let host = primitives::arc_edged_extrusion(
+ "split-arc-host".into(),
+ Frame3::IDENTITY,
+ vec![
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: 0.0,
+ sweep_angle: quarter / 2.0,
+ },
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: quarter / 2.0,
+ sweep_angle: quarter / 2.0,
+ },
+ primitives::ProfileEdge::Line {
+ from: [0.0, 2.0],
+ to: [0.0, 1.5],
+ },
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.5,
+ start_angle: quarter,
+ sweep_angle: -quarter,
+ },
+ primitives::ProfileEdge::Line {
+ from: [1.5, 0.0],
+ to: [2.0, 0.0],
+ },
+ ],
+ 3.0,
accuracy,
)
.unwrap();
- let cylinder =
- primitives::cylinder("cylinder".into(), Frame3::IDENTITY, 4.0, 4.0, accuracy).unwrap();
- let box_ = primitives::cuboid(
- "box".into(),
+ let cutter = primitives::cuboid(
+ "split-arc-opening".into(),
Frame3 {
- origin: [-4.0, -4.0, -1.0],
+ origin: [1.1, 0.9, 0.5],
..Frame3::IDENTITY
},
- [8.0, 8.0, 6.0],
+ [1.0, 0.35, 1.5],
accuracy,
)
.unwrap();
- let conic =
- primitives::frustum("frustum".into(), Frame3::IDENTITY, 4.0, 5.0, 4.0, accuracy)
- .unwrap();
- for host in [&cylinder, &box_, &conic] {
- let result = boolean_brep(
- host,
- &torus,
- BooleanOp::Subtraction,
- format!("{}-minus-torus", host.id),
- )
- .unwrap();
- result.brep.validate().unwrap();
- assert_eq!(result.brep.solids.len(), 1);
- assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
- assert!(result.brep.topology.faces.iter().any(|face| {
- matches!(
- result.brep.geometry.surfaces[face.surface as usize],
- SurfaceGeometry::Torus { .. }
- ) && face.provenance.role == FaceRole::Cut
- && face.provenance.reversed
- }));
- tessellate(&result.brep, 0.02, 2_000_000).unwrap();
- }
- }
-
- #[test]
- fn restricted_shell_offsets_supported_closed_families() {
- let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
- let inputs = [
- (
- primitives::cuboid("box".into(), Frame3::IDENTITY, [2.0; 3], accuracy).unwrap(),
- 12,
- ),
- (
- primitives::cylinder("cylinder".into(), Frame3::IDENTITY, 1.0, 2.0, accuracy)
- .unwrap(),
- 6,
- ),
- (
- primitives::sphere("sphere".into(), Frame3::IDENTITY, 1.0, accuracy).unwrap(),
- 2,
- ),
- (
- primitives::torus("torus".into(), Frame3::IDENTITY, 3.0, 1.0, accuracy).unwrap(),
- 2,
- ),
- (
- primitives::cone("cone".into(), Frame3::IDENTITY, 2.0, 3.0, accuracy).unwrap(),
- 4,
- ),
- (
- primitives::frustum("frustum".into(), Frame3::IDENTITY, 2.0, 1.0, 3.0, accuracy)
- .unwrap(),
- 6,
- ),
- (
- extrusion(
- "profile-shell",
- vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]],
- vec![vec![[1.0, 1.0], [1.0, 3.0], [3.0, 3.0], [3.0, 1.0]]],
- ),
- 20,
- ),
- ];
- for (input, expected_faces) in &inputs {
- let result = shell_brep(&input, 0.2, format!("{}-shell", input.id)).unwrap();
- result.brep.validate().unwrap();
- assert_eq!(result.brep.solids.len(), 1);
- assert_eq!(result.brep.solids[0].cavity_shells.len(), 1);
- assert_eq!(result.brep.topology.faces.len(), *expected_faces);
+ let cut = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ "split-arc-cut".into(),
+ )
+ .unwrap();
+ cut.brep.validate().unwrap();
+ let mut outer_faces = 0;
+ for face in &cut.brep.topology.faces {
+ let SurfaceGeometry::Cylinder { radius, .. } =
+ &cut.brep.geometry.surfaces[face.surface as usize]
+ else {
+ continue;
+ };
+ if (*radius - 2.0).abs() > accuracy.geometric {
+ continue;
+ }
+ outer_faces += 1;
assert_eq!(
- result.report.face_mappings.len(),
- input.topology.faces.len()
+ face.provenance
+ .sources
+ .iter()
+ .filter(|source| source.entity == host.id)
+ .count(),
+ 1,
+ "output face {} should belong to exactly one trimmed outer arc",
+ face.id
);
- assert!(result.brep.topology.faces.iter().any(|face| {
- face.provenance.role == FaceRole::Cut
- && face.provenance.reversed
- && face
- .provenance
- .sources
- .iter()
- .all(|source| source.entity == input.id)
- }));
- tessellate(&result.brep, 0.02, 2_000_000).unwrap();
}
- assert!(matches!(
- shell_brep(&inputs[2].0, 1.0, "invalid".into()),
- Err(GeometryError::UnresolvedIntersection(_))
- ));
+ assert!(outer_faces >= 2);
}
#[test]
- fn identical_general_analytic_body_uses_coincident_ownership() {
+ fn chained_curved_cut_keeps_each_split_top_cap_on_its_source_region() {
let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
- let wall = primitives::circular_wall(
- "wall".into(),
+ let quarter = std::f64::consts::FRAC_PI_2;
+ let host = primitives::arc_edged_extrusion(
+ "split-cap-arc-host".into(),
Frame3::IDENTITY,
+ vec![
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: 0.0,
+ sweep_angle: quarter,
+ },
+ primitives::ProfileEdge::Line {
+ from: [0.0, 2.0],
+ to: [0.0, 1.5],
+ },
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.5,
+ start_angle: quarter,
+ sweep_angle: -quarter,
+ },
+ primitives::ProfileEdge::Line {
+ from: [1.5, 0.0],
+ to: [2.0, 0.0],
+ },
+ ],
3.0,
- 0.4,
- 2.0,
- 0.2,
- 1.4,
accuracy,
)
.unwrap();
- let union = boolean_brep(&wall, &wall, BooleanOp::Union, "same".into()).unwrap();
- assert!(union.report.coincident);
- assert_eq!(union.brep.topology.faces.len(), wall.topology.faces.len());
- assert!(union
- .brep
- .topology
- .faces
- .iter()
- .all(|face| face.provenance.role == FaceRole::Coincident));
- let empty = boolean_brep(&wall, &wall, BooleanOp::Subtraction, "empty".into()).unwrap();
- assert!(empty.brep.solids.is_empty());
+ let sector = |id: &str, start: f64, end: f64, bottom: f64, height: f64| {
+ let reach = 3.0;
+ primitives::linear_extrusion(
+ id.into(),
+ Frame3 {
+ origin: [0.0, 0.0, bottom],
+ ..Frame3::IDENTITY
+ },
+ vec![
+ [0.0, 0.0],
+ [reach * start.cos(), reach * start.sin()],
+ [reach * end.cos(), reach * end.sin()],
+ ],
+ Vec::new(),
+ height,
+ accuracy,
+ )
+ .unwrap()
+ };
+ let first_cutter = sector("full-height-sector", 0.7, 0.9, -1.0, 5.0);
+ let first = boolean_brep(
+ &host,
+ &first_cutter,
+ BooleanOp::Subtraction,
+ "split-cap-first".into(),
+ )
+ .unwrap();
+ first.brep.validate().unwrap();
+ assert_eq!(first.brep.solids.len(), 2);
+ let second_cutter = sector("lower_cutout-sector", 0.2, 0.3, 0.5, 1.5);
+ let second = boolean_brep(
+ &first.brep,
+ &second_cutter,
+ BooleanOp::Subtraction,
+ "split-cap-second".into(),
+ )
+ .unwrap();
+ second.brep.validate().unwrap();
+ let mut caps = 0;
+ for face in &second.brep.topology.faces {
+ let SurfaceGeometry::Plane { frame } =
+ &second.brep.geometry.surfaces[face.surface as usize]
+ else {
+ continue;
+ };
+ if dot(frame.z, [0.0, 0.0, 1.0]) < 1.0 - 1.0e-10
+ || (frame.origin[2] - 3.0).abs() > accuracy.intersection
+ {
+ continue;
+ }
+ caps += 1;
+ assert_eq!(
+ face.provenance
+ .sources
+ .iter()
+ .filter(|source| source.entity == first.brep.id)
+ .count(),
+ 1,
+ "top cap {} should come from one prior top region",
+ face.id
+ );
+ }
+ assert_eq!(caps, 2);
}
#[test]
- fn generic_pipeline_classifies_noncanonical_planar_solid_containment() {
+ fn wide_curved_opening_uses_multiple_planar_sector_cutters() {
let accuracy = sphere("accuracy", [0.0; 3], 1.0).accuracy;
- let tetrahedron = primitives::planar_polyhedron(
- "tetrahedron".into(),
+ let start = -std::f64::consts::FRAC_PI_2;
+ let sweep = 3.0 * std::f64::consts::FRAC_PI_2;
+ let point = |radius: f64, angle: f64| [radius * angle.cos(), radius * angle.sin()];
+ let host = primitives::arc_edged_extrusion(
+ "wide-arc".into(),
+ Frame3::IDENTITY,
vec![
- [-0.2, -0.2, 0.8],
- [0.2, -0.2, 0.8],
- [0.0, 0.2, 0.8],
- [0.0, 0.0, 1.2],
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.1,
+ start_angle: start,
+ sweep_angle: sweep,
+ },
+ primitives::ProfileEdge::Line {
+ from: point(2.1, start + sweep),
+ to: point(1.9, start + sweep),
+ },
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.9,
+ start_angle: start + sweep,
+ sweep_angle: -sweep,
+ },
+ primitives::ProfileEdge::Line {
+ from: point(1.9, start),
+ to: point(2.1, start),
+ },
],
- vec![vec![0, 2, 1], vec![0, 1, 3], vec![0, 3, 2], vec![1, 2, 3]],
+ 3.0,
accuracy,
)
.unwrap();
- let cylinder =
- primitives::cylinder("cylinder".into(), Frame3::IDENTITY, 2.0, 2.0, accuracy).unwrap();
-
- let union =
- boolean_brep(&cylinder, &tetrahedron, BooleanOp::Union, "union".into()).unwrap();
- assert_eq!(
- union.brep.topology.faces.len(),
- cylinder.topology.faces.len()
- );
-
- let intersection = boolean_brep(
- &cylinder,
- &tetrahedron,
- BooleanOp::Intersection,
- "intersection".into(),
- )
- .unwrap();
+ let opening_start = start + 1.2 / 2.0;
+ let opening_end = start + 8.2 / 2.0;
+ let cutters = (0..3)
+ .map(|index| {
+ let from = opening_start + (opening_end - opening_start) * index as f64 / 3.0;
+ let to = opening_start + (opening_end - opening_start) * (index + 1) as f64 / 3.0;
+ let reach = 2.1 / ((to - from) / 2.0).cos() + 0.2;
+ primitives::linear_extrusion(
+ format!("sector-{index}"),
+ Frame3::IDENTITY,
+ vec![[0.0, 0.0], point(reach, from), point(reach, to)],
+ vec![],
+ 2.0,
+ accuracy,
+ )
+ .unwrap()
+ })
+ .collect::>();
+ let cut = subtract_planar_cutters(&host, &cutters, "wide-cut".into()).unwrap();
+ cut.brep.validate().unwrap();
+ tessellate(&cut.brep, 0.01, 2_000_000).unwrap();
+ let angle = start + 4.7 / 2.0;
assert_eq!(
- intersection.brep.topology.faces.len(),
- tetrahedron.topology.faces.len()
+ super::super::query::classify_point(
+ &cut.brep,
+ [2.0 * angle.cos(), 2.0 * angle.sin(), 1.0]
+ )
+ .unwrap(),
+ super::super::query::PointClassification::Outside
);
-
- let subtraction = boolean_brep(
- &cylinder,
- &tetrahedron,
- BooleanOp::Subtraction,
- "subtraction".into(),
- )
- .unwrap();
- assert_eq!(subtraction.brep.solids.len(), 1);
- assert_eq!(subtraction.brep.solids[0].cavity_shells.len(), 1);
- subtraction.brep.validate().unwrap();
- tessellate(&subtraction.brep, 0.01, 2_000_000).unwrap();
}
}
diff --git a/main/opengeometry/src/analytic/box_booleans.rs b/main/opengeometry/src/analytic/box_booleans.rs
index 0187ee5..a2520cb 100644
--- a/main/opengeometry/src/analytic/box_booleans.rs
+++ b/main/opengeometry/src/analytic/box_booleans.rs
@@ -2,8 +2,9 @@ use std::collections::{BTreeMap, VecDeque};
use super::{
booleans::{BooleanOp, BooleanReport, BooleanResult, FaceMapping},
- geometry::{dot, norm, sub, unit},
+ geometry::{dot, norm, scale, sub, unit},
primitives::{boundary, cuboid, uv_line, Builder},
+ query::{classify_point_validated, face_contains_uv, PointClassification},
topology::*,
CurveGeometry, Frame3, GeometryError, Point3, SurfaceGeometry,
};
@@ -24,13 +25,125 @@ fn coverage() -> GeometryError {
}
}
fn source(input: &BoxInput<'_>, face: usize) -> FaceSource {
+ let authored = &input.brep.topology.faces[face];
+ if matches!(authored.provenance.role, FaceRole::Preserved)
+ && authored.provenance.sources.len() == 1
+ {
+ return authored.provenance.sources[0].clone();
+ }
FaceSource {
entity: input.brep.id.clone(),
body: input.brep.id.clone(),
- key: input.brep.topology.faces[face].key.clone(),
+ key: authored.key.clone(),
face: face as u32,
}
}
+
+fn rectilinear_input<'a>(
+ brep: &'a BrepEnvelope,
+ axes: Frame3,
+) -> Result, GeometryError> {
+ brep.validate()?;
+ if !matches!(brep.quality, GeometryQuality::Analytic)
+ || brep.geometry.surfaces.is_empty()
+ || brep.solids.is_empty()
+ || brep
+ .geometry
+ .surfaces
+ .iter()
+ .any(|surface| !matches!(surface, SurfaceGeometry::Plane { .. }))
+ || brep
+ .geometry
+ .curves
+ .iter()
+ .any(|curve| !matches!(curve, CurveGeometry::Line { .. }))
+ {
+ return Err(coverage());
+ }
+ let directions = [axes.x, axes.y, axes.z];
+ for surface in &brep.geometry.surfaces {
+ let normal = surface.frame().z;
+ if directions
+ .iter()
+ .all(|axis| dot(normal, *axis).abs() < 1.0 - 1.0e-10)
+ {
+ return Err(coverage());
+ }
+ }
+ let mut lo = [f64::INFINITY; 3];
+ let mut hi = [f64::NEG_INFINITY; 3];
+ for vertex in &brep.topology.vertices {
+ let point = axes.local(vertex.position);
+ for axis in 0..3 {
+ lo[axis] = lo[axis].min(point[axis]);
+ hi[axis] = hi[axis].max(point[axis]);
+ }
+ }
+ if (0..3).any(|axis| hi[axis] - lo[axis] <= 4.0 * brep.accuracy.geometric) {
+ return Err(coverage());
+ }
+ for edge in &brep.topology.edges {
+ let EdgeGeometry::Curve { curve, .. } = edge.geometry else {
+ return Err(coverage());
+ };
+ let CurveGeometry::Line { direction, .. } = brep.geometry.curves[curve as usize] else {
+ return Err(coverage());
+ };
+ if directions
+ .iter()
+ .all(|axis| dot(direction, *axis).abs() < 1.0 - 1.0e-10)
+ {
+ return Err(coverage());
+ }
+ }
+ Ok(BoxInput {
+ brep,
+ frame: Frame3 {
+ origin: axes.point(lo),
+ ..axes
+ },
+ size: std::array::from_fn(|axis| hi[axis] - lo[axis]),
+ })
+}
+
+fn classified_inside(brep: &BrepEnvelope, point: Point3) -> Result {
+ match classify_point_validated(brep, point)? {
+ PointClassification::Inside => Ok(true),
+ PointClassification::Outside => Ok(false),
+ _ => Err(GeometryError::UnresolvedIntersection(
+ "rectilinear arrangement cell classification is unresolved".into(),
+ )),
+ }
+}
+
+fn containing_faces(
+ input: &BoxInput<'_>,
+ point: Point3,
+ outward: Point3,
+ tolerance: f64,
+) -> Result, GeometryError> {
+ let mut matches = Vec::new();
+ for (index, face) in input.brep.topology.faces.iter().enumerate() {
+ let SurfaceGeometry::Plane { frame } = input.brep.geometry.surfaces[face.surface as usize]
+ else {
+ return Err(coverage());
+ };
+ let local = frame.local(point);
+ if local[2].abs() > tolerance {
+ continue;
+ }
+ if face_contains_uv(input.brep, face, [local[0], local[1]])? != Some(true) {
+ continue;
+ }
+ let normal = scale(frame.z, face.sense.multiplier());
+ let alignment = dot(normal, outward);
+ if alignment.abs() < 1.0 - 1.0e-10 {
+ continue;
+ }
+ matches.push((index, alignment < 0.0));
+ }
+ Ok(matches)
+}
pub(super) fn full_box(brep: &BrepEnvelope) -> Result, GeometryError> {
brep.validate()?;
let t = &brep.topology;
@@ -223,6 +336,31 @@ pub fn boolean_boxes(
) -> Result {
let a = full_box(a)?;
let b = full_box(b)?;
+ boolean_grid(a, b, operation, id, true)
+}
+
+pub(super) fn boolean_rectilinear(
+ a: &BrepEnvelope,
+ b: &BrepEnvelope,
+ operation: BooleanOp,
+ id: String,
+) -> Result {
+ let axes = match b.geometry.surfaces.first() {
+ Some(SurfaceGeometry::Plane { frame }) => *frame,
+ _ => return Err(coverage()),
+ };
+ let a = rectilinear_input(a, axes)?;
+ let b = rectilinear_input(b, axes)?;
+ boolean_grid(a, b, operation, id, false)
+}
+
+fn boolean_grid(
+ a: BoxInput<'_>,
+ b: BoxInput<'_>,
+ operation: BooleanOp,
+ id: String,
+ canonical_boxes: bool,
+) -> Result {
if [a.frame.x, a.frame.y, a.frame.z] != [b.frame.x, b.frame.y, b.frame.z] {
return Err(coverage());
}
@@ -250,6 +388,16 @@ pub fn boolean_boxes(
));
}
let mut grid: [Vec; 3] = std::array::from_fn(|i| vec![alo[i], ahi[i], blo[i], bhi[i]]);
+ if !canonical_boxes {
+ for input in [&a, &b] {
+ for vertex in &input.brep.topology.vertices {
+ let point = a.frame.local(vertex.position);
+ for axis in 0..3 {
+ grid[axis].push(point[axis]);
+ }
+ }
+ }
+ }
// Only arithmetic roundoff is merged; actual unresolved feature widths fail.
let magnitude = [a.frame.origin, b.frame.origin]
.into_iter()
@@ -311,7 +459,15 @@ pub fn boolean_boxes(
}
}
let n = grid.each_ref().map(|g| g.len() - 1);
- let count = n[0] * n[1] * n[2];
+ let count = n
+ .into_iter()
+ .try_fold(1usize, |product, cells| product.checked_mul(cells))
+ .filter(|count| *count <= 2_000_000)
+ .ok_or_else(|| {
+ GeometryError::LimitExceeded(
+ "rectilinear Boolean grid exceeds two million cells".into(),
+ )
+ })?;
let mut material = vec![false; count];
for x in 0..n[0] {
for y in 0..n[1] {
@@ -320,8 +476,16 @@ pub fn boolean_boxes(
let center = std::array::from_fn(|i| {
grid[i][p[i]] + (grid[i][p[i] + 1] - grid[i][p[i]]) / 2.0
});
- let ia = inside(center, alo, ahi);
- let ib = inside(center, blo, bhi);
+ let ia = if canonical_boxes {
+ inside(center, alo, ahi)
+ } else {
+ classified_inside(a.brep, a.frame.point(center))?
+ };
+ let ib = if canonical_boxes {
+ inside(center, blo, bhi)
+ } else {
+ classified_inside(b.brep, a.frame.point(center))?
+ };
material[flat_index(p, n)] = match operation {
BooleanOp::Union => ia || ib,
BooleanOp::Intersection => ia && ib,
@@ -388,21 +552,31 @@ pub fn boolean_boxes(
let mut owner = None;
for (input, lo, hi, cutter) in [(&a, alo, ahi, false), (&b, blo, bhi, true)]
{
- for side in [false, true] {
- let plane = if side { hi[axis] } else { lo[axis] };
- if coordinate == plane
- && (0..3)
- .filter(|i| *i != axis)
- .all(|i| center[i] > lo[i] && center[i] < hi[i])
- {
- let face = face_index(axis, side);
- let reversed = side != upper;
- if !reversed || (operation == BooleanOp::Subtraction && cutter)
- {
- sources.push(source(input, face));
- if owner.is_none() {
- owner = Some((input, face, reversed, cutter, lo, hi));
- }
+ let faces = if canonical_boxes {
+ [false, true]
+ .into_iter()
+ .filter_map(|side| {
+ let plane = if side { hi[axis] } else { lo[axis] };
+ (coordinate == plane
+ && (0..3)
+ .filter(|i| *i != axis)
+ .all(|i| center[i] > lo[i] && center[i] < hi[i]))
+ .then_some((face_index(axis, side), side != upper))
+ })
+ .collect::>()
+ } else {
+ containing_faces(
+ input,
+ a.frame.point(center),
+ scale([a.frame.x, a.frame.y, a.frame.z][axis], normal_sign),
+ accuracy.intersection,
+ )?
+ };
+ for (face, reversed) in faces {
+ if !reversed || (operation == BooleanOp::Subtraction && cutter) {
+ sources.push(source(input, face));
+ if owner.is_none() {
+ owner = Some((input, face, reversed, cutter, lo, hi));
}
}
}
@@ -523,10 +697,19 @@ pub fn boolean_boxes(
}
other => other,
})?;
- let whole = [u, v]
- .into_iter()
- .all(|i| grid[i][p[i]] == lo[i] && grid[i][p[i] + 1] == hi[i]);
- builder.brep.topology.faces[face].sense = if reversed {
+ let whole = canonical_boxes
+ && [u, v]
+ .into_iter()
+ .all(|i| grid[i][p[i]] == lo[i] && grid[i][p[i] + 1] == hi[i]);
+ let surface_reversed = if canonical_boxes {
+ reversed
+ } else {
+ dot(
+ face_frame.z,
+ scale([a.frame.x, a.frame.y, a.frame.z][axis], normal_sign),
+ ) < 0.0
+ };
+ builder.brep.topology.faces[face].sense = if surface_reversed {
Orientation::Reverse
} else {
Orientation::Forward
@@ -635,7 +818,7 @@ pub fn boolean_boxes(
out.validate()?;
let face_mappings = [&a, &b]
.into_iter()
- .flat_map(|input| (0..6).map(move |face| source(input, face)))
+ .flat_map(|input| (0..input.brep.topology.faces.len()).map(move |face| source(input, face)))
.map(|source| FaceMapping {
result_faces: out
.topology
diff --git a/main/opengeometry/src/analytic/curved_layered_boolean.rs b/main/opengeometry/src/analytic/curved_layered_boolean.rs
new file mode 100644
index 0000000..6d826c8
--- /dev/null
+++ b/main/opengeometry/src/analytic/curved_layered_boolean.rs
@@ -0,0 +1,1540 @@
+//! Vertical subtraction for an arc-edged extrusion and a planar prism.
+//! The planar arrangement remains analytic at every height slab; the result is
+//! assembled from planar caps and planar/cylindrical side faces in one B-rep.
+
+use std::collections::{BTreeMap, VecDeque};
+
+use super::{
+ booleans::{analytic_face_mappings, brep_face_source, BooleanOp, BooleanReport, BooleanResult},
+ geometry::{add, cross, dot, norm, scale, sub, unit},
+ primitives::{boundary, plane_boundary, uv_line, Builder, Use},
+ query::{classify_point_in_shell, face_contains_uv, PointClassification},
+ topology::{
+ Accuracy, BrepEnvelope, EdgeGeometry, Face, FaceProvenance, FaceRole, GeometryQuality,
+ Orientation, Shell, SolidRegion,
+ },
+ CurveGeometry, Frame3, GeometryError, Point3, Surface, SurfaceGeometry,
+};
+use crate::{
+ geometry::poly2d::Pt2,
+ geometry::{
+ boolean2d::PlanarBooleanOp,
+ curved_boolean2d::{
+ boolean_curved_regions, intersections, winding, CurveEdge2, CurveRegion2,
+ },
+ },
+ math::interval::Interval,
+};
+
+fn coverage() -> GeometryError {
+ GeometryError::CoverageGap {
+ families: [
+ "vertical arc-edged extrusion".into(),
+ "planar cutter".into(),
+ ],
+ }
+}
+
+fn area(ring: &[CurveEdge2]) -> f64 {
+ ring.iter().map(CurveEdge2::twice_area).sum::() / 2.0
+}
+
+fn canonical_arc_start(start: f64, sweep: f64) -> f64 {
+ let lower = start.min(start + sweep);
+ lower.rem_euclid(std::f64::consts::TAU) + start - lower
+}
+
+fn reverse(ring: &[CurveEdge2]) -> Vec {
+ ring.iter().rev().map(CurveEdge2::reverse).collect()
+}
+
+fn face_region(
+ host: &BrepEnvelope,
+ face: &Face,
+ base: Frame3,
+) -> Result {
+ let read_loop = |loop_id: u32| -> Result, GeometryError> {
+ let start = host
+ .topology
+ .loops
+ .get(loop_id as usize)
+ .ok_or_else(coverage)?
+ .start_halfedge;
+ let mut current = start;
+ let mut edges = Vec::new();
+ loop {
+ let halfedge = host
+ .topology
+ .halfedges
+ .get(current as usize)
+ .ok_or_else(coverage)?;
+ let from = base.local(
+ host.topology
+ .vertices
+ .get(halfedge.from as usize)
+ .ok_or_else(coverage)?
+ .position,
+ );
+ let to = base.local(
+ host.topology
+ .vertices
+ .get(halfedge.to as usize)
+ .ok_or_else(coverage)?
+ .position,
+ );
+ let edge = host
+ .topology
+ .edges
+ .get(halfedge.edge as usize)
+ .ok_or_else(coverage)?;
+ let EdgeGeometry::Curve { curve, range } = edge.geometry else {
+ return Err(coverage());
+ };
+ let curve = host
+ .geometry
+ .curves
+ .get(curve as usize)
+ .ok_or_else(coverage)?;
+ edges.push(match curve {
+ CurveGeometry::Line { .. } => CurveEdge2::Line {
+ from: [from[0], from[1]],
+ to: [to[0], to[1]],
+ },
+ CurveGeometry::Circle { frame, radius } => {
+ if dot(frame.z, base.z).abs() < 1.0 - 1e-10 {
+ return Err(coverage());
+ }
+ let centre = base.local(frame.origin);
+ let start_angle = (from[1] - centre[1]).atan2(from[0] - centre[0]);
+ let sense = if halfedge.geometry_use.sense == Orientation::Forward {
+ 1.0
+ } else {
+ -1.0
+ };
+ CurveEdge2::Arc {
+ center: [centre[0], centre[1]],
+ radius: *radius,
+ start_angle,
+ sweep_angle: sense * dot(frame.z, base.z).signum() * (range.hi - range.lo),
+ }
+ }
+ _ => return Err(coverage()),
+ });
+ current = halfedge.next.ok_or_else(coverage)?;
+ if current == start {
+ break;
+ }
+ if edges.len() > host.topology.halfedges.len() {
+ return Err(coverage());
+ }
+ }
+ if edges.len() == 1 {
+ if let CurveEdge2::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } = edges[0].clone()
+ {
+ if sweep_angle.abs() >= std::f64::consts::TAU - 1e-9 {
+ edges = vec![
+ CurveEdge2::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle: sweep_angle / 2.0,
+ },
+ CurveEdge2::Arc {
+ center,
+ radius,
+ start_angle: start_angle + sweep_angle / 2.0,
+ sweep_angle: sweep_angle / 2.0,
+ },
+ ];
+ }
+ }
+ }
+ Ok(edges)
+ };
+ let mut edges = read_loop(face.trim.outer)?;
+ if area(&edges) > 0.0 {
+ edges = reverse(&edges);
+ }
+ let holes = face
+ .trim
+ .holes
+ .iter()
+ .map(|loop_id| {
+ let mut ring = read_loop(*loop_id)?;
+ if area(&ring) < 0.0 {
+ ring = reverse(&ring);
+ }
+ Ok(ring)
+ })
+ .collect::, GeometryError>>()?;
+ Ok(CurveRegion2 {
+ outer: edges,
+ holes,
+ })
+}
+
+fn face_profile(
+ host: &BrepEnvelope,
+ base: Frame3,
+ level: f64,
+ tolerance: f64,
+) -> Result {
+ let face = host
+ .topology
+ .faces
+ .iter()
+ .find(|face| {
+ matches!(host.geometry.surfaces.get(face.surface as usize),
+ Some(SurfaceGeometry::Plane { frame }) if dot(frame.z, base.z) > 1.0 - 1e-10
+ && (base.local(frame.origin)[2] - level).abs() <= tolerance)
+ })
+ .ok_or_else(coverage)?;
+ face_region(host, face, base)
+}
+
+fn sectional_regions(
+ host: &BrepEnvelope,
+ base: Frame3,
+ level: f64,
+ tolerance: f64,
+) -> Result, GeometryError> {
+ let mut edges = Vec::new();
+ for face in &host.topology.faces {
+ let start = host.topology.loops[face.trim.outer as usize].start_halfedge;
+ let mut current = start;
+ let mut lowest = f64::INFINITY;
+ let mut highest = f64::NEG_INFINITY;
+ let mut boundary = Vec::new();
+ loop {
+ let halfedge = &host.topology.halfedges[current as usize];
+ let from = host.topology.vertices[halfedge.from as usize].position;
+ let to = host.topology.vertices[halfedge.to as usize].position;
+ for point in [from, to] {
+ let height = base.local(point)[2];
+ lowest = lowest.min(height);
+ highest = highest.max(height);
+ }
+ boundary.push((current, from, to));
+ current = halfedge.next.ok_or_else(coverage)?;
+ if current == start {
+ break;
+ }
+ if boundary.len() > host.topology.halfedges.len() {
+ return Err(coverage());
+ }
+ }
+ if level <= lowest + tolerance || level >= highest - tolerance {
+ continue;
+ }
+ if !face.trim.holes.is_empty() {
+ return Err(coverage());
+ }
+ match &host.geometry.surfaces[face.surface as usize] {
+ SurfaceGeometry::Plane { frame } if dot(frame.z, base.z).abs() > 1.0e-10 => {
+ return Err(coverage());
+ }
+ SurfaceGeometry::Cylinder { frame, .. }
+ if dot(frame.z, base.z).abs() < 1.0 - 1.0e-10 =>
+ {
+ return Err(coverage());
+ }
+ SurfaceGeometry::Plane { .. } | SurfaceGeometry::Cylinder { .. } => {}
+ _ => return Err(coverage()),
+ }
+ let bottom = boundary
+ .iter()
+ .filter(|(_, from, to)| {
+ (base.local(*from)[2] - lowest).abs() <= tolerance
+ && (base.local(*to)[2] - lowest).abs() <= tolerance
+ })
+ .collect::>();
+ if bottom.len() != 1 {
+ return Err(coverage());
+ }
+ let &(halfedge_id, from, to) = bottom[0];
+ let halfedge = &host.topology.halfedges[halfedge_id as usize];
+ let edge = &host.topology.edges[halfedge.edge as usize];
+ let EdgeGeometry::Curve { curve, range } = edge.geometry else {
+ return Err(coverage());
+ };
+ let from = base.local(from);
+ let to = base.local(to);
+ match &host.geometry.curves[curve as usize] {
+ CurveGeometry::Line { .. } => edges.push(CurveEdge2::Line {
+ from: [from[0], from[1]],
+ to: [to[0], to[1]],
+ }),
+ CurveGeometry::Circle { frame, radius } => {
+ if dot(frame.z, base.z).abs() < 1.0 - 1.0e-10 {
+ return Err(coverage());
+ }
+ let centre = base.local(frame.origin);
+ let start_angle = (from[1] - centre[1]).atan2(from[0] - centre[0]);
+ let sense = if halfedge.geometry_use.sense == Orientation::Forward {
+ 1.0
+ } else {
+ -1.0
+ };
+ let sweep = sense * dot(frame.z, base.z).signum() * (range.hi - range.lo);
+ if sweep.abs() >= std::f64::consts::TAU - 1.0e-9 {
+ for half in 0..2 {
+ edges.push(CurveEdge2::Arc {
+ center: [centre[0], centre[1]],
+ radius: *radius,
+ start_angle: start_angle + sweep * half as f64 / 2.0,
+ sweep_angle: sweep / 2.0,
+ });
+ }
+ } else {
+ edges.push(CurveEdge2::Arc {
+ center: [centre[0], centre[1]],
+ radius: *radius,
+ start_angle,
+ sweep_angle: sweep,
+ });
+ }
+ }
+ _ => return Err(coverage()),
+ }
+ }
+ let mut rings = Vec::>::new();
+ while let Some(first) = edges.pop() {
+ let start = first.point(0.0);
+ let mut end = first.point(1.0);
+ let mut ring = vec![first];
+ while (end.x - start.x).hypot(end.z - start.z) > tolerance {
+ let matches = edges
+ .iter()
+ .enumerate()
+ .filter_map(|(index, edge)| {
+ let from = edge.point(0.0);
+ let to = edge.point(1.0);
+ let forward = (from.x - end.x).hypot(from.z - end.z) <= tolerance;
+ let reverse = (to.x - end.x).hypot(to.z - end.z) <= tolerance;
+ (forward || reverse).then_some((index, reverse))
+ })
+ .collect::>();
+ if matches.len() != 1 {
+ return Err(coverage());
+ }
+ let (index, reverse) = matches[0];
+ let mut next = edges.swap_remove(index);
+ if reverse {
+ next = next.reverse();
+ }
+ end = next.point(1.0);
+ ring.push(next);
+ }
+ if area(&ring).abs() <= tolerance * tolerance {
+ return Err(coverage());
+ }
+ rings.push(ring);
+ }
+ let mut samples = Vec::new();
+ for ring in &rings {
+ let edge = &ring[0];
+ let middle = edge.point(0.5);
+ let before = edge.point(0.49);
+ let after = edge.point(0.51);
+ let tangent = Pt2::new(after.x - before.x, after.z - before.z);
+ let length = tangent.x.hypot(tangent.z);
+ if length <= tolerance {
+ return Err(coverage());
+ }
+ let offset = (length * 0.1).min(1.0e-4).max(tolerance * 16.0);
+ let normal = Pt2::new(-tangent.z * offset / length, tangent.x * offset / length);
+ let left = Pt2::new(middle.x + normal.x, middle.z + normal.z);
+ let right = Pt2::new(middle.x - normal.x, middle.z - normal.z);
+ let left_inside = winding(left, ring, tolerance) != 0;
+ let right_inside = winding(right, ring, tolerance) != 0;
+ let sample = match (left_inside, right_inside) {
+ (true, false) => left,
+ (false, true) => right,
+ _ => return Err(coverage()),
+ };
+ samples.push(sample);
+ }
+ let depths = samples
+ .iter()
+ .enumerate()
+ .map(|(index, sample)| {
+ rings
+ .iter()
+ .enumerate()
+ .filter(|(other, ring)| *other != index && winding(*sample, ring, tolerance) != 0)
+ .count()
+ })
+ .collect::>();
+ let mut regions = Vec::<(usize, CurveRegion2)>::new();
+ for (index, ring) in rings.iter().enumerate() {
+ if depths[index] % 2 == 0 {
+ regions.push((
+ index,
+ CurveRegion2 {
+ outer: if area(ring) < 0.0 {
+ ring.clone()
+ } else {
+ reverse(ring)
+ },
+ holes: Vec::new(),
+ },
+ ));
+ }
+ }
+ for (index, ring) in rings.iter().enumerate() {
+ if depths[index] % 2 == 0 {
+ continue;
+ }
+ let parent = regions
+ .iter()
+ .enumerate()
+ .filter(|(_, (outer, region))| {
+ depths[*outer] + 1 == depths[index]
+ && winding(samples[index], ®ion.outer, tolerance) != 0
+ })
+ .map(|(parent, (_, region))| (parent, area(®ion.outer).abs()))
+ .min_by(|left, right| left.1.total_cmp(&right.1))
+ .map(|(parent, _)| parent)
+ .ok_or_else(coverage)?;
+ regions[parent].1.holes.push(if area(ring) > 0.0 {
+ ring.clone()
+ } else {
+ reverse(ring)
+ });
+ }
+ Ok(regions.into_iter().map(|(_, region)| region).collect())
+}
+
+fn append_points(regions: &[CurveRegion2], points: &mut Vec<[f64; 2]>) {
+ for region in regions {
+ for ring in std::iter::once(®ion.outer).chain(®ion.holes) {
+ points.extend(ring.iter().map(|edge| {
+ let p = edge.point(0.0);
+ [p.x, p.z]
+ }));
+ }
+ }
+}
+
+fn split_ring(ring: &[CurveEdge2], points: &[[f64; 2]], tolerance: f64) -> Vec {
+ ring.iter()
+ .flat_map(|edge| {
+ let mut stations = vec![0.0, 1.0];
+ for point in points {
+ if let Some(station) = edge.parameter(Pt2::new(point[0], point[1]), tolerance) {
+ if station > 1e-10 && station < 1.0 - 1e-10 {
+ stations.push(station);
+ }
+ }
+ }
+ stations.sort_by(f64::total_cmp);
+ stations.dedup_by(|a, b| (*a - *b).abs() < 1e-10);
+ stations
+ .windows(2)
+ .map(|span| edge.slice(span[0], span[1]))
+ .collect::>()
+ })
+ .collect()
+}
+
+fn split_regions(regions: &mut [CurveRegion2], points: &[[f64; 2]], tolerance: f64) {
+ for region in regions {
+ region.outer = split_ring(®ion.outer, points, tolerance);
+ for hole in &mut region.holes {
+ *hole = split_ring(hole, points, tolerance);
+ }
+ }
+}
+
+struct Facets {
+ builder: Builder,
+ vertices: Vec<(Point3, u32)>,
+ edges: BTreeMap<(u32, u32, i64, i64, i64), (u32, u32, u32, bool)>,
+ accuracy: Accuracy,
+ base: Frame3,
+}
+
+struct SidePatch {
+ edge: CurveEdge2,
+ levels: Vec,
+ provenance: FaceProvenance,
+}
+
+fn same_side_patch(a: &SidePatch, b: &SidePatch, tolerance: f64) -> bool {
+ if std::mem::discriminant(&a.edge) != std::mem::discriminant(&b.edge)
+ || a.provenance.role != b.provenance.role
+ || a.provenance.reversed != b.provenance.reversed
+ || a.provenance.sources.len() != b.provenance.sources.len()
+ || !a
+ .provenance
+ .sources
+ .iter()
+ .zip(&b.provenance.sources)
+ .all(|(a, b)| {
+ a.entity == b.entity && a.body == b.body && a.key == b.key && a.face == b.face
+ })
+ || (a.edge.length() - b.edge.length()).abs() > tolerance
+ {
+ return false;
+ }
+ [0.0, 0.5, 1.0].into_iter().all(|station| {
+ let a = a.edge.point(station);
+ let b = b.edge.point(station);
+ (a.x - b.x).hypot(a.z - b.z) <= tolerance
+ })
+}
+
+impl Facets {
+ fn new(id: String, accuracy: Accuracy, base: Frame3) -> Result {
+ Ok(Self {
+ builder: Builder::new(id, accuracy)?,
+ vertices: Vec::new(),
+ edges: BTreeMap::new(),
+ accuracy,
+ base,
+ })
+ }
+
+ fn vertex(&mut self, point: Point3) -> u32 {
+ if let Some((_, id)) = self
+ .vertices
+ .iter()
+ .find(|(other, _)| norm(sub(*other, point)) <= self.accuracy.geometric / 4.0)
+ {
+ return *id;
+ }
+ let id = self.builder.vertex(point);
+ self.vertices.push((point, id));
+ id
+ }
+
+ fn edge(
+ &mut self,
+ from: Point3,
+ to: Point3,
+ middle: Point3,
+ circle: Option<([f64; 2], f64, f64, f64)>,
+ ) -> Result<(u32, u32, u32, Orientation), GeometryError> {
+ let from_id = self.vertex(from);
+ let to_id = self.vertex(to);
+ if from_id == to_id {
+ return Err(coverage());
+ }
+ let step = self.accuracy.geometric / 4.0;
+ let key = (
+ from_id.min(to_id),
+ from_id.max(to_id),
+ (middle[0] / step).round() as i64,
+ (middle[1] / step).round() as i64,
+ (middle[2] / step).round() as i64,
+ );
+ if let Some(&(id, stored_from, stored_to, forward)) = self.edges.get(&key) {
+ let same = from_id == stored_from && to_id == stored_to;
+ let orientation = if same == forward {
+ Orientation::Forward
+ } else {
+ Orientation::Reverse
+ };
+ return Ok((id, from_id, to_id, orientation));
+ }
+ let (curve, range, forward) = if let Some((centre, radius, start, sweep)) = circle {
+ let origin = self
+ .base
+ .point([centre[0], centre[1], self.base.local(from)[2]]);
+ let frame = Frame3 {
+ origin,
+ ..self.base
+ };
+ let start = canonical_arc_start(start, sweep);
+ let end = start + sweep;
+ (
+ CurveGeometry::Circle { frame, radius },
+ Interval::new(start.min(end), start.max(end))?,
+ sweep > 0.0,
+ )
+ } else {
+ let delta = sub(to, from);
+ (
+ CurveGeometry::Line {
+ origin: from,
+ direction: unit(delta)?,
+ },
+ Interval::new(0.0, norm(delta))?,
+ true,
+ )
+ };
+ let id = self.builder.edge(curve, range, false);
+ self.edges.insert(key, (id, from_id, to_id, forward));
+ Ok((
+ id,
+ from_id,
+ to_id,
+ if forward {
+ Orientation::Forward
+ } else {
+ Orientation::Reverse
+ },
+ ))
+ }
+
+ fn edge_at(
+ &mut self,
+ edge: &CurveEdge2,
+ level: f64,
+ ) -> Result<(u32, u32, u32, Orientation), GeometryError> {
+ let point = |t| {
+ let p = edge.point(t);
+ self.base.point([p.x, p.z, level])
+ };
+ let curve = match edge {
+ CurveEdge2::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => Some((*center, *radius, *start_angle, *sweep_angle)),
+ CurveEdge2::Line { .. } => None,
+ };
+ self.edge(point(0.0), point(1.0), point(0.5), curve)
+ }
+
+ fn cap_uses(
+ &mut self,
+ ring: &[CurveEdge2],
+ level: f64,
+ frame: Frame3,
+ ) -> Result, GeometryError> {
+ let mut uses = Vec::new();
+ for edge in ring {
+ let (id, from, to, sense) = self.edge_at(edge, level)?;
+ uses.push(plane_boundary(&self.builder, frame, id, from, to, sense)?);
+ }
+ Ok(uses)
+ }
+
+ fn cap(
+ &mut self,
+ regions: &[CurveRegion2],
+ level: f64,
+ up: bool,
+ provenance: FaceProvenance,
+ ) -> Result<(), GeometryError> {
+ let origin = self.base.point([0.0, 0.0, level]);
+ let frame = if up {
+ Frame3 {
+ origin,
+ ..self.base
+ }
+ } else {
+ Frame3 {
+ origin,
+ x: self.base.x,
+ y: scale(self.base.y, -1.0),
+ z: scale(self.base.z, -1.0),
+ }
+ };
+ for region in regions {
+ let outer = if up {
+ region.outer.clone()
+ } else {
+ reverse(®ion.outer)
+ };
+ let holes = if up {
+ region.holes.clone()
+ } else {
+ region.holes.iter().map(|ring| reverse(ring)).collect()
+ };
+ let mut bounds = [[f64::INFINITY, f64::NEG_INFINITY]; 2];
+ for edge in outer.iter().chain(holes.iter().flatten()) {
+ let mut stations = vec![0.0, 0.5, 1.0];
+ if let CurveEdge2::Arc { center, radius, .. } = edge {
+ for angle in [
+ 0.0,
+ std::f64::consts::FRAC_PI_2,
+ std::f64::consts::PI,
+ 3.0 * std::f64::consts::FRAC_PI_2,
+ ] {
+ let point = Pt2::new(
+ center[0] + radius * angle.cos(),
+ center[1] + radius * angle.sin(),
+ );
+ if let Some(station) = edge.parameter(point, self.accuracy.intersection) {
+ stations.push(station);
+ }
+ }
+ }
+ for t in stations {
+ let point = edge.point(t);
+ let local = frame.local(self.base.point([point.x, point.z, level]));
+ for axis in 0..2 {
+ bounds[axis][0] = bounds[axis][0].min(local[axis]);
+ bounds[axis][1] = bounds[axis][1].max(local[axis]);
+ }
+ }
+ }
+ for bound in &mut bounds {
+ bound[0] -= self.accuracy.geometric;
+ bound[1] += self.accuracy.geometric;
+ }
+ let outer_uses = self.cap_uses(&outer, level, frame)?;
+ let hole_uses = holes
+ .iter()
+ .map(|ring| self.cap_uses(ring, level, frame))
+ .collect::, _>>()?;
+ let id = self.builder.brep.topology.faces.len();
+ self.builder.face_with_holes(
+ &format!("{}-cap-{id}", if up { "upper" } else { "lower" }),
+ SurfaceGeometry::Plane { frame },
+ bounds,
+ outer_uses,
+ hole_uses,
+ )?;
+ self.builder.brep.topology.faces[id].provenance = provenance.clone();
+ }
+ Ok(())
+ }
+
+ fn side(
+ &mut self,
+ edge: &CurveEdge2,
+ levels: &[f64],
+ provenance: FaceProvenance,
+ ) -> Result<(), GeometryError> {
+ if levels.len() < 2 || levels.windows(2).any(|span| span[1] <= span[0]) {
+ return Err(coverage());
+ }
+ let lo = levels[0];
+ let hi = *levels.last().ok_or_else(coverage)?;
+ let base = self.base;
+ let point = |t, level| {
+ let p = edge.point(t);
+ base.point([p.x, p.z, level])
+ };
+ let lower_from = point(0.0, lo);
+ let lower_to = point(1.0, lo);
+ let (bottom, bf, bt, bottom_sense) = self.edge_at(edge, lo)?;
+ let (top, tf, tt, top_sense) = self.edge_at(edge, hi)?;
+ let mut end_segments = Vec::with_capacity(levels.len() - 1);
+ let mut start_segments = Vec::with_capacity(levels.len() - 1);
+ for span in levels.windows(2) {
+ let from = point(1.0, span[0]);
+ let to = point(1.0, span[1]);
+ let (edge, from_id, to_id, sense) =
+ self.edge(from, to, scale(add(from, to), 0.5), None)?;
+ end_segments.push((edge, from_id, to_id, sense, span[0], span[1]));
+ }
+ for span in levels.windows(2).rev() {
+ let from = point(0.0, span[1]);
+ let to = point(0.0, span[0]);
+ let (edge, from_id, to_id, sense) =
+ self.edge(from, to, scale(add(from, to), 0.5), None)?;
+ start_segments.push((edge, from_id, to_id, sense, span[0], span[1]));
+ }
+ let id = self.builder.brep.topology.faces.len();
+ match edge {
+ CurveEdge2::Line { .. } => {
+ let edge_direction = unit(sub(lower_to, lower_from))?;
+ let normal = unit(cross(edge_direction, self.base.z))?;
+ let frame = Frame3::from_axis(lower_from, normal, edge_direction)?;
+ let mut uses = Vec::with_capacity(2 + 2 * end_segments.len());
+ uses.push(plane_boundary(
+ &self.builder,
+ frame,
+ bottom,
+ bf,
+ bt,
+ bottom_sense,
+ )?);
+ for &(edge, from, to, sense, _, _) in &end_segments {
+ uses.push(plane_boundary(&self.builder, frame, edge, from, to, sense)?);
+ }
+ uses.push(plane_boundary(
+ &self.builder,
+ frame,
+ top,
+ tt,
+ tf,
+ if top_sense == Orientation::Forward {
+ Orientation::Reverse
+ } else {
+ Orientation::Forward
+ },
+ )?);
+ for &(edge, from, to, sense, _, _) in &start_segments {
+ uses.push(plane_boundary(&self.builder, frame, edge, from, to, sense)?);
+ }
+ self.builder.face(
+ &format!("vertical-plane-{id}"),
+ SurfaceGeometry::Plane { frame },
+ [
+ [
+ -self.accuracy.geometric,
+ norm(sub(lower_to, lower_from)) + self.accuracy.geometric,
+ ],
+ [-self.accuracy.geometric, hi - lo + self.accuracy.geometric],
+ ],
+ uses,
+ )?;
+ // Layer boundaries are walked clockwise with material to
+ // their right; the side frame's natural normal points left.
+ self.builder.brep.topology.faces[id].sense = Orientation::Reverse;
+ }
+ CurveEdge2::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => {
+ let origin = self.base.point([center[0], center[1], 0.0]);
+ let frame = Frame3 {
+ origin,
+ ..self.base
+ };
+ let start_angle = canonical_arc_start(*start_angle, *sweep_angle);
+ let end_angle = start_angle + sweep_angle;
+ let mut uses = Vec::with_capacity(2 + 2 * end_segments.len());
+ uses.push(boundary(
+ bottom,
+ bf,
+ bt,
+ bottom_sense,
+ uv_line([0.0, lo], [1.0, 0.0]),
+ ));
+ for &(edge, from, to, sense, start, end) in &end_segments {
+ uses.push(boundary(
+ edge,
+ from,
+ to,
+ sense,
+ if sense == Orientation::Forward {
+ uv_line([end_angle, start], [0.0, 1.0])
+ } else {
+ uv_line([end_angle, end], [0.0, -1.0])
+ },
+ ));
+ }
+ uses.push(boundary(
+ top,
+ tt,
+ tf,
+ if top_sense == Orientation::Forward {
+ Orientation::Reverse
+ } else {
+ Orientation::Forward
+ },
+ uv_line([0.0, hi], [1.0, 0.0]),
+ ));
+ for &(edge, from, to, sense, start, end) in &start_segments {
+ uses.push(boundary(
+ edge,
+ from,
+ to,
+ sense,
+ if sense == Orientation::Forward {
+ uv_line([start_angle, end], [0.0, -1.0])
+ } else {
+ uv_line([start_angle, start], [0.0, 1.0])
+ },
+ ));
+ }
+ self.builder.face(
+ &format!("vertical-cylinder-{id}"),
+ SurfaceGeometry::Cylinder {
+ frame,
+ radius: *radius,
+ },
+ [
+ [
+ start_angle.min(end_angle) - self.accuracy.geometric / radius,
+ start_angle.max(end_angle) + self.accuracy.geometric / radius,
+ ],
+ [lo - self.accuracy.geometric, hi + self.accuracy.geometric],
+ ],
+ uses,
+ )?;
+ // Layer outer rings are clockwise and holes counterclockwise.
+ // A counterclockwise circular edge therefore bounds an inner
+ // face; its natural radial normal points into material.
+ if *sweep_angle > 0.0 {
+ self.builder.brep.topology.faces[id].sense = Orientation::Reverse;
+ }
+ }
+ }
+ self.builder.brep.topology.faces[id].provenance = provenance;
+ Ok(())
+ }
+}
+
+fn source_for_side(
+ brep: &BrepEnvelope,
+ point: Point3,
+ axis: Point3,
+ tolerance: f64,
+) -> Result, GeometryError> {
+ let mut sources = Vec::new();
+ for face in &brep.topology.faces {
+ let surface = brep.geometry.surface(face.surface)?;
+ let on = match surface {
+ SurfaceGeometry::Plane { frame } => {
+ let local = frame.local(point);
+ local[2].abs() <= tolerance && dot(frame.z, axis).abs() < 1.0 - 1e-10
+ }
+ SurfaceGeometry::Cylinder { frame, radius } => {
+ let local = frame.local(point);
+ (local[0].hypot(local[1]) - radius).abs() <= tolerance
+ }
+ _ => false,
+ };
+ if !on {
+ continue;
+ }
+ let hint = [
+ face.trim.uv_bounds[0].midpoint(),
+ face.trim.uv_bounds[1].midpoint(),
+ ];
+ let uv = surface.project(point, Some(hint))?;
+ match face_contains_uv(brep, face, uv)? {
+ Some(true) => sources.push(brep_face_source(brep, face.id)),
+ Some(false) => {}
+ None => return Err(coverage()),
+ }
+ }
+ Ok(sources)
+}
+
+fn contains_region_point(region: &CurveRegion2, point: Pt2, tolerance: f64) -> bool {
+ winding(point, ®ion.outer, tolerance) != 0
+ && region
+ .holes
+ .iter()
+ .all(|hole| winding(point, hole, tolerance) == 0)
+}
+
+// Cap ancestry only asks whether two trimmed regions share positive area. Split
+// at the same analytic edge intersections as the Boolean arrangement, then
+// probe each open boundary interval on its material side. This also handles
+// coincident circular edges without assembling a second, potentially
+// degenerate Boolean boundary solely for provenance.
+fn cap_regions_overlap(a: &CurveRegion2, b: &CurveRegion2, tolerance: f64) -> bool {
+ for (region, other) in [(a, b), (b, a)] {
+ for ring in std::iter::once(®ion.outer).chain(®ion.holes) {
+ for edge in ring {
+ let mut parameters = vec![0.0, 1.0];
+ for other_ring in std::iter::once(&other.outer).chain(&other.holes) {
+ for other_edge in other_ring {
+ parameters.extend(
+ intersections(edge, other_edge, tolerance)
+ .into_iter()
+ .filter_map(|point| edge.parameter(point, tolerance)),
+ );
+ }
+ }
+ parameters.sort_by(f64::total_cmp);
+ parameters
+ .dedup_by(|left, right| (*left - *right).abs() * edge.length() <= tolerance);
+ for span in parameters.windows(2) {
+ let interval_length = (span[1] - span[0]) * edge.length();
+ if interval_length <= tolerance * 4.0 {
+ continue;
+ }
+ let midpoint = (span[0] + span[1]) / 2.0;
+ let point = edge.point(midpoint);
+ let tangent = edge.tangent(midpoint);
+ let tangent_length = tangent.x.hypot(tangent.z);
+ if tangent_length <= tolerance {
+ continue;
+ }
+ let mut offset = (interval_length * 0.1).min(1.0e-4);
+ for _ in 0..8 {
+ if offset <= tolerance * 2.0 {
+ break;
+ }
+ let probe = Pt2::new(
+ point.x + tangent.z * offset / tangent_length,
+ point.z - tangent.x * offset / tangent_length,
+ );
+ if contains_region_point(region, probe, tolerance)
+ && contains_region_point(other, probe, tolerance)
+ {
+ return true;
+ }
+ offset *= 0.5;
+ }
+ }
+ }
+ }
+ }
+ false
+}
+
+#[cfg(test)]
+mod cap_overlap_tests {
+ use super::*;
+
+ fn rectangle(x0: f64, z0: f64, x1: f64, z1: f64) -> CurveRegion2 {
+ let points = [[x0, z0], [x0, z1], [x1, z1], [x1, z0]];
+ CurveRegion2 {
+ outer: (0..4)
+ .map(|index| CurveEdge2::Line {
+ from: points[index],
+ to: points[(index + 1) % 4],
+ })
+ .collect(),
+ holes: Vec::new(),
+ }
+ }
+
+ #[test]
+ fn cap_overlap_requires_positive_shared_area() {
+ let host = rectangle(0.0, 0.0, 2.0, 2.0);
+ assert!(cap_regions_overlap(
+ &host,
+ &rectangle(1.0, 1.0, 3.0, 3.0),
+ 1e-7
+ ));
+ assert!(cap_regions_overlap(
+ &host,
+ &rectangle(0.5, 0.5, 1.5, 1.5),
+ 1e-7
+ ));
+ assert!(!cap_regions_overlap(
+ &host,
+ &rectangle(2.0, 0.0, 3.0, 1.0),
+ 1e-7
+ ));
+ assert!(!cap_regions_overlap(
+ &host,
+ &rectangle(3.0, 0.0, 4.0, 1.0),
+ 1e-7
+ ));
+ }
+
+ #[test]
+ fn cap_overlap_excludes_a_cutter_inside_a_hole() {
+ let mut host = rectangle(0.0, 0.0, 3.0, 3.0);
+ host.holes
+ .push(reverse(&rectangle(1.0, 1.0, 2.0, 2.0).outer));
+ assert!(!cap_regions_overlap(
+ &host,
+ &rectangle(1.2, 1.2, 1.8, 1.8),
+ 1e-7
+ ));
+ assert!(cap_regions_overlap(
+ &host,
+ &rectangle(0.8, 1.2, 1.2, 1.8),
+ 1e-7
+ ));
+ }
+}
+
+fn source_for_cap_region(
+ brep: &BrepEnvelope,
+ base: Frame3,
+ level: f64,
+ region: &CurveRegion2,
+ tolerance: f64,
+) -> Result, GeometryError> {
+ let mut candidates = Vec::new();
+ for face in &brep.topology.faces {
+ let SurfaceGeometry::Plane { frame } = brep.geometry.surface(face.surface)? else {
+ continue;
+ };
+ if dot(frame.z, base.z).abs() > 1.0 - 1.0e-10
+ && (base.local(frame.origin)[2] - level).abs() <= tolerance
+ {
+ candidates.push(face);
+ }
+ }
+ let mut sources = Vec::new();
+ for face in candidates {
+ let source_region = face_region(brep, face, base)?;
+ if cap_regions_overlap(region, &source_region, tolerance) {
+ sources.push(brep_face_source(brep, face.id));
+ }
+ }
+ Ok(sources)
+}
+
+fn cap_provenance(
+ host: &BrepEnvelope,
+ cutters: &[&BrepEnvelope],
+ base: Frame3,
+ level: f64,
+ region: &CurveRegion2,
+ tolerance: f64,
+) -> Result {
+ let mut sources = source_for_cap_region(host, base, level, region, tolerance)?;
+ let mut cut = false;
+ for cutter in cutters {
+ let cutter_sources = source_for_cap_region(cutter, base, level, region, tolerance)?;
+ cut |= !cutter_sources.is_empty();
+ sources.extend(cutter_sources);
+ }
+ if sources.is_empty() {
+ return Err(coverage());
+ }
+ Ok(FaceProvenance {
+ sources,
+ role: if cut { FaceRole::Cut } else { FaceRole::Split },
+ reversed: cut,
+ })
+}
+
+fn finish(
+ mut facets: Facets,
+ a: &BrepEnvelope,
+ cutters: &[&BrepEnvelope],
+) -> Result {
+ let mut assigned = vec![false; facets.builder.brep.topology.faces.len()];
+ let mut outer_shells = Vec::new();
+ let mut cavity_shells = Vec::new();
+ for start in 0..assigned.len() {
+ if assigned[start] {
+ continue;
+ }
+ let shell = facets.builder.brep.topology.shells.len() as u32;
+ let mut faces = Vec::new();
+ let mut queue = VecDeque::from([start]);
+ assigned[start] = true;
+ while let Some(face) = queue.pop_front() {
+ facets.builder.brep.topology.faces[face].shell_ref = Some(shell);
+ faces.push(face as u32);
+ for halfedge in facets
+ .builder
+ .brep
+ .topology
+ .halfedges
+ .iter()
+ .filter(|edge| edge.face == Some(face as u32))
+ {
+ let twin = halfedge.twin.ok_or_else(coverage)?;
+ let adjacent = facets.builder.brep.topology.halfedges[twin as usize]
+ .face
+ .ok_or_else(coverage)? as usize;
+ if !assigned[adjacent] {
+ assigned[adjacent] = true;
+ queue.push_back(adjacent);
+ }
+ }
+ }
+ let mut signed_volume = 0.0;
+ for &face_id in &faces {
+ let face = &facets.builder.brep.topology.faces[face_id as usize];
+ let SurfaceGeometry::Plane { frame } = facets
+ .builder
+ .brep
+ .geometry
+ .surfaces
+ .get(face.surface as usize)
+ .ok_or_else(coverage)?
+ else {
+ continue;
+ };
+ let outward = dot(frame.z, facets.base.z) * face.sense.multiplier();
+ if outward.abs() <= 1.0 - 1e-10 {
+ continue;
+ }
+ let region = face_region(&facets.builder.brep, face, facets.base)?;
+ let cap_area = area(®ion.outer).abs()
+ - region
+ .holes
+ .iter()
+ .map(|hole| area(hole).abs())
+ .sum::();
+ if !cap_area.is_finite() || cap_area <= facets.accuracy.geometric.powi(2) {
+ return Err(coverage());
+ }
+ signed_volume += facets.base.local(frame.origin)[2] * outward * cap_area;
+ }
+ if !signed_volume.is_finite() || signed_volume.abs() <= facets.accuracy.geometric.powi(3) {
+ return Err(coverage());
+ }
+ facets.builder.brep.topology.shells.push(Shell {
+ id: shell,
+ faces,
+ is_closed: true,
+ });
+ if signed_volume > 0.0 {
+ outer_shells.push((shell, signed_volume));
+ } else {
+ cavity_shells.push(shell);
+ }
+ }
+ for &(outer_shell, _) in &outer_shells {
+ facets.builder.brep.solids.push(SolidRegion {
+ outer_shell,
+ cavity_shells: Vec::new(),
+ });
+ }
+ for cavity_shell in cavity_shells {
+ let shell = &facets.builder.brep.topology.shells[cavity_shell as usize];
+ let face = &facets.builder.brep.topology.faces[shell.faces[0] as usize];
+ let start = facets.builder.brep.topology.loops[face.trim.outer as usize].start_halfedge;
+ let vertex = facets.builder.brep.topology.halfedges[start as usize].from;
+ let point = facets.builder.brep.topology.vertices[vertex as usize].position;
+ let mut enclosing = Vec::new();
+ for (index, &(outer_shell, volume)) in outer_shells.iter().enumerate() {
+ let outer = &facets.builder.brep.topology.shells[outer_shell as usize];
+ match classify_point_in_shell(&facets.builder.brep, &outer.faces, point)? {
+ PointClassification::Inside => enclosing.push((index, volume)),
+ PointClassification::Outside => {}
+ PointClassification::Boundary | PointClassification::Unknown => {
+ return Err(coverage())
+ }
+ }
+ }
+ let Some(&(index, _)) = enclosing
+ .iter()
+ .min_by(|left, right| left.1.total_cmp(&right.1))
+ else {
+ return Err(coverage());
+ };
+ facets.builder.brep.solids[index]
+ .cavity_shells
+ .push(cavity_shell);
+ }
+ let mut out = facets.builder.brep;
+ out.revision = cutters
+ .iter()
+ .fold(a.revision, |revision, cutter| revision.max(cutter.revision))
+ .checked_add(1)
+ .ok_or_else(coverage)?;
+ out.validate()?;
+ let face_mappings =
+ analytic_face_mappings(&out, std::iter::once(a).chain(cutters.iter().copied()));
+ Ok(BooleanResult {
+ report: BooleanReport {
+ operation: BooleanOp::Subtraction,
+ quality: GeometryQuality::Analytic,
+ contacts: Vec::new(),
+ coincident: true,
+ face_mappings,
+ },
+ brep: out,
+ })
+}
+
+pub(super) fn subtract_vertical_arc_extrusion(
+ a: &BrepEnvelope,
+ b: &BrepEnvelope,
+ id: String,
+) -> Result {
+ subtract_vertical_arc_extrusion_batch(a, &[b], id)
+}
+
+pub(super) fn subtract_vertical_arc_extrusion_batch(
+ a: &BrepEnvelope,
+ cutters: &[&BrepEnvelope],
+ id: String,
+) -> Result {
+ if cutters.is_empty() {
+ return Err(coverage());
+ }
+ let original_host = a
+ .topology
+ .faces
+ .iter()
+ .any(|face| face.key == "top" || face.key == "upper_cap");
+ if !a
+ .geometry
+ .surfaces
+ .iter()
+ .any(|surface| matches!(surface, SurfaceGeometry::Cylinder { .. }))
+ {
+ return Err(coverage());
+ }
+ if cutters.iter().any(|cutter| {
+ cutter.solids.len() != 1
+ || cutter
+ .geometry
+ .surfaces
+ .iter()
+ .any(|surface| !matches!(surface, SurfaceGeometry::Plane { .. }))
+ }) {
+ return Err(coverage());
+ }
+ let accuracy = cutters
+ .iter()
+ .fold(a.accuracy, |accuracy, cutter| Accuracy {
+ geometric: accuracy.geometric.max(cutter.accuracy.geometric),
+ intersection: accuracy.intersection.max(cutter.accuracy.intersection),
+ tessellation: accuracy.tessellation.max(cutter.accuracy.tessellation),
+ exchange: accuracy.exchange.max(cutter.accuracy.exchange),
+ });
+ // A transverse round cut also adds a cylinder to an extruded profile.
+ // Its axis differs from the host extrusion axis, so it cannot be treated as
+ // the vertical arc support used by this layered reconstruction.
+ let cap_axes = a
+ .topology
+ .faces
+ .iter()
+ .filter(|face| {
+ face.key == "top"
+ || face.key == "upper_cap"
+ || face.key.ends_with(":top")
+ || face.key.starts_with("upper-cap-")
+ })
+ .filter_map(
+ |face| match a.geometry.surfaces.get(face.surface as usize) {
+ Some(SurfaceGeometry::Plane { frame }) => Some(frame.z),
+ _ => None,
+ },
+ )
+ .collect::>();
+ let axis = a
+ .topology
+ .faces
+ .iter()
+ .find_map(
+ |face| match a.geometry.surfaces.get(face.surface as usize) {
+ Some(SurfaceGeometry::Cylinder { frame, .. })
+ if cap_axes
+ .iter()
+ .any(|cap| dot(*cap, frame.z).abs() > 1.0 - 1e-10) =>
+ {
+ Some(frame.z)
+ }
+ _ => None,
+ },
+ )
+ .ok_or_else(coverage)?;
+ let host_top = a
+ .topology
+ .faces
+ .iter()
+ .filter_map(
+ |face| match a.geometry.surfaces.get(face.surface as usize) {
+ Some(SurfaceGeometry::Plane { frame }) if dot(frame.z, axis) > 1.0 - 1e-10 => {
+ Some(*frame)
+ }
+ _ => None,
+ },
+ )
+ .max_by(|left, right| dot(left.origin, axis).total_cmp(&dot(right.origin, axis)))
+ .ok_or_else(coverage)?;
+ let positions = a
+ .topology
+ .vertices
+ .iter()
+ .map(|vertex| host_top.local(vertex.position)[2])
+ .collect::>();
+ let low = positions.iter().copied().fold(f64::INFINITY, f64::min);
+ let high = positions.iter().copied().fold(f64::NEG_INFINITY, f64::max);
+ if !low.is_finite() || high - low <= accuracy.geometric * 4.0 {
+ return Err(coverage());
+ }
+ let base = Frame3 {
+ origin: host_top.point([0.0, 0.0, low]),
+ ..host_top
+ };
+ let original_profile = if original_host {
+ Some(face_profile(a, base, high - low, accuracy.intersection)?)
+ } else {
+ None
+ };
+ let cutter_profiles = cutters
+ .iter()
+ .map(|cutter| {
+ let cutter_levels = cutter
+ .topology
+ .vertices
+ .iter()
+ .map(|vertex| base.local(vertex.position)[2])
+ .collect::>();
+ let lo = cutter_levels.iter().copied().fold(f64::INFINITY, f64::min);
+ let hi = cutter_levels
+ .iter()
+ .copied()
+ .fold(f64::NEG_INFINITY, f64::max);
+ if !lo.is_finite()
+ || hi - lo <= accuracy.geometric * 4.0
+ || cutter_levels.iter().any(|level| {
+ (level - lo).abs() > accuracy.geometric
+ && (level - hi).abs() > accuracy.geometric
+ })
+ {
+ return Err(coverage());
+ }
+ Ok((
+ *cutter,
+ lo,
+ hi,
+ face_profile(cutter, base, hi, accuracy.intersection)?,
+ ))
+ })
+ .collect::, GeometryError>>()?;
+ let mut levels = vec![0.0, high - low];
+ if !original_host {
+ for vertex in &a.topology.vertices {
+ let level = base.local(vertex.position)[2];
+ if level > accuracy.geometric && level < high - low - accuracy.geometric {
+ levels.push(level);
+ }
+ }
+ }
+ for (_, lo, hi, _) in &cutter_profiles {
+ for level in [*lo, *hi] {
+ if level > accuracy.geometric && level < high - low - accuracy.geometric {
+ levels.push(level);
+ }
+ }
+ }
+ levels.sort_by(f64::total_cmp);
+ levels.dedup_by(|left, right| (*left - *right).abs() <= accuracy.geometric);
+ let mut layers = levels
+ .windows(2)
+ .map(|span| {
+ let middle = (span[0] + span[1]) / 2.0;
+ let host_regions = if let Some(profile) = &original_profile {
+ vec![profile.clone()]
+ } else {
+ sectional_regions(a, base, middle, accuracy.intersection)?
+ };
+ let active = cutter_profiles
+ .iter()
+ .filter(|(_, lo, hi, _)| middle > *lo && middle < *hi)
+ .map(|(_, _, _, profile)| profile.clone())
+ .collect::>();
+ boolean_curved_regions(
+ &host_regions,
+ &active,
+ PlanarBooleanOp::Subtraction,
+ accuracy.intersection,
+ )
+ .map_err(GeometryError::UnresolvedIntersection)
+ })
+ .collect::, _>>()?;
+ let mut exposed_up = (1..layers.len())
+ .map(|index| {
+ boolean_curved_regions(
+ &layers[index - 1],
+ &layers[index],
+ PlanarBooleanOp::Subtraction,
+ accuracy.intersection,
+ )
+ .map_err(GeometryError::UnresolvedIntersection)
+ })
+ .collect::, _>>()?;
+ let mut exposed_down = (1..layers.len())
+ .map(|index| {
+ boolean_curved_regions(
+ &layers[index],
+ &layers[index - 1],
+ PlanarBooleanOp::Subtraction,
+ accuracy.intersection,
+ )
+ .map_err(GeometryError::UnresolvedIntersection)
+ })
+ .collect::, _>>()?;
+ let mut all_points = Vec::new();
+ for region in &layers {
+ append_points(region, &mut all_points);
+ }
+ for region in exposed_up.iter().chain(&exposed_down) {
+ append_points(region, &mut all_points);
+ }
+ for region in &mut layers {
+ split_regions(region, &all_points, accuracy.intersection);
+ }
+ for region in exposed_up.iter_mut().chain(exposed_down.iter_mut()) {
+ split_regions(region, &all_points, accuracy.intersection);
+ }
+ let mut facets = Facets::new(id, accuracy, base)?;
+ let mut side_patches = Vec::::new();
+ for region in &layers[0] {
+ let provenance = cap_provenance(a, &[], base, 0.0, region, accuracy.intersection)?;
+ facets.cap(std::slice::from_ref(region), 0.0, false, provenance)?;
+ }
+ for (index, regions) in layers.iter().enumerate() {
+ let lo = levels[index];
+ let hi = levels[index + 1];
+ for region in regions {
+ for ring in std::iter::once(®ion.outer).chain(®ion.holes) {
+ for edge in ring {
+ let p = edge.point(0.5);
+ let middle = base.point([p.x, p.z, (lo + hi) / 2.0]);
+ let from_a = source_for_side(a, middle, base.z, accuracy.intersection)?;
+ let mut from_b = Vec::new();
+ for cutter in cutters {
+ from_b.extend(source_for_side(
+ cutter,
+ middle,
+ base.z,
+ accuracy.intersection,
+ )?);
+ }
+ let cut = from_a.is_empty() && !from_b.is_empty();
+ let sources = if cut { from_b } else { from_a };
+ if sources.is_empty() {
+ return Err(coverage());
+ }
+ side_patches.push(SidePatch {
+ edge: edge.clone(),
+ levels: vec![lo, hi],
+ provenance: FaceProvenance {
+ sources,
+ role: if cut { FaceRole::Cut } else { FaceRole::Split },
+ reversed: cut,
+ },
+ });
+ }
+ }
+ }
+ if index + 1 < layers.len() {
+ let cap_level = levels[index + 1];
+ for region in &exposed_up[index] {
+ let provenance =
+ cap_provenance(a, cutters, base, cap_level, region, accuracy.intersection)?;
+ facets.cap(std::slice::from_ref(region), cap_level, true, provenance)?;
+ }
+ for region in &exposed_down[index] {
+ let provenance =
+ cap_provenance(a, cutters, base, cap_level, region, accuracy.intersection)?;
+ facets.cap(std::slice::from_ref(region), cap_level, false, provenance)?;
+ }
+ }
+ }
+ for region in layers.last().ok_or_else(coverage)? {
+ let provenance = cap_provenance(a, &[], base, high - low, region, accuracy.intersection)?;
+ facets.cap(std::slice::from_ref(region), high - low, true, provenance)?;
+ }
+ let mut consumed = vec![false; side_patches.len()];
+ for first in 0..side_patches.len() {
+ if consumed[first] {
+ continue;
+ }
+ consumed[first] = true;
+ let mut levels = side_patches[first].levels.clone();
+ loop {
+ let candidates = (first + 1..side_patches.len())
+ .filter(|&index| {
+ !consumed[index]
+ && (side_patches[index].levels[0] - levels[levels.len() - 1]).abs()
+ <= accuracy.geometric
+ && same_side_patch(
+ &side_patches[first],
+ &side_patches[index],
+ accuracy.geometric / 4.0,
+ )
+ })
+ .collect::>();
+ if candidates.len() != 1 {
+ break;
+ }
+ let next = candidates[0];
+ consumed[next] = true;
+ levels.push(side_patches[next].levels[1]);
+ }
+ facets.side(
+ &side_patches[first].edge,
+ &levels,
+ side_patches[first].provenance.clone(),
+ )?;
+ }
+ finish(facets, a, cutters)
+}
diff --git a/main/opengeometry/src/analytic/exchange.rs b/main/opengeometry/src/analytic/exchange.rs
index 8339560..88a43e8 100644
--- a/main/opengeometry/src/analytic/exchange.rs
+++ b/main/opengeometry/src/analytic/exchange.rs
@@ -774,8 +774,17 @@ mod tests {
primitives::cone("c".into(), frame(), 1.0, 2.0, accuracy()).unwrap(),
primitives::frustum("f".into(), frame(), 1.0, 0.4, 2.0, accuracy()).unwrap(),
primitives::torus("t".into(), frame(), 2.0, 0.5, accuracy()).unwrap(),
- primitives::circular_wall("w".into(), frame(), 2.0, 0.2, 1.5, 0.3, -2.1, accuracy())
- .unwrap(),
+ primitives::annular_sector_extrusion(
+ "w".into(),
+ frame(),
+ 2.0,
+ 0.2,
+ 1.5,
+ 0.3,
+ -2.1,
+ accuracy(),
+ )
+ .unwrap(),
];
for brep in bodies {
let original = brep.to_json().unwrap();
diff --git a/main/opengeometry/src/analytic/export_curve.rs b/main/opengeometry/src/analytic/export_curve.rs
index 64b8733..3f74d65 100644
--- a/main/opengeometry/src/analytic/export_curve.rs
+++ b/main/opengeometry/src/analytic/export_curve.rs
@@ -106,7 +106,11 @@ pub fn fit_intersection_curve(
}
let a = curve.point_at(range.lo)?;
let b = curve.point_at(range.hi)?;
- let deviation = bounds_chord_deviation(curve.enclose(range)?, a, b);
+ let deviation = if let Some(bound) = definition.certified_chord_deviation(range, store)? {
+ bound
+ } else {
+ bounds_chord_deviation(curve.enclose(range)?, a, b)
+ };
if deviation <= tolerance {
achieved = achieved.max(deviation);
accepted.push(range);
@@ -152,7 +156,19 @@ pub fn fit_intersection_curve(
"export curve sample exceeds its certified fitting budget".into(),
));
}
- for surface in supports {
+ for (surface, (from, to)) in supports
+ .into_iter()
+ .zip([(left.uv_a, right.uv_a), (left.uv_b, right.uv_b)])
+ {
+ let pcurve: UV =
+ std::array::from_fn(|axis| from[axis] * (1.0 - fraction) + to[axis] * fraction);
+ if norm(sub(surface.point_at(pcurve)?, fitted))
+ > tolerance + definition.residual_tolerance
+ {
+ return Err(GeometryError::UnresolvedIntersection(
+ "export pcurve sample misses the fitted spatial curve".into(),
+ ));
+ }
let uv = surface.project(fitted, None)?;
if norm(sub(surface.point_at(uv)?, fitted))
> tolerance + definition.residual_tolerance
diff --git a/main/opengeometry/src/analytic/face_intersection.rs b/main/opengeometry/src/analytic/face_intersection.rs
index ef4c64b..10e0698 100644
--- a/main/opengeometry/src/analytic/face_intersection.rs
+++ b/main/opengeometry/src/analytic/face_intersection.rs
@@ -520,7 +520,9 @@ fn clip_face_branch(
continue;
}
if let Some(previous) = intervals.last_mut() {
- if start - previous.hi <= accuracy.intersection {
+ // Each trim transition can carry one geometric tolerance of
+ // correction, so their shared boundary may differ by two.
+ if start - previous.hi <= accuracy.intersection.max(2.0 * accuracy.geometric) {
*previous = Interval::new(previous.lo, end.max(previous.hi))?;
continue;
}
@@ -531,6 +533,51 @@ fn clip_face_branch(
Ok(intervals)
}
+fn bound_plane_cylinder_generator(
+ geometry: &GeometryStore,
+ branch: &SsiCurve,
+ faces: [&Face; 2],
+ accuracy: Accuracy,
+) -> Result, GeometryError> {
+ let super::CurveGeometry::Line { origin, direction } = geometry.curves[branch.curve as usize]
+ else {
+ return Err(GeometryError::CoverageGap {
+ families: ["plane/cylinder trim".into(), "nonlinear generator".into()],
+ });
+ };
+ let mut range = [-f64::MAX.sqrt(), f64::MAX.sqrt()];
+ for side in 0..2 {
+ let surface = geometry.surface(side as u32)?;
+ let start = surface.project(origin, None)?;
+ let next = surface.project(super::geometry::add(origin, direction), Some(start))?;
+ let periods = surface.charts()[0].periods;
+ for axis in 0..2 {
+ let bounds = faces[side].trim.uv_bounds[axis];
+ let initial = periodic_value(start[axis], bounds, periods[axis]);
+ let later = periodic_value(next[axis], bounds, periods[axis]);
+ let mut rate = later - initial;
+ if matches!(surface, SurfaceGeometry::Cylinder { .. })
+ && axis == 0
+ && rate.abs() > accuracy.intersection
+ {
+ return Err(GeometryError::CoverageGap {
+ families: ["plane/cylinder trim".into(), "nonaxial generator".into()],
+ });
+ }
+ if matches!(surface, SurfaceGeometry::Cylinder { .. }) && axis == 0 {
+ rate = 0.0;
+ }
+ if !clip_coordinate(initial, rate, bounds, &mut range) {
+ return Ok(None);
+ }
+ }
+ }
+ if !range[0].is_finite() || !range[1].is_finite() || range[1] - range[0] <= accuracy.geometric {
+ return Ok(None);
+ }
+ Ok(Some(Interval::new(range[0], range[1])?))
+}
+
fn intersect_bounded_surfaces(
geometry: &mut GeometryStore,
faces: [&Face; 2],
@@ -541,7 +588,32 @@ fn intersect_bounded_surfaces(
.iter()
.take(2)
.all(|surface| matches!(surface, SurfaceGeometry::Plane { .. }));
+ let plane_cylinder_pair = geometry.surfaces.len() >= 2
+ && matches!(
+ (&geometry.surfaces[0], &geometry.surfaces[1]),
+ (
+ SurfaceGeometry::Plane { .. },
+ SurfaceGeometry::Cylinder { .. }
+ ) | (
+ SurfaceGeometry::Cylinder { .. },
+ SurfaceGeometry::Plane { .. }
+ )
+ );
match intersect_surfaces(geometry, 0, 1, accuracy) {
+ Ok(mut result) if plane_cylinder_pair => {
+ let mut bounded = Vec::new();
+ for mut branch in result.curves {
+ if branch.domain.is_none() {
+ branch.domain =
+ bound_plane_cylinder_generator(geometry, &branch, faces, accuracy)?;
+ }
+ if branch.domain.is_some() {
+ bounded.push(branch);
+ }
+ }
+ result.curves = bounded;
+ Ok(result)
+ }
Ok(result) if planar_pair || result.curves.iter().all(|curve| curve.domain.is_some()) => {
Ok(result)
}
@@ -788,6 +860,67 @@ mod tests {
}
}
+ #[test]
+ fn vertical_plane_cylinder_generators_remain_exact_and_bounded() {
+ let quarter = std::f64::consts::FRAC_PI_2;
+ let host = primitives::arc_edged_extrusion(
+ "arc-profile".into(),
+ Frame3::IDENTITY,
+ vec![
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: 0.0,
+ sweep_angle: quarter,
+ },
+ primitives::ProfileEdge::Line {
+ from: [0.0, 2.0],
+ to: [0.0, 1.5],
+ },
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.5,
+ start_angle: quarter,
+ sweep_angle: -quarter,
+ },
+ primitives::ProfileEdge::Line {
+ from: [1.5, 0.0],
+ to: [2.0, 0.0],
+ },
+ ],
+ 3.0,
+ accuracy(),
+ )
+ .unwrap();
+ let cutter = primitives::cuboid(
+ "opening".into(),
+ Frame3 {
+ origin: [1.1, 0.9, 0.5],
+ ..Frame3::IDENTITY
+ },
+ [1.0, 0.35, 1.5],
+ accuracy(),
+ )
+ .unwrap();
+ let graph = intersect_breps(&host, &cutter).unwrap();
+ assert!(!graph.pairs.is_empty());
+ let branches = graph
+ .pairs
+ .iter()
+ .flat_map(|pair| &pair.graph.branches)
+ .collect::>();
+ assert!(!branches.is_empty());
+ for pair in &graph.pairs {
+ for branch in &pair.graph.branches {
+ assert!(branch.range.lo.is_finite() && branch.range.hi.is_finite());
+ assert!(!matches!(
+ pair.graph.geometry.curves[branch.curve as usize],
+ CurveGeometry::Intersection { .. }
+ ));
+ }
+ }
+ }
+
#[test]
fn bounded_nonparallel_cylinder_faces_use_universal_ssi() {
let accuracy = Accuracy {
diff --git a/main/opengeometry/src/analytic/intersection.rs b/main/opengeometry/src/analytic/intersection.rs
index 0214228..80f47f5 100644
--- a/main/opengeometry/src/analytic/intersection.rs
+++ b/main/opengeometry/src/analytic/intersection.rs
@@ -1,5 +1,7 @@
use super::{
- geometry::{add, cross, dot, norm, scale, sub, unit, PatchBounds, Surface, UVBox},
+ geometry::{
+ add, cross, dot, norm, scale, sub, unit, PatchBounds, Surface, SurfaceGeometry, UVBox,
+ },
topology::GeometryStore,
GeometryError, Point3, UV,
};
@@ -66,6 +68,176 @@ pub struct IntersectionPoint {
pub support_error: f64,
}
+fn intersect_interval(a: Interval, b: Interval) -> Option {
+ Interval::new(a.lo.max(b.lo), a.hi.min(b.hi)).ok()
+}
+
+fn interval_dot(
+ bounds: PatchBounds,
+ origin: Point3,
+ axis: Point3,
+) -> Result {
+ let mut value = Interval::point(0.0)?;
+ for coordinate in 0..3 {
+ value = value.add(
+ bounds.axes[coordinate]
+ .sub(Interval::point(origin[coordinate])?)?
+ .mul(Interval::point(axis[coordinate])?)?,
+ )?;
+ }
+ Ok(value)
+}
+
+fn signed_root(radial: Interval, sign: Interval) -> Result, GeometryError> {
+ if radial.hi < 0.0 {
+ return Ok(None);
+ }
+ let root = Interval::new(radial.lo.max(0.0), radial.hi)?.sqrt()?;
+ Ok(Some(if sign.lo >= 0.0 {
+ root
+ } else if sign.hi <= 0.0 {
+ Interval::new(-root.hi, -root.lo)?
+ } else {
+ Interval::new(-root.hi, root.hi)?
+ }))
+}
+
+fn perpendicular_cylinder_enclosure(
+ definition: &IntersectionDefinition,
+ store: &GeometryStore,
+ segment: usize,
+ range: Interval,
+ parent: PatchBounds,
+) -> Result , GeometryError> {
+ let (
+ SurfaceGeometry::Cylinder {
+ frame: host,
+ radius: host_radius,
+ },
+ SurfaceGeometry::Cylinder {
+ frame: cutter,
+ radius: cutter_radius,
+ },
+ ) = (
+ store.surface(definition.surfaces[0])?,
+ store.surface(definition.surfaces[1])?,
+ )
+ else {
+ return Ok(None);
+ };
+ let u_axis = cutter.z;
+ let v_axis = host.z;
+ if dot(u_axis, v_axis).abs() > 1e-12 {
+ return Ok(None);
+ }
+ let w_axis = cross(u_axis, v_axis);
+ let host_to_cutter = sub(cutter.origin, host.origin);
+ let centre = add(host.origin, scale(v_axis, dot(host_to_cutter, v_axis)));
+ let residual = sub(
+ sub(centre, cutter.origin),
+ scale(u_axis, dot(sub(centre, cutter.origin), u_axis)),
+ );
+ if norm(residual) > 1e-12 {
+ return Ok(None);
+ }
+
+ let first = &definition.anchors[segment];
+ let last = &definition.anchors[segment + 1];
+ let guide_axis = unit(sub(last.point, first.point))?;
+ let certificate = definition.residual_tolerance * 8.0
+ + 128.0 * f64::EPSILON * host_radius.max(*cutter_radius).max(norm(centre)).max(1.0);
+ let theta_parent = definition.uv_tubes[segment][2];
+ if theta_parent.width() >= std::f64::consts::PI {
+ return Ok(None);
+ }
+ let u_parent = interval_dot(parent, centre, u_axis)?;
+ if u_parent.contains(0.0) {
+ return Ok(None);
+ }
+ let host_squared = Interval::new(
+ (host_radius - certificate).max(0.0).powi(2),
+ (host_radius + certificate).powi(2),
+ )?;
+ let v_cos = dot(cutter.x, v_axis);
+ let v_sin = dot(cutter.y, v_axis);
+ let w_cos = dot(cutter.x, w_axis);
+ let w_sin = dot(cutter.y, w_axis);
+ let trig = |cosine: Interval,
+ sine: Interval,
+ along_cosine: f64,
+ along_sine: f64|
+ -> Result {
+ Ok(cosine
+ .mul(Interval::point(along_cosine)?)?
+ .add(sine.mul(Interval::point(along_sine)?)?)?
+ .mul(Interval::point(*cutter_radius)?)?)
+ };
+ let parent_cosine = theta_parent.cos()?;
+ let parent_sine = theta_parent.sin()?;
+ let parent_w = trig(parent_cosine, parent_sine, w_cos, w_sin)?;
+ let parent_dw = trig(parent_sine, parent_cosine, -w_cos, w_sin)?;
+ let parent_dv = trig(parent_sine, parent_cosine, -v_cos, v_sin)?;
+ let Some(parent_u) = signed_root(host_squared.sub(parent_w.square()?)?, u_parent)? else {
+ return Ok(None);
+ };
+ if parent_u.contains(0.0) {
+ return Ok(None);
+ }
+ let parent_du = Interval::point(-1.0)?
+ .mul(parent_w)?
+ .mul(parent_dw)?
+ .div(parent_u)?;
+ let derivative = parent_du
+ .mul(Interval::point(dot(guide_axis, u_axis))?)?
+ .add(parent_dv.mul(Interval::point(dot(guide_axis, v_axis))?)?)?
+ .add(parent_dw.mul(Interval::point(dot(guide_axis, w_axis))?)?)?;
+ if derivative.contains(0.0) {
+ return Ok(None);
+ }
+
+ let lo = definition.evaluate(range.lo, store)?.uv_b[0];
+ let hi = definition.evaluate(range.hi, store)?.uv_b[0];
+ let theta_pad = certificate / cutter_radius + 64.0 * f64::EPSILON;
+ let theta = Interval::new(lo.min(hi) - theta_pad, lo.max(hi) + theta_pad)?;
+ if theta.lo < theta_parent.lo - theta_pad || theta.hi > theta_parent.hi + theta_pad {
+ return Ok(None);
+ }
+ let cosine = theta.cos()?;
+ let sine = theta.sin()?;
+ let v = trig(cosine, sine, v_cos, v_sin)?;
+ let w = trig(cosine, sine, w_cos, w_sin)?;
+ let Some(u) = signed_root(host_squared.sub(w.square()?)?, u_parent)? else {
+ return Ok(None);
+ };
+ let local = [u, v, w];
+ let axes = [u_axis, v_axis, w_axis];
+
+ let mut world = [Interval::point(0.0)?; 3];
+ for coordinate in 0..3 {
+ let mut value = Interval::point(centre[coordinate])?;
+ for axis in 0..3 {
+ value = value.add(local[axis].mul(Interval::point(axes[axis][coordinate])?)?)?;
+ }
+ world[coordinate] = value.add(Interval::new(-certificate, certificate)?)?;
+ }
+ let minimum_u = ((host_radius - certificate).powi(2) - (cutter_radius + certificate).powi(2))
+ .max(0.0)
+ .sqrt();
+ if minimum_u <= certificate {
+ return Ok(None);
+ }
+ let radius = cutter_radius + certificate;
+ let axial_curvature = radius.powi(2) / minimum_u + radius.powi(4) / minimum_u.powi(3);
+ let host_angle_curvature = radius / minimum_u + radius.powi(3) / minimum_u.powi(3);
+ // The exported XYZ chord and both surface pcurves use the same linear
+ // parameter. Bound their support-space deviations over that parameter.
+ let host_support_curvature =
+ host_radius * (host_angle_curvature + (radius / minimum_u).powi(2));
+ let curvature = (radius + axial_curvature).max(host_support_curvature);
+ let chord_error = curvature * theta.width().powi(2) / 8.0 + certificate * 4.0;
+ Ok(Some((PatchBounds { axes: world }, chord_error)))
+}
+
impl IntersectionDefinition {
fn shape(&self) -> Result<(), GeometryError> {
if self.anchors.len() < 2
@@ -291,6 +463,21 @@ impl IntersectionDefinition {
GeometryError::InvalidGeometry("disjoint trace support boxes".into())
})?;
}
+ if let Some((tighter, _)) = perpendicular_cylinder_enclosure(
+ self,
+ store,
+ i,
+ Interval::new(
+ range.lo.max(self.anchors[i].parameter),
+ range.hi.min(self.anchors[i + 1].parameter),
+ )?,
+ PatchBounds { axes },
+ )? {
+ for axis in 0..3 {
+ axes[axis] =
+ intersect_interval(axes[axis], tighter.axes[axis]).unwrap_or(axes[axis]);
+ }
+ }
result = Some(match result {
None => PatchBounds { axes },
Some(previous) => PatchBounds {
@@ -300,6 +487,37 @@ impl IntersectionDefinition {
}
result.ok_or_else(|| GeometryError::InvalidGeometry("empty trace interval".into()))
}
+
+ pub fn certified_chord_deviation(
+ &self,
+ range: Interval,
+ store: &GeometryStore,
+ ) -> Result, GeometryError> {
+ let Some(segment) = self
+ .anchors
+ .windows(2)
+ .position(|pair| range.lo >= pair[0].parameter && range.hi <= pair[1].parameter)
+ else {
+ return Ok(None);
+ };
+ let a = store.surface(self.surfaces[0])?;
+ let b = store.surface(self.surfaces[1])?;
+ let tube = self.uv_tubes[segment];
+ let box_a = a.enclose([tube[0], tube[1]])?;
+ let box_b = b.enclose([tube[2], tube[3]])?;
+ let mut axes = [Interval::point(0.0)?; 3];
+ for coordinate in 0..3 {
+ let Some(overlap) = intersect_interval(box_a.axes[coordinate], box_b.axes[coordinate])
+ else {
+ return Ok(None);
+ };
+ axes[coordinate] = overlap;
+ }
+ Ok(
+ perpendicular_cylinder_enclosure(self, store, segment, range, PatchBounds { axes })?
+ .map(|(_, error)| error),
+ )
+ }
}
#[cfg(test)]
diff --git a/main/opengeometry/src/analytic/mod.rs b/main/opengeometry/src/analytic/mod.rs
index 12f76d2..3a7c2b8 100644
--- a/main/opengeometry/src/analytic/mod.rs
+++ b/main/opengeometry/src/analytic/mod.rs
@@ -1,5 +1,6 @@
pub mod booleans;
pub mod box_booleans;
+mod curved_layered_boolean;
pub mod diagnostics;
pub mod exchange;
pub mod export_curve;
@@ -9,6 +10,7 @@ pub mod ifc_exchange;
pub mod intersection;
pub mod modeling;
pub mod placement;
+mod planar_booleans;
pub mod primitives;
pub mod query;
pub mod ssi;
diff --git a/main/opengeometry/src/analytic/placement.rs b/main/opengeometry/src/analytic/placement.rs
index 0ec7335..20f935e 100644
--- a/main/opengeometry/src/analytic/placement.rs
+++ b/main/opengeometry/src/analytic/placement.rs
@@ -254,7 +254,7 @@ mod tests {
primitives::cone("cone".into(), Frame3::IDENTITY, 1.0, 2.0, accuracy()).unwrap(),
primitives::sphere("s".into(), Frame3::IDENTITY, 1.0, accuracy()).unwrap(),
primitives::torus("t".into(), Frame3::IDENTITY, 2.0, 0.5, accuracy()).unwrap(),
- primitives::circular_wall(
+ primitives::annular_sector_extrusion(
"w".into(),
Frame3::IDENTITY,
2.0,
diff --git a/main/opengeometry/src/analytic/planar_booleans.rs b/main/opengeometry/src/analytic/planar_booleans.rs
new file mode 100644
index 0000000..d93ecbe
--- /dev/null
+++ b/main/opengeometry/src/analytic/planar_booleans.rs
@@ -0,0 +1,1579 @@
+use std::{
+ cell::RefCell,
+ collections::{BTreeMap, VecDeque},
+};
+
+use super::{
+ booleans::{
+ analytic_face_mappings, brep_face_source, full_planar_extrusion, BooleanOp, BooleanReport,
+ BooleanResult, PrismaticInput,
+ },
+ geometry::{cross, dot, norm, scale, sub, unit},
+ primitives::{plane_boundary, Builder, Use},
+ query::face_contains_uv,
+ topology::{
+ Accuracy, BrepEnvelope, EdgeGeometry, FaceProvenance, FaceRole, FaceSource, Orientation,
+ Shell, SolidRegion,
+ },
+ CurveGeometry, Frame3, GeometryError, Point3, SurfaceGeometry,
+};
+use crate::{
+ geometry::{
+ boolean2d::{boolean_oriented_regions, regions_from_edges_by, PlanarBooleanOp, RingRegion},
+ poly2d::{winding_number2, Pt2},
+ },
+ math::interval::Interval,
+};
+
+fn coverage() -> GeometryError {
+ GeometryError::CoverageGap {
+ families: [
+ "layered planar profile extrusion".into(),
+ "layered planar profile extrusion".into(),
+ ],
+ }
+}
+
+fn contours(input: &PrismaticInput<'_>, frame: Frame3) -> Vec> {
+ input
+ .contours
+ .iter()
+ .map(|ring| {
+ ring.iter()
+ .map(|point| {
+ let local = frame.local(*point);
+ Pt2::new(local[0], local[1])
+ })
+ .collect()
+ })
+ .collect()
+}
+
+fn region_contours(regions: &[RingRegion]) -> Vec> {
+ regions
+ .iter()
+ .flat_map(|region| {
+ std::iter::once(region.outer.clone()).chain(region.holes.iter().cloned())
+ })
+ .collect()
+}
+
+fn region_points(regions: &[RingRegion], out: &mut Vec) {
+ for region in regions {
+ out.extend(region.outer.iter().copied());
+ for hole in ®ion.holes {
+ out.extend(hole.iter().copied());
+ }
+ }
+}
+
+fn loop_positions(brep: &BrepEnvelope, loop_id: u32) -> Result, GeometryError> {
+ let start = brep
+ .topology
+ .loops
+ .get(loop_id as usize)
+ .ok_or_else(coverage)?
+ .start_halfedge;
+ let mut current = start;
+ let mut points = Vec::new();
+ loop {
+ let halfedge = brep
+ .topology
+ .halfedges
+ .get(current as usize)
+ .ok_or_else(coverage)?;
+ points.push(
+ brep.topology
+ .vertices
+ .get(halfedge.from as usize)
+ .ok_or_else(coverage)?
+ .position,
+ );
+ current = halfedge.next.ok_or_else(coverage)?;
+ if current == start {
+ break;
+ }
+ if points.len() > brep.topology.halfedges.len() {
+ return Err(coverage());
+ }
+ }
+ Ok(points)
+}
+
+fn slice_regions(
+ brep: &BrepEnvelope,
+ frame: Frame3,
+ level: f64,
+ tolerance: f64,
+) -> Result, GeometryError> {
+ let mut segments = Vec::<(Pt2, Pt2)>::new();
+ for face in &brep.topology.faces {
+ let SurfaceGeometry::Plane { frame: face_frame } = brep
+ .geometry
+ .surfaces
+ .get(face.surface as usize)
+ .ok_or_else(coverage)?
+ else {
+ return Err(coverage());
+ };
+ if dot(face_frame.z, frame.z).abs() > 1.0e-10 {
+ continue;
+ }
+ if !face.trim.holes.is_empty() {
+ return Err(coverage());
+ }
+ let points = loop_positions(brep, face.trim.outer)?;
+ let mut crossings = Vec::::new();
+ for index in 0..points.len() {
+ let from = frame.local(points[index]);
+ let to = frame.local(points[(index + 1) % points.len()]);
+ if (from[2] < level && to[2] > level) || (from[2] > level && to[2] < level) {
+ let fraction = (level - from[2]) / (to[2] - from[2]);
+ let point = Pt2::new(
+ from[0] + fraction * (to[0] - from[0]),
+ from[1] + fraction * (to[1] - from[1]),
+ );
+ if crossings
+ .iter()
+ .all(|existing| (existing.x - point.x).hypot(existing.z - point.z) > tolerance)
+ {
+ crossings.push(point);
+ }
+ }
+ }
+ if crossings.is_empty() {
+ continue;
+ }
+ if crossings.len() != 2 {
+ return Err(coverage());
+ }
+ let mut from = crossings[0];
+ let mut to = crossings[1];
+ let edge = sub(
+ frame.point([to.x, to.z, level]),
+ frame.point([from.x, from.z, level]),
+ );
+ let outward = cross(edge, frame.z);
+ let actual = scale(face_frame.z, face.sense.multiplier());
+ if dot(outward, actual) < 0.0 {
+ std::mem::swap(&mut from, &mut to);
+ }
+ segments.push((from, to));
+ }
+ let mut loops = Vec::new();
+ while let Some((from, mut to)) = segments.pop() {
+ let mut ring = vec![from];
+ while (to.x - from.x).hypot(to.z - from.z) > tolerance {
+ ring.push(to);
+ let Some(next) = segments
+ .iter()
+ .position(|(start, _)| (start.x - to.x).hypot(start.z - to.z) <= tolerance)
+ else {
+ return Err(coverage());
+ };
+ let (_, end) = segments.swap_remove(next);
+ to = end;
+ if ring.len() > brep.topology.halfedges.len() {
+ return Err(coverage());
+ }
+ }
+ if ring.len() < 3 {
+ return Err(coverage());
+ }
+ loops.push(ring);
+ }
+ Ok(boolean_oriented_regions(
+ &loops,
+ &[],
+ PlanarBooleanOp::Union,
+ tolerance,
+ ))
+}
+
+fn side_sources(
+ brep: &BrepEnvelope,
+ point: Point3,
+ outward: Point3,
+ tolerance: f64,
+) -> Result, GeometryError> {
+ let mut sources = Vec::new();
+ for face in &brep.topology.faces {
+ let SurfaceGeometry::Plane { frame } = brep
+ .geometry
+ .surfaces
+ .get(face.surface as usize)
+ .ok_or_else(coverage)?
+ else {
+ return Err(coverage());
+ };
+ let local = frame.local(point);
+ if local[2].abs() > tolerance || dot(frame.z, outward).abs() < 1.0 - 1.0e-10 {
+ continue;
+ }
+ if face_contains_uv(brep, face, [local[0], local[1]])? == Some(true) {
+ sources.push(brep_face_source(brep, face.id));
+ }
+ }
+ Ok(sources)
+}
+
+fn region_sample(region: &RingRegion) -> Result {
+ let mut coordinates = Vec::new();
+ let mut holes = Vec::new();
+ for point in ®ion.outer {
+ coordinates.extend([point.x, point.z]);
+ }
+ for hole in ®ion.holes {
+ holes.push(coordinates.len() / 2);
+ for point in hole {
+ coordinates.extend([point.x, point.z]);
+ }
+ }
+ let triangles = earcutr::earcut(&coordinates, &holes, 2);
+ // Earcut may put a nearly collinear boundary triangle first. Its centroid
+ // can round onto the host face and fail cap provenance after a chained cut.
+ // The largest triangle gives a stable point strictly inside this region.
+ let triangle = triangles
+ .chunks_exact(3)
+ .max_by(|left, right| {
+ let area = |triangle: &[usize]| {
+ let point =
+ |index: usize| Pt2::new(coordinates[index * 2], coordinates[index * 2 + 1]);
+ let a = point(triangle[0]);
+ let b = point(triangle[1]);
+ let c = point(triangle[2]);
+ ((b.x - a.x) * (c.z - a.z) - (b.z - a.z) * (c.x - a.x)).abs()
+ };
+ area(left).total_cmp(&area(right))
+ })
+ .ok_or_else(coverage)?;
+ Ok(Pt2::new(
+ triangle
+ .iter()
+ .map(|&index| coordinates[index * 2])
+ .sum::()
+ / 3.0,
+ triangle
+ .iter()
+ .map(|&index| coordinates[index * 2 + 1])
+ .sum::()
+ / 3.0,
+ ))
+}
+
+fn cap_sources(
+ brep: &BrepEnvelope,
+ point: Point3,
+ outward: Point3,
+ cut: bool,
+ tolerance: f64,
+) -> Result, GeometryError> {
+ let mut sources = Vec::new();
+ for face in &brep.topology.faces {
+ let SurfaceGeometry::Plane { frame } = brep
+ .geometry
+ .surfaces
+ .get(face.surface as usize)
+ .ok_or_else(coverage)?
+ else {
+ return Err(coverage());
+ };
+ let local = frame.local(point);
+ if local[2].abs() > tolerance {
+ continue;
+ }
+ let actual = scale(frame.z, face.sense.multiplier());
+ let alignment = dot(actual, outward);
+ if (cut && alignment > -1.0 + 1.0e-10) || (!cut && alignment < 1.0 - 1.0e-10) {
+ continue;
+ }
+ if face_contains_uv(brep, face, [local[0], local[1]])? == Some(true) {
+ sources.push(brep_face_source(brep, face.id));
+ }
+ }
+ Ok(sources)
+}
+
+fn split_ring(ring: &[Pt2], vertices: &[Pt2], tolerance: f64) -> Vec {
+ let mut split = Vec::new();
+ for index in 0..ring.len() {
+ let from = ring[index];
+ let to = ring[(index + 1) % ring.len()];
+ let delta = Pt2::new(to.x - from.x, to.z - from.z);
+ let length2 = delta.x * delta.x + delta.z * delta.z;
+ if length2 <= tolerance * tolerance {
+ continue;
+ }
+ split.push(from);
+ let mut interior = Vec::new();
+ for &point in vertices {
+ let offset = Pt2::new(point.x - from.x, point.z - from.z);
+ let t = (offset.x * delta.x + offset.z * delta.z) / length2;
+ let distance = (offset.x * delta.z - offset.z * delta.x).abs() / length2.sqrt();
+ if t > tolerance / length2.sqrt()
+ && t < 1.0 - tolerance / length2.sqrt()
+ && distance <= tolerance
+ {
+ interior.push((t, point));
+ }
+ }
+ interior.sort_by(|left, right| left.0.total_cmp(&right.0));
+ for (_, point) in interior {
+ if split
+ .last()
+ .is_none_or(|last| (last.x - point.x).hypot(last.z - point.z) > tolerance)
+ {
+ split.push(point);
+ }
+ }
+ }
+ split
+}
+
+struct Facets {
+ builder: Builder,
+ vertices: Vec<(Point3, u32)>,
+ edges: BTreeMap<(u32, u32), u32>,
+ accuracy: Accuracy,
+}
+
+impl Facets {
+ fn new(id: String, accuracy: Accuracy) -> Result {
+ Ok(Self {
+ builder: Builder::new(id, accuracy)?,
+ vertices: Vec::new(),
+ edges: BTreeMap::new(),
+ accuracy,
+ })
+ }
+
+ fn vertex(&mut self, point: Point3) -> u32 {
+ if let Some((_, id)) = self
+ .vertices
+ .iter()
+ .find(|(existing, _)| norm(sub(*existing, point)) <= self.accuracy.geometric / 4.0)
+ {
+ return *id;
+ }
+ let id = self.builder.vertex(point);
+ self.vertices.push((point, id));
+ id
+ }
+
+ fn uses(&mut self, ring: &[Point3], frame: Frame3) -> Result, GeometryError> {
+ let ids = ring
+ .iter()
+ .map(|point| self.vertex(*point))
+ .collect::>();
+ let mut uses = Vec::with_capacity(ids.len());
+ for index in 0..ids.len() {
+ let next = (index + 1) % ids.len();
+ let from = ids[index];
+ let to = ids[next];
+ if from == to {
+ return Err(GeometryError::UnresolvedIntersection(
+ "layered planar face has a sub-tolerance edge".into(),
+ ));
+ }
+ let key = (from.min(to), from.max(to));
+ let edge = if let Some(&edge) = self.edges.get(&key) {
+ edge
+ } else {
+ let delta = sub(ring[next], ring[index]);
+ let length = norm(delta);
+ let edge = self.builder.edge(
+ CurveGeometry::Line {
+ origin: ring[index],
+ direction: unit(delta)?,
+ },
+ Interval::new(0.0, length)?,
+ false,
+ );
+ self.edges.insert(key, edge);
+ edge
+ };
+ let EdgeGeometry::Curve { curve, .. } =
+ self.builder.brep.topology.edges[edge as usize].geometry
+ else {
+ return Err(coverage());
+ };
+ let CurveGeometry::Line { origin, direction } =
+ self.builder.brep.geometry.curves[curve as usize]
+ else {
+ return Err(coverage());
+ };
+ let sense = if norm(sub(origin, ring[index])) <= self.accuracy.geometric / 4.0
+ && dot(direction, sub(ring[next], ring[index])) > 0.0
+ {
+ Orientation::Forward
+ } else {
+ Orientation::Reverse
+ };
+ uses.push(plane_boundary(&self.builder, frame, edge, from, to, sense)?);
+ }
+ Ok(uses)
+ }
+
+ fn face(
+ &mut self,
+ key: &str,
+ frame: Frame3,
+ outer: Vec,
+ holes: Vec>,
+ provenance: FaceProvenance,
+ ) -> Result<(), GeometryError> {
+ let mut bounds = [[f64::INFINITY, f64::NEG_INFINITY]; 2];
+ for point in outer.iter().chain(holes.iter().flatten()) {
+ let local = frame.local(*point);
+ if local[2].abs() > 4.0 * self.accuracy.geometric {
+ return Err(GeometryError::UnresolvedIntersection(
+ "layered planar face is not coplanar".into(),
+ ));
+ }
+ for axis in 0..2 {
+ bounds[axis][0] = bounds[axis][0].min(local[axis]);
+ bounds[axis][1] = bounds[axis][1].max(local[axis]);
+ }
+ }
+ for bound in &mut bounds {
+ bound[0] -= self.accuracy.geometric;
+ bound[1] += self.accuracy.geometric;
+ }
+ let outer_uses = self.uses(&outer, frame)?;
+ let hole_uses = holes
+ .iter()
+ .map(|hole| self.uses(hole, frame))
+ .collect::, _>>()?;
+ let face = self.builder.brep.topology.faces.len();
+ self.builder.face_with_holes(
+ &format!("{key}-{face}"),
+ super::SurfaceGeometry::Plane { frame },
+ bounds,
+ outer_uses,
+ hole_uses,
+ )?;
+ self.builder.brep.topology.faces[face].provenance = provenance;
+ Ok(())
+ }
+}
+
+fn cap(
+ facets: &mut Facets,
+ base: Frame3,
+ level: f64,
+ up: bool,
+ regions: &[RingRegion],
+ vertices: &[Pt2],
+ source_breps: &[(&BrepEnvelope, FaceRole)],
+) -> Result<(), GeometryError> {
+ let origin = base.point([0.0, 0.0, level]);
+ let frame = if up {
+ Frame3 { origin, ..base }
+ } else {
+ Frame3 {
+ origin,
+ x: base.x,
+ y: scale(base.y, -1.0),
+ z: scale(base.z, -1.0),
+ }
+ };
+ for region in regions {
+ let sample = region_sample(region)?;
+ let mut selected = None;
+ for &(brep, role) in source_breps {
+ let sources = cap_sources(
+ brep,
+ base.point([sample.x, sample.z, level]),
+ frame.z,
+ role == FaceRole::Cut,
+ facets.accuracy.intersection,
+ )?;
+ if !sources.is_empty() {
+ selected = Some((sources, role));
+ break;
+ }
+ }
+ let (sources, role) = selected.ok_or_else(|| {
+ GeometryError::InvalidTopology(format!(
+ "layered planar cap has no source face at level {level}"
+ ))
+ })?;
+ let mut outer = split_ring(®ion.outer, vertices, facets.accuracy.geometric / 4.0)
+ .into_iter()
+ .map(|point| base.point([point.x, point.z, level]))
+ .collect::>();
+ let mut holes = region
+ .holes
+ .iter()
+ .map(|ring| {
+ split_ring(ring, vertices, facets.accuracy.geometric / 4.0)
+ .into_iter()
+ .map(|point| base.point([point.x, point.z, level]))
+ .collect::>()
+ })
+ .collect::>();
+ if !up {
+ outer.reverse();
+ for hole in &mut holes {
+ hole.reverse();
+ }
+ }
+ facets.face(
+ if up { "upper-cap" } else { "lower-cap" },
+ frame,
+ outer,
+ holes,
+ FaceProvenance {
+ sources,
+ role,
+ reversed: role == FaceRole::Cut,
+ },
+ )?;
+ }
+ Ok(())
+}
+
+fn side_faces(
+ facets: &mut Facets,
+ a: &BrepEnvelope,
+ b: &BrepEnvelope,
+ base: Frame3,
+ regions: &[RingRegion],
+ vertices: &[Pt2],
+ lo: f64,
+ hi: f64,
+) -> Result<(), GeometryError> {
+ for region in regions {
+ for ring in std::iter::once(®ion.outer).chain(®ion.holes) {
+ let ring = split_ring(ring, vertices, facets.accuracy.geometric / 4.0);
+ for index in 0..ring.len() {
+ let from = ring[index];
+ let to = ring[(index + 1) % ring.len()];
+ let lower_from = base.point([from.x, from.z, lo]);
+ let lower_to = base.point([to.x, to.z, lo]);
+ let upper_to = base.point([to.x, to.z, hi]);
+ let upper_from = base.point([from.x, from.z, hi]);
+ let normal = unit(cross(
+ sub(lower_to, lower_from),
+ sub(upper_from, lower_from),
+ ))?;
+ let face_frame = Frame3::from_axis(lower_from, normal, sub(lower_to, lower_from))?;
+ let midpoint = scale(
+ [
+ lower_from[0] + lower_to[0] + upper_to[0] + upper_from[0],
+ lower_from[1] + lower_to[1] + upper_to[1] + upper_from[1],
+ lower_from[2] + lower_to[2] + upper_to[2] + upper_from[2],
+ ],
+ 0.25,
+ );
+ let a_sources = side_sources(a, midpoint, normal, facets.accuracy.intersection)?;
+ let b_sources = side_sources(b, midpoint, normal, facets.accuracy.intersection)?;
+ let is_cut = a_sources.is_empty() && !b_sources.is_empty();
+ let sources = if is_cut { b_sources } else { a_sources };
+ if sources.is_empty() {
+ return Err(GeometryError::InvalidTopology(
+ "layered planar side has no source face".into(),
+ ));
+ }
+ facets.face(
+ if is_cut { "cut-side" } else { "host-side" },
+ face_frame,
+ vec![lower_from, lower_to, upper_to, upper_from],
+ Vec::new(),
+ FaceProvenance {
+ sources,
+ role: if is_cut {
+ FaceRole::Cut
+ } else {
+ FaceRole::Split
+ },
+ reversed: is_cut,
+ },
+ )?;
+ }
+ }
+ }
+ Ok(())
+}
+
+fn shell_volume(brep: &BrepEnvelope, faces: &[u32]) -> Result {
+ let mut volume = 0.0;
+ for &face_id in faces {
+ let face = &brep.topology.faces[face_id as usize];
+ for &loop_id in std::iter::once(&face.trim.outer).chain(&face.trim.holes) {
+ let start = brep.topology.loops[loop_id as usize].start_halfedge;
+ let mut current = start;
+ let mut points = Vec::new();
+ loop {
+ let halfedge = &brep.topology.halfedges[current as usize];
+ points.push(brep.topology.vertices[halfedge.from as usize].position);
+ current = halfedge.next.ok_or_else(|| {
+ GeometryError::InvalidTopology("layered planar face has open loop".into())
+ })?;
+ if current == start {
+ break;
+ }
+ if points.len() > brep.topology.halfedges.len() {
+ return Err(coverage());
+ }
+ }
+ for index in 1..points.len() - 1 {
+ volume += dot(points[0], cross(points[index], points[index + 1])) / 6.0;
+ }
+ }
+ }
+ Ok(volume)
+}
+
+fn shell_contains_point(
+ brep: &BrepEnvelope,
+ faces: &[u32],
+ point: Point3,
+ tolerance: f64,
+) -> Result {
+ for direction in [
+ unit([1.0, 0.371, 0.127])?,
+ unit([0.193, 1.0, 0.419])?,
+ unit([0.311, 0.233, 1.0])?,
+ ] {
+ let mut hits: Vec = Vec::new();
+ let mut uncertain = false;
+ for &face_id in faces {
+ let face = &brep.topology.faces[face_id as usize];
+ let SurfaceGeometry::Plane { frame } = brep.geometry.surface(face.surface)? else {
+ return Err(coverage());
+ };
+ let denominator = dot(frame.z, direction);
+ let numerator = dot(frame.z, sub(frame.origin, point));
+ if denominator.abs() <= 64.0 * f64::EPSILON {
+ if numerator.abs() <= tolerance {
+ uncertain = true;
+ break;
+ }
+ continue;
+ }
+ let distance = numerator / denominator;
+ if distance <= tolerance {
+ continue;
+ }
+ let position = [
+ point[0] + direction[0] * distance,
+ point[1] + direction[1] * distance,
+ point[2] + direction[2] * distance,
+ ];
+ let local = frame.local(position);
+ match face_contains_uv(brep, face, [local[0], local[1]])? {
+ Some(true) if hits.iter().any(|hit| (hit - distance).abs() <= tolerance) => {
+ uncertain = true;
+ break;
+ }
+ Some(true) => hits.push(distance),
+ Some(false) => {}
+ None => {
+ uncertain = true;
+ break;
+ }
+ }
+ }
+ if !uncertain {
+ return Ok(hits.len() % 2 == 1);
+ }
+ }
+ Err(coverage())
+}
+
+fn finish(
+ mut facets: Facets,
+ a: &BrepEnvelope,
+ b: &BrepEnvelope,
+) -> Result {
+ let mut assigned = vec![false; facets.builder.brep.topology.faces.len()];
+ let mut outer_shells = Vec::new();
+ let mut cavity_shells = Vec::new();
+ for start in 0..assigned.len() {
+ if assigned[start] {
+ continue;
+ }
+ let shell = facets.builder.brep.topology.shells.len() as u32;
+ let mut faces = Vec::new();
+ let mut queue = VecDeque::from([start]);
+ assigned[start] = true;
+ while let Some(face) = queue.pop_front() {
+ facets.builder.brep.topology.faces[face].shell_ref = Some(shell);
+ faces.push(face as u32);
+ for halfedge in facets
+ .builder
+ .brep
+ .topology
+ .halfedges
+ .iter()
+ .filter(|edge| edge.face == Some(face as u32))
+ {
+ let twin = halfedge.twin.ok_or_else(|| {
+ GeometryError::InvalidTopology(
+ "layered planar Boolean has an open boundary".into(),
+ )
+ })?;
+ let adjacent = facets.builder.brep.topology.halfedges[twin as usize]
+ .face
+ .ok_or_else(|| {
+ GeometryError::InvalidTopology("layered planar twin has no face".into())
+ })? as usize;
+ if !assigned[adjacent] {
+ assigned[adjacent] = true;
+ queue.push_back(adjacent);
+ }
+ }
+ }
+ let volume = shell_volume(&facets.builder.brep, &faces)?;
+ if volume.abs() <= facets.accuracy.geometric.powi(3) {
+ return Err(coverage());
+ }
+ facets.builder.brep.topology.shells.push(Shell {
+ id: shell,
+ faces,
+ is_closed: true,
+ });
+ if volume > 0.0 {
+ outer_shells.push((shell, volume));
+ } else {
+ cavity_shells.push(shell);
+ }
+ }
+ for &(outer_shell, _) in &outer_shells {
+ facets.builder.brep.solids.push(SolidRegion {
+ outer_shell,
+ cavity_shells: Vec::new(),
+ });
+ }
+ for cavity_shell in cavity_shells {
+ let shell = &facets.builder.brep.topology.shells[cavity_shell as usize];
+ let face = &facets.builder.brep.topology.faces[shell.faces[0] as usize];
+ let loop_start =
+ facets.builder.brep.topology.loops[face.trim.outer as usize].start_halfedge;
+ let vertex = facets.builder.brep.topology.halfedges[loop_start as usize].from;
+ let point = facets.builder.brep.topology.vertices[vertex as usize].position;
+ let mut enclosing = Vec::new();
+ for (index, &(outer_shell, volume)) in outer_shells.iter().enumerate() {
+ let outer = &facets.builder.brep.topology.shells[outer_shell as usize];
+ if shell_contains_point(
+ &facets.builder.brep,
+ &outer.faces,
+ point,
+ facets.accuracy.intersection,
+ )? {
+ enclosing.push((index, volume));
+ }
+ }
+ let Some(&(index, _)) = enclosing
+ .iter()
+ .min_by(|left, right| left.1.total_cmp(&right.1))
+ else {
+ return Err(coverage());
+ };
+ facets.builder.brep.solids[index]
+ .cavity_shells
+ .push(cavity_shell);
+ }
+ let mut out = facets.builder.brep;
+ out.revision =
+ a.revision.max(b.revision).checked_add(1).ok_or_else(|| {
+ GeometryError::LimitExceeded("planar Boolean revision overflow".into())
+ })?;
+ out.validate()?;
+ let face_mappings = analytic_face_mappings(&out, [a, b]);
+ Ok(BooleanResult {
+ report: BooleanReport {
+ operation: BooleanOp::Subtraction,
+ quality: out.quality.clone(),
+ contacts: Vec::new(),
+ coincident: true,
+ face_mappings,
+ },
+ brep: out,
+ })
+}
+
+pub(super) fn subtract_layered_extrusions(
+ host: &BrepEnvelope,
+ cutter: &BrepEnvelope,
+ id: String,
+) -> Result {
+ host.validate()?;
+ let b = full_planar_extrusion(cutter)?;
+ if host
+ .geometry
+ .surfaces
+ .iter()
+ .any(|surface| !matches!(surface, SurfaceGeometry::Plane { .. }))
+ || host
+ .geometry
+ .curves
+ .iter()
+ .any(|curve| !matches!(curve, CurveGeometry::Line { .. }))
+ || host.solids.is_empty()
+ {
+ return Err(coverage());
+ }
+ let accuracy = Accuracy {
+ geometric: host.accuracy.geometric.max(cutter.accuracy.geometric),
+ intersection: host.accuracy.intersection.max(cutter.accuracy.intersection),
+ tessellation: host.accuracy.tessellation.max(cutter.accuracy.tessellation),
+ exchange: host.accuracy.exchange.max(cutter.accuracy.exchange),
+ };
+ let host_positions = host
+ .topology
+ .vertices
+ .iter()
+ .map(|vertex| b.frame.local(vertex.position)[2])
+ .collect::>();
+ let host_lo = host_positions.iter().copied().fold(f64::INFINITY, f64::min);
+ let host_hi = host_positions
+ .iter()
+ .copied()
+ .fold(f64::NEG_INFINITY, f64::max);
+ if !host_lo.is_finite() || host_hi - host_lo <= 4.0 * accuracy.geometric {
+ return Err(coverage());
+ }
+ let frame = Frame3 {
+ origin: b.frame.point([0.0, 0.0, host_lo]),
+ ..b.frame
+ };
+ for face in &host.topology.faces {
+ let SurfaceGeometry::Plane { frame: face_frame } = host
+ .geometry
+ .surfaces
+ .get(face.surface as usize)
+ .ok_or_else(coverage)?
+ else {
+ return Err(coverage());
+ };
+ let alignment = dot(face_frame.z, frame.z).abs();
+ if alignment > 1.0e-10 && alignment < 1.0 - 1.0e-10 {
+ return Err(coverage());
+ }
+ }
+ let b_contours = contours(&b, frame);
+ let b_lo = dot(sub(b.frame.origin, frame.origin), frame.z);
+ let b_hi = b_lo + b.height;
+ let host_height = host_hi - host_lo;
+ if b_hi <= accuracy.geometric || b_lo >= host_height - accuracy.geometric {
+ return Err(coverage());
+ }
+ let mut levels = host_positions
+ .iter()
+ .map(|level| level - host_lo)
+ .collect::>();
+ for level in [b_lo, b_hi] {
+ if level > accuracy.geometric && level < host_height - accuracy.geometric {
+ levels.push(level);
+ }
+ }
+ levels.sort_by(f64::total_cmp);
+ levels.dedup_by(|left, right| (*left - *right).abs() <= accuracy.geometric);
+ let layers = levels
+ .windows(2)
+ .map(|span| -> Result, GeometryError> {
+ let middle = (span[0] + span[1]) / 2.0;
+ let host_regions = slice_regions(host, frame, middle, accuracy.intersection)?;
+ Ok(boolean_oriented_regions(
+ ®ion_contours(&host_regions),
+ if middle > b_lo && middle < b_hi {
+ &b_contours
+ } else {
+ &[]
+ },
+ PlanarBooleanOp::Subtraction,
+ accuracy.intersection,
+ ))
+ })
+ .collect::, _>>()?;
+ let mut exposed_up = Vec::new();
+ let mut exposed_down = Vec::new();
+ for index in 1..layers.len() {
+ let below = region_contours(&layers[index - 1]);
+ let above = region_contours(&layers[index]);
+ exposed_up.push(boolean_oriented_regions(
+ &below,
+ &above,
+ PlanarBooleanOp::Subtraction,
+ accuracy.intersection,
+ ));
+ exposed_down.push(boolean_oriented_regions(
+ &above,
+ &below,
+ PlanarBooleanOp::Subtraction,
+ accuracy.intersection,
+ ));
+ }
+ let mut vertices = Vec::new();
+ for layer in &layers {
+ region_points(layer, &mut vertices);
+ }
+ for regions in exposed_up.iter().chain(&exposed_down) {
+ region_points(regions, &mut vertices);
+ }
+ let mut facets = Facets::new(id, accuracy)?;
+ cap(
+ &mut facets,
+ frame,
+ 0.0,
+ false,
+ &layers[0],
+ &vertices,
+ &[(host, FaceRole::Split)],
+ )?;
+ for (index, layer) in layers.iter().enumerate() {
+ side_faces(
+ &mut facets,
+ host,
+ cutter,
+ frame,
+ layer,
+ &vertices,
+ levels[index],
+ levels[index + 1],
+ )?;
+ if index + 1 < layers.len() {
+ cap(
+ &mut facets,
+ frame,
+ levels[index + 1],
+ true,
+ &exposed_up[index],
+ &vertices,
+ &[(host, FaceRole::Split), (cutter, FaceRole::Cut)],
+ )?;
+ cap(
+ &mut facets,
+ frame,
+ levels[index + 1],
+ false,
+ &exposed_down[index],
+ &vertices,
+ &[(host, FaceRole::Split), (cutter, FaceRole::Cut)],
+ )?;
+ }
+ }
+ cap(
+ &mut facets,
+ frame,
+ host_height,
+ true,
+ layers.last().ok_or_else(coverage)?,
+ &vertices,
+ &[(host, FaceRole::Split)],
+ )?;
+ finish(facets, host, cutter)
+}
+
+struct PlanarFace {
+ id: u32,
+ frame: Frame3,
+ polygon: Vec,
+ holes: Vec>,
+}
+
+fn planar_faces(
+ brep: &BrepEnvelope,
+ tolerance: f64,
+) -> Result>, GeometryError> {
+ if brep.solids.is_empty()
+ || brep
+ .geometry
+ .curves
+ .iter()
+ .any(|curve| !matches!(curve, CurveGeometry::Line { .. }))
+ {
+ return Ok(None);
+ }
+ let mut faces = Vec::with_capacity(brep.topology.faces.len());
+ for face in &brep.topology.faces {
+ let Some(SurfaceGeometry::Plane { frame: surface }) =
+ brep.geometry.surfaces.get(face.surface as usize)
+ else {
+ return Ok(None);
+ };
+ let frame = if face.sense == Orientation::Forward {
+ *surface
+ } else {
+ Frame3 {
+ origin: surface.origin,
+ x: surface.x,
+ y: scale(surface.y, -1.0),
+ z: scale(surface.z, -1.0),
+ }
+ };
+ let polygon = loop_positions(brep, face.trim.outer)?;
+ if polygon.len() < 3 {
+ return Ok(None);
+ }
+ let mut holes = face
+ .trim
+ .holes
+ .iter()
+ .map(|&loop_id| loop_positions(brep, loop_id))
+ .collect::, _>>()?;
+ let orient = |mut ring: Vec, outer: bool| -> Option> {
+ let area = ring
+ .iter()
+ .enumerate()
+ .map(|(index, point)| {
+ let a = frame.local(*point);
+ let b = frame.local(ring[(index + 1) % ring.len()]);
+ a[0] * b[1] - b[0] * a[1]
+ })
+ .sum::();
+ if ring.len() < 3 || area.abs() <= tolerance * tolerance {
+ return None;
+ }
+ if (area > 0.0) != outer {
+ ring.reverse();
+ }
+ Some(ring)
+ };
+ let Some(polygon) = orient(polygon, true) else {
+ return Ok(None);
+ };
+ for hole in &mut holes {
+ let Some(oriented) = orient(std::mem::take(hole), false) else {
+ return Ok(None);
+ };
+ *hole = oriented;
+ }
+ faces.push(PlanarFace {
+ id: face.id,
+ frame,
+ polygon,
+ holes,
+ });
+ }
+ Ok(Some(faces))
+}
+
+fn convex_faces(
+ brep: &BrepEnvelope,
+ tolerance: f64,
+) -> Result>, GeometryError> {
+ let Some(faces) = planar_faces(brep, tolerance)? else {
+ return Ok(None);
+ };
+ if brep.solids.len() != 1
+ || brep.topology.shells.len() != 1
+ || faces.iter().any(|face| !face.holes.is_empty())
+ {
+ return Ok(None);
+ }
+ for face in &faces {
+ if brep
+ .topology
+ .vertices
+ .iter()
+ .any(|vertex| dot(face.frame.z, sub(vertex.position, face.frame.origin)) > tolerance)
+ {
+ return Ok(None);
+ }
+ }
+ Ok(Some(faces))
+}
+
+fn planar_section_segments(
+ brep: &BrepEnvelope,
+ faces: &[PlanarFace],
+ section: Frame3,
+ tolerance: f64,
+) -> Result, GeometryError> {
+ let mut segments = Vec::<(Pt2, Pt2)>::new();
+ for source in faces {
+ let direction = cross(source.frame.z, section.z);
+ if norm(direction) <= 1.0e-10 {
+ continue;
+ }
+ let direction = unit(direction)?;
+ let mut crossings = Vec::new();
+ for ring in std::iter::once(&source.polygon).chain(&source.holes) {
+ for index in 0..ring.len() {
+ let from = ring[index];
+ let to = ring[(index + 1) % ring.len()];
+ let from_distance = dot(section.z, sub(from, section.origin));
+ let to_distance = dot(section.z, sub(to, section.origin));
+ if from_distance.abs() <= tolerance {
+ crossings.push(from);
+ }
+ if (from_distance < -tolerance && to_distance > tolerance)
+ || (from_distance > tolerance && to_distance < -tolerance)
+ {
+ let fraction = from_distance / (from_distance - to_distance);
+ crossings.push([
+ from[0] + fraction * (to[0] - from[0]),
+ from[1] + fraction * (to[1] - from[1]),
+ from[2] + fraction * (to[2] - from[2]),
+ ]);
+ }
+ }
+ }
+ crossings.sort_by(|left, right| dot(*left, direction).total_cmp(&dot(*right, direction)));
+ crossings.dedup_by(|left, right| norm(sub(*left, *right)) <= tolerance);
+ let face = &brep.topology.faces[source.id as usize];
+ let SurfaceGeometry::Plane { frame: surface } =
+ &brep.geometry.surfaces[face.surface as usize]
+ else {
+ return Err(coverage());
+ };
+ for pair in crossings.windows(2) {
+ if norm(sub(pair[0], pair[1])) <= tolerance {
+ continue;
+ }
+ let midpoint = scale(
+ [
+ pair[0][0] + pair[1][0],
+ pair[0][1] + pair[1][1],
+ pair[0][2] + pair[1][2],
+ ],
+ 0.5,
+ );
+ let local = surface.local(midpoint);
+ let on_trim_edge = std::iter::once(&source.polygon)
+ .chain(&source.holes)
+ .any(|ring| {
+ ring.iter().enumerate().any(|(index, &from)| {
+ let to = ring[(index + 1) % ring.len()];
+ let edge = sub(to, from);
+ let length_squared = dot(edge, edge);
+ if length_squared <= tolerance * tolerance {
+ return false;
+ }
+ let fraction = dot(sub(midpoint, from), edge) / length_squared;
+ fraction >= -tolerance
+ && fraction <= 1.0 + tolerance
+ && norm(sub(sub(midpoint, from), scale(edge, fraction))) <= tolerance
+ })
+ });
+ if face_contains_uv(brep, face, [local[0], local[1]])? != Some(true) && !on_trim_edge {
+ continue;
+ }
+ let a = section.local(pair[0]);
+ let b = section.local(pair[1]);
+ let mut from = Pt2::new(a[0], a[1]);
+ let mut to = Pt2::new(b[0], b[1]);
+ let edge = sub(pair[1], pair[0]);
+ if dot(cross(edge, section.z), source.frame.z) < 0.0 {
+ std::mem::swap(&mut from, &mut to);
+ }
+ segments.push((from, to));
+ }
+ }
+ Ok(segments)
+}
+
+fn clip_convex_face(polygon: &[Point3], planes: &[PlanarFace], tolerance: f64) -> Vec {
+ let mut result = polygon.to_vec();
+ for plane in planes {
+ if result.len() < 3 {
+ return Vec::new();
+ }
+ let mut next = Vec::new();
+ let mut previous = *result.last().unwrap();
+ let mut previous_distance = dot(plane.frame.z, sub(previous, plane.frame.origin));
+ for ¤t in &result {
+ let distance = dot(plane.frame.z, sub(current, plane.frame.origin));
+ let previous_inside = previous_distance <= tolerance;
+ let current_inside = distance <= tolerance;
+ if previous_inside != current_inside {
+ let fraction = previous_distance / (previous_distance - distance);
+ next.push([
+ previous[0] + fraction * (current[0] - previous[0]),
+ previous[1] + fraction * (current[1] - previous[1]),
+ previous[2] + fraction * (current[2] - previous[2]),
+ ]);
+ }
+ if current_inside {
+ next.push(current);
+ }
+ previous = current;
+ previous_distance = distance;
+ }
+ next.dedup_by(|left, right| norm(sub(*left, *right)) <= tolerance / 4.0);
+ if next.len() > 1 && norm(sub(next[0], *next.last().unwrap())) <= tolerance / 4.0 {
+ next.pop();
+ }
+ result = next;
+ }
+ if result.len() < 3 {
+ Vec::new()
+ } else {
+ result
+ }
+}
+
+fn convex_section_polygon(
+ cutter: &BrepEnvelope,
+ faces: &[PlanarFace],
+ section: Frame3,
+ tolerance: f64,
+) -> Vec {
+ let mut limits = [[f64::INFINITY, f64::NEG_INFINITY]; 2];
+ for vertex in &cutter.topology.vertices {
+ let local = section.local(vertex.position);
+ for axis in 0..2 {
+ limits[axis][0] = limits[axis][0].min(local[axis]);
+ limits[axis][1] = limits[axis][1].max(local[axis]);
+ }
+ }
+ if limits.iter().any(|bound| !bound[0].is_finite()) {
+ return Vec::new();
+ }
+ let span = (limits[0][1] - limits[0][0])
+ .max(limits[1][1] - limits[1][0])
+ .max(1.0);
+ let corners = [
+ [limits[0][0] - span, limits[1][0] - span],
+ [limits[0][1] + span, limits[1][0] - span],
+ [limits[0][1] + span, limits[1][1] + span],
+ [limits[0][0] - span, limits[1][1] + span],
+ ]
+ .map(|point| section.point([point[0], point[1], 0.0]));
+ let clipped = clip_convex_face(&corners, faces, tolerance);
+ if clipped.len() < 3 {
+ return Vec::new();
+ }
+ let result = clipped
+ .iter()
+ .map(|point| {
+ let local = section.local(*point);
+ Pt2::new(local[0], local[1])
+ })
+ .collect::>();
+ let twice_area = result
+ .iter()
+ .enumerate()
+ .map(|(index, point)| {
+ let next = result[(index + 1) % result.len()];
+ point.x * next.z - next.x * point.z
+ })
+ .sum::();
+ if twice_area.abs() <= tolerance * tolerance {
+ Vec::new()
+ } else {
+ result
+ }
+}
+
+fn split_planar_ring(ring: &[Point3], vertices: &[Point3], tolerance: f64) -> Vec {
+ let mut split = Vec::new();
+ for index in 0..ring.len() {
+ let from = ring[index];
+ let to = ring[(index + 1) % ring.len()];
+ let delta = sub(to, from);
+ let length = norm(delta);
+ if length <= tolerance {
+ continue;
+ }
+ split.push(from);
+ let mut interior = Vec::new();
+ for &point in vertices {
+ let offset = sub(point, from);
+ let fraction = dot(offset, delta) / (length * length);
+ if fraction > tolerance / length
+ && fraction < 1.0 - tolerance / length
+ && norm(sub(offset, scale(delta, fraction))) <= tolerance
+ {
+ interior.push((fraction, point));
+ }
+ }
+ interior.sort_by(|left, right| left.0.total_cmp(&right.0));
+ for (_, point) in interior {
+ if split
+ .last()
+ .is_none_or(|last| norm(sub(*last, point)) > tolerance)
+ {
+ split.push(point);
+ }
+ }
+ }
+ split
+}
+
+struct PlanarFacet {
+ key: String,
+ frame: Frame3,
+ outer: Vec,
+ holes: Vec>,
+ provenance: FaceProvenance,
+}
+
+pub(super) fn subtract_planar_polyhedra(
+ host: &BrepEnvelope,
+ cutter: &BrepEnvelope,
+ id: String,
+) -> Result {
+ let accuracy = Accuracy {
+ geometric: host.accuracy.geometric.max(cutter.accuracy.geometric),
+ intersection: host.accuracy.intersection.max(cutter.accuracy.intersection),
+ tessellation: host.accuracy.tessellation.max(cutter.accuracy.tessellation),
+ exchange: host.accuracy.exchange.max(cutter.accuracy.exchange),
+ };
+ let Some(host_faces) = planar_faces(host, accuracy.intersection)? else {
+ return Err(coverage());
+ };
+ let Some(cutter_faces) = planar_faces(cutter, accuracy.intersection)? else {
+ return Err(coverage());
+ };
+ let convex_cutter_faces = convex_faces(cutter, accuracy.intersection)?;
+ if cutter.topology.vertices.iter().all(|vertex| {
+ matches!(
+ super::query::classify_point(host, vertex.position),
+ Ok(super::query::PointClassification::Inside)
+ )
+ }) || host.topology.vertices.iter().all(|vertex| {
+ matches!(
+ super::query::classify_point(cutter, vertex.position),
+ Ok(super::query::PointClassification::Inside)
+ )
+ }) {
+ return Err(coverage());
+ }
+ let project = |points: &[Point3], frame: Frame3| {
+ points
+ .iter()
+ .map(|point| {
+ let local = frame.local(*point);
+ Pt2::new(local[0], local[1])
+ })
+ .collect::>()
+ };
+ let mut pieces = Vec::new();
+ for face in &host_faces {
+ let mut base = vec![project(&face.polygon, face.frame)];
+ base.extend(face.holes.iter().map(|ring| project(ring, face.frame)));
+ let regions = if let Some(convex_faces) = &convex_cutter_faces {
+ let section =
+ convex_section_polygon(cutter, convex_faces, face.frame, accuracy.intersection);
+ let cut = if section.is_empty() {
+ Vec::new()
+ } else {
+ vec![section]
+ };
+ boolean_oriented_regions(
+ &base,
+ &cut,
+ PlanarBooleanOp::Subtraction,
+ accuracy.intersection,
+ )
+ } else {
+ let mut edges =
+ planar_section_segments(cutter, &cutter_faces, face.frame, accuracy.intersection)?;
+ let coplanar = cutter_faces
+ .iter()
+ .filter(|other| {
+ dot(other.frame.z, face.frame.z).abs() >= 1.0 - 1.0e-10
+ && dot(other.frame.z, sub(face.frame.origin, other.frame.origin)).abs()
+ <= accuracy.intersection
+ })
+ .flat_map(|other| {
+ std::iter::once(&other.polygon)
+ .chain(other.holes.iter())
+ .map(|ring| project(ring, face.frame))
+ })
+ .collect::>();
+ for ring in base.iter().chain(&coplanar) {
+ edges.extend(
+ ring.iter()
+ .enumerate()
+ .map(|(index, &point)| (point, ring[(index + 1) % ring.len()])),
+ );
+ }
+ let failure = RefCell::new(None);
+ let regions = regions_from_edges_by(&edges, accuracy.intersection, |point| {
+ if base
+ .iter()
+ .map(|ring| winding_number2(point, ring))
+ .sum::()
+ == 0
+ {
+ return false;
+ }
+ let position = face.frame.point([point.x, point.z, 0.0]);
+ let classification = super::query::classify_point(cutter, position);
+ let classification = match classification {
+ Ok(super::query::PointClassification::Boundary) => {
+ super::query::classify_point(
+ cutter,
+ sub(
+ position,
+ scale(
+ face.frame.z,
+ accuracy.geometric.max(accuracy.intersection) * 4.0,
+ ),
+ ),
+ )
+ }
+ other => other,
+ };
+ match classification {
+ Ok(super::query::PointClassification::Outside) => true,
+ Ok(super::query::PointClassification::Inside) => false,
+ Ok(super::query::PointClassification::Boundary)
+ | Ok(super::query::PointClassification::Unknown) => {
+ *failure.borrow_mut() = Some(coverage());
+ false
+ }
+ Err(error) => {
+ *failure.borrow_mut() = Some(error);
+ false
+ }
+ }
+ });
+ if let Some(error) = failure.into_inner() {
+ return Err(error);
+ }
+ regions
+ };
+ for region in regions {
+ pieces.push(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(),
+ holes: region
+ .holes
+ .iter()
+ .map(|ring| {
+ ring.iter()
+ .map(|point| face.frame.point([point.x, point.z, 0.0]))
+ .collect()
+ })
+ .collect(),
+ provenance: FaceProvenance {
+ sources: vec![brep_face_source(host, face.id)],
+ role: FaceRole::Split,
+ reversed: false,
+ },
+ });
+ }
+ }
+ for face in &cutter_faces {
+ let mut edges =
+ planar_section_segments(host, &host_faces, face.frame, accuracy.intersection)?;
+ let coplanar = host_faces
+ .iter()
+ .filter(|other| {
+ dot(other.frame.z, face.frame.z).abs() >= 1.0 - 1.0e-10
+ && dot(other.frame.z, sub(face.frame.origin, other.frame.origin)).abs()
+ <= accuracy.intersection
+ })
+ .flat_map(|other| {
+ std::iter::once(&other.polygon)
+ .chain(other.holes.iter())
+ .map(|ring| project(ring, face.frame))
+ })
+ .collect::>();
+ let mut cutter_region = vec![project(&face.polygon, face.frame)];
+ cutter_region.extend(face.holes.iter().map(|ring| project(ring, face.frame)));
+ for ring in cutter_region.iter().chain(&coplanar) {
+ edges.extend(
+ ring.iter()
+ .enumerate()
+ .map(|(index, &point)| (point, ring[(index + 1) % ring.len()])),
+ );
+ }
+ let failure = RefCell::new(None);
+ let regions = regions_from_edges_by(&edges, accuracy.intersection, |point| {
+ if cutter_region
+ .iter()
+ .map(|ring| winding_number2(point, ring))
+ .sum::()
+ == 0
+ || coplanar
+ .iter()
+ .map(|ring| winding_number2(point, ring))
+ .sum::()
+ != 0
+ {
+ return false;
+ }
+ match super::query::classify_point(host, face.frame.point([point.x, point.z, 0.0])) {
+ Ok(super::query::PointClassification::Inside)
+ | Ok(super::query::PointClassification::Boundary) => true,
+ Ok(super::query::PointClassification::Outside) => false,
+ Ok(super::query::PointClassification::Unknown) => {
+ *failure.borrow_mut() = Some(coverage());
+ false
+ }
+ Err(error) => {
+ *failure.borrow_mut() = Some(error);
+ false
+ }
+ }
+ });
+ if let Some(error) = failure.into_inner() {
+ return Err(error);
+ }
+ 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),
+ };
+ for region in regions {
+ 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();
+ pieces.push(PlanarFacet {
+ key: format!("planar-cut-{}", face.id),
+ frame: reversed,
+ outer,
+ holes,
+ provenance: FaceProvenance {
+ sources: vec![brep_face_source(cutter, face.id)],
+ role: FaceRole::Cut,
+ reversed: true,
+ },
+ });
+ }
+ }
+ if pieces.is_empty() {
+ return Err(coverage());
+ }
+ let vertices = pieces
+ .iter()
+ .flat_map(|piece| {
+ piece
+ .outer
+ .iter()
+ .chain(piece.holes.iter().flatten())
+ .copied()
+ })
+ .collect::>();
+ let mut facets = Facets::new(id, accuracy)?;
+ for piece in pieces {
+ let outer = split_planar_ring(&piece.outer, &vertices, accuracy.geometric / 4.0);
+ let holes = piece
+ .holes
+ .iter()
+ .map(|ring| split_planar_ring(ring, &vertices, accuracy.geometric / 4.0))
+ .collect();
+ facets.face(&piece.key, piece.frame, outer, holes, piece.provenance)?;
+ }
+ let mut result = finish(facets, host, cutter)?;
+ result.report.coincident = false;
+ Ok(result)
+}
diff --git a/main/opengeometry/src/analytic/primitives.rs b/main/opengeometry/src/analytic/primitives.rs
index 7c31472..55946be 100644
--- a/main/opengeometry/src/analytic/primitives.rs
+++ b/main/opengeometry/src/analytic/primitives.rs
@@ -6,6 +6,7 @@ use super::{
Curve, CurveGeometry, Frame3, GeometryError, Point3, Surface, SurfaceGeometry,
};
use crate::math::interval::Interval;
+use serde::Deserialize;
use std::collections::BTreeMap;
pub(super) struct Use {
@@ -705,7 +706,7 @@ pub(super) fn cylinder_with_circular_hole(
|| height <= 4.0 * accuracy.geometric
{
return Err(GeometryError::UnresolvedIntersection(
- "circular cylinder wall is below geometric resolution".into(),
+ "cylindrical shell is below geometric resolution".into(),
));
}
@@ -1458,8 +1459,716 @@ pub fn linear_extrusion(
builder.finish()
}
+#[derive(Clone, Debug, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum ProfileEdge {
+ Line {
+ from: [f64; 2],
+ to: [f64; 2],
+ },
+ Arc {
+ center: [f64; 2],
+ radius: f64,
+ start_angle: f64,
+ sweep_angle: f64,
+ },
+}
+
+impl ProfileEdge {
+ fn endpoints(&self) -> ([f64; 2], [f64; 2]) {
+ match self {
+ Self::Line { from, to } => (*from, *to),
+ Self::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => {
+ let point = |angle: f64| {
+ [
+ center[0] + radius * angle.cos(),
+ center[1] + radius * angle.sin(),
+ ]
+ };
+ (point(*start_angle), point(start_angle + sweep_angle))
+ }
+ }
+ }
+
+ fn reversed(&self) -> Self {
+ match self {
+ Self::Line { from, to } => Self::Line {
+ from: *to,
+ to: *from,
+ },
+ Self::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => Self::Arc {
+ center: *center,
+ radius: *radius,
+ start_angle: start_angle + sweep_angle,
+ sweep_angle: -sweep_angle,
+ },
+ }
+ }
+
+ fn signed_area_twice(&self) -> f64 {
+ let (from, to) = self.endpoints();
+ match self {
+ Self::Line { .. } => from[0] * to[1] - to[0] * from[1],
+ Self::Arc {
+ center,
+ radius,
+ sweep_angle,
+ ..
+ } => {
+ radius * radius * sweep_angle + center[0] * (to[1] - from[1])
+ - center[1] * (to[0] - from[0])
+ }
+ }
+ }
+}
+
+fn profile_edge_contains(edge: &ProfileEdge, point: [f64; 2], tolerance: f64) -> bool {
+ match edge {
+ ProfileEdge::Line { from, to } => {
+ let dx = to[0] - from[0];
+ let dy = to[1] - from[1];
+ let length = dx.hypot(dy);
+ let px = point[0] - from[0];
+ let py = point[1] - from[1];
+ (dx * py - dy * px).abs() <= tolerance * length
+ && px * dx + py * dy >= -tolerance * length
+ && px * dx + py * dy <= length * length + tolerance * length
+ }
+ ProfileEdge::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => {
+ let dx = point[0] - center[0];
+ let dy = point[1] - center[1];
+ if (dx.hypot(dy) - radius).abs() > tolerance {
+ return false;
+ }
+ let angle = dy.atan2(dx);
+ let travel = if *sweep_angle > 0.0 {
+ (angle - start_angle).rem_euclid(std::f64::consts::TAU)
+ } else {
+ (start_angle - angle).rem_euclid(std::f64::consts::TAU)
+ };
+ travel <= sweep_angle.abs() + tolerance / radius
+ || std::f64::consts::TAU - travel <= tolerance / radius
+ }
+ }
+}
+
+fn profile_edge_intersections(a: &ProfileEdge, b: &ProfileEdge, tolerance: f64) -> Vec<[f64; 2]> {
+ let mut candidates = Vec::new();
+ let mut offer = |point: [f64; 2]| {
+ if point.iter().all(|value| value.is_finite())
+ && profile_edge_contains(a, point, tolerance)
+ && profile_edge_contains(b, point, tolerance)
+ && !candidates.iter().any(|other: &[f64; 2]| {
+ (other[0] - point[0]).hypot(other[1] - point[1]) <= tolerance
+ })
+ {
+ candidates.push(point);
+ }
+ };
+ match (a, b) {
+ (ProfileEdge::Line { from: p, to: q }, ProfileEdge::Line { from: r, to: s }) => {
+ let d = [q[0] - p[0], q[1] - p[1]];
+ let e = [s[0] - r[0], s[1] - r[1]];
+ let divisor = d[0] * e[1] - d[1] * e[0];
+ if divisor.abs() > tolerance * (d[0].hypot(d[1]) + e[0].hypot(e[1])) {
+ let relative = [r[0] - p[0], r[1] - p[1]];
+ let t = (relative[0] * e[1] - relative[1] * e[0]) / divisor;
+ offer([p[0] + t * d[0], p[1] + t * d[1]]);
+ } else {
+ for point in [*p, *q, *r, *s] {
+ offer(point);
+ }
+ }
+ }
+ (ProfileEdge::Line { from, to }, ProfileEdge::Arc { center, radius, .. })
+ | (ProfileEdge::Arc { center, radius, .. }, ProfileEdge::Line { from, to }) => {
+ let d = [to[0] - from[0], to[1] - from[1]];
+ let f = [from[0] - center[0], from[1] - center[1]];
+ let aa = d[0] * d[0] + d[1] * d[1];
+ let bb = 2.0 * (f[0] * d[0] + f[1] * d[1]);
+ let cc = f[0] * f[0] + f[1] * f[1] - radius * radius;
+ let discriminant = bb * bb - 4.0 * aa * cc;
+ if discriminant >= -tolerance * tolerance * aa {
+ let root = discriminant.max(0.0).sqrt();
+ for t in [(-bb - root) / (2.0 * aa), (-bb + root) / (2.0 * aa)] {
+ offer([from[0] + t * d[0], from[1] + t * d[1]]);
+ }
+ }
+ }
+ (
+ ProfileEdge::Arc {
+ center: ca,
+ radius: ra,
+ ..
+ },
+ ProfileEdge::Arc {
+ center: cb,
+ radius: rb,
+ ..
+ },
+ ) => {
+ let d = (cb[0] - ca[0]).hypot(cb[1] - ca[1]);
+ if d <= tolerance && (ra - rb).abs() <= tolerance {
+ let (a0, a1) = a.endpoints();
+ let (b0, b1) = b.endpoints();
+ for point in [a0, a1, b0, b1] {
+ offer(point);
+ }
+ let ProfileEdge::Arc {
+ start_angle,
+ sweep_angle,
+ ..
+ } = a
+ else {
+ unreachable!()
+ };
+ let midpoint_angle = start_angle + sweep_angle * 0.5;
+ offer([
+ ca[0] + ra * midpoint_angle.cos(),
+ ca[1] + ra * midpoint_angle.sin(),
+ ]);
+ } else if d > tolerance
+ && d <= ra + rb + tolerance
+ && d + ra.min(*rb) + tolerance >= ra.max(*rb)
+ {
+ let along = (ra * ra - rb * rb + d * d) / (2.0 * d);
+ let perpendicular_sq = ra * ra - along * along;
+ if perpendicular_sq >= -tolerance * tolerance {
+ let ux = (cb[0] - ca[0]) / d;
+ let uy = (cb[1] - ca[1]) / d;
+ let base = [ca[0] + along * ux, ca[1] + along * uy];
+ let off = perpendicular_sq.max(0.0).sqrt();
+ offer([base[0] - off * uy, base[1] + off * ux]);
+ offer([base[0] + off * uy, base[1] - off * ux]);
+ }
+ }
+ }
+ }
+ candidates
+}
+
+fn validate_arc_profile_loop(edges: &[ProfileEdge], g: f64) -> Result {
+ if edges.len() < 2 {
+ return Err(GeometryError::InvalidGeometry(
+ "arc-edged profile needs at least two edges".into(),
+ ));
+ }
+ for (index, edge) in edges.iter().enumerate() {
+ let (from, to) = edge.endpoints();
+ if !from.into_iter().chain(to).all(f64::is_finite) {
+ return Err(GeometryError::InvalidGeometry(
+ "profile coordinates must be finite".into(),
+ ));
+ }
+ match edge {
+ ProfileEdge::Line { .. } => {
+ if (to[0] - from[0]).hypot(to[1] - from[1]) <= 4.0 * g {
+ return Err(GeometryError::UnresolvedIntersection(
+ "profile line is below resolution".into(),
+ ));
+ }
+ }
+ ProfileEdge::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => {
+ if !center.iter().all(|value| value.is_finite())
+ || !radius.is_finite()
+ || !start_angle.is_finite()
+ || !sweep_angle.is_finite()
+ || *radius <= 4.0 * g
+ || sweep_angle.abs() >= std::f64::consts::TAU
+ || radius * sweep_angle.abs() <= 4.0 * g
+ {
+ return Err(GeometryError::InvalidGeometry(
+ "profile arc is unresolved".into(),
+ ));
+ }
+ }
+ }
+ let (next, _) = edges[(index + 1) % edges.len()].endpoints();
+ if (to[0] - next[0]).hypot(to[1] - next[1]) > g {
+ return Err(GeometryError::InvalidGeometry(
+ "profile edges do not close".into(),
+ ));
+ }
+ }
+ for first in 0..edges.len() {
+ for second in (first + 1)..edges.len() {
+ let adjacent = second == first + 1 || first == 0 && second == edges.len() - 1;
+ let expected = if second == first + 1 {
+ Some(edges[first].endpoints().1)
+ } else if first == 0 && second == edges.len() - 1 {
+ Some(edges[first].endpoints().0)
+ } else {
+ None
+ };
+ for point in profile_edge_intersections(&edges[first], &edges[second], g) {
+ let at_expected = expected
+ .is_some_and(|shared| (point[0] - shared[0]).hypot(point[1] - shared[1]) <= g);
+ let at_second_join = edges.len() == 2
+ && (point[0] - edges[first].endpoints().0[0])
+ .hypot(point[1] - edges[first].endpoints().0[1])
+ <= g;
+ if !adjacent || !(at_expected || at_second_join) {
+ return Err(GeometryError::InvalidGeometry(
+ "arc-edged profile self-intersects".into(),
+ ));
+ }
+ }
+ }
+ }
+ let signed_area_twice: f64 = edges.iter().map(ProfileEdge::signed_area_twice).sum();
+ if signed_area_twice.abs() <= 16.0 * g * g {
+ return Err(GeometryError::UnresolvedIntersection(
+ "profile area is below resolution".into(),
+ ));
+ }
+ Ok(signed_area_twice)
+}
+
+fn arc_profile_contains(edges: &[ProfileEdge], point: [f64; 2], g: f64) -> bool {
+ use crate::geometry::{
+ curved_boolean2d::{winding, CurveEdge2},
+ poly2d::Pt2,
+ };
+
+ let ring = edges
+ .iter()
+ .map(|edge| match edge {
+ ProfileEdge::Line { from, to } => CurveEdge2::Line {
+ from: *from,
+ to: *to,
+ },
+ ProfileEdge::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => CurveEdge2::Arc {
+ center: *center,
+ radius: *radius,
+ start_angle: *start_angle,
+ sweep_angle: *sweep_angle,
+ },
+ })
+ .collect::>();
+ winding(Pt2::new(point[0], point[1]), &ring, g) != 0
+}
+
+struct ArcExtrusionLoop {
+ edges: Vec,
+ bottom_vertices: Vec,
+ top_vertices: Vec,
+ bottom_edges: Vec,
+ top_edges: Vec,
+ vertical_edges: Vec,
+}
+
+pub fn arc_edged_extrusion(
+ id: String,
+ frame: Frame3,
+ outer: Vec,
+ height: f64,
+ accuracy: Accuracy,
+) -> Result {
+ arc_edged_extrusion_with_holes(id, frame, outer, Vec::new(), height, accuracy)
+}
+
+pub fn arc_edged_extrusion_with_holes(
+ id: String,
+ frame: Frame3,
+ mut outer: Vec,
+ mut holes: Vec>,
+ height: f64,
+ accuracy: Accuracy,
+) -> Result {
+ frame.validate()?;
+ accuracy.validate()?;
+ dimensions(&[height])?;
+ let g = accuracy.geometric;
+ if height <= 4.0 * g {
+ return Err(GeometryError::UnresolvedIntersection(
+ "arc-edged extrusion height is below geometric resolution".into(),
+ ));
+ }
+ if validate_arc_profile_loop(&outer, g)? < 0.0 {
+ outer = outer.iter().rev().map(ProfileEdge::reversed).collect();
+ }
+ for (index, hole) in holes.iter_mut().enumerate() {
+ if validate_arc_profile_loop(hole, g)? > 0.0 {
+ *hole = hole.iter().rev().map(ProfileEdge::reversed).collect();
+ }
+ for hole_edge in hole.iter() {
+ for outer_edge in &outer {
+ if !profile_edge_intersections(hole_edge, outer_edge, g).is_empty() {
+ return Err(GeometryError::InvalidGeometry(format!(
+ "arc-edged profile hole {index} touches its outer boundary"
+ )));
+ }
+ }
+ }
+ if !arc_profile_contains(&outer, hole[0].endpoints().0, g) {
+ return Err(GeometryError::InvalidGeometry(format!(
+ "arc-edged profile hole {index} lies outside the outer boundary"
+ )));
+ }
+ }
+ for first in 0..holes.len() {
+ for second in (first + 1)..holes.len() {
+ if holes[first].iter().any(|a| {
+ holes[second]
+ .iter()
+ .any(|b| !profile_edge_intersections(a, b, g).is_empty())
+ }) || arc_profile_contains(&holes[first], holes[second][0].endpoints().0, g)
+ || arc_profile_contains(&holes[second], holes[first][0].endpoints().0, g)
+ {
+ return Err(GeometryError::InvalidGeometry(format!(
+ "arc-edged profile holes {first} and {second} overlap or nest"
+ )));
+ }
+ }
+ }
+
+ let mut builder = Builder::new(id, accuracy)?;
+ let mut loops = Vec::with_capacity(holes.len() + 1);
+ for edges in std::iter::once(outer).chain(holes) {
+ let points = edges
+ .iter()
+ .map(|edge| edge.endpoints().0)
+ .collect::>();
+ let bottom_vertices = points
+ .iter()
+ .map(|point| builder.vertex(frame.point([point[0], point[1], 0.0])))
+ .collect::>();
+ let top_vertices = points
+ .iter()
+ .map(|point| builder.vertex(frame.point([point[0], point[1], height])))
+ .collect::>();
+ let mut bottom_edges = Vec::with_capacity(edges.len());
+ let mut top_edges = Vec::with_capacity(edges.len());
+ let mut vertical_edges = Vec::with_capacity(edges.len());
+ for (index, edge) in edges.iter().enumerate() {
+ let from = builder.brep.topology.vertices[bottom_vertices[index] as usize].position;
+ let top_from = builder.brep.topology.vertices[top_vertices[index] as usize].position;
+ let (bottom_curve, top_curve, range) = match edge {
+ ProfileEdge::Line { from: p, to: q } => {
+ let vector = frame.vector([q[0] - p[0], q[1] - p[1], 0.0]);
+ let length = norm(vector);
+ let direction = unit(vector)?;
+ (
+ CurveGeometry::Line {
+ origin: from,
+ direction,
+ },
+ CurveGeometry::Line {
+ origin: top_from,
+ direction,
+ },
+ Interval::new(0.0, length)?,
+ )
+ }
+ ProfileEdge::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => {
+ let origin = frame.point([center[0], center[1], 0.0]);
+ let circle_frame = Frame3 { origin, ..frame };
+ let top_circle_frame = Frame3 {
+ origin: add(origin, scale(frame.z, height)),
+ ..frame
+ };
+ let end_angle = start_angle + sweep_angle;
+ (
+ CurveGeometry::Circle {
+ frame: circle_frame,
+ radius: *radius,
+ },
+ CurveGeometry::Circle {
+ frame: top_circle_frame,
+ radius: *radius,
+ },
+ Interval::new(start_angle.min(end_angle), start_angle.max(end_angle))?,
+ )
+ }
+ };
+ bottom_edges.push(builder.edge(bottom_curve, range, false));
+ top_edges.push(builder.edge(top_curve, range, false));
+ vertical_edges.push(builder.edge(
+ CurveGeometry::Line {
+ origin: from,
+ direction: frame.z,
+ },
+ Interval::new(0.0, height)?,
+ false,
+ ));
+ }
+ loops.push(ArcExtrusionLoop {
+ edges,
+ bottom_vertices,
+ top_vertices,
+ bottom_edges,
+ top_edges,
+ vertical_edges,
+ });
+ }
+
+ let mut bounds = [[f64::INFINITY, f64::NEG_INFINITY]; 2];
+ for edge in &loops[0].edges {
+ match edge {
+ ProfileEdge::Line { from, to } => {
+ for point in [from, to] {
+ for axis in 0..2 {
+ bounds[axis][0] = bounds[axis][0].min(point[axis]);
+ bounds[axis][1] = bounds[axis][1].max(point[axis]);
+ }
+ }
+ }
+ ProfileEdge::Arc { center, radius, .. } => {
+ for axis in 0..2 {
+ bounds[axis][0] = bounds[axis][0].min(center[axis] - radius);
+ bounds[axis][1] = bounds[axis][1].max(center[axis] + radius);
+ }
+ }
+ }
+ }
+ for bound in &mut bounds {
+ bound[0] -= g;
+ bound[1] += g;
+ }
+ let top_frame = Frame3 {
+ origin: frame.point([0.0, 0.0, height]),
+ ..frame
+ };
+ let bottom_frame = Frame3 {
+ y: scale(frame.y, -1.0),
+ z: scale(frame.z, -1.0),
+ ..frame
+ };
+ let cap_uses = |profile: &ArcExtrusionLoop, top: bool| -> Result, GeometryError> {
+ let count = profile.edges.len();
+ let mut uses = Vec::with_capacity(count);
+ for index in 0..count {
+ let next = (index + 1) % count;
+ let forward = !matches!(profile.edges[index], ProfileEdge::Arc { sweep_angle, .. }
+ if sweep_angle < 0.0);
+ let top_sense = if forward {
+ Orientation::Forward
+ } else {
+ Orientation::Reverse
+ };
+ let bottom_sense = if forward {
+ Orientation::Reverse
+ } else {
+ Orientation::Forward
+ };
+ uses.push(if top {
+ plane_boundary(
+ &builder,
+ top_frame,
+ profile.top_edges[index],
+ profile.top_vertices[index],
+ profile.top_vertices[next],
+ top_sense,
+ )?
+ } else {
+ plane_boundary(
+ &builder,
+ bottom_frame,
+ profile.bottom_edges[index],
+ profile.bottom_vertices[next],
+ profile.bottom_vertices[index],
+ bottom_sense,
+ )?
+ });
+ }
+ if !top {
+ uses.reverse();
+ }
+ Ok(uses)
+ };
+ let top_outer = cap_uses(&loops[0], true)?;
+ let top_holes = loops[1..]
+ .iter()
+ .map(|profile| cap_uses(profile, true))
+ .collect::, _>>()?;
+ let bottom_outer = cap_uses(&loops[0], false)?;
+ let bottom_holes = loops[1..]
+ .iter()
+ .map(|profile| cap_uses(profile, false))
+ .collect::, _>>()?;
+ builder.face_with_holes(
+ "top",
+ SurfaceGeometry::Plane { frame: top_frame },
+ bounds,
+ top_outer,
+ top_holes,
+ )?;
+ builder.face_with_holes(
+ "bottom",
+ SurfaceGeometry::Plane {
+ frame: bottom_frame,
+ },
+ [bounds[0], [-bounds[1][1], -bounds[1][0]]],
+ bottom_outer,
+ bottom_holes,
+ )?;
+
+ for (loop_index, profile) in loops.iter().enumerate() {
+ for (index, edge) in profile.edges.iter().enumerate() {
+ let next = (index + 1) % profile.edges.len();
+ let bottom_from = profile.bottom_vertices[index];
+ let bottom_to = profile.bottom_vertices[next];
+ let top_from = profile.top_vertices[index];
+ let top_to = profile.top_vertices[next];
+ let forward =
+ !matches!(edge, ProfileEdge::Arc { sweep_angle, .. } if *sweep_angle < 0.0);
+ let bottom_sense = if forward {
+ Orientation::Forward
+ } else {
+ Orientation::Reverse
+ };
+ let top_sense = if forward {
+ Orientation::Reverse
+ } else {
+ Orientation::Forward
+ };
+ match edge {
+ ProfileEdge::Line { .. } => {
+ let origin = builder.brep.topology.vertices[bottom_from as usize].position;
+ let destination = builder.brep.topology.vertices[bottom_to as usize].position;
+ let edge_direction = unit(sub(destination, origin))?;
+ let side_frame = Frame3::from_axis(
+ origin,
+ unit(cross(edge_direction, frame.z))?,
+ edge_direction,
+ )?;
+ let uses = [
+ (
+ profile.bottom_edges[index],
+ bottom_from,
+ bottom_to,
+ Orientation::Forward,
+ ),
+ (
+ profile.vertical_edges[next],
+ bottom_to,
+ top_to,
+ Orientation::Forward,
+ ),
+ (
+ profile.top_edges[index],
+ top_to,
+ top_from,
+ Orientation::Reverse,
+ ),
+ (
+ profile.vertical_edges[index],
+ top_from,
+ bottom_from,
+ Orientation::Reverse,
+ ),
+ ]
+ .into_iter()
+ .map(|(edge, from, to, sense)| {
+ plane_boundary(&builder, side_frame, edge, from, to, sense)
+ })
+ .collect::, _>>()?;
+ builder.face(
+ &format!("side-{loop_index}-{index}"),
+ SurfaceGeometry::Plane { frame: side_frame },
+ [[-g, norm(sub(destination, origin)) + g], [-g, height + g]],
+ uses,
+ )?;
+ }
+ ProfileEdge::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => {
+ let end_angle = start_angle + sweep_angle;
+ let circle_frame = Frame3 {
+ origin: frame.point([center[0], center[1], 0.0]),
+ ..frame
+ };
+ let uses = vec![
+ boundary(
+ profile.bottom_edges[index],
+ bottom_from,
+ bottom_to,
+ bottom_sense,
+ uv_line([0.0, 0.0], [1.0, 0.0]),
+ ),
+ boundary(
+ profile.vertical_edges[next],
+ bottom_to,
+ top_to,
+ Orientation::Forward,
+ uv_line([end_angle, 0.0], [0.0, 1.0]),
+ ),
+ boundary(
+ profile.top_edges[index],
+ top_to,
+ top_from,
+ top_sense,
+ uv_line([0.0, height], [1.0, 0.0]),
+ ),
+ boundary(
+ profile.vertical_edges[index],
+ top_from,
+ bottom_from,
+ Orientation::Reverse,
+ uv_line([*start_angle, 0.0], [0.0, 1.0]),
+ ),
+ ];
+ let face_id = builder.brep.topology.faces.len();
+ builder.face(
+ &format!("side-{loop_index}-{index}"),
+ SurfaceGeometry::Cylinder {
+ frame: circle_frame,
+ radius: *radius,
+ },
+ [
+ [
+ start_angle.min(end_angle) - g / radius,
+ start_angle.max(end_angle) + g / radius,
+ ],
+ [-g, height + g],
+ ],
+ uses,
+ )?;
+ if !forward {
+ builder.brep.topology.faces[face_id].sense = Orientation::Reverse;
+ }
+ }
+ }
+ }
+ }
+ builder.finish()
+}
+
#[derive(Clone, Debug)]
-pub struct StraightWallArchedOpening {
+pub struct BoxArchedOpening {
pub id: String,
pub station: f64,
pub width: f64,
@@ -1468,13 +2177,13 @@ pub struct StraightWallArchedOpening {
}
#[allow(clippy::too_many_arguments)]
-pub fn straight_wall_with_arched_opening(
+pub fn box_with_arched_opening(
id: String,
frame: Frame3,
width: f64,
depth: f64,
height: f64,
- opening: StraightWallArchedOpening,
+ opening: BoxArchedOpening,
accuracy: Accuracy,
) -> Result {
frame.validate()?;
@@ -1492,7 +2201,7 @@ pub fn straight_wall_with_arched_opening(
|| opening.bottom + opening.height >= height - 4.0 * accuracy.geometric
{
return Err(GeometryError::InvalidGeometry(
- "arched wall opening must remain resolved and strictly inside the wall".into(),
+ "arched opening must remain resolved and strictly inside the box".into(),
));
}
let extrusion_frame = Frame3 {
@@ -1516,17 +2225,17 @@ pub fn straight_wall_with_arched_opening(
depth,
accuracy,
)?;
- let header = result
+ let upper_cap = result
.topology
.faces
.iter()
.find(|face| face.key == "side-1-0")
.map(|face| face.id)
- .ok_or_else(|| GeometryError::InvalidTopology("arched header face is missing".into()))?;
- let header_loop = result.topology.faces[header as usize].trim.outer;
- let header_uses = loop_halfedges_for_primitive(&result, header_loop)?;
+ .ok_or_else(|| GeometryError::InvalidTopology("arched upper_cap face is missing".into()))?;
+ let upper_cap_loop = result.topology.faces[upper_cap as usize].trim.outer;
+ let upper_cap_uses = loop_halfedges_for_primitive(&result, upper_cap_loop)?;
let mut arch_edges = Vec::new();
- for halfedge in &header_uses {
+ for halfedge in &upper_cap_uses {
let use_ = &result.topology.halfedges[*halfedge as usize];
let endpoints = [use_.from, use_.to].map(|vertex| {
extrusion_frame.local(result.topology.vertices[vertex as usize].position)
@@ -1599,8 +2308,8 @@ pub fn straight_wall_with_arched_opening(
radius,
});
{
- let face = &mut result.topology.faces[header as usize];
- face.key = format!("{}-arched-header", opening.id);
+ let face = &mut result.topology.faces[upper_cap as usize];
+ face.key = format!("{}-arched-cap", opening.id);
face.surface = cylinder_surface;
face.sense = Orientation::Reverse;
face.trim.uv_bounds = [
@@ -1615,7 +2324,7 @@ pub fn straight_wall_with_arched_opening(
.topology
.halfedges
.iter()
- .filter(|halfedge| halfedge.face == Some(header) || arch_edges.contains(&halfedge.edge))
+ .filter(|halfedge| halfedge.face == Some(upper_cap) || arch_edges.contains(&halfedge.edge))
.map(|halfedge| halfedge.id)
.collect::>();
for halfedge_id in affected {
@@ -1632,35 +2341,78 @@ pub fn straight_wall_with_arched_opening(
))
}
};
- let start = result.geometry.curve(curve)?.point_at(range.lo)?;
- let end = result.geometry.curve(curve)?.point_at(range.hi)?;
+ let curve_geometry = &result.geometry.curves[curve as usize];
let surface_geometry = result.geometry.surface(surface)?;
- let mut uv_hint = surface_geometry.project(start, None)?;
- let mut uv_end = surface_geometry.project(end, Some(uv_hint))?;
- if surface == cylinder_surface {
- for uv in [&mut uv_hint, &mut uv_end] {
- if uv[0] < -accuracy.intersection {
- uv[0] += std::f64::consts::TAU;
+ let pcurve_geometry = match (curve_geometry, surface_geometry) {
+ (CurveGeometry::Line { origin, direction }, SurfaceGeometry::Plane { frame }) => {
+ PcurveGeometry::Line2 {
+ origin: [
+ dot(sub(*origin, frame.origin), frame.x),
+ dot(sub(*origin, frame.origin), frame.y),
+ ],
+ direction: [dot(*direction, frame.x), dot(*direction, frame.y)],
}
}
- }
- let uv_rate = if range.width() > accuracy.geometric {
- std::array::from_fn(|axis| (uv_end[axis] - uv_hint[axis]) / range.width())
- } else {
- [0.0; 2]
+ (
+ CurveGeometry::Circle {
+ frame: circle,
+ radius,
+ },
+ SurfaceGeometry::Plane { frame },
+ ) => PcurveGeometry::Conic2 {
+ origin: [
+ dot(sub(circle.origin, frame.origin), frame.x),
+ dot(sub(circle.origin, frame.origin), frame.y),
+ ],
+ axis_a: [
+ dot(scale(circle.x, *radius), frame.x),
+ dot(scale(circle.x, *radius), frame.y),
+ ],
+ axis_b: [
+ dot(scale(circle.y, *radius), frame.x),
+ dot(scale(circle.y, *radius), frame.y),
+ ],
+ },
+ (
+ CurveGeometry::Line { origin, direction },
+ SurfaceGeometry::Cylinder { frame, .. },
+ ) => {
+ let radial = sub(*origin, frame.origin);
+ PcurveGeometry::Line2 {
+ origin: [
+ dot(radial, frame.y).atan2(dot(radial, frame.x)),
+ dot(radial, frame.z),
+ ],
+ direction: [0.0, dot(*direction, frame.z)],
+ }
+ }
+ (
+ CurveGeometry::Circle { frame: circle, .. },
+ SurfaceGeometry::Cylinder { frame, .. },
+ ) => {
+ if dot(circle.z, frame.z).abs() < 1.0 - 1e-10 {
+ return Err(GeometryError::UnsupportedGeometry(
+ "arched upper_cap circle is not coaxial with its cylindrical face".into(),
+ ));
+ }
+ let angular_rate = dot(circle.z, frame.z).signum();
+ let mut angle = dot(circle.x, frame.y).atan2(dot(circle.x, frame.x));
+ let middle = angle + angular_rate * range.midpoint();
+ angle += ((std::f64::consts::FRAC_PI_2 - middle) / std::f64::consts::TAU).round()
+ * std::f64::consts::TAU;
+ PcurveGeometry::Line2 {
+ origin: [angle, dot(sub(circle.origin, frame.origin), frame.z)],
+ direction: [angular_rate, 0.0],
+ }
+ }
+ _ => {
+ return Err(GeometryError::UnsupportedGeometry(
+ "arched boundary requires a line or circle on a plane or cylinder".into(),
+ ))
+ }
};
let pcurve = result.geometry.pcurves.len() as u32;
- result
- .geometry
- .pcurves
- .push(PcurveGeometry::ProjectedCurve {
- curve,
- surface,
- chart: 0,
- uv_hint,
- uv_rate,
- parameter_origin: range.lo,
- });
+ result.geometry.pcurves.push(pcurve_geometry);
result.topology.halfedges[halfedge_id as usize]
.geometry_use
.pcurve = Some(pcurve);
@@ -1787,7 +2539,7 @@ pub(super) fn plane_boundary(
}
#[derive(Clone, Debug)]
-pub struct CircularWallOpening {
+pub struct AnnularSectorOpening {
pub id: String,
pub angle: f64,
pub width: f64,
@@ -1795,7 +2547,7 @@ pub struct CircularWallOpening {
pub height: f64,
}
-struct BuiltCircularWallOpening {
+struct BuiltAnnularSectorOpening {
source: String,
outer_angles: [f64; 2],
inner_angles: [f64; 2],
@@ -1810,13 +2562,13 @@ struct BuiltCircularWallOpening {
connectors: [[u32; 2]; 2],
}
-impl BuiltCircularWallOpening {
+impl BuiltAnnularSectorOpening {
fn touches_bottom(&self) -> bool {
self.elevations[0] == 0.0
}
}
-pub fn circular_wall(
+pub fn annular_sector_extrusion(
id: String,
frame: Frame3,
radius: f64,
@@ -1826,7 +2578,7 @@ pub fn circular_wall(
sweep_angle: f64,
accuracy: Accuracy,
) -> Result {
- circular_wall_with_openings(
+ annular_sector_extrusion_with_openings(
id,
frame,
radius,
@@ -1840,7 +2592,7 @@ pub fn circular_wall(
}
#[allow(clippy::too_many_arguments)]
-pub fn circular_wall_with_openings(
+pub fn annular_sector_extrusion_with_openings(
id: String,
frame: Frame3,
radius: f64,
@@ -1848,7 +2600,7 @@ pub fn circular_wall_with_openings(
height: f64,
start_angle: f64,
sweep_angle: f64,
- openings: Vec,
+ openings: Vec,
accuracy: Accuracy,
) -> Result {
frame.validate()?;
@@ -1858,17 +2610,17 @@ pub fn circular_wall_with_openings(
let outer = radius + thickness * 0.5;
if inner <= 0.0 || !outer.is_finite() {
return Err(GeometryError::InvalidGeometry(
- "wall radius must exceed half thickness".into(),
+ "annular sector radius must exceed half thickness".into(),
));
}
if !start_angle.is_finite() || !sweep_angle.is_finite() || sweep_angle == 0.0 {
return Err(GeometryError::InvalidGeometry(
- "wall angles must be finite with a nonzero sweep".into(),
+ "annular sector angles must be finite with a nonzero sweep".into(),
));
}
if sweep_angle.abs() >= std::f64::consts::TAU {
return Err(GeometryError::UnsupportedGeometry(
- "circular-wall sweep must be smaller than one full turn".into(),
+ "annular sector sweep must be smaller than one full turn".into(),
));
}
let end_angle = start_angle + sweep_angle;
@@ -1881,7 +2633,7 @@ pub fn circular_wall_with_openings(
|| height <= 4.0 * accuracy.geometric
{
return Err(GeometryError::InvalidGeometry(
- "wall features are below geometric resolution".into(),
+ "annular sector features are below geometric resolution".into(),
));
}
let mut prepared_openings = Vec::with_capacity(openings.len());
@@ -1889,7 +2641,7 @@ pub fn circular_wall_with_openings(
for opening in openings {
if opening.id.is_empty() || !opening_ids.insert(opening.id.clone()) {
return Err(GeometryError::InvalidGeometry(
- "circular-wall opening identities must be nonempty and unique".into(),
+ "annular sector opening identities must be nonempty and unique".into(),
));
}
if !opening.angle.is_finite()
@@ -1900,13 +2652,13 @@ pub fn circular_wall_with_openings(
|| opening.height <= 4.0 * accuracy.geometric
{
return Err(GeometryError::InvalidGeometry(
- "circular-wall opening dimensions and station must be finite and resolved".into(),
+ "annular sector opening dimensions and station must be finite and resolved".into(),
));
}
let half_width = opening.width * 0.5;
if half_width >= inner {
return Err(GeometryError::UnsupportedGeometry(
- "circular-wall opening width reaches the wall axis".into(),
+ "annular sector opening width reaches the reference arc".into(),
));
}
let outer_delta = (half_width / outer).asin();
@@ -1919,22 +2671,22 @@ pub fn circular_wall_with_openings(
|| inner * (hi - inner_angles[1]) <= clearance
{
return Err(GeometryError::UnsupportedGeometry(
- "circular-wall opening must stay strictly inside both authored wall ends".into(),
+ "annular sector opening must stay strictly inside both sweep ends".into(),
));
}
if elevations[0] < 0.0 || elevations[1] >= height {
return Err(GeometryError::InvalidGeometry(
- "circular-wall opening elevation lies outside the wall".into(),
+ "annular sector opening elevation lies outside the extrusion".into(),
));
}
if elevations[0] > 0.0 && elevations[0] <= clearance {
return Err(GeometryError::UnresolvedIntersection(
- "circular-wall opening sill is below geometric resolution".into(),
+ "annular sector opening lower clearance is below geometric resolution".into(),
));
}
if height - elevations[1] <= clearance {
return Err(GeometryError::UnresolvedIntersection(
- "circular-wall opening head clearance is below geometric resolution".into(),
+ "annular sector opening upper clearance is below geometric resolution".into(),
));
}
if elevations[0] == -0.0 {
@@ -1952,7 +2704,7 @@ pub fn circular_wall_with_openings(
&& vertical_overlap > -4.0 * accuracy.geometric
{
return Err(GeometryError::UnsupportedGeometry(
- "circular-wall openings overlap or are below geometric separation".into(),
+ "annular sector openings overlap or are below geometric separation".into(),
));
}
}
@@ -2141,7 +2893,7 @@ pub fn circular_wall_with_openings(
);
}
}
- built_openings.push(BuiltCircularWallOpening {
+ built_openings.push(BuiltAnnularSectorOpening {
source,
outer_angles,
inner_angles,
@@ -2178,10 +2930,10 @@ pub fn circular_wall_with_openings(
if bottom_openings.is_empty() {
bottom_spans.push(BottomSpan {
outer_edge: ob.ok_or_else(|| {
- GeometryError::InvalidTopology("circular-wall bottom edge missing".into())
+ GeometryError::InvalidTopology("annular sector bottom edge missing".into())
})?,
inner_edge: ib.ok_or_else(|| {
- GeometryError::InvalidTopology("circular-wall bottom edge missing".into())
+ GeometryError::InvalidTopology("annular sector bottom edge missing".into())
})?,
outer_vertices: [ob0, ob1],
inner_vertices: [ib0, ib1],
@@ -2244,7 +2996,7 @@ pub fn circular_wall_with_openings(
Ok(vec![
boundary(
opening.lower_outer_arc.ok_or_else(|| {
- GeometryError::InvalidTopology("opening sill edge missing".into())
+ GeometryError::InvalidTopology("opening lower_cap edge missing".into())
})?,
obr,
obl,
@@ -2335,7 +3087,7 @@ pub fn circular_wall_with_openings(
Ok(vec![
boundary(
opening.lower_inner_arc.ok_or_else(|| {
- GeometryError::InvalidTopology("opening sill edge missing".into())
+ GeometryError::InvalidTopology("opening lower_cap edge missing".into())
})?,
ibl,
ibr,
@@ -2607,29 +3359,33 @@ pub fn circular_wall_with_openings(
if !opening.touches_bottom() {
let lower_outer_arc = opening.lower_outer_arc.ok_or_else(|| {
- GeometryError::InvalidTopology("opening sill edge missing".into())
+ GeometryError::InvalidTopology("opening lower_cap edge missing".into())
})?;
let lower_inner_arc = opening.lower_inner_arc.ok_or_else(|| {
- GeometryError::InvalidTopology("opening sill edge missing".into())
+ GeometryError::InvalidTopology("opening lower_cap edge missing".into())
})?;
- let mut sill_frame = frame;
- sill_frame.origin = frame.point([0.0, 0.0, opening.elevations[0]]);
- let sill_uses = [
+ let mut lower_cap_frame = frame;
+ lower_cap_frame.origin = frame.point([0.0, 0.0, opening.elevations[0]]);
+ let lower_cap_uses = [
(lower_outer_arc, obl, obr, F),
(opening.connectors[0][1], obr, ibr, R),
(lower_inner_arc, ibr, ibl, R),
(opening.connectors[0][0], ibl, obl, F),
]
.into_iter()
- .map(|(edge, from, to, sense)| plane_boundary(&b, sill_frame, edge, from, to, sense))
+ .map(|(edge, from, to, sense)| {
+ plane_boundary(&b, lower_cap_frame, edge, from, to, sense)
+ })
.collect::, _>>()?;
let face = b.brep.topology.faces.len() as u32;
b.face(
- &format!("opening-{opening_index}-sill"),
- SurfaceGeometry::Plane { frame: sill_frame },
+ &format!("opening-{opening_index}-lower_cap"),
+ SurfaceGeometry::Plane {
+ frame: lower_cap_frame,
+ },
curved_plane_bounds(
&b,
- sill_frame,
+ lower_cap_frame,
[
lower_outer_arc,
opening.connectors[0][1],
@@ -2637,35 +3393,35 @@ pub fn circular_wall_with_openings(
opening.connectors[0][0],
],
)?,
- sill_uses,
+ lower_cap_uses,
)?;
- mark_cut(&mut b, face, 0, "sill".into());
+ mark_cut(&mut b, face, 0, "lower_cap".into());
}
- let header_frame = Frame3 {
+ let upper_cap_frame = Frame3 {
origin: frame.point([0.0, 0.0, opening.elevations[1]]),
y: scale(frame.y, -1.0),
z: scale(frame.z, -1.0),
..frame
};
- let header_uses = [
+ let upper_cap_uses = [
(opening.upper_outer_arc, otr, otl, R),
(opening.connectors[1][0], otl, itl, R),
(opening.upper_inner_arc, itl, itr, F),
(opening.connectors[1][1], itr, otr, F),
]
.into_iter()
- .map(|(edge, from, to, sense)| plane_boundary(&b, header_frame, edge, from, to, sense))
+ .map(|(edge, from, to, sense)| plane_boundary(&b, upper_cap_frame, edge, from, to, sense))
.collect::, _>>()?;
let face = b.brep.topology.faces.len() as u32;
b.face(
- &format!("opening-{opening_index}-header"),
+ &format!("opening-{opening_index}-upper_cap"),
SurfaceGeometry::Plane {
- frame: header_frame,
+ frame: upper_cap_frame,
},
curved_plane_bounds(
&b,
- header_frame,
+ upper_cap_frame,
[
opening.upper_outer_arc,
opening.connectors[1][0],
@@ -2673,9 +3429,9 @@ pub fn circular_wall_with_openings(
opening.connectors[1][1],
],
)?,
- header_uses,
+ upper_cap_uses,
)?;
- mark_cut(&mut b, face, 1, "header".into());
+ mark_cut(&mut b, face, 1, "upper_cap".into());
let center_tangent = frame.vector([
-((opening.inner_angles[0] + opening.inner_angles[1]) * 0.5).sin(),
@@ -2701,12 +3457,12 @@ pub fn circular_wall_with_openings(
.collect::, _>>()?;
let face = b.brep.topology.faces.len() as u32;
b.face(
- &format!("opening-{opening_index}-jamb-lower"),
+ &format!("opening-{opening_index}-side-left"),
SurfaceGeometry::Plane { frame: left_frame },
plane_bounds(&b, left_frame, [obl, ibl, itl, otl]),
left_uses,
)?;
- mark_cut(&mut b, face, 2, "jamb-lower".into());
+ mark_cut(&mut b, face, 2, "side-left".into());
let right_frame = Frame3::from_axis(
b.brep.topology.vertices[ibr as usize].position,
@@ -2727,12 +3483,12 @@ pub fn circular_wall_with_openings(
.collect::, _>>()?;
let face = b.brep.topology.faces.len() as u32;
b.face(
- &format!("opening-{opening_index}-jamb-upper"),
+ &format!("opening-{opening_index}-side-right"),
SurfaceGeometry::Plane { frame: right_frame },
plane_bounds(&b, right_frame, [ibr, obr, otr, itr]),
right_uses,
)?;
- mark_cut(&mut b, face, 3, "jamb-upper".into());
+ mark_cut(&mut b, face, 3, "side-right".into());
}
if !built_openings.is_empty() {
b.brep.topology.faces[0].provenance.role = FaceRole::Split;
@@ -2801,7 +3557,7 @@ fn arched_opening_top_edge(
}
}
Err(GeometryError::InvalidTopology(
- "arched opening header edge is missing".into(),
+ "arched opening cap edge is missing".into(),
))
}
@@ -2976,7 +3732,7 @@ fn upper_intersection_definition(
}
#[allow(clippy::too_many_arguments)]
-pub fn circular_wall_with_arched_opening(
+pub fn annular_sector_extrusion_with_arched_opening(
id: String,
frame: Frame3,
radius: f64,
@@ -2984,7 +3740,7 @@ pub fn circular_wall_with_arched_opening(
height: f64,
start_angle: f64,
sweep_angle: f64,
- opening: CircularWallOpening,
+ opening: AnnularSectorOpening,
accuracy: Accuracy,
) -> Result {
let arch_radius = opening.width * 0.5;
@@ -2995,7 +3751,7 @@ pub fn circular_wall_with_arched_opening(
));
}
let spring_elevation = opening.bottom + lower_height;
- let mut result = circular_wall_with_openings(
+ let mut result = annular_sector_extrusion_with_openings(
id,
frame,
radius,
@@ -3003,13 +3759,13 @@ pub fn circular_wall_with_arched_opening(
height,
start_angle,
sweep_angle,
- vec![CircularWallOpening {
+ vec![AnnularSectorOpening {
height: lower_height,
..opening.clone()
}],
accuracy,
)?;
- let plain = circular_wall(
+ let plain = annular_sector_extrusion(
"arched-opening-support".into(),
frame,
radius,
@@ -3059,15 +3815,13 @@ pub fn circular_wall_with_arched_opening(
let outer_edge = arched_opening_top_edge(&result, 0, frame, spring_elevation)?;
let inner_edge = arched_opening_top_edge(&result, 1, frame, spring_elevation)?;
- let header = result
+ let upper_cap = result
.topology
.faces
.iter()
- .find(|face| face.key == "opening-0-header")
+ .find(|face| face.key == "opening-0-upper_cap")
.map(|face| face.id)
- .ok_or_else(|| {
- GeometryError::InvalidTopology("arched opening header face missing".into())
- })?;
+ .ok_or_else(|| GeometryError::InvalidTopology("arched opening cap face missing".into()))?;
let cutter_surface = result.geometry.surfaces.len() as u32;
result
.geometry
@@ -3122,7 +3876,7 @@ pub fn circular_wall_with_arched_opening(
.curves
.push(CurveGeometry::Intersection { definition });
result.topology.edges[edge_id as usize].geometry = EdgeGeometry::Curve { curve, range };
- let wall_pcurve = result.geometry.pcurves.len() as u32;
+ let support_pcurve = result.geometry.pcurves.len() as u32;
result
.geometry
.pcurves
@@ -3141,8 +3895,8 @@ pub fn circular_wall_with_arched_opening(
for halfedge in &mut result.topology.halfedges {
if halfedge.edge == edge_id {
halfedge.geometry_use.pcurve = Some(if halfedge.face == Some(side as u32) {
- wall_pcurve
- } else if halfedge.face == Some(header) {
+ support_pcurve
+ } else if halfedge.face == Some(upper_cap) {
cutter_pcurve
} else {
return Err(GeometryError::InvalidTopology(
@@ -3154,9 +3908,10 @@ pub fn circular_wall_with_arched_opening(
}
let cutter_surface_geometry = result.geometry.surfaces[cutter_surface as usize].clone();
- for halfedge_id in
- loop_halfedges_for_primitive(&result, result.topology.faces[header as usize].trim.outer)?
- {
+ for halfedge_id in loop_halfedges_for_primitive(
+ &result,
+ result.topology.faces[upper_cap as usize].trim.outer,
+ )? {
let halfedge = result.topology.halfedges[halfedge_id as usize].clone();
if halfedge.edge == outer_edge || halfedge.edge == inner_edge {
continue;
@@ -3214,16 +3969,17 @@ pub fn circular_wall_with_arched_opening(
.geometry_use
.pcurve = Some(pcurve);
}
- result.topology.faces[header as usize].surface = cutter_surface;
- result.topology.faces[header as usize].sense = Orientation::Reverse;
- result.topology.faces[header as usize].key = "opening-0-arch".into();
+ result.topology.faces[upper_cap as usize].surface = cutter_surface;
+ result.topology.faces[upper_cap as usize].sense = Orientation::Reverse;
+ result.topology.faces[upper_cap as usize].key = "opening-0-arch".into();
let mut bounds = [
[f64::INFINITY, f64::NEG_INFINITY],
[f64::INFINITY, f64::NEG_INFINITY],
];
- for halfedge_id in
- loop_halfedges_for_primitive(&result, result.topology.faces[header as usize].trim.outer)?
- {
+ for halfedge_id in loop_halfedges_for_primitive(
+ &result,
+ result.topology.faces[upper_cap as usize].trim.outer,
+ )? {
let halfedge = &result.topology.halfedges[halfedge_id as usize];
let pcurve = halfedge.geometry_use.pcurve.unwrap();
let range = result.topology.edges[halfedge.edge as usize]
@@ -3238,7 +3994,7 @@ pub fn circular_wall_with_arched_opening(
}
}
}
- result.topology.faces[header as usize].trim.uv_bounds = [
+ result.topology.faces[upper_cap as usize].trim.uv_bounds = [
Interval::new(bounds[0][0], bounds[0][1])?,
Interval::new(bounds[1][0], bounds[1][1])?,
];
@@ -3478,6 +4234,296 @@ mod tests {
);
}
+ #[test]
+ fn arc_edged_extrusion_preserves_cylindrical_sides_for_both_sweep_signs() {
+ for sign in [1.0, -1.0] {
+ let sweep = sign * std::f64::consts::FRAC_PI_2;
+ let outer = 2.0;
+ let inner = 1.5;
+ let point = |radius: f64, angle: f64| [radius * angle.cos(), radius * angle.sin()];
+ let edges = vec![
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: outer,
+ start_angle: 0.0,
+ sweep_angle: sweep,
+ },
+ ProfileEdge::Line {
+ from: point(outer, sweep),
+ to: point(inner, sweep),
+ },
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: inner,
+ start_angle: sweep,
+ sweep_angle: -sweep,
+ },
+ ProfileEdge::Line {
+ from: point(inner, 0.0),
+ to: point(outer, 0.0),
+ },
+ ];
+ let body = arc_edged_extrusion(
+ format!("arc-strip-{sign}"),
+ Frame3::IDENTITY,
+ edges,
+ 3.0,
+ accuracy(),
+ )
+ .unwrap();
+ body.validate().unwrap();
+ assert_eq!(body.topology.faces.len(), 6);
+ assert_eq!(body.topology.shells.len(), 1);
+ assert_eq!(body.solids.len(), 1);
+ assert_eq!(
+ body.geometry
+ .surfaces
+ .iter()
+ .filter(|surface| matches!(surface, SurfaceGeometry::Cylinder { .. }))
+ .count(),
+ 2
+ );
+ assert_eq!(
+ body.geometry
+ .curves
+ .iter()
+ .filter(|curve| matches!(curve, CurveGeometry::Circle { .. }))
+ .count(),
+ 4
+ );
+ let middle = sweep * 0.5;
+ assert_eq!(
+ crate::analytic::query::classify_point(
+ &body,
+ [1.75 * middle.cos(), 1.75 * middle.sin(), 1.0],
+ )
+ .unwrap(),
+ crate::analytic::query::PointClassification::Inside,
+ );
+ assert_eq!(
+ crate::analytic::query::classify_point(
+ &body,
+ [1.0 * middle.cos(), 1.0 * middle.sin(), 1.0],
+ )
+ .unwrap(),
+ crate::analytic::query::PointClassification::Outside,
+ );
+ let mesh = crate::analytic::tessellation::tessellate(&body, 0.01, 100_000).unwrap();
+ assert!(!mesh.indices.is_empty());
+ }
+ }
+
+ #[test]
+ fn arc_edged_extrusion_preserves_an_analytic_profile_hole() {
+ let quarter = std::f64::consts::FRAC_PI_2;
+ let outer = vec![
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: 0.0,
+ sweep_angle: quarter,
+ },
+ ProfileEdge::Line {
+ from: [0.0, 2.0],
+ to: [0.0, 1.0],
+ },
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.0,
+ start_angle: quarter,
+ sweep_angle: -quarter,
+ },
+ ProfileEdge::Line {
+ from: [1.0, 0.0],
+ to: [2.0, 0.0],
+ },
+ ];
+ let hole = vec![
+ ProfileEdge::Arc {
+ center: [1.2, 1.2],
+ radius: 0.1,
+ start_angle: 0.0,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ ProfileEdge::Arc {
+ center: [1.2, 1.2],
+ radius: 0.1,
+ start_angle: -std::f64::consts::PI,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ ];
+ let body = arc_edged_extrusion_with_holes(
+ "arc-profile-hole".into(),
+ Frame3::IDENTITY,
+ outer,
+ vec![hole],
+ 3.0,
+ accuracy(),
+ )
+ .unwrap();
+ body.validate().unwrap();
+ assert_eq!(body.topology.faces[0].trim.holes.len(), 1);
+ assert_eq!(body.topology.faces[1].trim.holes.len(), 1);
+ assert_eq!(body.topology.shells.len(), 1);
+ assert_eq!(body.solids.len(), 1);
+ assert_eq!(
+ crate::analytic::query::classify_point(&body, [1.2, 1.2, 1.0]).unwrap(),
+ crate::analytic::query::PointClassification::Outside
+ );
+ assert_eq!(
+ crate::analytic::query::classify_point(&body, [1.4, 1.0, 1.0]).unwrap(),
+ crate::analytic::query::PointClassification::Inside
+ );
+ assert!(
+ !crate::analytic::tessellation::tessellate(&body, 0.01, 100_000)
+ .unwrap()
+ .indices
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn arc_edged_extrusion_rejects_holes_outside_or_crossing_its_profile() {
+ let quarter = std::f64::consts::FRAC_PI_2;
+ let outer = vec![
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: 0.0,
+ sweep_angle: quarter,
+ },
+ ProfileEdge::Line {
+ from: [0.0, 2.0],
+ to: [0.0, 1.0],
+ },
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.0,
+ start_angle: quarter,
+ sweep_angle: -quarter,
+ },
+ ProfileEdge::Line {
+ from: [1.0, 0.0],
+ to: [2.0, 0.0],
+ },
+ ];
+ let square = |x0, y0, x1, y1| {
+ let points = [[x0, y0], [x1, y0], [x1, y1], [x0, y1]];
+ (0..4)
+ .map(|index| ProfileEdge::Line {
+ from: points[index],
+ to: points[(index + 1) % 4],
+ })
+ .collect::>()
+ };
+ for hole in [square(3.0, 3.0, 3.2, 3.2), square(1.9, 0.1, 2.1, 0.3)] {
+ assert!(arc_edged_extrusion_with_holes(
+ "invalid-arc-hole".into(),
+ Frame3::IDENTITY,
+ outer.clone(),
+ vec![hole],
+ 3.0,
+ accuracy(),
+ )
+ .is_err());
+ }
+ }
+
+ #[test]
+ fn arc_edged_extrusion_rejects_exact_line_arc_crossings_and_overlap() {
+ let crossing = vec![
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.0,
+ start_angle: 0.0,
+ sweep_angle: std::f64::consts::PI,
+ },
+ ProfileEdge::Line {
+ from: [-1.0, 0.0],
+ to: [0.0, 1.2],
+ },
+ ProfileEdge::Line {
+ from: [0.0, 1.2],
+ to: [1.0, 0.0],
+ },
+ ];
+ assert!(arc_edged_extrusion(
+ "crossing".into(),
+ Frame3::IDENTITY,
+ crossing,
+ 2.0,
+ accuracy(),
+ )
+ .is_err());
+ let overlapping = vec![
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.0,
+ start_angle: 0.0,
+ sweep_angle: std::f64::consts::PI,
+ },
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.0,
+ start_angle: std::f64::consts::PI,
+ sweep_angle: -std::f64::consts::FRAC_PI_2,
+ },
+ ProfileEdge::Line {
+ from: [0.0, 1.0],
+ to: [1.0, 0.0],
+ },
+ ];
+ assert!(arc_edged_extrusion(
+ "overlap".into(),
+ Frame3::IDENTITY,
+ overlapping,
+ 2.0,
+ accuracy(),
+ )
+ .is_err());
+ }
+
+ #[test]
+ fn arc_edged_extrusion_accepts_nonradial_join_caps() {
+ let outer_end = std::f64::consts::FRAC_PI_2;
+ let inner_start = outer_end - 0.1;
+ let inner_end = 0.1;
+ let point = |radius: f64, angle: f64| [radius * angle.cos(), radius * angle.sin()];
+ let profile = vec![
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: 0.0,
+ sweep_angle: outer_end,
+ },
+ ProfileEdge::Line {
+ from: point(2.0, outer_end),
+ to: point(1.5, inner_start),
+ },
+ ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 1.5,
+ start_angle: inner_start,
+ sweep_angle: inner_end - inner_start,
+ },
+ ProfileEdge::Line {
+ from: point(1.5, inner_end),
+ to: point(2.0, 0.0),
+ },
+ ];
+ let body = arc_edged_extrusion(
+ "curved-join".into(),
+ Frame3::IDENTITY,
+ profile,
+ 2.4,
+ accuracy(),
+ )
+ .unwrap();
+ body.validate().unwrap();
+ assert_eq!(body.topology.faces.len(), 6);
+ assert_eq!(body.solids.len(), 1);
+ }
+
#[test]
fn linear_extrusion_rejects_invalid_profile_relationships() {
let square = vec![[0.0, 0.0], [4.0, 0.0], [4.0, 4.0], [0.0, 4.0]];
@@ -3576,7 +4622,7 @@ mod tests {
assert!(cylinder("c".into(), Frame3::IDENTITY, 1.0, 0.0, accuracy()).is_err());
}
#[test]
- fn gallery_and_arbitrary_wall_parameters_round_trip_without_changing_geometry() {
+ fn gallery_and_arbitrary_host_parameters_round_trip_without_changing_geometry() {
let frame = Frame3 {
y: [0.0, 0.0, -1.0],
z: [0.0, 1.0, 0.0],
@@ -3592,8 +4638,8 @@ mod tests {
for start in [0.15, 0.7, -2.3] {
for sweep in [1.8, -1.8, 0.33] {
bodies.push(
- circular_wall(
- "wall".into(),
+ annular_sector_extrusion(
+ "host".into(),
frame,
2.4,
0.3,
@@ -3614,11 +4660,20 @@ mod tests {
}
}
#[test]
- fn circular_walls_preserve_analytic_boundaries_and_authored_end_identity() {
+ fn annular_sector_extrusions_preserve_analytic_boundaries_and_authored_end_identity() {
let frame = Frame3::from_axis([10.0, -4.0, 6.0], [1.0, 2.0, 3.0], [1.0, 0.0, 0.0]).unwrap();
for sweep in [1.8, -1.8] {
- let b =
- circular_wall("wall".into(), frame, 3.0, 0.4, 2.5, 0.7, sweep, accuracy()).unwrap();
+ let b = annular_sector_extrusion(
+ "host".into(),
+ frame,
+ 3.0,
+ 0.4,
+ 2.5,
+ 0.7,
+ sweep,
+ accuracy(),
+ )
+ .unwrap();
assert_eq!(b.topology.faces.len(), 6);
assert_eq!(b.topology.vertices.len(), 8);
assert_eq!(b.topology.edges.len(), 12);
@@ -3646,9 +4701,19 @@ mod tests {
assert!((dot(normal, tangent) + sweep.signum()).abs() < 1e-12);
BrepEnvelope::from_json(&b.to_json().unwrap()).unwrap();
}
- assert!(circular_wall("wall".into(), frame, 1.0, 2.0, 1.0, 0.0, 1.0, accuracy()).is_err());
- assert!(circular_wall(
- "wall".into(),
+ assert!(annular_sector_extrusion(
+ "host".into(),
+ frame,
+ 1.0,
+ 2.0,
+ 1.0,
+ 0.0,
+ 1.0,
+ accuracy()
+ )
+ .is_err());
+ assert!(annular_sector_extrusion(
+ "host".into(),
frame,
1.0,
0.2,
@@ -3660,25 +4725,25 @@ mod tests {
.is_err());
}
#[test]
- fn circular_wall_openings_are_exact_trimmed_cylinder_holes() {
+ fn annular_sector_extrusion_openings_are_exact_trimmed_cylinder_holes() {
let openings = vec![
- CircularWallOpening {
- id: "window-a".into(),
+ AnnularSectorOpening {
+ id: "raised_cutout-a".into(),
angle: 0.65,
width: 0.9,
bottom: 0.7,
height: 1.2,
},
- CircularWallOpening {
- id: "window-b".into(),
+ AnnularSectorOpening {
+ id: "raised_cutout-b".into(),
angle: 1.45,
width: 0.6,
bottom: 1.4,
height: 0.8,
},
];
- let wall = circular_wall_with_openings(
- "wall".into(),
+ let host = annular_sector_extrusion_with_openings(
+ "host".into(),
Frame3::IDENTITY,
3.0,
0.4,
@@ -3689,26 +4754,26 @@ mod tests {
accuracy(),
)
.unwrap();
- wall.validate().unwrap();
- assert_eq!(wall.topology.faces.len(), 14);
- assert_eq!(wall.topology.faces[0].trim.holes.len(), 2);
- assert_eq!(wall.topology.faces[1].trim.holes.len(), 2);
- assert_eq!(wall.topology.faces[0].provenance.role, FaceRole::Split);
- assert_eq!(wall.topology.faces[1].provenance.role, FaceRole::Split);
+ host.validate().unwrap();
+ assert_eq!(host.topology.faces.len(), 14);
+ assert_eq!(host.topology.faces[0].trim.holes.len(), 2);
+ assert_eq!(host.topology.faces[1].trim.holes.len(), 2);
+ assert_eq!(host.topology.faces[0].provenance.role, FaceRole::Split);
+ assert_eq!(host.topology.faces[1].provenance.role, FaceRole::Split);
assert_eq!(
- wall.topology
+ host.topology
.faces
.iter()
.filter(|face| face.provenance.role == FaceRole::Cut)
.count(),
8
);
- assert!(wall.geometry.surfaces.iter().all(|surface| matches!(
+ assert!(host.geometry.surfaces.iter().all(|surface| matches!(
surface,
SurfaceGeometry::Cylinder { .. } | SurfaceGeometry::Plane { .. }
)));
- let coarse = crate::analytic::tessellation::tessellate(&wall, 0.04, 100_000).unwrap();
- let mesh = crate::analytic::tessellation::tessellate(&wall, 0.01, 100_000).unwrap();
+ let coarse = crate::analytic::tessellation::tessellate(&host, 0.04, 100_000).unwrap();
+ let mesh = crate::analytic::tessellation::tessellate(&host, 0.01, 100_000).unwrap();
assert!(mesh.indices.len() > coarse.indices.len());
let positions = mesh
.positions
@@ -3723,18 +4788,18 @@ mod tests {
point[1].to_bits(),
point[2].to_bits(),
])));
- BrepEnvelope::from_json(&wall.to_json().unwrap()).unwrap();
+ BrepEnvelope::from_json(&host.to_json().unwrap()).unwrap();
- let door = circular_wall_with_openings(
- "wall".into(),
+ let lower_cutout = annular_sector_extrusion_with_openings(
+ "host".into(),
Frame3::IDENTITY,
3.0,
0.4,
3.0,
0.0,
2.1,
- vec![CircularWallOpening {
- id: "door".into(),
+ vec![AnnularSectorOpening {
+ id: "lower_cutout".into(),
angle: 1.0,
width: 0.9,
bottom: 0.0,
@@ -3743,11 +4808,12 @@ mod tests {
accuracy(),
)
.unwrap();
- door.validate().unwrap();
- assert_eq!(door.topology.faces[0].trim.holes.len(), 0);
- assert_eq!(door.topology.faces[1].trim.holes.len(), 0);
+ lower_cutout.validate().unwrap();
+ assert_eq!(lower_cutout.topology.faces[0].trim.holes.len(), 0);
+ assert_eq!(lower_cutout.topology.faces[1].trim.holes.len(), 0);
assert_eq!(
- door.topology
+ lower_cutout
+ .topology
.faces
.iter()
.filter(|face| face.key.starts_with("bottom-"))
@@ -3755,22 +4821,23 @@ mod tests {
2
);
assert_eq!(
- door.topology
+ lower_cutout
+ .topology
.faces
.iter()
.filter(|face| face.provenance.role == FaceRole::Cut)
.count(),
3
);
- crate::analytic::tessellation::tessellate(&door, 0.01, 100_000).unwrap();
- BrepEnvelope::from_json(&door.to_json().unwrap()).unwrap();
+ crate::analytic::tessellation::tessellate(&lower_cutout, 0.01, 100_000).unwrap();
+ BrepEnvelope::from_json(&lower_cutout.to_json().unwrap()).unwrap();
}
#[test]
- fn circular_wall_horizontal_opening_faces_bound_curved_edges() {
+ fn annular_sector_extrusion_horizontal_opening_faces_bound_curved_edges() {
let sweep = -std::f64::consts::TAU / 3.0;
- let wall = circular_wall_with_openings(
- "example-wall".into(),
+ let host = annular_sector_extrusion_with_openings(
+ "example-host".into(),
Frame3::IDENTITY,
2.4,
0.3,
@@ -3778,15 +4845,15 @@ mod tests {
0.0,
sweep,
vec![
- CircularWallOpening {
- id: "door".into(),
+ AnnularSectorOpening {
+ id: "lower_cutout".into(),
angle: sweep * 0.22,
width: 0.7,
bottom: 0.0,
height: 1.05,
},
- CircularWallOpening {
- id: "window".into(),
+ AnnularSectorOpening {
+ id: "raised_cutout".into(),
angle: sweep * 0.76,
width: 0.7,
bottom: 0.35,
@@ -3796,8 +4863,8 @@ mod tests {
accuracy(),
)
.unwrap();
- wall.validate().unwrap();
- crate::analytic::tessellation::tessellate(&wall, 0.01, 100_000).unwrap();
+ host.validate().unwrap();
+ crate::analytic::tessellation::tessellate(&host, 0.01, 100_000).unwrap();
}
#[test]
fn body_bounds_cover_curved_extrema_absent_from_vertices() {
diff --git a/main/opengeometry/src/analytic/query.rs b/main/opengeometry/src/analytic/query.rs
index 9a2c37b..4bf0e2d 100644
--- a/main/opengeometry/src/analytic/query.rs
+++ b/main/opengeometry/src/analytic/query.rs
@@ -53,7 +53,20 @@ fn trim_samples(brep: &BrepEnvelope, face: &Face, loop_id: u32) -> Result 1,
+ PcurveGeometry::Line2 { direction, .. } => {
+ let periods =
+ brep.geometry.surface(face.surface)?.charts()[face.trim.chart as usize].periods;
+ if periods.iter().enumerate().any(|(axis, period)| {
+ period.is_some_and(|period| {
+ ((direction[axis] * range.width()).abs() - period).abs()
+ <= 4.0 * brep.accuracy.geometric
+ })
+ }) {
+ 64
+ } else {
+ 1
+ }
+ }
PcurveGeometry::Conic2 { .. } => 64,
PcurveGeometry::ProjectedCurve { .. } | PcurveGeometry::IntersectionSide { .. } => 128,
};
@@ -128,6 +141,36 @@ fn lift_to_face(surface: &SurfaceGeometry, face: &Face, mut uv: UV) -> UV {
uv
}
+fn periodic_cylinder_band_limit(
+ brep: &BrepEnvelope,
+ face: &Face,
+ loop_id: u32,
+) -> Result, GeometryError> {
+ let loop_ = &brep.topology.loops[loop_id as usize];
+ let use_ = &brep.topology.halfedges[loop_.start_halfedge as usize];
+ if use_.next != Some(loop_.start_halfedge) || use_.from != use_.to {
+ return Ok(None);
+ }
+ let edge = &brep.topology.edges[use_.edge as usize];
+ let range = edge.geometry.range();
+ let Some(pcurve) = use_.geometry_use.pcurve else {
+ return Ok(None);
+ };
+ let PcurveGeometry::Line2 { direction, .. } = brep.geometry.pcurves[pcurve as usize] else {
+ return Ok(None);
+ };
+ let period = brep.geometry.surface(face.surface)?.charts()[face.trim.chart as usize].periods[0];
+ let Some(period) = period else {
+ return Ok(None);
+ };
+ if direction[1].abs() * range.width() > brep.accuracy.geometric
+ || ((direction[0] * range.width()).abs() - period).abs() > 4.0 * brep.accuracy.geometric
+ {
+ return Ok(None);
+ }
+ Ok(Some(lifted_uv(brep, loop_.start_halfedge, range.lo)?[1]))
+}
+
pub(crate) fn face_contains_uv(
brep: &BrepEnvelope,
face: &Face,
@@ -145,6 +188,19 @@ pub(crate) fn face_contains_uv(
if !face.trim.uv_bounds[0].contains(uv[0]) || !face.trim.uv_bounds[1].contains(uv[1]) {
return Ok(Some(false));
}
+ if matches!(surface, SurfaceGeometry::Cylinder { .. }) && face.trim.holes.len() == 1 {
+ if let (Some(outer), Some(inner)) = (
+ periodic_cylinder_band_limit(brep, face, face.trim.outer)?,
+ periodic_cylinder_band_limit(brep, face, face.trim.holes[0])?,
+ ) {
+ let lo = outer.min(inner);
+ let hi = outer.max(inner);
+ if (uv[1] - lo).abs() <= uv_tolerance || (uv[1] - hi).abs() <= uv_tolerance {
+ return Ok(None);
+ }
+ return Ok(Some(uv[1] > lo && uv[1] < hi));
+ }
+ }
let outer = trim_samples(brep, face, face.trim.outer)?;
let Some(mut inside) = in_loop(uv, &outer, uv_tolerance) else {
return Ok(None);
@@ -299,7 +355,7 @@ fn ray_box_domain(
origin: Point3,
direction: Point3,
) -> Result , GeometryError> {
- let Some(bounds) = brep.bounds()? else {
+ let Some(bounds) = brep.bounds_unchecked()? else {
return Ok(None);
};
let mut lo: f64 = 0.0;
@@ -326,16 +382,17 @@ fn ray_box_domain(
}
}
-fn classify_with_ray(
+fn classify_with_ray<'a>(
brep: &BrepEnvelope,
point: Point3,
direction: Point3,
+ faces: impl Iterator- ,
) -> Result
{
let Some(domain) = ray_box_domain(brep, point, direction)? else {
return Ok(PointClassification::Outside);
};
let mut hits: Vec<(f64, Point3)> = Vec::new();
- for face in &brep.topology.faces {
+ for face in faces {
let surface = brep.geometry.surface(face.surface)?;
let Some(roots) = support_roots(
surface,
@@ -395,6 +452,13 @@ pub fn classify_point(
point: Point3,
) -> Result {
brep.validate()?;
+ classify_point_validated(brep, point)
+}
+
+pub(crate) fn classify_point_validated(
+ brep: &BrepEnvelope,
+ point: Point3,
+) -> Result {
if point.into_iter().any(|value| !value.is_finite()) {
return Err(GeometryError::InvalidGeometry(
"point classification requires finite coordinates".into(),
@@ -406,7 +470,32 @@ pub fn classify_point(
unit([0.311, 0.233, 1.0])?,
];
for direction in directions {
- let result = classify_with_ray(brep, point, direction)?;
+ let result = classify_with_ray(brep, point, direction, brep.topology.faces.iter())?;
+ if result != PointClassification::Unknown {
+ return Ok(result);
+ }
+ }
+ Ok(PointClassification::Unknown)
+}
+
+pub(crate) fn classify_point_in_shell(
+ brep: &BrepEnvelope,
+ faces: &[u32],
+ point: Point3,
+) -> Result {
+ for direction in [
+ unit([1.0, 0.371, 0.127])?,
+ unit([0.193, 1.0, 0.419])?,
+ unit([0.311, 0.233, 1.0])?,
+ ] {
+ let result = classify_with_ray(
+ brep,
+ point,
+ direction,
+ faces
+ .iter()
+ .map(|face| &brep.topology.faces[*face as usize]),
+ )?;
if result != PointClassification::Unknown {
return Ok(result);
}
diff --git a/main/opengeometry/src/analytic/tessellation.rs b/main/opengeometry/src/analytic/tessellation.rs
index 8636faf..4798e3c 100644
--- a/main/opengeometry/src/analytic/tessellation.rs
+++ b/main/opengeometry/src/analytic/tessellation.rs
@@ -1718,8 +1718,8 @@ mod tests {
primitives::frustum("f".into(), Frame3::IDENTITY, 2.0, 1.0, 3.0, accuracy()).unwrap(),
primitives::sphere("s".into(), Frame3::IDENTITY, 1.0, accuracy()).unwrap(),
primitives::torus("t".into(), Frame3::IDENTITY, 3.0, 1.0, accuracy()).unwrap(),
- primitives::circular_wall(
- "wall".into(),
+ primitives::annular_sector_extrusion(
+ "annular-sector".into(),
Frame3::IDENTITY,
3.0,
0.4,
@@ -1828,10 +1828,10 @@ mod tests {
assert_eq!(original.outline_edge_ids, mesh.outline_edge_ids);
}
#[test]
- fn thin_circular_wall_keeps_its_footprint_at_coarse_deflection() {
+ fn thin_annular_sector_extrusion_keeps_its_footprint_at_coarse_deflection() {
let sweep = 7.0 * (0.2_f64 / 3.0).sqrt();
let thickness = 0.0001;
- let b = primitives::circular_wall(
+ let b = primitives::annular_sector_extrusion(
"thin".into(),
Frame3::IDENTITY,
3.0,
diff --git a/main/opengeometry/src/analytic/topology.rs b/main/opengeometry/src/analytic/topology.rs
index 2dc94ec..6d5bdf3 100644
--- a/main/opengeometry/src/analytic/topology.rs
+++ b/main/opengeometry/src/analytic/topology.rs
@@ -455,6 +455,10 @@ impl BrepEnvelope {
pub fn bounds(&self) -> Result, GeometryError> {
self.validate()?;
+ self.bounds_unchecked()
+ }
+
+ pub(crate) fn bounds_unchecked(&self) -> Result , GeometryError> {
let mut result: Option = None;
let mut include = |bounds: PatchBounds| {
result = Some(match result {
diff --git a/main/opengeometry/src/analytic/wasm.rs b/main/opengeometry/src/analytic/wasm.rs
index bae0c6a..0844d45 100644
--- a/main/opengeometry/src/analytic/wasm.rs
+++ b/main/opengeometry/src/analytic/wasm.rs
@@ -1,5 +1,5 @@
use super::{
- booleans::{boolean_brep, shell_brep, BooleanOp, BooleanReport},
+ booleans::{boolean_brep, shell_brep, subtract_planar_cutters, BooleanOp, BooleanReport},
primitives,
query::{classify_point, PointClassification},
tessellation::{Tessellation, TessellationCache},
@@ -21,7 +21,7 @@ struct TessellationOptions {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
-struct CircularWallOpeningConfig {
+struct AnnularSectorOpeningConfig {
id: String,
angle: f64,
width: f64,
@@ -31,7 +31,7 @@ struct CircularWallOpeningConfig {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
-struct StraightWallArchedOpeningConfig {
+struct BoxArchedOpeningConfig {
id: String,
station: f64,
width: f64,
@@ -97,6 +97,15 @@ enum PrimitiveConfig {
holes: Vec>,
height: f64,
},
+ ArcEdgedExtrusion {
+ id: String,
+ frame: Frame3,
+ accuracy: Accuracy,
+ outer: Vec,
+ #[serde(default)]
+ holes: Vec>,
+ height: f64,
+ },
PolygonLoft {
id: String,
frame: Frame3,
@@ -105,14 +114,14 @@ enum PrimitiveConfig {
upper: Vec<[f64; 3]>,
alignment: PolygonLoftAlignmentConfig,
},
- StraightWallWithArchedOpening {
+ BoxWithArchedOpening {
id: String,
frame: Frame3,
accuracy: Accuracy,
width: f64,
depth: f64,
height: f64,
- opening: StraightWallArchedOpeningConfig,
+ opening: BoxArchedOpeningConfig,
},
PlanarPolyhedron {
id: String,
@@ -182,7 +191,7 @@ enum PrimitiveConfig {
major_radius: f64,
minor_radius: f64,
},
- CircularWall {
+ AnnularSectorExtrusion {
id: String,
frame: Frame3,
accuracy: Accuracy,
@@ -192,7 +201,7 @@ enum PrimitiveConfig {
start_angle: f64,
sweep_angle: f64,
},
- CircularWallWithOpenings {
+ AnnularSectorExtrusionWithOpenings {
id: String,
frame: Frame3,
accuracy: Accuracy,
@@ -201,9 +210,9 @@ enum PrimitiveConfig {
height: f64,
start_angle: f64,
sweep_angle: f64,
- openings: Vec,
+ openings: Vec,
},
- CircularWallWithArchedOpening {
+ AnnularSectorExtrusionWithArchedOpening {
id: String,
frame: Frame3,
accuracy: Accuracy,
@@ -212,7 +221,7 @@ enum PrimitiveConfig {
height: f64,
start_angle: f64,
sweep_angle: f64,
- opening: CircularWallOpeningConfig,
+ opening: AnnularSectorOpeningConfig,
},
CoaxialCircleLoft {
id: String,
@@ -263,6 +272,16 @@ impl PrimitiveConfig {
holes,
height,
} => primitives::linear_extrusion(id, frame, outer, holes, height, accuracy),
+ Self::ArcEdgedExtrusion {
+ id,
+ frame,
+ accuracy,
+ outer,
+ holes,
+ height,
+ } => primitives::arc_edged_extrusion_with_holes(
+ id, frame, outer, holes, height, accuracy,
+ ),
Self::PolygonLoft {
id,
frame,
@@ -286,7 +305,7 @@ impl PrimitiveConfig {
},
accuracy,
),
- Self::StraightWallWithArchedOpening {
+ Self::BoxWithArchedOpening {
id,
frame,
accuracy,
@@ -294,13 +313,13 @@ impl PrimitiveConfig {
depth,
height,
opening,
- } => primitives::straight_wall_with_arched_opening(
+ } => primitives::box_with_arched_opening(
id,
frame,
width,
depth,
height,
- primitives::StraightWallArchedOpening {
+ primitives::BoxArchedOpening {
id: opening.id,
station: opening.station,
width: opening.width,
@@ -412,7 +431,7 @@ impl PrimitiveConfig {
major_radius,
minor_radius,
} => primitives::torus(id, frame, major_radius, minor_radius, accuracy),
- Self::CircularWall {
+ Self::AnnularSectorExtrusion {
id,
frame,
accuracy,
@@ -421,7 +440,7 @@ impl PrimitiveConfig {
height,
start_angle,
sweep_angle,
- } => primitives::circular_wall(
+ } => primitives::annular_sector_extrusion(
id,
frame,
radius,
@@ -431,7 +450,7 @@ impl PrimitiveConfig {
sweep_angle,
accuracy,
),
- Self::CircularWallWithOpenings {
+ Self::AnnularSectorExtrusionWithOpenings {
id,
frame,
accuracy,
@@ -441,7 +460,7 @@ impl PrimitiveConfig {
start_angle,
sweep_angle,
openings,
- } => primitives::circular_wall_with_openings(
+ } => primitives::annular_sector_extrusion_with_openings(
id,
frame,
radius,
@@ -451,7 +470,7 @@ impl PrimitiveConfig {
sweep_angle,
openings
.into_iter()
- .map(|opening| primitives::CircularWallOpening {
+ .map(|opening| primitives::AnnularSectorOpening {
id: opening.id,
angle: opening.angle,
width: opening.width,
@@ -461,7 +480,7 @@ impl PrimitiveConfig {
.collect(),
accuracy,
),
- Self::CircularWallWithArchedOpening {
+ Self::AnnularSectorExtrusionWithArchedOpening {
id,
frame,
accuracy,
@@ -471,7 +490,7 @@ impl PrimitiveConfig {
start_angle,
sweep_angle,
opening,
- } => primitives::circular_wall_with_arched_opening(
+ } => primitives::annular_sector_extrusion_with_arched_opening(
id,
frame,
radius,
@@ -479,7 +498,7 @@ impl PrimitiveConfig {
height,
start_angle,
sweep_angle,
- primitives::CircularWallOpening {
+ primitives::AnnularSectorOpening {
id: opening.id,
angle: opening.angle,
width: opening.width,
@@ -709,6 +728,25 @@ impl OGAnalyticBrep {
})
}
+ pub fn subtract_planar_cutters(
+ &self,
+ cutters_json: &str,
+ id: &str,
+ ) -> Result {
+ if cutters_json.len() > 16 * 1024 * 1024 {
+ return Err(js_error(GeometryError::LimitExceeded(
+ "planar cutter payload exceeds 16 MiB".into(),
+ )));
+ }
+ let cutters: Vec = serde_json::from_str(cutters_json)
+ .map_err(|error| js_error(GeometryError::InvalidGeometry(error.to_string())))?;
+ let result = subtract_planar_cutters(&self.brep, &cutters, id.into()).map_err(js_error)?;
+ Ok(Self {
+ brep: result.brep,
+ boolean_report: Some(result.report),
+ })
+ }
+
pub fn shell(&self, thickness: f64, id: &str) -> Result {
let result = shell_brep(&self.brep, thickness, id.into()).map_err(js_error)?;
Ok(Self {
@@ -829,6 +867,44 @@ mod tests {
assert!(serde_json::from_value::(incomplete).is_err());
}
+ #[test]
+ fn arc_edged_extrusion_config_preserves_circle_edges() {
+ let config = serde_json::json!({
+ "kind": "arc_edged_extrusion",
+ "id": "curved-profile",
+ "frame": Frame3::IDENTITY,
+ "accuracy": {
+ "geometric": 1e-9,
+ "intersection": 1e-10,
+ "tessellation": 0.001,
+ "exchange": 1e-5
+ },
+ "outer": [
+ {"kind":"arc","center":[0.0,0.0],"radius":2.0,"start_angle":0.0,"sweep_angle":1.5707963267948966},
+ {"kind":"line","from":[0.0,2.0],"to":[0.0,1.5]},
+ {"kind":"arc","center":[0.0,0.0],"radius":1.5,"start_angle":1.5707963267948966,"sweep_angle":-1.5707963267948966},
+ {"kind":"line","from":[1.5,0.0],"to":[2.0,0.0]}
+ ],
+ "height": 3.0
+ });
+ let body = serde_json::from_value::(config)
+ .unwrap()
+ .build()
+ .unwrap();
+ assert_eq!(body.topology.faces.len(), 6);
+ assert_eq!(
+ body.geometry
+ .surfaces
+ .iter()
+ .filter(|surface| matches!(
+ surface,
+ crate::analytic::SurfaceGeometry::Cylinder { .. }
+ ))
+ .count(),
+ 2
+ );
+ }
+
#[test]
fn polygon_loft_config_builds_capped_planar_brep() {
let config = serde_json::json!({
@@ -854,10 +930,10 @@ mod tests {
}
#[test]
- fn circular_wall_opening_config_is_strict_and_analytic() {
+ fn annular_sector_extrusion_opening_config_is_strict_and_analytic() {
let config = serde_json::json!({
- "kind": "circular_wall_with_openings",
- "id": "wall",
+ "kind": "annular_sector_extrusion_with_openings",
+ "id": "host",
"frame": Frame3::IDENTITY,
"accuracy": {
"geometric": 1e-9,
@@ -871,7 +947,7 @@ mod tests {
"start_angle": 0.0,
"sweep_angle": 2.0,
"openings": [{
- "id": "window",
+ "id": "raised_cutout",
"angle": 1.0,
"width": 0.8,
"bottom": 0.9,
diff --git a/main/opengeometry/src/export/projection.rs b/main/opengeometry/src/export/projection.rs
index 304ff8e..8110a4f 100644
--- a/main/opengeometry/src/export/projection.rs
+++ b/main/opengeometry/src/export/projection.rs
@@ -914,7 +914,7 @@ mod tests {
#[test]
fn analytic_projection_uses_v2_edges_and_face_normals() {
let brep = crate::analytic::primitives::cuboid(
- "wall-volume".into(),
+ "prism-volume".into(),
crate::analytic::Frame3::IDENTITY,
[4.0, 0.2, 3.0],
crate::analytic::topology::Accuracy {
@@ -941,7 +941,7 @@ mod tests {
.iter()
.any(|segment| segment.class == EdgeClass::VisibleOutline));
assert!(projected.segments.iter().all(|segment| {
- segment.source_entity_id.as_deref() == Some("wall-volume")
+ segment.source_entity_id.as_deref() == Some("prism-volume")
&& matches!(segment.geometry, Segment2D::Line { .. })
}));
}
diff --git a/main/opengeometry/src/geometry/boolean2d.rs b/main/opengeometry/src/geometry/boolean2d.rs
index b89b08e..e93ae67 100644
--- a/main/opengeometry/src/geometry/boolean2d.rs
+++ b/main/opengeometry/src/geometry/boolean2d.rs
@@ -268,10 +268,20 @@ fn winding_regions_by(
}
}
}
+ regions_from_edges_by(&edges, eps, filled)
+}
+
+/// Arrange closed contour edges and open intersection segments, classifying
+/// both sides of every segment with the supplied material predicate.
+pub fn regions_from_edges_by(
+ edges: &[Edge2],
+ eps: f64,
+ filled: impl Fn(Pt2) -> bool,
+) -> Vec {
if edges.is_empty() {
return Vec::new();
}
- let verts: Vec = edges.iter().map(|e| e.0).collect();
+ let verts: Vec = edges.iter().flat_map(|&(a, b)| [a, b]).collect();
// 2. split every edge at all crossings and on-edge vertices.
let mut split: Vec = Vec::new();
@@ -445,6 +455,22 @@ mod tests {
ring
}
+ #[test]
+ fn open_section_segment_partitions_a_classified_face() {
+ let square = ccw_square(0.0, 0.0, 2.0, 2.0);
+ let mut edges = square
+ .iter()
+ .enumerate()
+ .map(|(index, &point)| (point, square[(index + 1) % square.len()]))
+ .collect::>();
+ edges.push((p(1.0, 0.0), p(1.0, 2.0)));
+ let regions = regions_from_edges_by(&edges, DEFAULT_EPS, |point| {
+ point.x > 0.0 && point.x < 1.0 && point.z > 0.0 && point.z < 2.0
+ });
+ assert_eq!(regions.len(), 1);
+ assert!((signed_area2(®ions[0].outer) - 2.0).abs() < 1.0e-9);
+ }
+
/// A CW cutter strictly inside a CCW base subtracts to an annulus:
/// one region whose hole is the cutter.
#[test]
diff --git a/main/opengeometry/src/geometry/curved_boolean2d.rs b/main/opengeometry/src/geometry/curved_boolean2d.rs
new file mode 100644
index 0000000..b7ab2af
--- /dev/null
+++ b/main/opengeometry/src/geometry/curved_boolean2d.rs
@@ -0,0 +1,1012 @@
+//! Analytic 2D region Boolean for closed line/arc rings in the XZ plane.
+//! Curves are split only at exact line/circle and circle/circle intersections;
+//! no chords are introduced into the returned boundary.
+
+use super::boolean2d::PlanarBooleanOp;
+use super::poly2d::Pt2;
+use serde::{Deserialize, Serialize};
+use std::collections::{HashMap, HashSet};
+use std::f64::consts::TAU;
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+pub enum CurveEdge2 {
+ Line {
+ from: [f64; 2],
+ to: [f64; 2],
+ },
+ Arc {
+ center: [f64; 2],
+ radius: f64,
+ start_angle: f64,
+ sweep_angle: f64,
+ },
+}
+
+#[derive(Clone, Debug, Deserialize, Serialize)]
+#[serde(deny_unknown_fields)]
+pub struct CurveRegion2 {
+ pub outer: Vec,
+ #[serde(default)]
+ pub holes: Vec>,
+}
+
+fn p(a: [f64; 2]) -> Pt2 {
+ Pt2::new(a[0], a[1])
+}
+fn arr(a: Pt2) -> [f64; 2] {
+ [a.x, a.z]
+}
+fn add(a: Pt2, b: Pt2) -> Pt2 {
+ Pt2::new(a.x + b.x, a.z + b.z)
+}
+fn sub(a: Pt2, b: Pt2) -> Pt2 {
+ Pt2::new(a.x - b.x, a.z - b.z)
+}
+fn scale(a: Pt2, s: f64) -> Pt2 {
+ Pt2::new(a.x * s, a.z * s)
+}
+fn dot(a: Pt2, b: Pt2) -> f64 {
+ a.x * b.x + a.z * b.z
+}
+fn cross(a: Pt2, b: Pt2) -> f64 {
+ a.x * b.z - a.z * b.x
+}
+fn distance(a: Pt2, b: Pt2) -> f64 {
+ sub(a, b).x.hypot(sub(a, b).z)
+}
+
+fn compare_points(a: Pt2, b: Pt2) -> std::cmp::Ordering {
+ a.x.total_cmp(&b.x).then_with(|| a.z.total_cmp(&b.z))
+}
+
+fn canonicalize_ring(ring: &mut Vec) {
+ if let Some((index, _)) = ring.iter().enumerate().min_by(|(_, first), (_, second)| {
+ compare_points(first.point(0.0), second.point(0.0))
+ .then_with(|| compare_points(first.point(0.5), second.point(0.5)))
+ .then_with(|| compare_points(first.point(1.0), second.point(1.0)))
+ }) {
+ ring.rotate_left(index);
+ }
+}
+
+impl CurveEdge2 {
+ pub fn point(&self, t: f64) -> Pt2 {
+ match self {
+ Self::Line { from, to } => add(p(*from), scale(sub(p(*to), p(*from)), t)),
+ Self::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => {
+ let angle = start_angle + sweep_angle * t;
+ Pt2::new(
+ center[0] + radius * angle.cos(),
+ center[1] + radius * angle.sin(),
+ )
+ }
+ }
+ }
+
+ pub(crate) fn tangent(&self, t: f64) -> Pt2 {
+ match self {
+ Self::Line { from, to } => sub(p(*to), p(*from)),
+ Self::Arc {
+ radius,
+ start_angle,
+ sweep_angle,
+ ..
+ } => {
+ let angle = start_angle + sweep_angle * t;
+ Pt2::new(
+ -radius * sweep_angle * angle.sin(),
+ radius * sweep_angle * angle.cos(),
+ )
+ }
+ }
+ }
+
+ pub(crate) fn length(&self) -> f64 {
+ match self {
+ Self::Line { .. } => distance(self.point(0.0), self.point(1.0)),
+ Self::Arc {
+ radius,
+ sweep_angle,
+ ..
+ } => radius * sweep_angle.abs(),
+ }
+ }
+
+ pub(crate) fn reverse(&self) -> Self {
+ match self {
+ Self::Line { from, to } => Self::Line {
+ from: *to,
+ to: *from,
+ },
+ Self::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => Self::Arc {
+ center: *center,
+ radius: *radius,
+ start_angle: start_angle + sweep_angle,
+ sweep_angle: -*sweep_angle,
+ },
+ }
+ }
+
+ pub(crate) fn slice(&self, a: f64, b: f64) -> Self {
+ match self {
+ Self::Line { .. } => Self::Line {
+ from: arr(self.point(a)),
+ to: arr(self.point(b)),
+ },
+ Self::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => Self::Arc {
+ center: *center,
+ radius: *radius,
+ start_angle: start_angle + sweep_angle * a,
+ sweep_angle: sweep_angle * (b - a),
+ },
+ }
+ }
+
+ pub(crate) fn parameter(&self, point: Pt2, eps: f64) -> Option {
+ match self {
+ Self::Line { from, to } => {
+ let d = sub(p(*to), p(*from));
+ let len2 = dot(d, d);
+ if len2 <= eps * eps {
+ return None;
+ }
+ let t = dot(sub(point, p(*from)), d) / len2;
+ (t >= -eps / len2.sqrt()
+ && t <= 1.0 + eps / len2.sqrt()
+ && distance(self.point(t), point) <= eps)
+ .then_some(t.clamp(0.0, 1.0))
+ }
+ Self::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => {
+ let radial = sub(point, p(*center));
+ if (radial.x.hypot(radial.z) - radius).abs() > eps {
+ return None;
+ }
+ let angle = radial.z.atan2(radial.x);
+ let travel = if *sweep_angle > 0.0 {
+ (angle - start_angle).rem_euclid(TAU)
+ } else {
+ (start_angle - angle).rem_euclid(TAU)
+ };
+ let delta = eps / radius;
+ if travel <= sweep_angle.abs() + delta {
+ Some((travel / sweep_angle.abs()).clamp(0.0, 1.0))
+ } else if TAU - travel <= delta {
+ Some(0.0)
+ } else {
+ None
+ }
+ }
+ }
+ }
+
+ pub(crate) fn twice_area(&self) -> f64 {
+ let a = self.point(0.0);
+ let b = self.point(1.0);
+ match self {
+ Self::Line { .. } => cross(a, b),
+ Self::Arc {
+ center,
+ radius,
+ sweep_angle,
+ ..
+ } => radius * radius * sweep_angle + center[0] * (b.z - a.z) - center[1] * (b.x - a.x),
+ }
+ }
+}
+
+pub(crate) fn intersections(a: &CurveEdge2, b: &CurveEdge2, eps: f64) -> Vec {
+ let mut candidates = Vec::new();
+ let mut offer = |hit: Pt2| {
+ if a.parameter(hit, eps).is_some()
+ && b.parameter(hit, eps).is_some()
+ && !candidates.iter().any(|prior| distance(*prior, hit) <= eps)
+ {
+ candidates.push(hit);
+ }
+ };
+ match (a, b) {
+ (CurveEdge2::Line { from, to }, CurveEdge2::Line { from: bf, to: bt }) => {
+ let d = sub(p(*to), p(*from));
+ let e = sub(p(*bt), p(*bf));
+ let denom = cross(d, e);
+ if denom.abs() > eps * d.x.hypot(d.z) * e.x.hypot(e.z) {
+ offer(add(
+ p(*from),
+ scale(d, cross(sub(p(*bf), p(*from)), e) / denom),
+ ));
+ } else {
+ for endpoint in [*from, *to, *bf, *bt] {
+ offer(p(endpoint));
+ }
+ }
+ }
+ (CurveEdge2::Line { from, to }, CurveEdge2::Arc { center, radius, .. })
+ | (CurveEdge2::Arc { center, radius, .. }, CurveEdge2::Line { from, to }) => {
+ let d = sub(p(*to), p(*from));
+ let f = sub(p(*from), p(*center));
+ let aa = dot(d, d);
+ if aa <= eps * eps {
+ return candidates;
+ }
+ let bb = 2.0 * dot(f, d);
+ let cc = dot(f, f) - radius * radius;
+ let disc = bb * bb - 4.0 * aa * cc;
+ if disc >= -eps * eps * aa {
+ let root = disc.max(0.0).sqrt();
+ for t in [(-bb - root) / (2.0 * aa), (-bb + root) / (2.0 * aa)] {
+ offer(add(p(*from), scale(d, t)));
+ }
+ }
+ }
+ (
+ CurveEdge2::Arc {
+ center: ca,
+ radius: ra,
+ ..
+ },
+ CurveEdge2::Arc {
+ center: cb,
+ radius: rb,
+ ..
+ },
+ ) => {
+ let centre_delta = sub(p(*cb), p(*ca));
+ let d = centre_delta.x.hypot(centre_delta.z);
+ if d <= eps && (ra - rb).abs() <= eps {
+ for endpoint in [a.point(0.0), a.point(1.0), b.point(0.0), b.point(1.0)] {
+ offer(endpoint);
+ }
+ } else if d > eps && d <= ra + rb + eps && d + ra.min(*rb) + eps >= ra.max(*rb) {
+ let along = (ra * ra - rb * rb + d * d) / (2.0 * d);
+ let height2 = ra * ra - along * along;
+ if height2 >= -eps * eps {
+ let u = scale(centre_delta, 1.0 / d);
+ let base = add(p(*ca), scale(u, along));
+ let normal = Pt2::new(-u.z, u.x);
+ let h = height2.max(0.0).sqrt();
+ offer(add(base, scale(normal, h)));
+ offer(add(base, scale(normal, -h)));
+ }
+ }
+ }
+ }
+ candidates
+}
+
+fn ring_area(ring: &[CurveEdge2]) -> f64 {
+ ring.iter().map(CurveEdge2::twice_area).sum::() * 0.5
+}
+
+fn validate_ring(ring: &[CurveEdge2], expected_positive: bool, eps: f64) -> Result<(), String> {
+ if ring.len() < 2 {
+ return Err("Curved Boolean ring needs at least two edges".into());
+ }
+ for (index, edge) in ring.iter().enumerate() {
+ if !edge.length().is_finite()
+ || edge.length() <= 4.0 * eps
+ || ![
+ edge.point(0.0).x,
+ edge.point(0.0).z,
+ edge.point(1.0).x,
+ edge.point(1.0).z,
+ ]
+ .iter()
+ .all(|v| v.is_finite())
+ {
+ return Err("Curved Boolean edge is unresolved".into());
+ }
+ if let CurveEdge2::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } = edge
+ {
+ if !center.iter().all(|v| v.is_finite())
+ || !radius.is_finite()
+ || !start_angle.is_finite()
+ || !sweep_angle.is_finite()
+ || *radius <= 4.0 * eps
+ || sweep_angle.abs() >= TAU
+ {
+ return Err("Curved Boolean arc is invalid".into());
+ }
+ }
+ let next = &ring[(index + 1) % ring.len()];
+ if distance(edge.point(1.0), next.point(0.0)) > eps {
+ return Err("Curved Boolean ring is open".into());
+ }
+ }
+ let area = ring_area(ring);
+ if area.abs() <= eps * eps || (area > 0.0) != expected_positive {
+ return Err("Curved Boolean ring has degenerate or incorrect winding".into());
+ }
+ Ok(())
+}
+
+pub(crate) fn winding(point: Pt2, ring: &[CurveEdge2], eps: f64) -> i32 {
+ let mut total = 0;
+ for edge in ring {
+ match edge {
+ CurveEdge2::Line { from, to } => {
+ let a = p(*from);
+ let b = p(*to);
+ let az = if (a.z - point.z).abs() <= eps * 0.25 {
+ point.z
+ } else {
+ a.z
+ };
+ let bz = if (b.z - point.z).abs() <= eps * 0.25 {
+ point.z
+ } else {
+ b.z
+ };
+ if (az <= point.z && bz > point.z) || (bz <= point.z && az > point.z) {
+ let x = a.x + (point.z - az) * (b.x - a.x) / (bz - az);
+ if x > point.x + eps * 0.01 {
+ total += if bz > az { 1 } else { -1 };
+ }
+ }
+ }
+ CurveEdge2::Arc {
+ center,
+ radius,
+ start_angle,
+ sweep_angle,
+ } => {
+ let sine = (point.z - center[1]) / radius;
+ if sine.abs() >= 1.0 {
+ continue;
+ }
+ let first = sine.asin();
+ for angle in [first, std::f64::consts::PI - first] {
+ let hit = Pt2::new(center[0] + radius * angle.cos(), point.z);
+ if hit.x <= point.x + eps * 0.01 {
+ continue;
+ }
+ let travel = if *sweep_angle > 0.0 {
+ (angle - start_angle).rem_euclid(TAU)
+ } else {
+ (start_angle - angle).rem_euclid(TAU)
+ };
+ let dz = radius * sweep_angle * angle.cos();
+ if travel > sweep_angle.abs() + eps / radius
+ || (travel <= eps / radius && dz < 0.0)
+ || (travel >= sweep_angle.abs() - eps / radius && dz > 0.0)
+ {
+ continue;
+ }
+ if dz.abs() > eps * radius {
+ total += if dz > 0.0 { 1 } else { -1 };
+ }
+ }
+ }
+ }
+ }
+ total
+}
+
+fn quant(point: Pt2, eps: f64) -> (i64, i64) {
+ let step = eps * 2.0;
+ (
+ (point.x / step).round() as i64,
+ (point.z / step).round() as i64,
+ )
+}
+
+fn reverse_ring(ring: &[CurveEdge2]) -> Vec {
+ ring.iter().rev().map(CurveEdge2::reverse).collect()
+}
+
+fn probe(edge: &CurveEdge2, side: f64, eps: f64) -> Pt2 {
+ let mid = edge.point(0.5);
+ let tangent = edge.tangent(0.5);
+ let length = tangent.x.hypot(tangent.z);
+ let distance = (edge.length() * 0.1).min(1.0e-4).max(eps * 2.0);
+ add(
+ mid,
+ Pt2::new(
+ -side * tangent.z * distance / length,
+ side * tangent.x * distance / length,
+ ),
+ )
+}
+
+/// Boolean regions with analytic line and circular-arc boundaries. Input and
+/// output use CW outer rings and CCW holes, matching `booleanRegions2D`.
+pub fn boolean_curved_regions(
+ a: &[CurveRegion2],
+ b: &[CurveRegion2],
+ operation: PlanarBooleanOp,
+ eps: f64,
+) -> Result, String> {
+ if !eps.is_finite() || eps <= 0.0 {
+ return Err("Invalid Boolean tolerance".into());
+ }
+ let validate = |regions: &[CurveRegion2]| -> Result<(), String> {
+ for region in regions {
+ validate_ring(®ion.outer, false, eps)?;
+ for hole in ®ion.holes {
+ validate_ring(hole, true, eps)?;
+ }
+ }
+ Ok(())
+ };
+ validate(a)?;
+ validate(b)?;
+ let edges: Vec = a
+ .iter()
+ .chain(b)
+ .flat_map(|region| {
+ std::iter::once(®ion.outer)
+ .chain(region.holes.iter())
+ .flat_map(|ring| ring.iter().cloned())
+ })
+ .collect();
+ if edges.is_empty() {
+ return Ok(Vec::new());
+ }
+ let inside = |point: Pt2, regions: &[CurveRegion2]| -> bool {
+ regions.iter().any(|region| {
+ winding(point, ®ion.outer, eps) != 0
+ && !region
+ .holes
+ .iter()
+ .any(|hole| winding(point, hole, eps) != 0)
+ })
+ };
+ let filled = |point: Pt2| {
+ let left = inside(point, a);
+ let right = inside(point, b);
+ match operation {
+ PlanarBooleanOp::Union => left || right,
+ PlanarBooleanOp::Intersection => left && right,
+ PlanarBooleanOp::Subtraction => left && !right,
+ }
+ };
+ let mut split = Vec::new();
+ for (index, edge) in edges.iter().enumerate() {
+ let mut ts = vec![0.0, 1.0];
+ for (other_index, other) in edges.iter().enumerate() {
+ if index == other_index {
+ continue;
+ }
+ for point in intersections(edge, other, eps) {
+ if let Some(t) = edge.parameter(point, eps) {
+ ts.push(t);
+ }
+ }
+ }
+ ts.sort_by(|x, y| x.total_cmp(y));
+ let mut clean: Vec = Vec::new();
+ for t in ts {
+ if clean
+ .last()
+ .is_none_or(|last| (t - last) * edge.length() > eps)
+ {
+ clean.push(t);
+ }
+ }
+ if clean
+ .last()
+ .is_some_and(|last| 1.0 - last > eps / edge.length())
+ {
+ clean.push(1.0);
+ }
+ for window in clean.windows(2) {
+ let piece = edge.slice(window[0], window[1]);
+ if piece.length() > eps {
+ split.push(piece);
+ }
+ }
+ }
+ let mut boundary = Vec::new();
+ let mut seen = HashSet::new();
+ for edge in split {
+ let left = filled(probe(&edge, 1.0, eps));
+ let right = filled(probe(&edge, -1.0, eps));
+ if left == right {
+ continue;
+ }
+ let oriented = if left { edge } else { edge.reverse() };
+ let key = (
+ quant(oriented.point(0.0), eps),
+ quant(oriented.point(0.5), eps),
+ quant(oriented.point(1.0), eps),
+ );
+ if seen.insert(key) {
+ boundary.push(oriented);
+ }
+ }
+ let mut outgoing: HashMap<(i64, i64), Vec> = HashMap::new();
+ for (index, edge) in boundary.iter().enumerate() {
+ outgoing
+ .entry(quant(edge.point(0.0), eps))
+ .or_default()
+ .push(index);
+ }
+ let mut used = vec![false; boundary.len()];
+ let mut loops = Vec::new();
+ for start in 0..boundary.len() {
+ if used[start] {
+ continue;
+ }
+ let mut current = start;
+ let mut ring = Vec::new();
+ for _ in 0..=boundary.len() {
+ if used[current] {
+ break;
+ }
+ used[current] = true;
+ let edge = &boundary[current];
+ ring.push(edge.clone());
+ let at = quant(edge.point(1.0), eps);
+ if at == quant(boundary[start].point(0.0), eps) {
+ break;
+ }
+ let reverse = scale(edge.tangent(1.0), -1.0);
+ let mut best = None;
+ let mut best_turn = f64::INFINITY;
+ for &candidate in outgoing.get(&at).map(Vec::as_slice).unwrap_or(&[]) {
+ if used[candidate] {
+ continue;
+ }
+ let direction = boundary[candidate].tangent(0.0);
+ let mut turn = -cross(reverse, direction).atan2(dot(reverse, direction));
+ if turn <= 1.0e-12 {
+ turn += TAU;
+ }
+ if turn < best_turn {
+ best_turn = turn;
+ best = Some(candidate);
+ }
+ }
+ match best {
+ Some(next) => current = next,
+ None => break,
+ }
+ }
+ if ring.len() < 2
+ || distance(ring.last().unwrap().point(1.0), ring[0].point(0.0)) > eps
+ || ring_area(&ring).abs() <= eps * eps
+ {
+ return Err("Curved Boolean produced an open or degenerate boundary".into());
+ }
+ loops.push(ring);
+ }
+ let mut result: Vec = loops
+ .iter()
+ .filter(|ring| ring_area(ring) > 0.0)
+ .map(|ring| CurveRegion2 {
+ outer: reverse_ring(ring),
+ holes: Vec::new(),
+ })
+ .collect();
+ for hole in loops.iter().filter(|ring| ring_area(ring) < 0.0) {
+ let sample = probe(&hole[0], -1.0, eps);
+ let index = result
+ .iter()
+ .enumerate()
+ .filter(|(_, region)| winding(sample, ®ion.outer, eps) != 0)
+ .min_by(|(_, first), (_, second)| {
+ ring_area(&first.outer)
+ .abs()
+ .total_cmp(&ring_area(&second.outer).abs())
+ })
+ .map(|(index, _)| index);
+ if let Some(index) = index {
+ result[index].holes.push(reverse_ring(hole));
+ }
+ }
+ for region in &mut result {
+ canonicalize_ring(&mut region.outer);
+ for hole in &mut region.holes {
+ canonicalize_ring(hole);
+ }
+ region
+ .holes
+ .sort_by(|a, b| compare_points(a[0].point(0.0), b[0].point(0.0)));
+ }
+ result.sort_by(|a, b| compare_points(a.outer[0].point(0.0), b.outer[0].point(0.0)));
+ Ok(result)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn sector(start: f64, end: f64) -> CurveRegion2 {
+ let at = |radius: f64, angle: f64| [radius * angle.cos(), radius * angle.sin()];
+ CurveRegion2 {
+ outer: vec![
+ CurveEdge2::Arc {
+ center: [0.0, 0.0],
+ radius: 2.0,
+ start_angle: end,
+ sweep_angle: start - end,
+ },
+ CurveEdge2::Line {
+ from: at(2.0, start),
+ to: at(1.0, start),
+ },
+ CurveEdge2::Arc {
+ center: [0.0, 0.0],
+ radius: 1.0,
+ start_angle: start,
+ sweep_angle: end - start,
+ },
+ CurveEdge2::Line {
+ from: at(1.0, end),
+ to: at(2.0, end),
+ },
+ ],
+ holes: Vec::new(),
+ }
+ }
+
+ fn rectangle(x0: f64, z0: f64, x1: f64, z1: f64) -> CurveRegion2 {
+ let points = [[x0, z0], [x0, z1], [x1, z1], [x1, z0]];
+ CurveRegion2 {
+ outer: (0..4)
+ .map(|index| CurveEdge2::Line {
+ from: points[index],
+ to: points[(index + 1) % 4],
+ })
+ .collect(),
+ holes: Vec::new(),
+ }
+ }
+
+ fn area(regions: &[CurveRegion2]) -> f64 {
+ regions
+ .iter()
+ .map(|region| {
+ -ring_area(®ion.outer)
+ - region.holes.iter().map(|hole| ring_area(hole)).sum::()
+ })
+ .sum()
+ }
+
+ #[test]
+ fn coincident_arc_boundaries_preserve_exact_overlap() {
+ let a = sector(0.0, std::f64::consts::FRAC_PI_2);
+ let b = sector(
+ std::f64::consts::FRAC_PI_4,
+ 3.0 * std::f64::consts::FRAC_PI_4,
+ );
+ let expected = 1.5 * std::f64::consts::FRAC_PI_4;
+ for (operation, target) in [
+ (PlanarBooleanOp::Intersection, expected),
+ (
+ PlanarBooleanOp::Union,
+ 1.5 * 3.0 * std::f64::consts::FRAC_PI_4,
+ ),
+ (PlanarBooleanOp::Subtraction, expected),
+ ] {
+ let out = boolean_curved_regions(&[a.clone()], &[b.clone()], operation, 1e-7).unwrap();
+ assert_eq!(out.len(), 1);
+ assert!(
+ (area(&out) - target).abs() < 1e-6,
+ "{operation:?}: expected {target}, got {}",
+ area(&out)
+ );
+ assert!(out[0]
+ .outer
+ .iter()
+ .any(|edge| matches!(edge, CurveEdge2::Arc { .. })));
+ }
+ }
+
+ #[test]
+ fn line_arc_cut_splits_sector_without_chording() {
+ let base = sector(0.0, std::f64::consts::FRAC_PI_2);
+ let cutter = rectangle(1.2, -0.2, 1.8, 2.2);
+ let out = boolean_curved_regions(
+ &[base.clone()],
+ &[cutter],
+ PlanarBooleanOp::Subtraction,
+ 1e-7,
+ )
+ .unwrap();
+ assert_eq!(out.len(), 2);
+ assert!(out.iter().all(|region| ring_area(®ion.outer) < 0.0));
+ assert!(
+ out.iter()
+ .flat_map(|region| ®ion.outer)
+ .filter(|edge| matches!(edge, CurveEdge2::Arc { .. }))
+ .count()
+ >= 2
+ );
+ assert!(area(&out) > 0.0 && area(&out) < area(&[base]));
+ }
+
+ #[test]
+ fn identical_arc_region_union_and_subtraction_are_canonical() {
+ let source = sector(-0.4, 1.7);
+ let union = boolean_curved_regions(
+ &[source.clone()],
+ &[source.clone()],
+ PlanarBooleanOp::Union,
+ 1e-7,
+ )
+ .unwrap();
+ assert_eq!(union.len(), 1);
+ assert!((area(&union) - area(&[source.clone()])).abs() < 1e-6);
+ let difference = boolean_curved_regions(
+ &[source.clone()],
+ &[source],
+ PlanarBooleanOp::Subtraction,
+ 1e-7,
+ )
+ .unwrap();
+ assert!(difference.is_empty());
+ }
+
+ #[test]
+ fn random_concentric_sector_booleans_match_analytic_areas() {
+ let mut seed = 0x8a53_71de_u64;
+ let mut random = || {
+ seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
+ ((seed >> 32) as f64) / (u32::MAX as f64)
+ };
+ for case in 0..120 {
+ let a0 = -0.7 + 1.4 * random();
+ let a1 = a0 + 0.2 + 1.0 * random();
+ let b0 = -0.7 + 1.4 * random();
+ let b1 = b0 + 0.2 + 1.0 * random();
+ let a = sector(a0, a1);
+ let b = sector(b0, b1);
+ let overlap = (a1.min(b1) - a0.max(b0)).max(0.0);
+ for (operation, expected) in [
+ (PlanarBooleanOp::Union, 1.5 * (a1 - a0 + b1 - b0 - overlap)),
+ (PlanarBooleanOp::Intersection, 1.5 * overlap),
+ (PlanarBooleanOp::Subtraction, 1.5 * (a1 - a0 - overlap)),
+ ] {
+ let out =
+ boolean_curved_regions(&[a.clone()], &[b.clone()], operation, 1e-7).unwrap();
+ assert!(
+ (area(&out) - expected).abs() < 2e-6,
+ "case {case} {operation:?}: expected {expected}, got {}",
+ area(&out)
+ );
+ assert!(
+ out.iter()
+ .flat_map(|region| ®ion.outer)
+ .any(|edge| matches!(edge, CurveEdge2::Arc { .. }))
+ || out.is_empty()
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn continuation_pair_subtracts_from_both_sides_of_a_third_host() {
+ let base = rectangle(-2.0, -0.1, 0.30000000000000027, 0.1);
+ let north = rectangle(-0.1, 0.0, 0.1, 2.0);
+ let south_arc = CurveRegion2 {
+ outer: vec![
+ CurveEdge2::Line {
+ from: [-0.10000000000000009, 1.3471114790620887e-16],
+ to: [0.09999999999999998, 1.1021821192326179e-16],
+ },
+ CurveEdge2::Arc {
+ center: [1.0, 0.0],
+ radius: 0.9,
+ start_angle: std::f64::consts::PI,
+ sweep_angle: std::f64::consts::FRAC_PI_2,
+ },
+ CurveEdge2::Line {
+ from: [0.9999999999999999, -0.9],
+ to: [0.9999999999999998, -1.1],
+ },
+ CurveEdge2::Arc {
+ center: [1.0, 0.0],
+ radius: 1.1,
+ start_angle: 3.0 * std::f64::consts::FRAC_PI_2,
+ sweep_angle: -std::f64::consts::FRAC_PI_2,
+ },
+ ],
+ holes: Vec::new(),
+ };
+ let out = boolean_curved_regions(
+ &[base],
+ &[north, south_arc],
+ PlanarBooleanOp::Subtraction,
+ 1e-6,
+ )
+ .unwrap();
+ assert_eq!(out.len(), 2);
+ assert!(out
+ .iter()
+ .any(|region| region.outer.iter().any(|edge| edge.point(0.5).x < -1.0)));
+ let original = rectangle(-2.0, -0.1, 0.0, 0.1);
+ let intersections: Vec<_> = out
+ .iter()
+ .map(|region| {
+ boolean_curved_regions(
+ &[region.clone()],
+ &[original.clone()],
+ PlanarBooleanOp::Intersection,
+ 1e-6,
+ )
+ .unwrap()
+ })
+ .collect();
+ assert_eq!(
+ intersections
+ .iter()
+ .filter(|regions| !regions.is_empty())
+ .count(),
+ 1
+ );
+ for region in out {
+ boolean_curved_regions(
+ &[region],
+ &[original.clone()],
+ PlanarBooleanOp::Subtraction,
+ 1e-6,
+ )
+ .unwrap();
+ }
+ }
+
+ #[test]
+ fn subtracting_concentric_disk_keeps_an_exact_circular_hole() {
+ let disk = |radius: f64| CurveRegion2 {
+ outer: vec![
+ CurveEdge2::Arc {
+ center: [0.0, 0.0],
+ radius,
+ start_angle: 0.0,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ CurveEdge2::Arc {
+ center: [0.0, 0.0],
+ radius,
+ start_angle: -std::f64::consts::PI,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ ],
+ holes: Vec::new(),
+ };
+ let out = boolean_curved_regions(
+ &[disk(2.0)],
+ &[disk(1.0)],
+ PlanarBooleanOp::Subtraction,
+ 1e-7,
+ )
+ .unwrap();
+ assert_eq!(out.len(), 1);
+ assert_eq!(out[0].holes.len(), 1);
+ assert!((area(&out) - 3.0 * std::f64::consts::PI).abs() < 1e-7);
+ assert!(out[0].holes[0]
+ .iter()
+ .all(|edge| matches!(edge, CurveEdge2::Arc { .. })));
+ }
+
+ #[test]
+ fn intersecting_disks_keep_exact_circle_arcs_and_lens_area() {
+ let disk = |cx: f64| CurveRegion2 {
+ outer: vec![
+ CurveEdge2::Arc {
+ center: [cx, 0.0],
+ radius: 2.0,
+ start_angle: 0.0,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ CurveEdge2::Arc {
+ center: [cx, 0.0],
+ radius: 2.0,
+ start_angle: -std::f64::consts::PI,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ ],
+ holes: Vec::new(),
+ };
+ let out = boolean_curved_regions(
+ &[disk(-1.0)],
+ &[disk(1.0)],
+ PlanarBooleanOp::Intersection,
+ 1e-7,
+ )
+ .unwrap();
+ let expected = 8.0 * (0.5_f64).acos() - 2.0 * 3.0_f64.sqrt();
+ assert_eq!(out.len(), 1);
+ assert!(out[0]
+ .outer
+ .iter()
+ .all(|edge| matches!(edge, CurveEdge2::Arc { .. })));
+ assert!((area(&out) - expected).abs() < 1e-7);
+ }
+
+ #[test]
+ fn disjoint_output_has_canonical_region_and_ring_order() {
+ let left = rectangle(-3.0, -1.0, -2.0, 1.0);
+ let right = rectangle(2.0, -1.0, 3.0, 1.0);
+ let first = boolean_curved_regions(
+ &[right.clone(), left.clone()],
+ &[],
+ PlanarBooleanOp::Union,
+ 1e-7,
+ )
+ .unwrap();
+ let second =
+ boolean_curved_regions(&[left, right], &[], PlanarBooleanOp::Union, 1e-7).unwrap();
+ assert_eq!(
+ serde_json::to_value(&first).unwrap(),
+ serde_json::to_value(&second).unwrap()
+ );
+ assert_eq!(first.len(), 2);
+ assert_eq!(first[0].outer[0].point(0.0).x, -3.0);
+ assert_eq!(first[1].outer[0].point(0.0).x, 2.0);
+ }
+
+ #[test]
+ fn translated_and_rotated_circle_lenses_match_analytic_area() {
+ let disk = |center: [f64; 2], angle: f64| CurveRegion2 {
+ outer: vec![
+ CurveEdge2::Arc {
+ center,
+ radius: 2.0,
+ start_angle: angle,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ CurveEdge2::Arc {
+ center,
+ radius: 2.0,
+ start_angle: angle - std::f64::consts::PI,
+ sweep_angle: -std::f64::consts::PI,
+ },
+ ],
+ holes: Vec::new(),
+ };
+ for index in 0..80 {
+ let angle = index as f64 * 0.783_124;
+ let distance = 0.25 + (index % 31) as f64 * 0.115;
+ let centre = [
+ 3.0 + (index % 7) as f64 * 0.23,
+ -4.0 + (index % 9) as f64 * 0.31,
+ ];
+ let other = [
+ centre[0] + distance * angle.cos(),
+ centre[1] + distance * angle.sin(),
+ ];
+ let actual = boolean_curved_regions(
+ &[disk(centre, angle / 3.0)],
+ &[disk(other, angle / 5.0)],
+ PlanarBooleanOp::Intersection,
+ 1e-7,
+ )
+ .unwrap();
+ let expected = 8.0 * (distance / 4.0).acos()
+ - 0.5 * distance * (16.0 - distance * distance).sqrt();
+ assert_eq!(actual.len(), 1, "case {index}");
+ assert!(
+ (area(&actual) - expected).abs() < 1e-6,
+ "case {index}: expected {expected}, got {}",
+ area(&actual)
+ );
+ }
+ }
+}
diff --git a/main/opengeometry/src/geometry/offset_regions.rs b/main/opengeometry/src/geometry/offset_regions.rs
index 22c1451..82ea72a 100644
--- a/main/opengeometry/src/geometry/offset_regions.rs
+++ b/main/opengeometry/src/geometry/offset_regions.rs
@@ -1,9 +1,10 @@
//! Fixed-width polyline offset: centreline + width -> stroked region(s), plus a
//! group offset that merges several overlapping strokes into one. Built on the
-//! generic `geometry` 2D toolkit. Domain-neutral (walls, roads, pipe routes,
-//! hatching, font strokes, etc.).
+//! generic `geometry` 2D toolkit. It accepts arbitrary planar paths and
+//! ring boundaries.
use crate::geometry::boolean2d::*;
+use crate::geometry::curved_boolean2d::{boolean_curved_regions, CurveRegion2};
use crate::geometry::offset2d::*;
use crate::geometry::poly2d::*;
use openmaths::Vector3;
@@ -12,6 +13,242 @@ use wasm_bindgen::prelude::*;
/// One stroked region: a CW outer ring + its CCW inner-void holes (canonical).
pub type OffsetRegion = (Vec, Vec>);
+pub fn boolean_offset_regions(
+ a: &[OffsetRegion],
+ b: &[OffsetRegion],
+ operation: PlanarBooleanOp,
+) -> Result, String> {
+ let base_y = a
+ .iter()
+ .chain(b)
+ .find_map(|(outer, _)| outer.first().map(|point| point.y))
+ .unwrap_or(0.0);
+ let contours = |regions: &[OffsetRegion]| -> Result>, String> {
+ let mut loops = Vec::new();
+ for (outer, holes) in regions {
+ for (ring, outer_ring) in
+ std::iter::once((outer, true)).chain(holes.iter().map(|hole| (hole, false)))
+ {
+ if ring.len() < 3
+ || ring.iter().any(|point| {
+ !point.x.is_finite()
+ || !point.y.is_finite()
+ || !point.z.is_finite()
+ || (point.y - base_y).abs() > DEFAULT_EPS
+ })
+ {
+ return Err(
+ "Boolean region ring must contain at least three finite coplanar points"
+ .into(),
+ );
+ }
+ let area = signed_area_xz(ring);
+ if area.abs() <= DEFAULT_EPS * DEFAULT_EPS
+ || (outer_ring && area >= 0.0)
+ || (!outer_ring && area <= 0.0)
+ {
+ return Err("Boolean region ring has degenerate or incorrect winding".into());
+ }
+ loops.push(ring.iter().map(xz).collect());
+ }
+ }
+ Ok(loops)
+ };
+ let a_contours = contours(a)?;
+ let b_contours = contours(b)?;
+ Ok(
+ boolean_oriented_regions(&a_contours, &b_contours, operation, DEFAULT_EPS)
+ .into_iter()
+ .map(|region| finalize_region(®ion.outer, ®ion.holes, base_y))
+ .collect(),
+ )
+}
+
+#[derive(serde::Deserialize)]
+struct BooleanRegionJson {
+ outer: Vec<[f64; 3]>,
+ holes: Vec>,
+}
+
+#[wasm_bindgen(js_name = booleanRegions2D)]
+pub fn boolean_regions_2d_wasm(
+ a_json: String,
+ b_json: String,
+ operation: String,
+) -> Result {
+ let parse = |json: &str| -> Result, JsValue> {
+ let regions: Vec = serde_json::from_str(json).map_err(|error| {
+ JsValue::from_str(&format!("Invalid Boolean regions JSON: {error}"))
+ })?;
+ Ok(regions
+ .into_iter()
+ .map(|region| {
+ let points = |ring: Vec<[f64; 3]>| {
+ ring.into_iter()
+ .map(|point| Vector3::new(point[0], point[1], point[2]))
+ .collect()
+ };
+ (
+ points(region.outer),
+ region.holes.into_iter().map(points).collect(),
+ )
+ })
+ .collect())
+ };
+ let operation = match operation.as_str() {
+ "union" => PlanarBooleanOp::Union,
+ "intersection" => PlanarBooleanOp::Intersection,
+ "subtraction" => PlanarBooleanOp::Subtraction,
+ _ => return Err(JsValue::from_str("Unknown 2D Boolean operation")),
+ };
+ let result = boolean_offset_regions(&parse(&a_json)?, &parse(&b_json)?, operation)
+ .map_err(|error| JsValue::from_str(&error))?;
+ OGOffsetRegionsResult::from_regions(result).map_err(|error| JsValue::from_str(&error))
+}
+
+/// Exact line/arc region Boolean. The JSON rings contain analytic edges rather
+/// than vertices, and the result keeps arcs as arcs. As with booleanRegions2D,
+/// outer rings are CW in XZ and holes are CCW.
+#[wasm_bindgen(js_name = booleanCurvedRegions2D)]
+pub fn boolean_curved_regions_2d_wasm(
+ a_json: String,
+ b_json: String,
+ operation: String,
+) -> Result {
+ let parse = |json: &str| -> Result, JsValue> {
+ serde_json::from_str(json).map_err(|error| {
+ JsValue::from_str(&format!("Invalid curved Boolean regions JSON: {error}"))
+ })
+ };
+ let operation = match operation.as_str() {
+ "union" => PlanarBooleanOp::Union,
+ "intersection" => PlanarBooleanOp::Intersection,
+ "subtraction" => PlanarBooleanOp::Subtraction,
+ _ => return Err(JsValue::from_str("Unknown 2D Boolean operation")),
+ };
+ let result = boolean_curved_regions(&parse(&a_json)?, &parse(&b_json)?, operation, DEFAULT_EPS)
+ .map_err(|error| JsValue::from_str(&error))?;
+ serde_json::to_string(&result).map_err(|error| {
+ JsValue::from_str(&format!("Cannot serialize curved Boolean result: {error}"))
+ })
+}
+
+#[cfg(test)]
+mod planar_boolean_export_tests {
+ use super::*;
+
+ fn ring(points: &[(f64, f64)]) -> Vec {
+ points
+ .iter()
+ .map(|(x, z)| Vector3::new(*x, 0.0, *z))
+ .collect()
+ }
+
+ #[test]
+ fn exported_boolean_preserves_split_regions_and_winding() {
+ let a = vec![(
+ ring(&[(0.0, 0.0), (0.0, 4.0), (10.0, 4.0), (10.0, 0.0)]),
+ Vec::new(),
+ )];
+ let b = vec![(
+ ring(&[(4.0, -1.0), (4.0, 5.0), (6.0, 5.0), (6.0, -1.0)]),
+ Vec::new(),
+ )];
+ let out = boolean_offset_regions(&a, &b, PlanarBooleanOp::Subtraction).unwrap();
+ assert_eq!(out.len(), 2);
+ for (outer, holes) in out {
+ assert!(holes.is_empty());
+ assert!(signed_area_xz(&outer) < 0.0);
+ }
+ }
+
+ #[test]
+ fn exported_boolean_matches_random_rectangle_areas() {
+ let mut seed = 0x9e37_79b9_u64;
+ let mut random = || {
+ seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
+ ((seed >> 32) as f64) / (u32::MAX as f64)
+ };
+ let rectangle = |x0: f64, z0: f64, x1: f64, z1: f64| {
+ vec![(ring(&[(x0, z0), (x0, z1), (x1, z1), (x1, z0)]), Vec::new())]
+ };
+ let area = |regions: Vec| {
+ regions
+ .iter()
+ .map(|(outer, holes)| {
+ -signed_area_xz(outer)
+ - holes.iter().map(|hole| signed_area_xz(hole)).sum::()
+ })
+ .sum::()
+ };
+ for _ in 0..100 {
+ let aw = 0.5 + random() * 4.0;
+ let ah = 0.5 + random() * 4.0;
+ let bx = -2.0 + random() * 6.0;
+ let bz = -2.0 + random() * 6.0;
+ let bw = 0.5 + random() * 4.0;
+ let bh = 0.5 + random() * 4.0;
+ let overlap = (aw.min(bx + bw) - 0.0_f64.max(bx)).max(0.0)
+ * (ah.min(bz + bh) - 0.0_f64.max(bz)).max(0.0);
+ let a = rectangle(0.0, 0.0, aw, ah);
+ let b = rectangle(bx, bz, bx + bw, bz + bh);
+ for (operation, expected) in [
+ (PlanarBooleanOp::Union, aw * ah + bw * bh - overlap),
+ (PlanarBooleanOp::Intersection, overlap),
+ (PlanarBooleanOp::Subtraction, aw * ah - overlap),
+ ] {
+ let result = boolean_offset_regions(&a, &b, operation).unwrap();
+ assert!(
+ (area(result) - expected).abs() < 1.0e-5,
+ "{operation:?}: expected {expected}"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn exported_boolean_preserves_random_oblique_holes() {
+ let mut seed = 0x8bad_f00d_u64;
+ let mut random = || {
+ seed = seed
+ .wrapping_mul(2862933555777941757)
+ .wrapping_add(3037000493);
+ ((seed >> 32) as f64) / (u32::MAX as f64)
+ };
+ let host = vec![(
+ ring(&[(0.0, 0.0), (0.0, 4.0), (4.0, 4.0), (4.0, 0.0)]),
+ Vec::new(),
+ )];
+ for _ in 0..40 {
+ let width = 0.4 + random() * 1.6;
+ let depth = 0.4 + random() * 1.6;
+ let angle = random() * std::f64::consts::PI;
+ let (sin, cos) = angle.sin_cos();
+ let corners = [
+ (-width / 2.0, -depth / 2.0),
+ (-width / 2.0, depth / 2.0),
+ (width / 2.0, depth / 2.0),
+ (width / 2.0, -depth / 2.0),
+ ];
+ let cutter = vec![(
+ corners
+ .iter()
+ .map(|(x, z)| {
+ Vector3::new(2.0 + x * cos - z * sin, 0.0, 2.0 + x * sin + z * cos)
+ })
+ .collect(),
+ Vec::new(),
+ )];
+ let output =
+ boolean_offset_regions(&host, &cutter, PlanarBooleanOp::Subtraction).unwrap();
+ assert_eq!(output.len(), 1);
+ assert_eq!(output[0].1.len(), 1);
+ let area = -signed_area_xz(&output[0].0) - signed_area_xz(&output[0].1[0]);
+ assert!((area - (16.0 - width * depth)).abs() < 1.0e-6);
+ }
+ }
+}
+
/// Offset a centreline by ±half-width into one or more stroked regions, via the
/// deterministic analytic offset. A simple centreline (open, L, closed loop,
/// reflex, tight) yields ONE region from the single-ring offset. A self-crossing
@@ -19,7 +256,7 @@ pub type OffsetRegion = (Vec, Vec>);
/// nonzero winding, so the overlapping strokes at the crossing MERGE into one
/// clean region (no internal edges) — the cleanup a single-ring offset can't do.
/// Each region is `normalize_winding` (CW outer / CCW holes) + `canonical_ring`.
-/// Empty vec = nothing buildable.
+/// Empty vec means no region remains.
pub fn offset_polyline_regions(
centreline: &[Vector3],
width: f64,
@@ -127,9 +364,9 @@ fn forbidden_stadium(a: Pt2, b: Pt2, distance: f64) -> Vec {
/// One interior hole of a ring being inset: its own closed ring plus one
/// clearance distance per hole edge (edge i = ring[i] → ring[i+1]). The hole
-/// is an excluded region — an easement island, a protected tree pit — so the
-/// inset GROWS the hole by its distances (the buildable region keeps clear of
-/// the hole's edges, exactly as it keeps clear of the outer lot lines).
+/// is an excluded region — an excluded island — so the
+/// inset GROWS the hole by its distances (the retained region keeps clear of
+/// the hole's edges, exactly as it keeps clear of the outer boundary).
pub struct OffsetHole {
pub ring: Vec,
pub distances: Vec,
@@ -174,7 +411,7 @@ fn prepare_inset_ring(
// Drop zero-length edges together with their distance slot, so the
// per-edge pairing stays aligned. (`simplify_polyline` is deliberately not
// used: its width-based vertex dropping would desync `distances`, and a
- // collinear vertex splitting one frontage into different setbacks is a
+ // collinear vertex splitting one boundary run into different clearances is a
// feature, not noise.)
let mut pts2: Vec = Vec::with_capacity(n);
let mut dist2: Vec = Vec::with_capacity(n);
@@ -206,7 +443,7 @@ fn prepare_inset_ring(
/// Inset a CLOSED ring inward, one distance per edge (edge i = ring[i] →
/// ring[i+1], after stripping an optional closing duplicate). The result is
-/// the CLEARANCE-EXACT buildable region: the ring's interior minus, per edge,
+/// the clearance-exact inset region: the ring's interior minus, per edge,
/// every point within that edge's distance of the edge SEGMENT (not just its
/// line) — resolved as a positive-winding clip of the ring against per-edge
/// forbidden stadiums. Corners therefore become circumscribed clearance arcs
@@ -222,7 +459,7 @@ fn prepare_inset_ring(
/// inset collapsed or the outer ring is degenerate; `Err` = malformed
/// ARGUMENTS (distance count mismatch, negative / non-finite distance, or a
/// degenerate hole ring — a hole is an explicit argument, so a broken one is a
-/// caller bug rather than "nothing buildable").
+/// caller bug rather than "no region remains").
pub fn offset_ring_variable(
ring: &[Vector3],
distances: &[f64],
@@ -233,7 +470,7 @@ pub fn offset_ring_variable(
// The clip needs the base ring CCW (+1) against CW cutters (−1).
let Some((pts2, dist2)) = prepare_inset_ring(ring, distances, true, "ring", eps)? else {
- return Ok(Vec::new()); // geometrically unbuildable input — empty, not an error
+ return Ok(Vec::new()); // geometrically collapsed input — empty, not an error
};
let m = pts2.len();
@@ -415,7 +652,7 @@ struct OffsetPolylineJson {
/// Wasm entry point: merge a GROUP of separate polylines (a crossing T / X / L
/// overlap) into one clean stroked region by nonzero-winding union of their mitered
/// bands. `polylines_json` is `[{centreline:[x,y,z,…], width, closed}, …]`. Returns
-/// CW-outer / CCW-hole regions (one or more); empty when nothing is buildable.
+/// CW-outer / CCW-hole regions (one or more); empty when no region remains.
#[wasm_bindgen(js_name = offsetPolylineGroupRegions)]
pub fn offset_polyline_group_regions_wasm(
polylines_json: String,
@@ -847,11 +1084,11 @@ mod tests {
}
}
- /// Front/side/rear setbacks on a rectangle land each edge at its own distance.
+ /// Distinct clearances on a rectangle land each edge at its own distance.
#[test]
- fn variable_inset_front_side_rear_setbacks() {
- // 20 (x) × 30 (z); edge 0 = front (z=0, setback 6), edges 1/3 = sides
- // (x=20 and x=0, setback 1.5), edge 2 = rear (z=30, setback 3).
+ fn variable_inset_distinct_edge_clearances() {
+ // 20 (x) × 30 (z); edge 0 has clearance 6, edges 1/3 have
+ // clearance 1.5, and edge 2 has clearance 3.
let ring = vec![pt(0.0, 0.0), pt(20.0, 0.0), pt(20.0, 30.0), pt(0.0, 30.0)];
let regions = variable(&ring, &[6.0, 1.5, 3.0, 1.5]).expect("valid arguments");
let (outer, _) = single(regions);
@@ -863,13 +1100,13 @@ mod tests {
assert!((area - 17.0 * 21.0).abs() < 1e-9, "area = {}", area);
}
- /// A zero-distance edge stays exactly in place (lot-line construction).
+ /// A zero-distance edge stays exactly in place (zero-clearance boundary).
#[test]
fn variable_inset_zero_distance_edge_stays() {
let ring = vec![pt(0.0, 0.0), pt(20.0, 0.0), pt(20.0, 30.0), pt(0.0, 30.0)];
let regions = variable(&ring, &[0.0, 1.5, 3.0, 1.5]).expect("valid arguments");
let (outer, _) = single(regions);
- // The front edge (z = 0) is untouched: its inset corners sit on it.
+ // Edge 0 (z = 0) is untouched: its inset corners sit on it.
assert!(has_vertex(&outer, 1.5, 0.0));
assert!(has_vertex(&outer, 18.5, 0.0));
}
@@ -951,8 +1188,8 @@ mod tests {
}
}
- /// Splitting one frontage into two collinear edges with different distances
- /// transitions between the two setback lines through the deeper edge's
+ /// Splitting one boundary run into two collinear edges with different distances
+ /// transitions between the two inset lines through the deeper edge's
/// clearance arc around the split point — NOT a perpendicular step, which
/// would claim points within the deep distance of the deep segment's
/// endpoint.
@@ -960,7 +1197,7 @@ mod tests {
fn variable_inset_collinear_split_edge_steps() {
let ring = vec![
pt(0.0, 0.0),
- pt(10.0, 0.0), // split point on the z=0 frontage
+ pt(10.0, 0.0), // split point on the z=0 boundary
pt(20.0, 0.0),
pt(20.0, 30.0),
pt(0.0, 30.0),
@@ -972,27 +1209,27 @@ mod tests {
!self_intersects2(&r2, DEFAULT_EPS),
"stepped envelope is simple"
);
- // Both setback lines exist…
+ // Both inset lines exist…
assert!(
outer.iter().any(|v| (v.z - 2.0).abs() < 1e-9 && v.x < 5.5),
- "shallow frontage line at z=2 (left of the deep edge's clearance arc)"
+ "shallow inset line at z=2 (left of the deep edge's clearance arc)"
);
assert!(
outer
.iter()
.any(|v| (v.z - 5.0).abs() < 1e-9 && v.x > 10.0 - 1e-9),
- "deep frontage line at z=5"
+ "deep inset line at z=5"
);
// …joined through the arc anchored at (10, 5) above the split point.
assert!(has_vertex(&outer, 10.0, 5.0), "arc anchor above the split");
- // The would-be step corner (10, 2) is within 5 m of the deep frontage
+ // The would-be step corner (10, 2) is within 5 m of the deep boundary run
// segment, so it must NOT be claimed.
let deep_a = Pt2::new(10.0, 0.0);
let deep_b = Pt2::new(20.0, 0.0);
for v in &outer {
assert!(
dist_to_segment(Pt2::new(v.x, v.z), deep_a, deep_b) >= 5.0 - 1.0e-6,
- "vertex ({}, {}) violates the deep frontage clearance",
+ "vertex ({}, {}) violates the deep edge clearance",
v.x,
v.z
);
@@ -1000,7 +1237,7 @@ mod tests {
}
/// Every output vertex keeps at least its edge's distance to that edge —
- /// the compliance property a setback envelope exists to guarantee. Cases
+ /// the per-edge distance condition this inset must guarantee. Cases
/// include a sharp reflex notch (where a miter/bevel join would spike or
/// under-clear) and a deep notch whose far flank constrains points across
/// exterior space.
@@ -1012,7 +1249,7 @@ mod tests {
vec![6.0, 1.5, 3.0, 1.5],
),
(
- // Reflex notch in the top edge (sharp spike pointing into the lot).
+ // Reflex notch in the top edge (sharp spike pointing into the ring).
vec![
pt(0.0, 0.0),
pt(40.0, 0.0),
@@ -1050,11 +1287,10 @@ mod tests {
}
/// Clearance applies to edge SEGMENTS across exterior space: a narrow
- /// notch's far lot line constrains the envelope on the near side, exactly
- /// like the service's per-edge compliance checker measures it.
+ /// notch's far boundary edge constrains the inset on the near side.
#[test]
fn variable_inset_respects_distant_edges_across_a_notch() {
- // A 2-wide exterior slot (x 10..12) cut into the top of a 30×20 lot.
+ // A 2-wide exterior slot (x 10..12) cut into the top of a 30×20 ring.
let ring = vec![
pt(0.0, 0.0),
pt(30.0, 0.0),
@@ -1065,31 +1301,31 @@ mod tests {
pt(10.0, 20.0),
pt(0.0, 20.0),
];
- // Edge 5 = slot's left wall (10,8)→(10,20) faces the right lobe across
+ // Edge 5 = slot's left flank (10,8)→(10,20) faces the right lobe across
// the 2-wide slot with a 5 m clearance: it must carve into x ∈ (12, 15).
let distances = vec![1.0, 1.0, 1.0, 1.0, 1.0, 5.0, 1.0, 1.0];
let regions = variable(&ring, &distances).expect("valid arguments");
assert!(!regions.is_empty());
- let wall_a = Pt2::new(10.0, 8.0);
- let wall_b = Pt2::new(10.0, 20.0);
+ let flank_a = Pt2::new(10.0, 8.0);
+ let flank_b = Pt2::new(10.0, 20.0);
for region in ®ions {
for v in ®ion.0 {
assert!(
- dist_to_segment(Pt2::new(v.x, v.z), wall_a, wall_b) >= 5.0 - 1.0e-6,
- "vertex ({}, {}) is inside the slot wall's clearance",
+ dist_to_segment(Pt2::new(v.x, v.z), flank_a, flank_b) >= 5.0 - 1.0e-6,
+ "vertex ({}, {}) is inside the slot flank's clearance",
v.x,
v.z
);
}
}
- // The point (13, 14) is only 3 m from the slot's left wall — across
+ // The point (13, 14) is only 3 m from the slot's left flank — across
// exterior space — and must be excluded from every region.
let probe = Pt2::new(13.0, 14.0);
for region in ®ions {
let r2 = ring2(region);
assert!(
!point_in_ring2(probe, &r2),
- "(13, 14) violates the slot wall clearance but was claimed"
+ "(13, 14) violates the slot flank clearance but was claimed"
);
}
}
@@ -1125,7 +1361,7 @@ mod tests {
pt(0.0, 14.0),
];
// The corridor is 4 wide (x 8..12); a uniform 3.0 inset closes it but
- // leaves both 20×14 lobes buildable.
+ // leaves both 20×14 lobes in the result.
let regions = variable(&dumbbell, &[3.0; 12]).expect("valid arguments");
assert_eq!(regions.len(), 2, "pinched waist splits the envelope in two");
let original: Vec = dumbbell.iter().map(|v| Pt2::new(v.x, v.z)).collect();
@@ -1195,7 +1431,7 @@ mod tests {
}
/// A centred hole with its own clearance: the outer ring shrinks inward, the
- /// hole GROWS outward, and the buildable region is the ring between them.
+ /// hole GROWS outward, and the retained region is the ring between them.
#[test]
fn variable_inset_grows_holes_by_their_clearance() {
let ring = vec![pt(0.0, 0.0), pt(20.0, 0.0), pt(20.0, 20.0), pt(0.0, 20.0)];
@@ -1237,14 +1473,14 @@ mod tests {
nearest
);
}
- // The hole's clearance zone reaches (7, 10): inside the grown hole, not buildable.
+ // The hole's clearance zone reaches (7, 10): inside the grown hole, outside the retained region.
assert!(
point_in_ring2(Pt2::new(7.2, 10.0), &grown),
"clearance around the hole is void"
);
}
- /// A hole whose clearance reaches both lot lines splits the region into two lobes.
+ /// A hole whose clearance reaches both outer boundary edges splits the region into two lobes.
#[test]
fn variable_inset_hole_clearance_splits_region() {
let ring = vec![pt(0.0, 0.0), pt(20.0, 0.0), pt(20.0, 10.0), pt(0.0, 10.0)];
@@ -1253,7 +1489,7 @@ mod tests {
distances: vec![3.0; 4],
};
let regions = offset_ring_variable(&ring, &[0.0; 4], &[hole], DEFAULT_EPS).expect("valid");
- assert_eq!(regions.len(), 2, "the grown hole cuts the lot in two");
+ assert_eq!(regions.len(), 2, "the grown hole cuts the ring in two");
let mut left = 0;
let mut right = 0;
for (outer, holes) in ®ions {
@@ -1268,7 +1504,7 @@ mod tests {
assert_eq!((left, right), (1, 1), "one lobe each side of the hole");
}
- /// A degenerate hole is a caller bug, not "nothing buildable".
+ /// A degenerate hole is a caller bug, not "no region remains".
#[test]
fn variable_inset_rejects_degenerate_hole() {
let ring = vec![pt(0.0, 0.0), pt(20.0, 0.0), pt(20.0, 20.0), pt(0.0, 20.0)];
diff --git a/main/opengeometry/src/lib.rs b/main/opengeometry/src/lib.rs
index 0357f3d..82f70c6 100644
--- a/main/opengeometry/src/lib.rs
+++ b/main/opengeometry/src/lib.rs
@@ -1,5 +1,6 @@
pub mod geometry {
pub mod boolean2d;
+ pub mod curved_boolean2d;
pub mod geometrybuffer;
pub mod offset2d;
pub mod offset_regions;
diff --git a/main/opengeometry/src/operations/extrude.rs b/main/opengeometry/src/operations/extrude.rs
index 3edfe55..a26001d 100644
--- a/main/opengeometry/src/operations/extrude.rs
+++ b/main/opengeometry/src/operations/extrude.rs
@@ -387,7 +387,7 @@ mod tests {
]
}
- fn wall_outline_with_reflex_start() -> Vec {
+ fn reflex_profile_outline() -> Vec {
vec![
Vector3::new(-2.721670458045537, 0.0, -1.7107348430402753),
Vector3::new(-1.3465727086811485, 0.0, -0.826743432734597),
@@ -451,13 +451,14 @@ mod tests {
#[test]
fn extrude_profile_loops_uses_global_winding_for_reflex_first_corner() {
- let wall_outline = wall_outline_with_reflex_start();
+ let profile_outline = reflex_profile_outline();
- let brep = extrude_profile_loops(Uuid::new_v4(), &wall_outline, &[], 2.6)
- .expect("wall extrusion should succeed");
+ let brep = extrude_profile_loops(Uuid::new_v4(), &profile_outline, &[], 2.6)
+ .expect("profile extrusion should succeed");
- brep.validate_topology().expect("wall extrusion topology");
- assert_eq!(brep.faces.len(), wall_outline.len() + 2);
+ brep.validate_topology()
+ .expect("profile extrusion topology");
+ assert_eq!(brep.faces.len(), profile_outline.len() + 2);
assert!(brep.faces[0].normal.y < -0.999);
assert!(brep.faces[1].normal.y > 0.999);
}
diff --git a/main/opengeometry/src/operations/sweep.rs b/main/opengeometry/src/operations/sweep.rs
index 2aa7960..f04e0ba 100644
--- a/main/opengeometry/src/operations/sweep.rs
+++ b/main/opengeometry/src/operations/sweep.rs
@@ -1069,7 +1069,7 @@ mod tests {
unique
}
- fn assert_window_frame_dimensions(
+ fn assert_rectangular_frame_dimensions(
brep: &Brep,
expected_outer_width: f64,
expected_inner_width: f64,
@@ -1257,117 +1257,117 @@ mod tests {
}
#[test]
- fn closed_window_frame_loop_preserves_expected_dimensions() {
- let window_width = 1.2;
+ fn closed_rectangular_frame_loop_preserves_expected_dimensions() {
+ let aperture_width = 1.2;
let frame_width = 0.12;
let frame_depth = 0.12;
- let window_height = 1.0;
- let sill_height = 1.05;
- let half_window_width = window_width * 0.5;
+ let aperture_height = 1.0;
+ let base_elevation = 1.05;
+ let half_aperture_width = aperture_width * 0.5;
let half_frame_width = frame_width * 0.5;
let path = vec![
Vector3::new(
- -(half_window_width + half_frame_width),
- sill_height - half_frame_width,
+ -(half_aperture_width + half_frame_width),
+ base_elevation - half_frame_width,
0.0,
),
Vector3::new(
- -(half_window_width + half_frame_width),
- sill_height + window_height + half_frame_width,
+ -(half_aperture_width + half_frame_width),
+ base_elevation + aperture_height + half_frame_width,
0.0,
),
Vector3::new(
- half_window_width + half_frame_width,
- sill_height + window_height + half_frame_width,
+ half_aperture_width + half_frame_width,
+ base_elevation + aperture_height + half_frame_width,
0.0,
),
Vector3::new(
- half_window_width + half_frame_width,
- sill_height - half_frame_width,
+ half_aperture_width + half_frame_width,
+ base_elevation - half_frame_width,
0.0,
),
Vector3::new(
- -(half_window_width + half_frame_width),
- sill_height - half_frame_width,
+ -(half_aperture_width + half_frame_width),
+ base_elevation - half_frame_width,
0.0,
),
];
let profile = rectangle_profile(frame_width, frame_depth);
let brep = sweep_profile_along_path(&path, &profile, SweepOptions::default())
- .expect("closed window frame sweep should succeed");
+ .expect("closed rectangular frame sweep should succeed");
brep.validate_topology()
- .expect("closed window frame topology should validate");
+ .expect("closed rectangular frame topology should validate");
- assert_window_frame_dimensions(
+ assert_rectangular_frame_dimensions(
&brep,
- window_width + frame_width * 2.0,
- window_width,
- window_height + frame_width * 2.0,
- window_height,
+ aperture_width + frame_width * 2.0,
+ aperture_width,
+ aperture_height + frame_width * 2.0,
+ aperture_height,
);
}
#[test]
- fn larger_closed_window_frame_loop_preserves_expected_dimensions() {
- let window_width = 1.6;
+ fn larger_closed_rectangular_frame_loop_preserves_expected_dimensions() {
+ let aperture_width = 1.6;
let frame_width = 0.14;
let frame_depth = 0.2;
- let window_height = 1.2;
- let sill_height = 1.0;
- let half_window_width = window_width * 0.5;
+ let aperture_height = 1.2;
+ let base_elevation = 1.0;
+ let half_aperture_width = aperture_width * 0.5;
let half_frame_width = frame_width * 0.5;
let path = vec![
Vector3::new(
- -(half_window_width + half_frame_width),
- sill_height - half_frame_width,
+ -(half_aperture_width + half_frame_width),
+ base_elevation - half_frame_width,
0.0,
),
Vector3::new(
- -(half_window_width + half_frame_width),
- sill_height + window_height + half_frame_width,
+ -(half_aperture_width + half_frame_width),
+ base_elevation + aperture_height + half_frame_width,
0.0,
),
Vector3::new(
- half_window_width + half_frame_width,
- sill_height + window_height + half_frame_width,
+ half_aperture_width + half_frame_width,
+ base_elevation + aperture_height + half_frame_width,
0.0,
),
Vector3::new(
- half_window_width + half_frame_width,
- sill_height - half_frame_width,
+ half_aperture_width + half_frame_width,
+ base_elevation - half_frame_width,
0.0,
),
Vector3::new(
- -(half_window_width + half_frame_width),
- sill_height - half_frame_width,
+ -(half_aperture_width + half_frame_width),
+ base_elevation - half_frame_width,
0.0,
),
];
let profile = rectangle_profile(frame_width, frame_depth);
let brep = sweep_profile_along_path(&path, &profile, SweepOptions::default())
- .expect("larger closed window frame sweep should succeed");
+ .expect("larger closed rectangular frame sweep should succeed");
brep.validate_topology()
- .expect("larger closed window frame topology should validate");
+ .expect("larger closed rectangular frame topology should validate");
- assert_window_frame_dimensions(
+ assert_rectangular_frame_dimensions(
&brep,
- window_width + frame_width * 2.0,
- window_width,
- window_height + frame_width * 2.0,
- window_height,
+ aperture_width + frame_width * 2.0,
+ aperture_width,
+ aperture_height + frame_width * 2.0,
+ aperture_height,
);
}
#[test]
- fn open_door_frame_path_keeps_requested_widths() {
+ fn open_u_frame_path_keeps_requested_widths() {
let panel_width = 1.0;
let frame_width = 0.2;
let frame_depth = 0.3;
- let door_height = 2.1;
+ let opening_height = 2.1;
let half_panel_width = panel_width * 0.5;
let half_frame_width = frame_width * 0.5;
@@ -1375,12 +1375,12 @@ mod tests {
Vector3::new(-(half_panel_width + half_frame_width), 0.0, 0.0),
Vector3::new(
-(half_panel_width + half_frame_width),
- door_height + half_frame_width,
+ opening_height + half_frame_width,
0.0,
),
Vector3::new(
half_panel_width + half_frame_width,
- door_height + half_frame_width,
+ opening_height + half_frame_width,
0.0,
),
Vector3::new(half_panel_width + half_frame_width, 0.0, 0.0),
@@ -1388,9 +1388,9 @@ mod tests {
let profile = rectangle_profile(frame_width, frame_depth);
let brep = sweep_profile_along_path(&path, &profile, SweepOptions::default())
- .expect("open door frame sweep should succeed");
+ .expect("open open frame sweep should succeed");
brep.validate_topology()
- .expect("open door frame topology should validate");
+ .expect("open open frame topology should validate");
let tolerance = 1.0e-6;
let xs = unique_sorted_values(
@@ -1402,19 +1402,19 @@ mod tests {
tolerance,
);
- assert_eq!(xs.len(), 4, "door frame should preserve four x bands");
- assert_eq!(ys.len(), 3, "door frame should preserve three y bands");
+ assert_eq!(xs.len(), 4, "open frame should preserve four x bands");
+ assert_eq!(ys.len(), 3, "open frame should preserve three y bands");
assert!(
((xs[xs.len() - 1] - xs[0]) - (panel_width + frame_width * 2.0)).abs() <= tolerance,
- "door frame outer width changed unexpectedly"
+ "open frame outer width changed unexpectedly"
);
assert!(
((xs[xs.len() - 2] - xs[1]) - panel_width).abs() <= tolerance,
- "door frame inner width changed unexpectedly"
+ "open frame inner width changed unexpectedly"
);
assert!(
- ((ys[ys.len() - 1] - ys[0]) - (door_height + frame_width)).abs() <= tolerance,
- "door frame outer height changed unexpectedly"
+ ((ys[ys.len() - 1] - ys[0]) - (opening_height + frame_width)).abs() <= tolerance,
+ "open frame outer height changed unexpectedly"
);
}
diff --git a/main/opengeometry/tests/annular_sector_round_opening.rs b/main/opengeometry/tests/annular_sector_round_opening.rs
new file mode 100644
index 0000000..71ef052
--- /dev/null
+++ b/main/opengeometry/tests/annular_sector_round_opening.rs
@@ -0,0 +1,304 @@
+use opengeometry::analytic::{
+ booleans::{boolean_brep, BooleanOp},
+ export_curve::fit_intersection_curve,
+ face_intersection::intersect_breps,
+ primitives,
+ query::{classify_point, PointClassification},
+ tessellation::tessellate,
+ topology::Accuracy,
+ Frame3,
+};
+
+#[test]
+fn horizontal_cylinder_cuts_a_tessellated_round_opening_through_an_annular_sector_extrusion() {
+ let accuracy = Accuracy {
+ geometric: 6.3e-8,
+ intersection: 1.575e-8,
+ tessellation: 0.01,
+ exchange: 1e-5,
+ };
+ let host_frame = Frame3 {
+ origin: [0.0, 0.0, 0.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ };
+ let host = primitives::annular_sector_extrusion(
+ "host".into(),
+ host_frame,
+ 3.0,
+ 0.3,
+ 3.0,
+ 0.8,
+ -1.6,
+ accuracy,
+ )
+ .unwrap();
+ let cutter_frame = Frame3 {
+ origin: [2.7, 1.5, 0.0],
+ x: [0.0, 0.0, -1.0],
+ y: [0.0, 1.0, 0.0],
+ z: [1.0, 0.0, 0.0],
+ };
+ let cutter =
+ primitives::cylinder("round-opening".into(), cutter_frame, 0.5, 0.6, accuracy).unwrap();
+
+ let result = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ "host-with-round-opening".into(),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ let mesh = tessellate(&result.brep, 0.01, 2_000_000).unwrap();
+
+ assert_eq!(result.brep.topology.faces.len(), 7);
+ assert!(!mesh.indices.is_empty());
+ assert!(mesh.triangle_face_ids.contains(&6));
+}
+
+#[test]
+fn horizontal_cylinder_cuts_the_equivalent_arc_edged_extrusion() {
+ let accuracy = Accuracy {
+ geometric: 6.3e-8,
+ intersection: 1.575e-8,
+ tessellation: 0.01,
+ exchange: 1e-5,
+ };
+ let host_frame = Frame3 {
+ origin: [0.0, 0.0, 0.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ };
+ let outer_start = 0.8_f64;
+ let inner_end = -0.8_f64;
+ let point = |radius: f64, angle: f64| [radius * angle.cos(), radius * angle.sin()];
+ let host = primitives::arc_edged_extrusion(
+ "arc-host".into(),
+ host_frame,
+ vec![
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 3.0,
+ start_angle: outer_start,
+ sweep_angle: -1.6,
+ },
+ primitives::ProfileEdge::Line {
+ from: point(3.0, inner_end),
+ to: point(2.7, inner_end),
+ },
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.7,
+ start_angle: inner_end,
+ sweep_angle: 1.6,
+ },
+ primitives::ProfileEdge::Line {
+ from: point(2.7, outer_start),
+ to: point(3.0, outer_start),
+ },
+ ],
+ 3.0,
+ accuracy,
+ )
+ .unwrap();
+ let aperture_radius = 0.5_f64;
+ // The radial cutter must start inside the cavity across its full aperture.
+ // The inner curved face reaches sqrt(2.7² − 0.5²) ≈ 2.6533 m there;
+ // starting at nominal radius 2.7 m is tangent, not a through-opening.
+ let cutter_start = 2.65;
+ let cutter = primitives::cylinder(
+ "round-opening".into(),
+ Frame3 {
+ origin: [cutter_start, 1.5, 0.0],
+ x: [0.0, 0.0, -1.0],
+ y: [0.0, 1.0, 0.0],
+ z: [1.0, 0.0, 0.0],
+ },
+ aperture_radius,
+ 3.05 - cutter_start,
+ accuracy,
+ )
+ .unwrap();
+ let graph = intersect_breps(&host, &cutter).unwrap();
+ assert_eq!(graph.pairs.len(), 2);
+ for pair in &graph.pairs {
+ assert_eq!(pair.graph.branches.len(), 1);
+ assert!(!pair.graph.coincident);
+ assert!(pair.graph.contacts.is_empty());
+ let branch = &pair.graph.branches[0];
+ assert!(branch.endpoints[0]
+ .iter()
+ .zip(branch.endpoints[1])
+ .all(|(left, right)| (left - right).abs() <= accuracy.geometric));
+ }
+ let result = boolean_brep(
+ &host,
+ &cutter,
+ BooleanOp::Subtraction,
+ "arc-host-with-round-opening".into(),
+ )
+ .unwrap();
+ result.brep.validate().unwrap();
+ tessellate(&result.brep, 0.01, 2_000_000).unwrap();
+ for definition in 0..result.brep.geometry.intersections.len() {
+ let fit = fit_intersection_curve(
+ &result.brep.geometry,
+ definition as u32,
+ accuracy.exchange,
+ 20_000,
+ )
+ .unwrap();
+ assert!(fit.error_bound <= accuracy.exchange);
+ let trace = &result.brep.geometry.intersections[definition];
+ for (segment, pair) in trace.anchors.windows(2).enumerate().step_by(11) {
+ let lo = pair[0].parameter + 0.1 * (pair[1].parameter - pair[0].parameter);
+ let hi = pair[0].parameter + 0.9 * (pair[1].parameter - pair[0].parameter);
+ let range = opengeometry::math::interval::Interval::new(lo, hi).unwrap();
+ let bound = trace
+ .certified_chord_deviation(range, &result.brep.geometry)
+ .unwrap()
+ .expect("perpendicular cylinder segment has a bound");
+ let a = trace.evaluate(lo, &result.brep.geometry).unwrap().point;
+ let b = trace.evaluate(hi, &result.brep.geometry).unwrap().point;
+ let chord = [b[0] - a[0], b[1] - a[1], b[2] - a[2]];
+ let chord_squared = chord.iter().map(|value| value * value).sum::();
+ for sample in 0..=32 {
+ let t = lo + (hi - lo) * sample as f64 / 32.0;
+ let point = trace.evaluate(t, &result.brep.geometry).unwrap().point;
+ let offset = [point[0] - a[0], point[1] - a[1], point[2] - a[2]];
+ let portion = (offset.iter().zip(chord).map(|(x, y)| x * y).sum::()
+ / chord_squared)
+ .clamp(0.0, 1.0);
+ let distance = (0..3)
+ .map(|axis| (point[axis] - a[axis] - portion * chord[axis]).powi(2))
+ .sum::()
+ .sqrt();
+ assert!(
+ distance <= bound,
+ "trace {definition} segment {segment}: {distance} > {bound}"
+ );
+ }
+ }
+ }
+ let (step, report) =
+ opengeometry::analytic::exchange::export_step(&result.brep, "metre").unwrap();
+ assert_eq!(report.solids, 1);
+ assert!(step.contains("B_SPLINE_CURVE_WITH_KNOTS"));
+ assert_eq!(
+ classify_point(&result.brep, [2.8, 1.5, 0.0]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [2.8, 2.2, 0.0]).unwrap(),
+ PointClassification::Inside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [2.8, 1.5, 0.7]).unwrap(),
+ PointClassification::Inside
+ );
+}
+
+#[test]
+fn arched_annular_sector_extrusion_opening_reuses_the_spring_edges_and_an_analytic_cylinder_cap() {
+ let accuracy = Accuracy {
+ geometric: 6.3e-8,
+ intersection: 1.575e-8,
+ tessellation: 0.01,
+ exchange: 1e-5,
+ };
+ let host_frame = Frame3 {
+ origin: [0.0, 0.0, 0.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ };
+ let host = primitives::annular_sector_extrusion_with_arched_opening(
+ "arched-host".into(),
+ host_frame,
+ 3.0,
+ 0.3,
+ 3.0,
+ 1.6,
+ -1.6,
+ primitives::AnnularSectorOpening {
+ id: "arched-opening".into(),
+ angle: 0.8,
+ width: 1.0,
+ bottom: 0.5,
+ height: 1.25,
+ },
+ accuracy,
+ )
+ .unwrap();
+
+ host.validate().unwrap();
+ assert_eq!(host.topology.faces.len(), 10);
+ assert!(host
+ .topology
+ .faces
+ .iter()
+ .any(|face| face.key == "opening-0-arch"));
+ assert!(host.geometry.curves.iter().any(|curve| matches!(
+ curve,
+ opengeometry::analytic::CurveGeometry::Intersection { .. }
+ )));
+ let mesh = tessellate(&host, 0.01, 2_000_000).unwrap();
+ assert!(!mesh.indices.is_empty());
+}
+
+#[test]
+fn box_arched_opening_has_shared_circle_edges_and_a_cylindrical_arch_cap() {
+ let accuracy = Accuracy {
+ geometric: 4.0e-8,
+ intersection: 1.0e-8,
+ tessellation: 0.01,
+ exchange: 1e-5,
+ };
+ let host = primitives::box_with_arched_opening(
+ "straight-arched-host".into(),
+ Frame3::IDENTITY,
+ 4.0,
+ 0.3,
+ 3.0,
+ primitives::BoxArchedOpening {
+ id: "arched-opening".into(),
+ station: 2.0,
+ width: 1.0,
+ bottom: 0.4,
+ height: 1.4,
+ },
+ accuracy,
+ )
+ .unwrap();
+
+ host.validate().unwrap();
+ let arch_cap = host
+ .topology
+ .faces
+ .iter()
+ .find(|face| face.key == "arched-opening-arched-cap")
+ .unwrap();
+ assert!(matches!(
+ host.geometry.surfaces[arch_cap.surface as usize],
+ opengeometry::analytic::SurfaceGeometry::Cylinder { .. }
+ ));
+ assert_eq!(
+ host.topology
+ .edges
+ .iter()
+ .filter(|edge| matches!(
+ edge.geometry,
+ opengeometry::analytic::topology::EdgeGeometry::Curve { curve, .. }
+ if matches!(host.geometry.curves[curve as usize], opengeometry::analytic::CurveGeometry::Circle { .. })
+ ))
+ .count(),
+ 2
+ );
+ let mesh = tessellate(&host, 0.01, 2_000_000).unwrap();
+ assert!(mesh.triangle_face_ids.contains(&arch_cap.id));
+ let (_, report) = opengeometry::analytic::exchange::export_step(&host, "metre").unwrap();
+ assert_eq!(report.solids, 1);
+}
diff --git a/main/opengeometry/tests/circular_wall_round_opening.rs b/main/opengeometry/tests/circular_wall_round_opening.rs
deleted file mode 100644
index 813c479..0000000
--- a/main/opengeometry/tests/circular_wall_round_opening.rs
+++ /dev/null
@@ -1,156 +0,0 @@
-use opengeometry::analytic::{
- booleans::{boolean_brep, BooleanOp},
- primitives,
- tessellation::tessellate,
- topology::Accuracy,
- Frame3,
-};
-
-#[test]
-fn horizontal_cylinder_cuts_a_tessellated_round_opening_through_a_circular_wall() {
- let accuracy = Accuracy {
- geometric: 6.3e-8,
- intersection: 1.575e-8,
- tessellation: 0.01,
- exchange: 1e-5,
- };
- let wall_frame = Frame3 {
- origin: [0.0, 0.0, 0.0],
- x: [1.0, 0.0, 0.0],
- y: [0.0, 0.0, -1.0],
- z: [0.0, 1.0, 0.0],
- };
- let wall = primitives::circular_wall(
- "wall".into(),
- wall_frame,
- 3.0,
- 0.3,
- 3.0,
- 0.8,
- -1.6,
- accuracy,
- )
- .unwrap();
- let cutter_frame = Frame3 {
- origin: [2.7, 1.5, 0.0],
- x: [0.0, 0.0, -1.0],
- y: [0.0, 1.0, 0.0],
- z: [1.0, 0.0, 0.0],
- };
- let cutter =
- primitives::cylinder("round-opening".into(), cutter_frame, 0.5, 0.6, accuracy).unwrap();
-
- let result = boolean_brep(
- &wall,
- &cutter,
- BooleanOp::Subtraction,
- "wall-with-round-opening".into(),
- )
- .unwrap();
- result.brep.validate().unwrap();
- let mesh = tessellate(&result.brep, 0.01, 2_000_000).unwrap();
-
- assert_eq!(result.brep.topology.faces.len(), 7);
- assert!(!mesh.indices.is_empty());
- assert!(mesh.triangle_face_ids.contains(&6));
-}
-
-#[test]
-fn arched_circular_wall_opening_reuses_the_spring_edges_and_an_analytic_cylinder_cap() {
- let accuracy = Accuracy {
- geometric: 6.3e-8,
- intersection: 1.575e-8,
- tessellation: 0.01,
- exchange: 1e-5,
- };
- let wall_frame = Frame3 {
- origin: [0.0, 0.0, 0.0],
- x: [1.0, 0.0, 0.0],
- y: [0.0, 0.0, -1.0],
- z: [0.0, 1.0, 0.0],
- };
- let wall = primitives::circular_wall_with_arched_opening(
- "arched-wall".into(),
- wall_frame,
- 3.0,
- 0.3,
- 3.0,
- 1.6,
- -1.6,
- primitives::CircularWallOpening {
- id: "arched-opening".into(),
- angle: 0.8,
- width: 1.0,
- bottom: 0.5,
- height: 1.25,
- },
- accuracy,
- )
- .unwrap();
-
- wall.validate().unwrap();
- assert_eq!(wall.topology.faces.len(), 10);
- assert!(wall
- .topology
- .faces
- .iter()
- .any(|face| face.key == "opening-0-arch"));
- assert!(wall.geometry.curves.iter().any(|curve| matches!(
- curve,
- opengeometry::analytic::CurveGeometry::Intersection { .. }
- )));
- let mesh = tessellate(&wall, 0.01, 2_000_000).unwrap();
- assert!(!mesh.indices.is_empty());
-}
-
-#[test]
-fn straight_wall_arched_opening_has_shared_circle_edges_and_a_cylindrical_header() {
- let accuracy = Accuracy {
- geometric: 4.0e-8,
- intersection: 1.0e-8,
- tessellation: 0.01,
- exchange: 1e-5,
- };
- let wall = primitives::straight_wall_with_arched_opening(
- "straight-arched-wall".into(),
- Frame3::IDENTITY,
- 4.0,
- 0.3,
- 3.0,
- primitives::StraightWallArchedOpening {
- id: "arched-opening".into(),
- station: 2.0,
- width: 1.0,
- bottom: 0.4,
- height: 1.4,
- },
- accuracy,
- )
- .unwrap();
-
- wall.validate().unwrap();
- let header = wall
- .topology
- .faces
- .iter()
- .find(|face| face.key == "arched-opening-arched-header")
- .unwrap();
- assert!(matches!(
- wall.geometry.surfaces[header.surface as usize],
- opengeometry::analytic::SurfaceGeometry::Cylinder { .. }
- ));
- assert_eq!(
- wall.topology
- .edges
- .iter()
- .filter(|edge| matches!(
- edge.geometry,
- opengeometry::analytic::topology::EdgeGeometry::Curve { curve, .. }
- if matches!(wall.geometry.curves[curve as usize], opengeometry::analytic::CurveGeometry::Circle { .. })
- ))
- .count(),
- 2
- );
- let mesh = tessellate(&wall, 0.01, 2_000_000).unwrap();
- assert!(mesh.triangle_face_ids.contains(&header.id));
-}
diff --git a/main/opengeometry/tests/curved_mixed_openings.rs b/main/opengeometry/tests/curved_mixed_openings.rs
new file mode 100644
index 0000000..024e805
--- /dev/null
+++ b/main/opengeometry/tests/curved_mixed_openings.rs
@@ -0,0 +1,102 @@
+use opengeometry::analytic::{
+ booleans::subtract_planar_cutters,
+ primitives,
+ query::{classify_point, PointClassification},
+ tessellation::tessellate,
+ topology::Accuracy,
+ Frame3,
+};
+
+#[test]
+fn disjoint_planar_and_round_openings_share_a_curved_host_height_level() {
+ let accuracy = Accuracy {
+ geometric: 6.3e-8,
+ intersection: 1.575e-8,
+ tessellation: 0.01,
+ exchange: 1e-5,
+ };
+ let host_frame = Frame3 {
+ origin: [0.0, 0.0, 0.0],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 0.0, -1.0],
+ z: [0.0, 1.0, 0.0],
+ };
+ let point = |radius: f64, angle: f64| [radius * angle.cos(), radius * angle.sin()];
+ let host = primitives::arc_edged_extrusion(
+ "mixed-curved-host".into(),
+ host_frame,
+ vec![
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 3.0,
+ start_angle: 0.8,
+ sweep_angle: -1.6,
+ },
+ primitives::ProfileEdge::Line {
+ from: point(3.0, -0.8),
+ to: point(2.7, -0.8),
+ },
+ primitives::ProfileEdge::Arc {
+ center: [0.0, 0.0],
+ radius: 2.7,
+ start_angle: -0.8,
+ sweep_angle: 1.6,
+ },
+ primitives::ProfileEdge::Line {
+ from: point(2.7, 0.8),
+ to: point(3.0, 0.8),
+ },
+ ],
+ 3.0,
+ accuracy,
+ )
+ .unwrap();
+ let rectangle = primitives::cuboid(
+ "rectangular-opening".into(),
+ Frame3 {
+ origin: [2.2, 1.0, 1.1],
+ x: [1.0, 0.0, 0.0],
+ y: [0.0, 1.0, 0.0],
+ z: [0.0, 0.0, 1.0],
+ },
+ [1.0, 0.5, 0.4],
+ accuracy,
+ )
+ .unwrap();
+ let round = primitives::cylinder(
+ "round-opening".into(),
+ Frame3 {
+ origin: [2.65, 1.5, 0.0],
+ x: [0.0, 0.0, -1.0],
+ y: [0.0, 1.0, 0.0],
+ z: [1.0, 0.0, 0.0],
+ },
+ 0.5,
+ 0.4,
+ accuracy,
+ )
+ .unwrap();
+ for cutters in [
+ [rectangle.clone(), round.clone()],
+ [round.clone(), rectangle.clone()],
+ ] {
+ let result = subtract_planar_cutters(&host, &cutters, "mixed-curved-cut".into()).unwrap();
+ result.brep.validate().unwrap();
+ tessellate(&result.brep, 0.01, 2_000_000).unwrap();
+ assert_eq!(
+ classify_point(&result.brep, [2.6, 1.25, 1.3]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [2.8, 1.5, 0.0]).unwrap(),
+ PointClassification::Outside
+ );
+ assert_eq!(
+ classify_point(&result.brep, [2.8, 2.5, 0.0]).unwrap(),
+ PointClassification::Inside
+ );
+ let (_, report) =
+ opengeometry::analytic::exchange::export_step(&result.brep, "metre").unwrap();
+ assert_eq!(report.solids, 1);
+ }
+}
diff --git a/main/opengeometry/tests/step_roundtrip_oracle.rs b/main/opengeometry/tests/step_roundtrip_oracle.rs
index 16411ac..81a80a6 100644
--- a/main/opengeometry/tests/step_roundtrip_oracle.rs
+++ b/main/opengeometry/tests/step_roundtrip_oracle.rs
@@ -80,11 +80,11 @@ fn exported_cylinder_round_trips_as_one_analytic_cylinder() {
);
}
- // No planar facet fan stands in for the curved wall: the cylinder is one
+ // No planar facet fan stands in for the curved surface: the cylinder is one
// advanced face, so advanced faces are few (cylinder + 2 caps).
assert!(
text.matches("ADVANCED_FACE").count() <= 4,
- "cylinder wall must not be a facet fan"
+ "cylindrical face must not be a facet fan"
);
}
diff --git a/package.json b/package.json
index 4bf6c2f..02755ed 100644
--- a/package.json
+++ b/package.json
@@ -3,7 +3,7 @@
"author": "Vishwajeet Vinayak Mane",
"license": "MPL-2.0",
"version": "2.0.14",
- "description": "CAD kernel for the web built with Rust, WebAssembly, and Three.js for browser CAD, AEC/BIM, and geometry-heavy tools.",
+ "description": "CAD geometry kernel for the web built with Rust, WebAssembly, and Three.js.",
"type": "module",
"main": "index.js",
"scripts": {
@@ -11,7 +11,8 @@
"test": "cargo test --manifest-path main/opengeometry/Cargo.toml && cargo test --examples --manifest-path main/opengeometry/Cargo.toml",
"lint": "eslint main/opengeometry-three/src/**/*.ts --fix",
"lint:check": "eslint main/opengeometry-three/src/**/*.ts",
- "build-three": "rollup -c rollup.config.js",
+ "clean-dist": "node ./scripts/clean-dist.mjs",
+ "build-three": "npm run clean-dist && rollup -c rollup.config.js",
"build-example-three": "npm --prefix main/opengeometry-three run build-example-three",
"build-core": "cd main/opengeometry && wasm-pack build --target web && cargo build --release",
"prepare-dist": "node ./scripts/prepare-dist.mjs",
@@ -32,8 +33,6 @@
"geometry",
"webassembly",
"wasm",
- "aec",
- "bim",
"ifc",
"step",
"stl",
diff --git a/scripts/clean-dist.mjs b/scripts/clean-dist.mjs
new file mode 100644
index 0000000..222354a
--- /dev/null
+++ b/scripts/clean-dist.mjs
@@ -0,0 +1,4 @@
+import { rm } from "node:fs/promises";
+import { fileURLToPath } from "node:url";
+
+await rm(fileURLToPath(new URL("../dist", import.meta.url)), { recursive: true, force: true });