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..cbac77a --- /dev/null +++ b/AI-DOCs/2026-03-04-boolean-operations-implementation.md @@ -0,0 +1,82 @@ +# 2026-03-04 Boolean Operations Implementation + +## What changed + +- Updated the kernel boolean module to support **BRep-like outlines** for boolean outputs rather than raw triangulation edges. +- `OGBoolean` remains stateful (`last_result`) and now computes outlines by edge-adjacency analysis: + - build undirected edge buckets from result polygons + - suppress edges shared by coplanar faces (triangle-split seams) + - apply a feature-angle filter so smooth tessellation edges are hidden + - keep boundary edges and sharp-feature edges +- `OGBoolean::get_outline_geometry_serialized` now returns this BRep-like feature outline buffer. +- Added kernel test `coplanar_shared_triangle_edge_is_removed_from_outline` to verify internal triangulation diagonals are not emitted. +- Updated Three.js boolean binding API: + - Introduced `BooleanOperationKind` and `parseBooleanOperation` for deterministic operation selection. + - `BooleanShape` uses kernel outline output as before, but operation parsing now avoids ambiguous string casting in examples. +- Updated boolean example (`operations-boolean.ts`): + - added **Show Outline** toggle + - kept left/right shape selectors (Cuboid, Sphere, Cylinder, Wedge) + - operation dropdown now resolves via `parseBooleanOperation` for consistent behavior + +## Why it changed + +Follow-up requirements requested: + +1. BRep-like outlines instead of triangulated outlines +2. consistency in operation selection behavior +3. an explicit outline toggle in the example + +These are now addressed directly in kernel + wrapper + example layers. + +## Robustness strategy + +Boolean CSG core still uses robust controls: + +- epsilon-based plane classification (`front`/`back`/`coplanar`/`spanning`) +- snap-grid normalization for deterministic splits +- post-op weld by epsilon snapping + +Outline extraction now adds topology-aware edge filtering to remove coplanar split seams and smooth-surface tessellation artifacts. + +## How to test locally + +1. Kernel checks: + +```bash +cargo fmt --manifest-path main/opengeometry/Cargo.toml +cargo test --manifest-path main/opengeometry/Cargo.toml +``` + +2. TS lint: + +```bash +npm run lint:check +``` + +3. Build wasm + three package: + +```bash +npm run build-core +npm run build-three +``` + +4. Example run: + +```bash +npm --prefix main/opengeometry-three run dev-example-three +``` + +Then open: + +- `main/opengeometry-three/examples-vite/operations/boolean.html` + +## Backward compatibility + +- Existing non-boolean wrappers remain unchanged. +- Boolean API stays additive; operation constants are still `BooleanOperation.Union|Intersection|Difference`. +- New parser helper improves call-site safety without breaking existing valid operation strings. + +## Known caveats and follow-ups + +- In this environment, wasm package artifacts (`main/opengeometry/pkg/opengeometry`) are unavailable, so full TS build/example rendering remains blocked. +- Existing repo-level lint errors outside this change still fail `npm run lint:check`. diff --git a/main/opengeometry-three/examples-vite/index.html b/main/opengeometry-three/examples-vite/index.html index d4b3d00..d6e0f41 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

+

Union / intersection / difference with numeric robustness controls.

