diff --git a/AI-DOCs/2026-03-04-boolean-operations-implementation.md b/AI-DOCs/2026-03-04-boolean-operations-implementation.md new file mode 100644 index 0000000..f700d8e --- /dev/null +++ b/AI-DOCs/2026-03-04-boolean-operations-implementation.md @@ -0,0 +1,71 @@ +# 2026-03-04 — boolean-operations-implementation + +## What changed + +Implemented a new boolean operation system in `opengeometry-three` that can combine **any two `THREE.Mesh`-compatible OpenGeometry shapes** (cuboid, cylinder, sphere, wedge, opening, sweep, polygon-derived meshes) using: + +- `union` +- `subtract` +- `intersect` + +The implementation introduces: + +1. `src/operations/boolean.ts` + - `booleanOperation(left, right, options)` API + - `BooleanShape` mesh result type + - Configurable constraints (`gridResolution`, `constrainResultToPositiveY`, material controls) +2. `src/operations/index.ts` export barrel +3. Public package exports in `main/opengeometry-three/index.ts` +4. Runnable Vite example: + - `examples-vite/operations/boolean.html` + - `examples-vite/src/pages/operations-boolean.ts` +5. Example index update to surface the Boolean operation card. + +## Why it changed + +The project needed an end-to-end boolean workflow to generate a new resulting shape from two input shapes under configurable constraints, and make that available through JS bindings and the `opengeometry-three` package surface. + +Given the environment restriction preventing new registry downloads during this task, the implementation uses a dependency-free voxel classification strategy over existing `three` APIs rather than external CSG packages. + +## Primary-source alignment (RobustBoolean paper) + +The implementation follows the robustness direction from robust boolean literature by prioritizing **classification stability** over exact floating-point surface-surface reconstruction: + +- Input solids are transformed into a common field domain. +- Occupancy is decided with consistent inside/outside tests. +- Boolean logic is applied on classified cells. +- Surface is extracted from cell boundary transitions. + +This avoids common triangle-triangle degeneracy failure modes in direct mesh clipping and gives predictable behavior across diverse shape families. + +## How to test locally + +From repository root: + +1. Build/check Rust core: + - `cargo check --manifest-path main/opengeometry/Cargo.toml` + - `cargo test --manifest-path main/opengeometry/Cargo.toml` +2. Build Three examples: + - `npm --prefix main/opengeometry-three run build-example-three` +3. Run example dev server: + - `npm --prefix main/opengeometry-three run dev-example-three` +4. Open: + - `/operations/boolean.html` + +Interactive controls: +- operation (union/subtract/intersect) +- grid resolution +- shape offset +- positive-Y clamp + +## Backward compatibility + +- No existing APIs were removed. +- Existing shape wrappers are unchanged. +- New functionality is additive and exposed via new exports. + +## Known caveats / follow-ups + +1. Current boolean result is voxelized (resolution-controlled), so edges are stepped. +2. Higher grid resolution improves fidelity but increases compute time. +3. Next iteration: optional exact-surface backend (when dependency/network policy allows). diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index d4b3d00..7c7d9c4 100644 --- a/main/opengeometry-three/examples-vite/index.html +++ b/main/opengeometry-three/examples-vite/index.html @@ -134,7 +134,7 @@

Shapes

Operations

-

3 items

+

4 items

@@ -153,6 +153,14 @@

Operations

Control
Thickness
+ +

Boolean

new

+

Boolean union/subtract/intersection across kernel shapes with resolution constraints.

+
+
Domain
Kernel Ops
+
Control
Op, Grid, Clamp
+
+

Sweep Path + Profile

ready

Operation-level sweep from path primitive + profile primitive.

