diff --git a/packages/modeling/bench/splitPolygon.bench.js b/packages/modeling/bench/splitPolygon.bench.js new file mode 100644 index 000000000..364872640 --- /dev/null +++ b/packages/modeling/bench/splitPolygon.bench.js @@ -0,0 +1,143 @@ +/** + * Direct benchmark for splitPolygonByPlane - the hot path in boolean operations + * + * Run with: node --expose-gc bench/splitPolygon.bench.js + */ + +const { sphere, torus } = require('../src/primitives') +const splitPolygonByPlane = require('../src/operations/booleans/trees/splitPolygonByPlane') +const poly3 = require('../src/geometries/poly3') +const plane = require('../src/maths/plane') + +// Benchmark helper +const benchmark = (name, setup, fn, iterations = 1000) => { + const data = setup() + + // Force GC if available + if (global.gc) global.gc() + + // Warmup + for (let i = 0; i < 100; i++) fn(data) + + if (global.gc) global.gc() + const heapBefore = process.memoryUsage().heapUsed + + const start = process.hrtime.bigint() + for (let i = 0; i < iterations; i++) { + fn(data) + } + const end = process.hrtime.bigint() + + if (global.gc) global.gc() + const heapAfter = process.memoryUsage().heapUsed + + const totalNs = Number(end - start) + const avgNs = totalNs / iterations + const avgUs = avgNs / 1000 + const heapDelta = (heapAfter - heapBefore) / 1024 + + console.log(`${name.padEnd(50)} ${avgUs.toFixed(2).padStart(8)} µs/op heap: ${heapDelta > 0 ? '+' : ''}${heapDelta.toFixed(0)} KB`) + return avgUs +} + +console.log('='.repeat(80)) +console.log('splitPolygonByPlane Direct Benchmark') +console.log('='.repeat(80)) +console.log() + +// Get polygons from a sphere +const testSphere = sphere({ radius: 5, segments: 32 }) +const polygons = testSphere.polygons + +console.log(`Test geometry: sphere(32) with ${polygons.length} polygons`) +console.log(`Average vertices per polygon: ${(polygons.reduce((sum, p) => sum + p.vertices.length, 0) / polygons.length).toFixed(1)}`) +console.log() + +// Test 1: Coplanar case (fast path - type 0 or 1) +console.log('--- Coplanar Cases (fast path) ---') +benchmark('coplanar-front (type 0)', () => { + const polygon = polygons[0] + const pplane = poly3.plane(polygon) + return { polygon, splane: pplane } +}, (data) => splitPolygonByPlane(data.splane, data.polygon), 10000) + +// Test 2: Entirely front (type 2) +console.log() +console.log('--- One-side Cases (no split needed) ---') +benchmark('entirely front (type 2)', () => { + const polygon = polygons[0] + // Create a plane far behind the polygon + const splane = [0, 0, 1, -100] + return { polygon, splane } +}, (data) => splitPolygonByPlane(data.splane, data.polygon), 10000) + +benchmark('entirely back (type 3)', () => { + const polygon = polygons[0] + // Create a plane far in front of the polygon + const splane = [0, 0, 1, 100] + return { polygon, splane } +}, (data) => splitPolygonByPlane(data.splane, data.polygon), 10000) + +// Test 3: Spanning case (type 4) - this is where allocations hurt +console.log() +console.log('--- Spanning Cases (allocations happen here) ---') +benchmark('spanning split (type 4) - triangle', () => { + // A triangle that will be split + const polygon = poly3.create([[0, 0, 0], [10, 0, 0], [5, 10, 0]]) + const splane = [1, 0, 0, 5] // Split down the middle + return { polygon, splane } +}, (data) => splitPolygonByPlane(data.splane, data.polygon), 10000) + +benchmark('spanning split (type 4) - quad', () => { + const polygon = poly3.create([[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0]]) + const splane = [1, 0, 0, 5] + return { polygon, splane } +}, (data) => splitPolygonByPlane(data.splane, data.polygon), 10000) + +benchmark('spanning split (type 4) - hexagon', () => { + const polygon = poly3.create([ + [0, 0, 0], [5, 0, 0], [7.5, 4, 0], + [5, 8, 0], [0, 8, 0], [-2.5, 4, 0] + ]) + const splane = [1, 0, 0, 2.5] + return { polygon, splane } +}, (data) => splitPolygonByPlane(data.splane, data.polygon), 10000) + +// Test 4: Realistic mix from actual boolean operation +console.log() +console.log('--- Realistic Mix (simulating boolean op) ---') +benchmark('mixed types from sphere polygons', () => { + // Use multiple polygons with a plane that hits various cases + const splane = [0.577, 0.577, 0.577, 0] // Diagonal plane through origin + return { polygons: polygons.slice(0, 50), splane } +}, (data) => { + let spanning = 0 + for (const polygon of data.polygons) { + const result = splitPolygonByPlane(data.splane, polygon) + if (result.type === 4) spanning++ + } + return spanning +}, 1000) + +// Measure allocation overhead specifically +console.log() +console.log('--- Allocation Stress Test ---') +benchmark('10k spanning splits (allocation heavy)', () => { + const polygon = poly3.create([[0, 0, 0], [10, 0, 0], [10, 10, 0], [0, 10, 0]]) + const splane = [1, 0, 0, 5] + return { polygon, splane } +}, (data) => { + for (let i = 0; i < 100; i++) { + splitPolygonByPlane(data.splane, data.polygon) + } +}, 100) + +console.log() +console.log('='.repeat(80)) +console.log('Benchmark complete') +console.log() +console.log('Note: "spanning split" cases allocate the most:') +console.log(' - result object { type, front, back }') +console.log(' - vertexIsBack[] array') +console.log(' - frontvertices[] and backvertices[] arrays') +console.log(' - Two new poly3 objects (front and back)') diff --git a/packages/modeling/src/operations/booleans/trees/splitPolygonByPlane.js b/packages/modeling/src/operations/booleans/trees/splitPolygonByPlane.js index d8dfb8dec..af42d0b30 100644 --- a/packages/modeling/src/operations/booleans/trees/splitPolygonByPlane.js +++ b/packages/modeling/src/operations/booleans/trees/splitPolygonByPlane.js @@ -9,14 +9,26 @@ const splitLineSegmentByPlane = require('./splitLineSegmentByPlane') const EPS_SQUARED = EPS * EPS +// Object pool for reducing allocations in hot path. +// The result object is safe to reuse because callers extract front/back immediately. +// The temporary arrays are only used within splitPolygonByPlane. +const pool = { + result: { type: null, front: null, back: null }, + vertexIsBack: new Array(64), // Pre-allocated, grows if needed + frontvertices: new Array(32), + backvertices: new Array(32) +} + // Remove consecutive duplicate vertices from a polygon vertex list. // Compares last vertex to first to handle wraparound. -// Returns a new array (does not modify input). -// IMPORTANT: Caller must ensure vertices.length >= 3 before calling. -const removeConsecutiveDuplicates = (vertices) => { +// Returns a new array (polygon vertices must be fresh arrays since they're stored in geometry). +// Accepts optional count parameter for use with pre-allocated pooled arrays. +const removeConsecutiveDuplicates = (vertices, count) => { + const vertexCount = count !== undefined ? count : vertices.length + if (vertexCount < 3) return vertices.slice(0, vertexCount) const result = [] - let prevvertex = vertices[vertices.length - 1] - for (let i = 0; i < vertices.length; i++) { + let prevvertex = vertices[vertexCount - 1] + for (let i = 0; i < vertexCount; i++) { const vertex = vertices[i] if (vec3.squaredDistance(vertex, prevvertex) >= EPS_SQUARED) { result.push(vertex) @@ -36,12 +48,15 @@ const removeConsecutiveDuplicates = (vertices) => { // In case the polygon is spanning, returns: // .front: a Polygon3 of the front part // .back: a Polygon3 of the back part +// +// IMPORTANT: The returned object is reused between calls to reduce allocations. +// Callers must extract .front and .back before the next call to splitPolygonByPlane. const splitPolygonByPlane = (splane, polygon) => { - const result = { - type: null, - front: null, - back: null - } + const result = pool.result + result.type = null + result.front = null + result.back = null + // cache in local lets (speedup): const vertices = polygon.vertices const numvertices = vertices.length @@ -51,12 +66,16 @@ const splitPolygonByPlane = (splane, polygon) => { } else { let hasfront = false let hasback = false - const vertexIsBack = [] + // Use pooled array, grow if needed + let vertexIsBack = pool.vertexIsBack + if (vertexIsBack.length < numvertices) { + vertexIsBack = pool.vertexIsBack = new Array(numvertices * 2) + } const MINEPS = -EPS for (let i = 0; i < numvertices; i++) { const t = vec3.dot(splane, vertices[i]) - splane[3] const isback = (t < MINEPS) - vertexIsBack.push(isback) + vertexIsBack[i] = isback if (t > EPS) hasfront = true if (t < MINEPS) hasback = true } @@ -71,8 +90,19 @@ const splitPolygonByPlane = (splane, polygon) => { } else { // spanning result.type = 4 - const frontvertices = [] - const backvertices = [] + // Use pooled arrays, grow if needed (max 2 vertices added per original vertex) + const maxVerts = numvertices * 2 + let frontvertices = pool.frontvertices + let backvertices = pool.backvertices + if (frontvertices.length < maxVerts) { + frontvertices = pool.frontvertices = new Array(maxVerts) + } + if (backvertices.length < maxVerts) { + backvertices = pool.backvertices = new Array(maxVerts) + } + let frontCount = 0 + let backCount = 0 + let isback = vertexIsBack[0] for (let vertexindex = 0; vertexindex < numvertices; vertexindex++) { const vertex = vertices[vertexindex] @@ -82,35 +112,36 @@ const splitPolygonByPlane = (splane, polygon) => { if (isback === nextisback) { // line segment is on one side of the plane: if (isback) { - backvertices.push(vertex) + backvertices[backCount++] = vertex } else { - frontvertices.push(vertex) + frontvertices[frontCount++] = vertex } } else { // line segment intersects plane: const nextpoint = vertices[nextvertexindex] const intersectionpoint = splitLineSegmentByPlane(splane, vertex, nextpoint) if (isback) { - backvertices.push(vertex) - backvertices.push(intersectionpoint) - frontvertices.push(intersectionpoint) + backvertices[backCount++] = vertex + backvertices[backCount++] = intersectionpoint + frontvertices[frontCount++] = intersectionpoint } else { - frontvertices.push(vertex) - frontvertices.push(intersectionpoint) - backvertices.push(intersectionpoint) + frontvertices[frontCount++] = vertex + frontvertices[frontCount++] = intersectionpoint + backvertices[backCount++] = intersectionpoint } } isback = nextisback } // for vertexindex - // remove consecutive duplicate vertices (check length before calling to avoid function overhead) - if (frontvertices.length >= 3) { - const frontFiltered = removeConsecutiveDuplicates(frontvertices) + // remove consecutive duplicate vertices and create final polygons + // We need fresh arrays for the polygon vertices since they become part of the geometry + if (frontCount >= 3) { + const frontFiltered = removeConsecutiveDuplicates(frontvertices, frontCount) if (frontFiltered.length >= 3) { result.front = poly3.fromPointsAndPlane(frontFiltered, pplane) } } - if (backvertices.length >= 3) { - const backFiltered = removeConsecutiveDuplicates(backvertices) + if (backCount >= 3) { + const backFiltered = removeConsecutiveDuplicates(backvertices, backCount) if (backFiltered.length >= 3) { result.back = poly3.fromPointsAndPlane(backFiltered, pplane) }