+
+
Domain
Kernel Ops
+
Control
Op + Epsilon
+
+

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..5f19897 --- /dev/null +++ b/main/opengeometry-three/examples-vite/src/pages/operations-boolean.ts @@ -0,0 +1,175 @@ +import * as THREE from "three"; +import { Vector3 } from "../../../../opengeometry/pkg/opengeometry"; +import { + BooleanOperation, + BooleanShape, + Cuboid, + Cylinder, + parseBooleanOperation, + Sphere, + Wedge, +} from "@og-three"; +import { + bootstrapExample, + mountControls, + replaceSceneObject, +} from "../shared/runtime"; + +function buildOperand(kind: string, center: Vector3, color: number): THREE.Mesh { + switch (kind) { + case "sphere": { + const sphere = new Sphere({ + center, + radius: 0.9, + widthSegments: 30, + heightSegments: 20, + color, + }); + sphere.outline = true; + return sphere; + } + case "cylinder": { + const cylinder = new Cylinder({ + center, + radius: 0.7, + height: 1.9, + segments: 32, + angle: Math.PI * 2, + color, + }); + cylinder.outline = true; + return cylinder; + } + case "wedge": { + const wedge = new Wedge({ + center, + width: 1.6, + height: 1.8, + depth: 1.4, + color, + }); + wedge.outline = true; + return wedge; + } + default: { + const cuboid = new Cuboid({ + center, + width: 1.6, + height: 1.8, + depth: 1.5, + color, + }); + cuboid.outline = true; + return cuboid; + } + } +} + +void bootstrapExample({ + title: "Boolean (Union / Intersection / Difference)", + description: + "Boolean operations over Cuboid, Sphere, Cylinder, and Wedge operands with kernel-generated outlines.", + build: ({ scene }) => { + let result: THREE.Mesh | null = null; + + const update = (state: Record) => { + const left = buildOperand( + String(state.leftShape), + new Vector3(-0.55, 0.9, 0), + 0x10b981 + ); + const right = buildOperand( + String(state.rightShape), + new Vector3(0.55, 0.9, 0), + 0xf97316 + ); + + const operation = parseBooleanOperation(String(state.operation)); + + const boolean = new BooleanShape(left, right, operation, { + epsilon: state.epsilon as number, + snap: state.snap as number, + }); + boolean.outline = Boolean(state.showOutline); + + boolean.material = new THREE.MeshStandardMaterial({ + color: 0x2563eb, + transparent: true, + opacity: 0.72, + }); + + scene.add(left); + scene.add(right); + result = replaceSceneObject(scene, result, boolean); + + left.removeFromParent(); + right.removeFromParent(); + }; + + mountControls( + "Boolean Controls", + [ + { + type: "select", + key: "operation", + label: "Operation", + value: BooleanOperation.Union, + options: [ + { label: "Union", value: BooleanOperation.Union }, + { label: "Intersection", value: BooleanOperation.Intersection }, + { label: "Difference", value: BooleanOperation.Difference }, + ], + }, + { + type: "select", + key: "leftShape", + label: "Left Shape", + value: "cuboid", + options: [ + { label: "Cuboid", value: "cuboid" }, + { label: "Sphere", value: "sphere" }, + { label: "Cylinder", value: "cylinder" }, + { label: "Wedge", value: "wedge" }, + ], + }, + { + type: "select", + key: "rightShape", + label: "Right Shape", + value: "sphere", + options: [ + { label: "Cuboid", value: "cuboid" }, + { label: "Sphere", value: "sphere" }, + { label: "Cylinder", value: "cylinder" }, + { label: "Wedge", value: "wedge" }, + ], + }, + { + type: "boolean", + key: "showOutline", + label: "Show Outline", + value: true, + }, + { + type: "number", + key: "epsilon", + label: "Plane Epsilon", + min: 0.000001, + max: 0.005, + step: 0.000001, + value: 0.00001, + }, + { + type: "number", + key: "snap", + label: "Snap Grid", + min: 0.000001, + max: 0.005, + step: 0.000001, + value: 0.00001, + }, + ], + update + ); + }, +}); diff --git a/main/opengeometry-three/examples-vite/src/shared/runtime.ts b/main/opengeometry-three/examples-vite/src/shared/runtime.ts index 480b8b2..2d82085 100644 --- a/main/opengeometry-three/examples-vite/src/shared/runtime.ts +++ b/main/opengeometry-three/examples-vite/src/shared/runtime.ts @@ -25,9 +25,16 @@ export type ExampleControlDefinition = key: string; label: string; value: boolean; + } + | { + type: "select"; + key: string; + label: string; + value: string; + options: Array<{ label: string; value: string }>; }; -export type ExampleControlState = Record; +export type ExampleControlState = Record; interface BootstrapConfig { title: string; @@ -242,7 +249,7 @@ export function mountControls( inputs.appendChild(rangeInput); inputs.appendChild(numberInput); row.appendChild(inputs); - } else { + } else if (definition.type === "boolean") { const boolWrap = document.createElement("div"); boolWrap.className = "og-control-bool"; @@ -267,6 +274,25 @@ export function mountControls( boolWrap.appendChild(boolLabel); row.appendChild(header); row.appendChild(boolWrap); + } else { + row.appendChild(header); + const select = document.createElement("select"); + select.className = "og-control-select"; + + for (const option of definition.options) { + const optionElement = document.createElement("option"); + optionElement.value = option.value; + optionElement.textContent = option.label; + optionElement.selected = option.value === definition.value; + select.appendChild(optionElement); + } + + select.addEventListener("change", () => { + state[definition.key] = select.value; + emitChange(); + }); + + row.appendChild(select); } panel.appendChild(row); diff --git a/main/opengeometry-three/index.ts b/main/opengeometry-three/index.ts index 01a6e71..2bd6d9c 100644 --- a/main/opengeometry-three/index.ts +++ b/main/opengeometry-three/index.ts @@ -666,3 +666,8 @@ export * from './src/shapes/'; * Reusable example builders for quickly wiring demo scenes. */ export * from './src/examples/'; + +/** + * Boolean/constructive solid geometry operations. + */ +export * from './src/operations/'; diff --git a/main/opengeometry-three/src/operations/boolean.ts b/main/opengeometry-three/src/operations/boolean.ts new file mode 100644 index 0000000..85f7d1b --- /dev/null +++ b/main/opengeometry-three/src/operations/boolean.ts @@ -0,0 +1,176 @@ +import { OGBoolean } from "../../../opengeometry/pkg/opengeometry"; +import * as THREE from "three"; + +export const BooleanOperation = { + Union: "union", + Intersection: "intersection", + Difference: "difference", +} as const; + +export type BooleanOperationKind = + (typeof BooleanOperation)[keyof typeof BooleanOperation]; + +export function parseBooleanOperation(value: string): BooleanOperationKind { + switch (value) { + case BooleanOperation.Intersection: + return BooleanOperation.Intersection; + case BooleanOperation.Difference: + return BooleanOperation.Difference; + case BooleanOperation.Union: + default: + return BooleanOperation.Union; + } +} + +export interface BooleanConstraints { + epsilon?: number; + snap?: number; +} + +export class BooleanShape extends THREE.Mesh { + private readonly kernelBoolean = new OGBoolean(); + #outlineMesh: THREE.LineSegments | null = null; + + constructor( + left: THREE.Mesh, + right: THREE.Mesh, + operation: BooleanOperationKind, + constraints?: BooleanConstraints, + material?: THREE.Material + ) { + const { geometry, outline } = BooleanShape.computeGeometry( + left, + right, + operation, + constraints + ); + + super( + geometry, + material ?? + new THREE.MeshStandardMaterial({ + color: 0x3b82f6, + transparent: true, + opacity: 0.7, + }) + ); + + this.applyOutline(outline); + } + + run( + left: THREE.Mesh, + right: THREE.Mesh, + operation: BooleanOperationKind, + constraints?: BooleanConstraints + ) { + this.geometry.dispose(); + const { geometry, outline } = BooleanShape.computeGeometry( + left, + right, + operation, + constraints, + this.kernelBoolean + ); + this.geometry = geometry; + this.geometry.computeVertexNormals(); + this.applyOutline(outline); + } + + set outline(enable: boolean) { + if (!enable) { + if (this.#outlineMesh) { + this.remove(this.#outlineMesh); + this.#outlineMesh.geometry.dispose(); + (this.#outlineMesh.material as THREE.Material).dispose(); + this.#outlineMesh = null; + } + return; + } + + if (this.#outlineMesh) { + this.add(this.#outlineMesh); + } + } + + private applyOutline(outlinePositions: number[]) { + if (this.#outlineMesh) { + this.remove(this.#outlineMesh); + this.#outlineMesh.geometry.dispose(); + (this.#outlineMesh.material as THREE.Material).dispose(); + this.#outlineMesh = null; + } + + if (outlinePositions.length === 0) { + return; + } + + const outlineGeometry = new THREE.BufferGeometry(); + outlineGeometry.setAttribute( + "position", + new THREE.Float32BufferAttribute(outlinePositions, 3) + ); + const outlineMaterial = new THREE.LineBasicMaterial({ color: 0x111827 }); + this.#outlineMesh = new THREE.LineSegments(outlineGeometry, outlineMaterial); + this.add(this.#outlineMesh); + } + + static computeGeometry( + left: THREE.Mesh, + right: THREE.Mesh, + operation: BooleanOperationKind, + constraints?: BooleanConstraints, + kernelBoolean = new OGBoolean() + ) { + const leftBuffer = extractWorldSpaceTriangleBuffer(left); + const rightBuffer = extractWorldSpaceTriangleBuffer(right); + + const result = kernelBoolean.compute( + JSON.stringify(leftBuffer), + JSON.stringify(rightBuffer), + operation, + constraints ? JSON.stringify(constraints) : undefined + ); + + const outlineResult = kernelBoolean.get_outline_geometry_serialized(); + + const positions = JSON.parse(result) as number[]; + const outline = JSON.parse(outlineResult) as number[]; + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute( + "position", + new THREE.Float32BufferAttribute(positions, 3) + ); + geometry.computeVertexNormals(); + + return { geometry, outline }; + } +} + +function extractWorldSpaceTriangleBuffer(mesh: THREE.Mesh): number[] { + const geometry = mesh.geometry; + if (!(geometry instanceof THREE.BufferGeometry)) { + throw new Error("Boolean operations require THREE.BufferGeometry meshes."); + } + + const position = geometry.getAttribute("position"); + if (!position) { + throw new Error("Boolean operations require a position attribute."); + } + + mesh.updateWorldMatrix(true, false); + const world = mesh.matrixWorld; + + const source = geometry.toNonIndexed(); + const sourcePosition = source.getAttribute("position"); + const out: number[] = []; + const v = new THREE.Vector3(); + + for (let i = 0; i < sourcePosition.count; i++) { + v.fromBufferAttribute(sourcePosition, i).applyMatrix4(world); + out.push(v.x, v.y, v.z); + } + + source.dispose(); + return out; +} diff --git a/main/opengeometry-three/src/operations/index.ts b/main/opengeometry-three/src/operations/index.ts new file mode 100644 index 0000000..fc03593 --- /dev/null +++ b/main/opengeometry-three/src/operations/index.ts @@ -0,0 +1 @@ +export * from "./boolean"; diff --git a/main/opengeometry/src/lib.rs b/main/opengeometry/src/lib.rs index d33ebb4..e08d2cf 100644 --- a/main/opengeometry/src/lib.rs +++ b/main/opengeometry/src/lib.rs @@ -4,6 +4,7 @@ pub mod geometry { } pub mod operations { + pub mod boolean; pub mod extrude; pub mod offset; pub mod sweep; diff --git a/main/opengeometry/src/operations/boolean.rs b/main/opengeometry/src/operations/boolean.rs new file mode 100644 index 0000000..926e16f --- /dev/null +++ b/main/opengeometry/src/operations/boolean.rs @@ -0,0 +1,735 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use wasm_bindgen::prelude::*; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BooleanOperation { + Union, + Intersection, + Difference, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BooleanConstraints { + #[serde(default = "default_epsilon")] + pub epsilon: f64, + #[serde(default = "default_snap")] + pub snap: f64, +} + +fn default_epsilon() -> f64 { + 1e-6 +} + +fn default_snap() -> f64 { + 1e-6 +} + +impl Default for BooleanConstraints { + fn default() -> Self { + Self { + epsilon: default_epsilon(), + snap: default_snap(), + } + } +} + +#[wasm_bindgen] +pub struct OGBoolean { + last_result: Vec, + last_constraints: BooleanConstraints, +} + +#[wasm_bindgen] +impl OGBoolean { + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Self { + last_result: Vec::new(), + last_constraints: BooleanConstraints::default(), + } + } + + #[wasm_bindgen] + pub fn compute( + &mut self, + mesh_a_serialized: String, + mesh_b_serialized: String, + operation: String, + constraints_serialized: Option, + ) -> Result { + let vertices_a: Vec = serde_json::from_str(&mesh_a_serialized) + .map_err(|err| JsValue::from_str(&err.to_string()))?; + let vertices_b: Vec = serde_json::from_str(&mesh_b_serialized) + .map_err(|err| JsValue::from_str(&err.to_string()))?; + + let constraints = constraints_serialized + .as_deref() + .map(serde_json::from_str) + .transpose() + .map_err(|err| JsValue::from_str(&err.to_string()))? + .unwrap_or_default(); + + let op: BooleanOperation = + serde_json::from_str(&format!("\"{}\"", operation.to_lowercase())).map_err(|_| { + JsValue::from_str("operation must be union, intersection, or difference") + })?; + + let polygons_a = triangles_to_polygons(&vertices_a, &constraints)?; + let polygons_b = triangles_to_polygons(&vertices_b, &constraints)?; + + let mut result = match op { + BooleanOperation::Union => csg_union(polygons_a, polygons_b, constraints.epsilon), + BooleanOperation::Intersection => { + csg_intersection(polygons_a, polygons_b, constraints.epsilon) + } + BooleanOperation::Difference => { + csg_subtract(polygons_a, polygons_b, constraints.epsilon) + } + }; + + weld_vertices(&mut result, constraints.epsilon); + self.last_constraints = constraints; + self.last_result = result; + + let flattened = polygons_to_triangle_buffer(&self.last_result); + serde_json::to_string(&flattened).map_err(|err| JsValue::from_str(&err.to_string())) + } + + #[wasm_bindgen] + pub fn get_outline_geometry_serialized(&self) -> Result { + let outline = polygons_to_outline_buffer(&self.last_result, &self.last_constraints); + serde_json::to_string(&outline).map_err(|err| JsValue::from_str(&err.to_string())) + } +} + +#[derive(Debug, Clone, Copy)] +struct Vec3 { + x: f64, + y: f64, + z: f64, +} + +impl Vec3 { + fn new(x: f64, y: f64, z: f64) -> Self { + Self { x, y, z } + } + + fn plus(self, rhs: Self) -> Self { + Self::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z) + } + + fn minus(self, rhs: Self) -> Self { + Self::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z) + } + + fn times(self, t: f64) -> Self { + Self::new(self.x * t, self.y * t, self.z * t) + } + + fn dot(self, rhs: Self) -> f64 { + self.x * rhs.x + self.y * rhs.y + self.z * rhs.z + } + + fn cross(self, rhs: Self) -> Self { + Self::new( + self.y * rhs.z - self.z * rhs.y, + self.z * rhs.x - self.x * rhs.z, + self.x * rhs.y - self.y * rhs.x, + ) + } + + fn length(self) -> f64 { + self.dot(self).sqrt() + } + + fn normalize(self) -> Self { + let len = self.length(); + if len == 0.0 { + self + } else { + self.times(1.0 / len) + } + } +} + +#[derive(Debug, Clone)] +struct Vertex { + pos: Vec3, +} + +impl Vertex { + fn interpolate(&self, other: &Vertex, t: f64) -> Vertex { + Vertex { + pos: self.pos.plus(other.pos.minus(self.pos).times(t)), + } + } + + fn flip(&mut self) {} +} + +#[derive(Debug, Clone)] +struct Plane { + normal: Vec3, + w: f64, + epsilon: f64, +} + +impl Plane { + fn from_points(a: Vec3, b: Vec3, c: Vec3, epsilon: f64) -> Self { + let normal = b.minus(a).cross(c.minus(a)).normalize(); + Self { + normal, + w: normal.dot(a), + epsilon, + } + } + + fn flip(&mut self) { + self.normal = self.normal.times(-1.0); + self.w = -self.w; + } + + fn split_polygon( + &self, + polygon: &Polygon, + coplanar_front: &mut Vec, + coplanar_back: &mut Vec, + front: &mut Vec, + back: &mut Vec, + ) { + const COPLANAR: i32 = 0; + const FRONT: i32 = 1; + const BACK: i32 = 2; + const SPANNING: i32 = 3; + + let mut polygon_type = 0; + let mut types = Vec::with_capacity(polygon.vertices.len()); + for vertex in &polygon.vertices { + let t = self.normal.dot(vertex.pos) - self.w; + let vertex_type = if t < -self.epsilon { + BACK + } else if t > self.epsilon { + FRONT + } else { + COPLANAR + }; + polygon_type |= vertex_type; + types.push(vertex_type); + } + + match polygon_type { + COPLANAR => { + if self.normal.dot(polygon.plane.normal) > 0.0 { + coplanar_front.push(polygon.clone()); + } else { + coplanar_back.push(polygon.clone()); + } + } + FRONT => front.push(polygon.clone()), + BACK => back.push(polygon.clone()), + SPANNING => { + let mut f: Vec = Vec::new(); + let mut b: Vec = Vec::new(); + for i in 0..polygon.vertices.len() { + let j = (i + 1) % polygon.vertices.len(); + let ti = types[i]; + let tj = types[j]; + let vi = &polygon.vertices[i]; + let vj = &polygon.vertices[j]; + + if ti != BACK { + f.push(vi.clone()); + } + if ti != FRONT { + b.push(vi.clone()); + } + if (ti | tj) == SPANNING { + let t = (self.w - self.normal.dot(vi.pos)) + / self.normal.dot(vj.pos.minus(vi.pos)); + let v = vi.interpolate(vj, t); + f.push(v.clone()); + b.push(v); + } + } + + if f.len() >= 3 { + front.push(Polygon::new(f, self.epsilon)); + } + if b.len() >= 3 { + back.push(Polygon::new(b, self.epsilon)); + } + } + _ => unreachable!(), + } + } +} + +#[derive(Debug, Clone)] +struct Polygon { + vertices: Vec, + plane: Plane, +} + +impl Polygon { + fn new(vertices: Vec, epsilon: f64) -> Self { + let plane = Plane::from_points(vertices[0].pos, vertices[1].pos, vertices[2].pos, epsilon); + Self { vertices, plane } + } + + fn flip(&mut self) { + self.vertices.reverse(); + for v in &mut self.vertices { + v.flip(); + } + self.plane.flip(); + } +} + +#[derive(Debug, Clone)] +struct Node { + plane: Option, + front: Option>, + back: Option>, + polygons: Vec, +} + +impl Node { + fn new(polygons: Vec) -> Self { + let mut node = Self { + plane: None, + front: None, + back: None, + polygons: Vec::new(), + }; + node.build(polygons); + node + } + + fn all_polygons(&self) -> Vec { + let mut polygons = self.polygons.clone(); + if let Some(front) = &self.front { + polygons.extend(front.all_polygons()); + } + if let Some(back) = &self.back { + polygons.extend(back.all_polygons()); + } + polygons + } + + fn invert(&mut self) { + for polygon in &mut self.polygons { + polygon.flip(); + } + if let Some(plane) = &mut self.plane { + plane.flip(); + } + if let Some(front) = &mut self.front { + front.invert(); + } + if let Some(back) = &mut self.back { + back.invert(); + } + std::mem::swap(&mut self.front, &mut self.back); + } + + fn clip_polygons(&self, polygons: Vec) -> Vec { + let Some(plane) = &self.plane else { + return polygons; + }; + + let mut front = Vec::new(); + let mut back = Vec::new(); + let mut coplanar_front = Vec::new(); + let mut coplanar_back = Vec::new(); + + for polygon in polygons { + plane.split_polygon( + &polygon, + &mut coplanar_front, + &mut coplanar_back, + &mut front, + &mut back, + ); + } + + front.extend(coplanar_front); + back.extend(coplanar_back); + + if let Some(node) = &self.front { + front = node.clip_polygons(front); + } + if let Some(node) = &self.back { + back = node.clip_polygons(back); + } else { + back.clear(); + } + + front.extend(back); + front + } + + fn clip_to(&mut self, bsp: &Node) { + self.polygons = bsp.clip_polygons(self.polygons.clone()); + if let Some(front) = &mut self.front { + front.clip_to(bsp); + } + if let Some(back) = &mut self.back { + back.clip_to(bsp); + } + } + + fn build(&mut self, polygons: Vec) { + if polygons.is_empty() { + return; + } + + if self.plane.is_none() { + self.plane = Some(polygons[0].plane.clone()); + } + + let mut front = Vec::new(); + let mut back = Vec::new(); + let mut coplanar_front = Vec::new(); + let mut coplanar_back = Vec::new(); + + if let Some(plane) = &self.plane { + for polygon in polygons { + plane.split_polygon( + &polygon, + &mut coplanar_front, + &mut coplanar_back, + &mut front, + &mut back, + ); + } + } + + self.polygons.extend(coplanar_front); + self.polygons.extend(coplanar_back); + + if !front.is_empty() { + if self.front.is_none() { + self.front = Some(Box::new(Node::new(Vec::new()))); + } + if let Some(node) = &mut self.front { + node.build(front); + } + } + + if !back.is_empty() { + if self.back.is_none() { + self.back = Some(Box::new(Node::new(Vec::new()))); + } + if let Some(node) = &mut self.back { + node.build(back); + } + } + } +} + +fn csg_union(a: Vec, b: Vec, _epsilon: f64) -> Vec { + let mut a_node = Node::new(a); + let mut b_node = Node::new(b); + + a_node.clip_to(&b_node); + b_node.clip_to(&a_node); + b_node.invert(); + b_node.clip_to(&a_node); + b_node.invert(); + + a_node.build(b_node.all_polygons()); + a_node.all_polygons() +} + +fn csg_subtract(a: Vec, b: Vec, _epsilon: f64) -> Vec { + let mut a_node = Node::new(a); + let mut b_node = Node::new(b); + + a_node.invert(); + a_node.clip_to(&b_node); + b_node.clip_to(&a_node); + b_node.invert(); + b_node.clip_to(&a_node); + b_node.invert(); + a_node.build(b_node.all_polygons()); + a_node.invert(); + + a_node.all_polygons() +} + +fn csg_intersection(a: Vec, b: Vec, _epsilon: f64) -> Vec { + let mut a_node = Node::new(a); + let mut b_node = Node::new(b); + + a_node.invert(); + b_node.clip_to(&a_node); + b_node.invert(); + a_node.clip_to(&b_node); + b_node.clip_to(&a_node); + a_node.build(b_node.all_polygons()); + a_node.invert(); + + a_node.all_polygons() +} + +fn triangles_to_polygons( + vertices: &[f64], + constraints: &BooleanConstraints, +) -> Result, JsValue> { + if vertices.len() % 9 != 0 { + return Err(JsValue::from_str( + "triangle buffer must be flat xyz triples whose length is divisible by 9", + )); + } + + let polygons = vertices + .chunks_exact(9) + .map(|chunk| { + let points = [ + snap_vec3(Vec3::new(chunk[0], chunk[1], chunk[2]), constraints.snap), + snap_vec3(Vec3::new(chunk[3], chunk[4], chunk[5]), constraints.snap), + snap_vec3(Vec3::new(chunk[6], chunk[7], chunk[8]), constraints.snap), + ]; + Polygon::new( + points.into_iter().map(|pos| Vertex { pos }).collect(), + constraints.epsilon, + ) + }) + .collect(); + + Ok(polygons) +} + +fn snap_vec3(v: Vec3, snap: f64) -> Vec3 { + if snap <= 0.0 { + return v; + } + + Vec3::new( + (v.x / snap).round() * snap, + (v.y / snap).round() * snap, + (v.z / snap).round() * snap, + ) +} + +fn weld_vertices(polygons: &mut [Polygon], epsilon: f64) { + if epsilon <= 0.0 { + return; + } + + for polygon in polygons { + for vertex in &mut polygon.vertices { + vertex.pos = snap_vec3(vertex.pos, epsilon); + } + } +} + +fn polygons_to_triangle_buffer(polygons: &[Polygon]) -> Vec { + let mut out = Vec::new(); + for polygon in polygons { + if polygon.vertices.len() < 3 { + continue; + } + + let base = polygon.vertices[0].pos; + for i in 1..(polygon.vertices.len() - 1) { + let b = polygon.vertices[i].pos; + let c = polygon.vertices[i + 1].pos; + out.extend_from_slice(&[base.x, base.y, base.z, b.x, b.y, b.z, c.x, c.y, c.z]); + } + } + + out +} + +fn polygons_to_outline_buffer(polygons: &[Polygon], constraints: &BooleanConstraints) -> Vec { + let mut edges: HashMap> = HashMap::new(); + + for polygon in polygons { + if polygon.vertices.len() < 2 { + continue; + } + + for i in 0..polygon.vertices.len() { + let start = polygon.vertices[i].pos; + let end = polygon.vertices[(i + 1) % polygon.vertices.len()].pos; + let key = EdgeKey::from_points(start, end, constraints); + let entry = edges.entry(key).or_default(); + entry.push(EdgeSample { + start, + end, + normal: polygon.plane.normal, + }); + } + } + + let mut out = Vec::new(); + for samples in edges.values() { + if should_emit_edge(samples, constraints) { + let representative = samples[0]; + out.extend_from_slice(&[ + representative.start.x, + representative.start.y, + representative.start.z, + representative.end.x, + representative.end.y, + representative.end.z, + ]); + } + } + + out +} + +fn should_emit_edge(samples: &[EdgeSample], constraints: &BooleanConstraints) -> bool { + if samples.len() <= 1 { + return true; + } + + let feature_angle_degrees = 28.0_f64; + let feature_dot = (feature_angle_degrees.to_radians()).cos(); + + for i in 0..samples.len() { + for j in (i + 1)..samples.len() { + let dot = samples[i].normal.dot(samples[j].normal).abs(); + if dot < feature_dot { + return true; + } + } + } + + // If all normals are close, it is a smooth/coplanar tessellation edge and should be hidden. + let _ = constraints; + false +} + +#[derive(Debug, Clone, Copy)] +struct EdgeSample { + start: Vec3, + end: Vec3, + normal: Vec3, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +struct EdgeKey { + a: QuantizedPoint, + b: QuantizedPoint, +} + +impl EdgeKey { + fn from_points(a: Vec3, b: Vec3, constraints: &BooleanConstraints) -> Self { + let qa = QuantizedPoint::from_vec3(a, constraints); + let qb = QuantizedPoint::from_vec3(b, constraints); + + if qa <= qb { + Self { a: qa, b: qb } + } else { + Self { a: qb, b: qa } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +struct QuantizedPoint { + x: i64, + y: i64, + z: i64, +} + +impl QuantizedPoint { + fn from_vec3(v: Vec3, constraints: &BooleanConstraints) -> Self { + let tol = constraints.snap.max(constraints.epsilon).max(1e-5); + let scale = 1.0 / tol; + Self { + x: (v.x * scale).round() as i64, + y: (v.y * scale).round() as i64, + z: (v.z * scale).round() as i64, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn union_of_overlapping_cubes_returns_triangles() { + let cube_a = vec![ + -1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, -1.0, -1.0, -1.0, 1.0, 1.0, -1.0, + -1.0, 1.0, -1.0, + ]; + let cube_b = vec![ + 0.0, -1.0, -1.0, 2.0, -1.0, -1.0, 2.0, 1.0, -1.0, 0.0, -1.0, -1.0, 2.0, 1.0, -1.0, 0.0, + 1.0, -1.0, + ]; + + let constraints = BooleanConstraints::default(); + let a = triangles_to_polygons(&cube_a, &constraints).unwrap(); + let b = triangles_to_polygons(&cube_b, &constraints).unwrap(); + + let triangles = polygons_to_triangle_buffer(&csg_union(a, b, constraints.epsilon)); + assert!(!triangles.is_empty()); + assert_eq!(triangles.len() % 9, 0); + } + + #[test] + fn coplanar_shared_triangle_edge_is_removed_from_outline() { + let poly_a = Polygon::new( + vec![ + Vertex { + pos: Vec3::new(0.0, 0.0, 0.0), + }, + Vertex { + pos: Vec3::new(1.0, 0.0, 0.0), + }, + Vertex { + pos: Vec3::new(1.0, 0.0, 1.0), + }, + ], + 1e-6, + ); + let poly_b = Polygon::new( + vec![ + Vertex { + pos: Vec3::new(0.0, 0.0, 0.0), + }, + Vertex { + pos: Vec3::new(1.0, 0.0, 1.0), + }, + Vertex { + pos: Vec3::new(0.0, 0.0, 1.0), + }, + ], + 1e-6, + ); + + let outline = polygons_to_outline_buffer(&[poly_a, poly_b], &BooleanConstraints::default()); + // rectangle perimeter only: 4 line segments * 6 coordinates + assert_eq!(outline.len(), 24); + } + #[test] + fn outline_is_generated_for_polygon_result() { + let polygon = Polygon::new( + vec![ + Vertex { + pos: Vec3::new(0.0, 0.0, 0.0), + }, + Vertex { + pos: Vec3::new(1.0, 0.0, 0.0), + }, + Vertex { + pos: Vec3::new(1.0, 0.0, 1.0), + }, + Vertex { + pos: Vec3::new(0.0, 0.0, 1.0), + }, + ], + 1e-6, + ); + + let outline = polygons_to_outline_buffer(&[polygon], &BooleanConstraints::default()); + assert_eq!(outline.len(), 24); + } +}