diff --git a/main/opengeometry-three/examples-vite/operations/boolean.html b/main/opengeometry-three/examples-vite/operations/boolean.html new file mode 100644 index 0000000..dc870cb --- /dev/null +++ b/main/opengeometry-three/examples-vite/operations/boolean.html @@ -0,0 +1,12 @@ + + + + + + OpenGeometry • Boolean Operation + + +
+ + + diff --git a/main/opengeometry-three/examples-vite/src/pages/operations-boolean.ts b/main/opengeometry-three/examples-vite/src/pages/operations-boolean.ts new file mode 100644 index 0000000..d322903 --- /dev/null +++ b/main/opengeometry-three/examples-vite/src/pages/operations-boolean.ts @@ -0,0 +1,73 @@ +import { Cuboid, Cylinder, Sphere, Vector3, booleanOperation, type BooleanOperationType } from "@og-three"; +import * as THREE from "three"; +import { bootstrapExample, mountControls, replaceSceneObject } from "../shared/runtime"; + +function resolveOperation(mode: number): BooleanOperationType { + if (mode < 0.5) { + return "union"; + } + if (mode < 1.5) { + return "subtract"; + } + return "intersect"; +} + +bootstrapExample({ + title: "Operation: Boolean", + description: "Voxel-robust boolean operation between any two OpenGeometry shapes.", + build: ({ scene }) => { + let resultMesh: THREE.Mesh | null = null; + + mountControls( + "Boolean Parameters", + [ + { type: "number", key: "operation", label: "Operation (0:union,1:subtract,2:intersect)", min: 0, max: 2, step: 1, value: 0 }, + { type: "number", key: "resolution", label: "Grid Resolution", min: 8, max: 40, step: 1, value: 26 }, + { type: "number", key: "offset", label: "Offset", min: -1.2, max: 1.2, step: 0.05, value: 0.35 }, + { type: "boolean", key: "positiveY", label: "Clamp to Positive Y", value: false }, + ], + (state) => { + const left = new Cuboid({ + center: new Vector3(-0.2, 0, 0), + width: 2.2, + height: 1.8, + depth: 2.0, + color: 0x9ca3af, + }); + + const right = new Cylinder({ + center: new Vector3(state.offset as number, 0, 0.15), + radius: 0.9, + height: 2.2, + segments: 48, + color: 0x6b7280, + }); + + const cap = new Sphere({ + center: new Vector3((state.offset as number) * 0.5, 0.35, 0), + radius: 0.85, + widthSegments: 28, + heightSegments: 18, + color: 0x4b5563, + }); + + const stagedRight = booleanOperation(right, cap, { + operation: "union", + gridResolution: Math.max(8, (state.resolution as number) - 6), + color: 0x6b7280, + opacity: 0.2, + }); + + const result = booleanOperation(left, stagedRight, { + operation: resolveOperation(state.operation as number), + gridResolution: state.resolution as number, + constrainResultToPositiveY: state.positiveY as boolean, + color: 0x2563eb, + opacity: 0.72, + }); + + resultMesh = replaceSceneObject(scene, resultMesh, result); + } + ); + }, +}); diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts index 01a6e71..d5a611f 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -666,3 +666,7 @@ export * from './src/shapes/'; * Reusable example builders for quickly wiring demo scenes. */ export * from './src/examples/'; + +export * from "./src/shapes"; +export * from "./src/primitives"; +export * from "./src/operations"; diff --git a/main/opengeometry-three/src/examples/boolean.ts b/main/opengeometry-three/src/examples/boolean.ts new file mode 100644 index 0000000..c0e92c7 --- /dev/null +++ b/main/opengeometry-three/src/examples/boolean.ts @@ -0,0 +1,33 @@ +import * as THREE from "three"; +import { Vector3 } from "../../../opengeometry/pkg/opengeometry"; +import { Cuboid } from "../shapes/cuboid"; +import { Cylinder } from "../shapes/cylinder"; +import { booleanOperation } from "../operations"; + +export function createBooleanExample(scene: THREE.Scene) { + const left = new Cuboid({ + center: new Vector3(0, 0, 0), + width: 2, + height: 2, + depth: 2, + color: 0x6b7280, + }); + + const right = new Cylinder({ + center: new Vector3(0.55, 0, 0), + radius: 0.95, + height: 2.2, + segments: 42, + color: 0x9ca3af, + }); + + const result = booleanOperation(left, right, { + operation: "subtract", + gridResolution: 30, + color: 0x2563eb, + }); + + scene.add(result); + + return { left, right, result }; +} diff --git a/main/opengeometry-three/src/examples/index.ts b/main/opengeometry-three/src/examples/index.ts index 1e66161..b397407 100644 --- a/main/opengeometry-three/src/examples/index.ts +++ b/main/opengeometry-three/src/examples/index.ts @@ -6,3 +6,5 @@ export * from './shapes'; export * from './sweep'; export * from './offset'; export * from './wall-from-offsets'; + +export * from './boolean'; diff --git a/main/opengeometry-three/src/operations/boolean.ts b/main/opengeometry-three/src/operations/boolean.ts new file mode 100644 index 0000000..36105ee --- /dev/null +++ b/main/opengeometry-three/src/operations/boolean.ts @@ -0,0 +1,234 @@ +import * as THREE from "three"; + +export type BooleanOperationType = "union" | "subtract" | "intersect"; + +export interface BooleanOperationOptions { + operation: BooleanOperationType; + color?: number; + opacity?: number; + gridResolution?: number; + constrainResultToPositiveY?: boolean; +} + +interface BooleanField { + min: THREE.Vector3; + max: THREE.Vector3; + step: THREE.Vector3; + nx: number; + ny: number; + nz: number; + cells: Uint8Array; +} + +export class BooleanShape extends THREE.Mesh { + readonly leftId: string; + readonly rightId: string; + readonly operation: BooleanOperationType; + + constructor( + leftId: string, + rightId: string, + operation: BooleanOperationType, + geometry: THREE.BufferGeometry, + material: THREE.Material + ) { + super(geometry, material); + this.leftId = leftId; + this.rightId = rightId; + this.operation = operation; + } +} + +function buildMaterial(options: BooleanOperationOptions): THREE.MeshStandardMaterial { + return new THREE.MeshStandardMaterial({ + color: options.color ?? 0x3b82f6, + metalness: 0, + roughness: 0.7, + transparent: true, + opacity: options.opacity ?? 0.72, + side: THREE.DoubleSide, + }); +} + +function flattenedMesh(mesh: THREE.Mesh): THREE.Mesh { + const geometry = mesh.geometry.clone(); + mesh.updateWorldMatrix(true, false); + geometry.applyMatrix4(mesh.matrixWorld); + geometry.computeBoundingBox(); + return new THREE.Mesh(geometry, new THREE.MeshBasicMaterial()); +} + +const raycaster = new THREE.Raycaster(); +const direction = new THREE.Vector3(1, 0.1337, 0.017); + +function isPointInsideMesh(point: THREE.Vector3, mesh: THREE.Mesh): boolean { + raycaster.set(point, direction); + const intersects = raycaster.intersectObject(mesh, false); + return intersects.length % 2 === 1; +} + +function combineBounds(left: THREE.Mesh, right: THREE.Mesh): { min: THREE.Vector3; max: THREE.Vector3 } { + const leftBox = new THREE.Box3().setFromObject(left); + const rightBox = new THREE.Box3().setFromObject(right); + const min = leftBox.min.clone().min(rightBox.min); + const max = leftBox.max.clone().max(rightBox.max); + return { min, max }; +} + +function resultOccupancy(operation: BooleanOperationType, inLeft: boolean, inRight: boolean): boolean { + switch (operation) { + case "union": + return inLeft || inRight; + case "subtract": + return inLeft && !inRight; + case "intersect": + return inLeft && inRight; + } +} + +function createBooleanField(left: THREE.Mesh, right: THREE.Mesh, options: BooleanOperationOptions): BooleanField { + const resolution = Math.max(8, Math.min(64, Math.floor(options.gridResolution ?? 26))); + const { min, max } = combineBounds(left, right); + const size = max.clone().sub(min); + + const largest = Math.max(size.x, size.y, size.z, 1e-3); + const nx = Math.max(1, Math.ceil((size.x / largest) * resolution)); + const ny = Math.max(1, Math.ceil((size.y / largest) * resolution)); + const nz = Math.max(1, Math.ceil((size.z / largest) * resolution)); + + const step = new THREE.Vector3(size.x / nx, size.y / ny, size.z / nz); + const cells = new Uint8Array(nx * ny * nz); + + const leftFlat = flattenedMesh(left); + const rightFlat = flattenedMesh(right); + + const sample = new THREE.Vector3(); + let offset = 0; + for (let z = 0; z < nz; z++) { + for (let y = 0; y < ny; y++) { + for (let x = 0; x < nx; x++) { + sample.set( + min.x + (x + 0.5) * step.x, + min.y + (y + 0.5) * step.y, + min.z + (z + 0.5) * step.z + ); + + const inLeft = isPointInsideMesh(sample, leftFlat); + const inRight = isPointInsideMesh(sample, rightFlat); + cells[offset++] = resultOccupancy(options.operation, inLeft, inRight) ? 1 : 0; + } + } + } + + return { min, max, step, nx, ny, nz, cells }; +} + +function getCell(field: BooleanField, x: number, y: number, z: number): boolean { + if (x < 0 || y < 0 || z < 0 || x >= field.nx || y >= field.ny || z >= field.nz) { + return false; + } + + const index = x + y * field.nx + z * field.nx * field.ny; + return field.cells[index] === 1; +} + +function pushQuad( + positions: number[], + normals: number[], + indices: number[], + v0: THREE.Vector3, + v1: THREE.Vector3, + v2: THREE.Vector3, + v3: THREE.Vector3, + normal: THREE.Vector3 +) { + const base = positions.length / 3; + positions.push(v0.x, v0.y, v0.z, v1.x, v1.y, v1.z, v2.x, v2.y, v2.z, v3.x, v3.y, v3.z); + + for (let i = 0; i < 4; i++) { + normals.push(normal.x, normal.y, normal.z); + } + + indices.push(base, base + 1, base + 2, base, base + 2, base + 3); +} + +function buildGeometryFromField(field: BooleanField): THREE.BufferGeometry { + const positions: number[] = []; + const normals: number[] = []; + const indices: number[] = []; + + const origin = field.min; + const step = field.step; + + for (let z = 0; z < field.nz; z++) { + for (let y = 0; y < field.ny; y++) { + for (let x = 0; x < field.nx; x++) { + if (!getCell(field, x, y, z)) { + continue; + } + + const x0 = origin.x + x * step.x; + const x1 = x0 + step.x; + const y0 = origin.y + y * step.y; + const y1 = y0 + step.y; + const z0 = origin.z + z * step.z; + const z1 = z0 + step.z; + + if (!getCell(field, x + 1, y, z)) { + pushQuad(positions, normals, indices, new THREE.Vector3(x1, y0, z0), new THREE.Vector3(x1, y1, z0), new THREE.Vector3(x1, y1, z1), new THREE.Vector3(x1, y0, z1), new THREE.Vector3(1, 0, 0)); + } + if (!getCell(field, x - 1, y, z)) { + pushQuad(positions, normals, indices, new THREE.Vector3(x0, y0, z1), new THREE.Vector3(x0, y1, z1), new THREE.Vector3(x0, y1, z0), new THREE.Vector3(x0, y0, z0), new THREE.Vector3(-1, 0, 0)); + } + if (!getCell(field, x, y + 1, z)) { + pushQuad(positions, normals, indices, new THREE.Vector3(x0, y1, z1), new THREE.Vector3(x1, y1, z1), new THREE.Vector3(x1, y1, z0), new THREE.Vector3(x0, y1, z0), new THREE.Vector3(0, 1, 0)); + } + if (!getCell(field, x, y - 1, z)) { + pushQuad(positions, normals, indices, new THREE.Vector3(x0, y0, z0), new THREE.Vector3(x1, y0, z0), new THREE.Vector3(x1, y0, z1), new THREE.Vector3(x0, y0, z1), new THREE.Vector3(0, -1, 0)); + } + if (!getCell(field, x, y, z + 1)) { + pushQuad(positions, normals, indices, new THREE.Vector3(x1, y0, z1), new THREE.Vector3(x1, y1, z1), new THREE.Vector3(x0, y1, z1), new THREE.Vector3(x0, y0, z1), new THREE.Vector3(0, 0, 1)); + } + if (!getCell(field, x, y, z - 1)) { + pushQuad(positions, normals, indices, new THREE.Vector3(x0, y0, z0), new THREE.Vector3(x0, y1, z0), new THREE.Vector3(x1, y1, z0), new THREE.Vector3(x1, y0, z0), new THREE.Vector3(0, 0, -1)); + } + } + } + } + + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3)); + geometry.setAttribute("normal", new THREE.Float32BufferAttribute(normals, 3)); + geometry.setIndex(indices); + geometry.computeBoundingBox(); + return geometry; +} + +export function booleanOperation( + left: THREE.Mesh, + right: THREE.Mesh, + options: BooleanOperationOptions +): BooleanShape { + const field = createBooleanField(left, right, options); + const geometry = buildGeometryFromField(field); + + if (options.constrainResultToPositiveY && geometry.boundingBox) { + const minY = geometry.boundingBox.min.y; + if (minY < 0) { + geometry.translate(0, -minY, 0); + geometry.computeBoundingBox(); + } + } + + const booleanShape = new BooleanShape( + left.uuid, + right.uuid, + options.operation, + geometry, + buildMaterial(options) + ); + + booleanShape.castShadow = true; + booleanShape.receiveShadow = true; + return booleanShape; +} diff --git a/main/opengeometry-three/src/operations/index.ts b/main/opengeometry-three/src/operations/index.ts new file mode 100644 index 0000000..529f95e --- /dev/null +++ b/main/opengeometry-three/src/operations/index.ts @@ -0,0 +1,4 @@ +/** + * Boolean and other high-level operation utilities for OpenGeometry + Three.js. + */ +export * from './boolean';