@jbroll/jscad-modeling 2.12.8 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bench/splitPolygon.bench.js +143 -0
- package/benchmarks/compare.js +673 -0
- package/dist/jscad-modeling.min.js +110 -101
- package/isolate-0x1e680000-4181-v8.log +6077 -0
- package/package.json +2 -1
- package/src/geometries/poly3/create.js +5 -1
- package/src/geometries/poly3/create.test.js +1 -1
- package/src/geometries/poly3/fromPointsAndPlane.js +2 -3
- package/src/geometries/poly3/invert.js +7 -1
- package/src/geometries/poly3/measureBoundingSphere.js +9 -7
- package/src/index.d.ts +1 -0
- package/src/index.js +1 -0
- package/src/operations/booleans/trees/splitPolygonByPlane.js +64 -29
- package/src/operations/minkowski/index.d.ts +2 -0
- package/src/operations/minkowski/index.js +18 -0
- package/src/operations/minkowski/isConvex.d.ts +5 -0
- package/src/operations/minkowski/isConvex.js +67 -0
- package/src/operations/minkowski/isConvex.test.js +48 -0
- package/src/operations/minkowski/minkowskiSum.d.ts +6 -0
- package/src/operations/minkowski/minkowskiSum.js +223 -0
- package/src/operations/minkowski/minkowskiSum.test.js +161 -0
- package/src/operations/modifiers/reTesselateCoplanarPolygons.js +8 -10
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Compare benchmark results between fork and stock @jscad/modeling
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npm run bench:compare # run all benchmarks
|
|
7
|
+
* npm run bench:compare hull # run benchmarks matching "hull"
|
|
8
|
+
* npm run bench:compare expand # run benchmarks matching "expand"
|
|
9
|
+
* npm run bench:compare --stock @jscad/modeling@2.11.0 # compare against specific version
|
|
10
|
+
* npm run bench:compare hull --stock @jscad/modeling@2.11.0 # filter + specific version
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { spawnSync, execSync } = require('child_process')
|
|
14
|
+
|
|
15
|
+
// Re-exec with CPU pinning if not already pinned
|
|
16
|
+
if (!process.env.BENCH_PINNED && process.platform === 'linux') {
|
|
17
|
+
try {
|
|
18
|
+
// Check for hybrid CPU architecture (P-cores + E-cores)
|
|
19
|
+
const lscpuOutput = execSync('lscpu -e=MAXMHZ 2>/dev/null || true', { encoding: 'utf8' })
|
|
20
|
+
const uniqueFreqs = new Set(lscpuOutput.trim().split('\n').filter(l => l && !l.includes('MAXMHZ')))
|
|
21
|
+
if (uniqueFreqs.size > 2 || process.env.BENCH_FORCE_PIN) {
|
|
22
|
+
// Pin to fast cores (typically 0-11 on Intel hybrid)
|
|
23
|
+
const taskset = spawnSync('which', ['taskset'], { encoding: 'utf8' })
|
|
24
|
+
if (taskset.status === 0) {
|
|
25
|
+
console.log('Pinning to P-cores (0-11) for consistent performance...')
|
|
26
|
+
const result = spawnSync('taskset', ['-c', '0-11', process.execPath, ...process.argv.slice(1)], {
|
|
27
|
+
stdio: 'inherit',
|
|
28
|
+
env: { ...process.env, BENCH_PINNED: '1' }
|
|
29
|
+
})
|
|
30
|
+
process.exit(result.status)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch (e) {
|
|
34
|
+
// Ignore errors, proceed without pinning
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const fs = require('fs')
|
|
39
|
+
const path = require('path')
|
|
40
|
+
const os = require('os')
|
|
41
|
+
|
|
42
|
+
// Configuration
|
|
43
|
+
const WARMUP_RUNS = 3
|
|
44
|
+
const SAMPLES = 5
|
|
45
|
+
|
|
46
|
+
// Parse arguments: [filter] [--stock package]
|
|
47
|
+
let BENCHMARK_FILTER = null
|
|
48
|
+
let STOCK_PACKAGE = '@jscad/modeling'
|
|
49
|
+
|
|
50
|
+
for (let i = 2; i < process.argv.length; i++) {
|
|
51
|
+
const arg = process.argv[i]
|
|
52
|
+
if (arg === '--stock' && process.argv[i + 1]) {
|
|
53
|
+
STOCK_PACKAGE = process.argv[++i]
|
|
54
|
+
} else if (!arg.startsWith('-')) {
|
|
55
|
+
BENCHMARK_FILTER = arg
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Calculate median of an array of numbers
|
|
61
|
+
*/
|
|
62
|
+
const median = (arr) => {
|
|
63
|
+
const sorted = [...arr].sort((a, b) => a - b)
|
|
64
|
+
const mid = Math.floor(sorted.length / 2)
|
|
65
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Run a single timed execution with optional iteration count
|
|
70
|
+
*/
|
|
71
|
+
const timeOnce = (fn, iterations = 1) => {
|
|
72
|
+
if (global.gc) global.gc()
|
|
73
|
+
const start = process.hrtime.bigint()
|
|
74
|
+
for (let i = 0; i < iterations; i++) {
|
|
75
|
+
fn()
|
|
76
|
+
}
|
|
77
|
+
const end = process.hrtime.bigint()
|
|
78
|
+
return Number(end - start) / 1e6 / iterations
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Format time with appropriate unit
|
|
83
|
+
*/
|
|
84
|
+
const formatTime = (ms) => {
|
|
85
|
+
if (ms < 1) return `${(ms * 1000).toFixed(1)} µs`
|
|
86
|
+
if (ms < 1000) return `${ms.toFixed(2)} ms`
|
|
87
|
+
return `${(ms / 1000).toFixed(2)} s`
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Install stock package to temp directory and return its path
|
|
92
|
+
*/
|
|
93
|
+
const installStockPackage = (packageSpec) => {
|
|
94
|
+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jscad-bench-'))
|
|
95
|
+
|
|
96
|
+
console.log(`Installing ${packageSpec} to temp directory...`)
|
|
97
|
+
|
|
98
|
+
fs.writeFileSync(path.join(tempDir, 'package.json'), JSON.stringify({
|
|
99
|
+
name: 'jscad-bench-temp',
|
|
100
|
+
version: '1.0.0',
|
|
101
|
+
private: true
|
|
102
|
+
}))
|
|
103
|
+
|
|
104
|
+
const result = spawnSync('npm', ['install', packageSpec, '--no-save'], {
|
|
105
|
+
cwd: tempDir,
|
|
106
|
+
encoding: 'utf8',
|
|
107
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
if (result.status !== 0) {
|
|
111
|
+
console.error(`Failed to install ${packageSpec}:`)
|
|
112
|
+
console.error(result.stderr)
|
|
113
|
+
process.exit(1)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const stockPath = path.join(tempDir, 'node_modules', '@jscad', 'modeling')
|
|
117
|
+
if (!fs.existsSync(stockPath)) {
|
|
118
|
+
console.error(`Package not found at expected path: ${stockPath}`)
|
|
119
|
+
process.exit(1)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
console.log(`Stock package installed: ${stockPath}`)
|
|
123
|
+
return { tempDir, stockPath }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Get package version from package.json
|
|
128
|
+
*/
|
|
129
|
+
const getPackageVersion = (packagePath) => {
|
|
130
|
+
const pkgJson = JSON.parse(fs.readFileSync(path.join(packagePath, 'package.json'), 'utf8'))
|
|
131
|
+
return pkgJson.version
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Define benchmark operations
|
|
136
|
+
*/
|
|
137
|
+
const defineBenchmarks = (jscad) => {
|
|
138
|
+
const { primitives, booleans, transforms, extrusions, hulls, expansions, minkowski } = jscad
|
|
139
|
+
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
name: 'boolean-subtract-cube-sphere-64',
|
|
143
|
+
fn: () => {
|
|
144
|
+
const cube = primitives.cube({ size: 10 })
|
|
145
|
+
const sphere = primitives.sphere({ radius: 7, segments: 64 })
|
|
146
|
+
return booleans.subtract(cube, sphere)
|
|
147
|
+
},
|
|
148
|
+
iterations: 3
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'boolean-intersect-cube-sphere-64',
|
|
152
|
+
fn: () => {
|
|
153
|
+
const cube = primitives.cube({ size: 15 })
|
|
154
|
+
const sphere = primitives.sphere({ radius: 10, segments: 64 })
|
|
155
|
+
return booleans.intersect(cube, sphere)
|
|
156
|
+
},
|
|
157
|
+
iterations: 2
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
name: 'menger-intersection-depth3',
|
|
161
|
+
fn: () => {
|
|
162
|
+
// Build Sierpinski carpet in 2D, extrude, intersect 3 rotated copies
|
|
163
|
+
const sierpinskiCarpet = (size, depth) => {
|
|
164
|
+
if (depth === 0) return primitives.square({ size })
|
|
165
|
+
const s = size / 3
|
|
166
|
+
const parts = []
|
|
167
|
+
for (let x = -1; x <= 1; x++) {
|
|
168
|
+
for (let y = -1; y <= 1; y++) {
|
|
169
|
+
if (x === 0 && y === 0) continue
|
|
170
|
+
parts.push(transforms.translate([x * s, y * s, 0], sierpinskiCarpet(s, depth - 1)))
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return booleans.union(parts)
|
|
174
|
+
}
|
|
175
|
+
const carpet = sierpinskiCarpet(60, 3)
|
|
176
|
+
const extruded = extrusions.extrudeLinear({ height: 120 }, carpet)
|
|
177
|
+
const centered = transforms.translate([0, 0, -60], extruded)
|
|
178
|
+
return booleans.intersect(
|
|
179
|
+
centered,
|
|
180
|
+
transforms.rotateY(Math.PI / 2, centered),
|
|
181
|
+
transforms.rotateX(Math.PI / 2, centered)
|
|
182
|
+
)
|
|
183
|
+
},
|
|
184
|
+
iterations: 1
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: 'menger-sponge-depth4',
|
|
188
|
+
fn: () => {
|
|
189
|
+
const menger = (size, depth) => {
|
|
190
|
+
if (depth === 0) return primitives.cube({ size })
|
|
191
|
+
const s = size / 3
|
|
192
|
+
const parts = []
|
|
193
|
+
for (let x = -1; x <= 1; x++) {
|
|
194
|
+
for (let y = -1; y <= 1; y++) {
|
|
195
|
+
for (let z = -1; z <= 1; z++) {
|
|
196
|
+
const zeros = (x === 0 ? 1 : 0) + (y === 0 ? 1 : 0) + (z === 0 ? 1 : 0)
|
|
197
|
+
if (zeros <= 1) {
|
|
198
|
+
parts.push(transforms.translate([x * s, y * s, z * s], menger(s, depth - 1)))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return booleans.union(parts)
|
|
204
|
+
}
|
|
205
|
+
return menger(60, 4)
|
|
206
|
+
},
|
|
207
|
+
iterations: 1
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'swiss-cheese-200holes',
|
|
211
|
+
fn: () => {
|
|
212
|
+
const seededRandom = (seed) => () => {
|
|
213
|
+
seed = (seed * 1103515245 + 12345) & 0x7fffffff
|
|
214
|
+
return seed / 0x7fffffff
|
|
215
|
+
}
|
|
216
|
+
const rand = seededRandom(12345)
|
|
217
|
+
const cubeSize = 80
|
|
218
|
+
const holeRadius = 5
|
|
219
|
+
const body = primitives.cube({ size: cubeSize })
|
|
220
|
+
const halfSize = cubeSize / 2 + holeRadius
|
|
221
|
+
const holeSpheres = []
|
|
222
|
+
for (let i = 0; i < 200; i++) {
|
|
223
|
+
holeSpheres.push(transforms.translate(
|
|
224
|
+
[(rand() - 0.5) * 2 * halfSize, (rand() - 0.5) * 2 * halfSize, (rand() - 0.5) * 2 * halfSize],
|
|
225
|
+
primitives.sphere({ radius: holeRadius, segments: 12 })
|
|
226
|
+
))
|
|
227
|
+
}
|
|
228
|
+
return booleans.subtract(body, booleans.union(holeSpheres))
|
|
229
|
+
},
|
|
230
|
+
iterations: 1
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
name: 'chainmail-5x5',
|
|
234
|
+
fn: () => {
|
|
235
|
+
const rows = 5, cols = 5, ringRadius = 5, tubeRadius = 1, segments = 12
|
|
236
|
+
const spacing = ringRadius * 1.5
|
|
237
|
+
const rings = []
|
|
238
|
+
for (let row = 0; row < rows; row++) {
|
|
239
|
+
for (let col = 0; col < cols; col++) {
|
|
240
|
+
const x = col * spacing
|
|
241
|
+
const y = row * spacing
|
|
242
|
+
const offset = (row % 2) * spacing / 2
|
|
243
|
+
const rotation = (row + col) % 2 === 0 ? 0 : Math.PI / 2
|
|
244
|
+
rings.push(transforms.translate(
|
|
245
|
+
[x + offset, y, 0],
|
|
246
|
+
transforms.rotateX(rotation, primitives.torus({
|
|
247
|
+
innerRadius: ringRadius - tubeRadius,
|
|
248
|
+
outerRadius: ringRadius + tubeRadius,
|
|
249
|
+
innerSegments: segments,
|
|
250
|
+
outerSegments: segments
|
|
251
|
+
}))
|
|
252
|
+
))
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
return booleans.union(rings)
|
|
256
|
+
},
|
|
257
|
+
iterations: 1
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
name: 'sphere-cloud-40',
|
|
261
|
+
fn: () => {
|
|
262
|
+
const seededRandom = (seed) => () => {
|
|
263
|
+
seed = (seed * 1103515245 + 12345) & 0x7fffffff
|
|
264
|
+
return seed / 0x7fffffff
|
|
265
|
+
}
|
|
266
|
+
const rand = seededRandom(42)
|
|
267
|
+
const spheres = []
|
|
268
|
+
for (let i = 0; i < 40; i++) {
|
|
269
|
+
spheres.push(transforms.translate(
|
|
270
|
+
[(rand() - 0.5) * 80, (rand() - 0.5) * 80, (rand() - 0.5) * 80],
|
|
271
|
+
primitives.sphere({ radius: 6, segments: 16 })
|
|
272
|
+
))
|
|
273
|
+
}
|
|
274
|
+
return booleans.union(spheres)
|
|
275
|
+
},
|
|
276
|
+
iterations: 1
|
|
277
|
+
},
|
|
278
|
+
{
|
|
279
|
+
name: 'HIGH-union-spheres-96',
|
|
280
|
+
fn: () => {
|
|
281
|
+
const a = primitives.sphere({ radius: 10, segments: 96, center: [0, 0, 0] })
|
|
282
|
+
const b = primitives.sphere({ radius: 10, segments: 96, center: [8, 0, 0] })
|
|
283
|
+
return booleans.union(a, b)
|
|
284
|
+
},
|
|
285
|
+
iterations: 1
|
|
286
|
+
},
|
|
287
|
+
{
|
|
288
|
+
name: 'HIGH-mounting-plate-25holes',
|
|
289
|
+
fn: () => {
|
|
290
|
+
let plate = primitives.cuboid({ size: [120, 80, 5] })
|
|
291
|
+
for (let x = 0; x < 5; x++) {
|
|
292
|
+
for (let y = 0; y < 5; y++) {
|
|
293
|
+
const hole = primitives.cylinder({
|
|
294
|
+
radius: 3,
|
|
295
|
+
height: 10,
|
|
296
|
+
segments: 32,
|
|
297
|
+
center: [-48 + x * 24, -32 + y * 16, 0]
|
|
298
|
+
})
|
|
299
|
+
plate = booleans.subtract(plate, hole)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
return plate
|
|
303
|
+
},
|
|
304
|
+
iterations: 1
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: 'HIGH-sphere-cloud-70',
|
|
308
|
+
fn: () => {
|
|
309
|
+
const seededRandom = (seed) => () => {
|
|
310
|
+
seed = (seed * 1103515245 + 12345) & 0x7fffffff
|
|
311
|
+
return seed / 0x7fffffff
|
|
312
|
+
}
|
|
313
|
+
const rand = seededRandom(42)
|
|
314
|
+
const spheres = []
|
|
315
|
+
for (let i = 0; i < 70; i++) {
|
|
316
|
+
spheres.push(transforms.translate(
|
|
317
|
+
[(rand() - 0.5) * 80, (rand() - 0.5) * 80, (rand() - 0.5) * 80],
|
|
318
|
+
primitives.sphere({ radius: 6, segments: 16 })
|
|
319
|
+
))
|
|
320
|
+
}
|
|
321
|
+
return booleans.union(spheres)
|
|
322
|
+
},
|
|
323
|
+
iterations: 1
|
|
324
|
+
},
|
|
325
|
+
// Hull operations - convex hull of multiple 3D shapes
|
|
326
|
+
{
|
|
327
|
+
name: 'hull-spheres-8',
|
|
328
|
+
fn: () => {
|
|
329
|
+
const shapes = []
|
|
330
|
+
for (let i = 0; i < 8; i++) {
|
|
331
|
+
const angle = (i / 8) * Math.PI * 2
|
|
332
|
+
const x = Math.cos(angle) * 20
|
|
333
|
+
const y = Math.sin(angle) * 20
|
|
334
|
+
const z = (i % 2) * 15 - 7.5
|
|
335
|
+
shapes.push(primitives.sphere({ radius: 5, segments: 16, center: [x, y, z] }))
|
|
336
|
+
}
|
|
337
|
+
return hulls.hull(shapes)
|
|
338
|
+
},
|
|
339
|
+
iterations: 3
|
|
340
|
+
},
|
|
341
|
+
// Expand 3D - expand a cube with round corners
|
|
342
|
+
{
|
|
343
|
+
name: 'expand-cube-round-seg8',
|
|
344
|
+
fn: () => {
|
|
345
|
+
const cube = primitives.cube({ size: 20 })
|
|
346
|
+
return expansions.expand({ delta: 2, corners: 'round', segments: 8 }, cube)
|
|
347
|
+
},
|
|
348
|
+
iterations: 3
|
|
349
|
+
},
|
|
350
|
+
// Expand 3D - expand a sphere (more faces = shows O(n) scaling)
|
|
351
|
+
{
|
|
352
|
+
name: 'expand-sphere16-round-seg8',
|
|
353
|
+
fn: () => {
|
|
354
|
+
const sphere = primitives.sphere({ radius: 10, segments: 16 })
|
|
355
|
+
return expansions.expand({ delta: 2, corners: 'round', segments: 8 }, sphere)
|
|
356
|
+
},
|
|
357
|
+
iterations: 1
|
|
358
|
+
},
|
|
359
|
+
// Minkowski sum - alternative to expand for convex shapes (only in fork)
|
|
360
|
+
...(minkowski ? [{
|
|
361
|
+
name: 'minkowski-cube-sphere8',
|
|
362
|
+
fn: () => {
|
|
363
|
+
const cube = primitives.cube({ size: 20 })
|
|
364
|
+
const sphere = primitives.sphere({ radius: 2, segments: 8 })
|
|
365
|
+
return minkowski.minkowskiSum(cube, sphere)
|
|
366
|
+
},
|
|
367
|
+
iterations: 10
|
|
368
|
+
}] : []),
|
|
369
|
+
// ExtrudeRotate - lathe a profile into a vase shape
|
|
370
|
+
{
|
|
371
|
+
name: 'extrudeRotate-vase-seg32',
|
|
372
|
+
fn: () => {
|
|
373
|
+
// Create a vase profile (2D shape)
|
|
374
|
+
const profile = primitives.polygon({
|
|
375
|
+
points: [
|
|
376
|
+
[0, 0], [10, 0], [12, 5], [8, 15], [6, 25], [8, 30], [10, 35], [0, 35]
|
|
377
|
+
]
|
|
378
|
+
})
|
|
379
|
+
return extrusions.extrudeRotate({ segments: 32 }, profile)
|
|
380
|
+
},
|
|
381
|
+
iterations: 5
|
|
382
|
+
},
|
|
383
|
+
// HullChain - creates a chain of hulls (useful for organic shapes)
|
|
384
|
+
{
|
|
385
|
+
name: 'hullChain-spheres-10',
|
|
386
|
+
fn: () => {
|
|
387
|
+
const shapes = []
|
|
388
|
+
for (let i = 0; i < 10; i++) {
|
|
389
|
+
const t = i / 9
|
|
390
|
+
shapes.push(primitives.sphere({
|
|
391
|
+
radius: 3 + Math.sin(t * Math.PI) * 2,
|
|
392
|
+
segments: 12,
|
|
393
|
+
center: [i * 8, Math.sin(t * Math.PI * 2) * 10, 0]
|
|
394
|
+
}))
|
|
395
|
+
}
|
|
396
|
+
return hulls.hullChain(shapes)
|
|
397
|
+
},
|
|
398
|
+
iterations: 2
|
|
399
|
+
}
|
|
400
|
+
]
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Run comparison benchmarks
|
|
405
|
+
*/
|
|
406
|
+
const runComparison = (forkJscad, stockJscad, forkVersion, stockVersion) => {
|
|
407
|
+
let forkBenchmarks = defineBenchmarks(forkJscad)
|
|
408
|
+
let stockBenchmarks = defineBenchmarks(stockJscad)
|
|
409
|
+
|
|
410
|
+
// Filter benchmarks if pattern specified
|
|
411
|
+
if (BENCHMARK_FILTER) {
|
|
412
|
+
const pattern = BENCHMARK_FILTER.toLowerCase()
|
|
413
|
+
const filterFn = (b) => b.name.toLowerCase().includes(pattern)
|
|
414
|
+
forkBenchmarks = forkBenchmarks.filter(filterFn)
|
|
415
|
+
stockBenchmarks = stockBenchmarks.filter(filterFn)
|
|
416
|
+
if (forkBenchmarks.length === 0) {
|
|
417
|
+
console.error(`No benchmarks match filter: "${BENCHMARK_FILTER}"`)
|
|
418
|
+
console.log('Available benchmarks:')
|
|
419
|
+
defineBenchmarks(forkJscad).forEach((b) => console.log(` - ${b.name}`))
|
|
420
|
+
process.exit(1)
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
console.log('\n' + '═'.repeat(100))
|
|
425
|
+
console.log('BENCHMARK COMPARISON')
|
|
426
|
+
console.log('═'.repeat(100))
|
|
427
|
+
console.log(`Fork: @jbroll/jscad-modeling v${forkVersion} (local)`)
|
|
428
|
+
console.log(`Stock: @jscad/modeling v${stockVersion} (npm)`)
|
|
429
|
+
console.log(`Runs: ${WARMUP_RUNS} warmup + ${SAMPLES} samples per benchmark`)
|
|
430
|
+
console.log(`Method: Interleaved; improvement from all runs, variance from samples only`)
|
|
431
|
+
if (!global.gc) {
|
|
432
|
+
console.log('Tip: Run with --expose-gc for more accurate results')
|
|
433
|
+
}
|
|
434
|
+
console.log('─'.repeat(100))
|
|
435
|
+
|
|
436
|
+
const header = 'Benchmark'.padEnd(38) +
|
|
437
|
+
'Stock (ms)'.padStart(12) +
|
|
438
|
+
'Fork (ms)'.padStart(12) +
|
|
439
|
+
'Change'.padStart(10) +
|
|
440
|
+
' StdDev%'.padStart(10) +
|
|
441
|
+
'Status'.padStart(10)
|
|
442
|
+
console.log(header)
|
|
443
|
+
console.log('─'.repeat(100))
|
|
444
|
+
|
|
445
|
+
const results = []
|
|
446
|
+
let improvements = 0
|
|
447
|
+
let regressions = 0
|
|
448
|
+
let unchanged = 0
|
|
449
|
+
|
|
450
|
+
for (let i = 0; i < forkBenchmarks.length; i++) {
|
|
451
|
+
const forkBench = forkBenchmarks[i]
|
|
452
|
+
// Find matching stock benchmark by name (handles fork-only benchmarks)
|
|
453
|
+
const stockBench = stockBenchmarks.find((b) => b.name === forkBench.name)
|
|
454
|
+
const iterations = forkBench.iterations || 1
|
|
455
|
+
|
|
456
|
+
process.stdout.write(` ${forkBench.name.padEnd(36)}`)
|
|
457
|
+
|
|
458
|
+
// Skip if benchmark only exists in fork (new feature)
|
|
459
|
+
if (!stockBench) {
|
|
460
|
+
console.log('N/A'.padStart(12) + formatTime(timeOnce(forkBench.fn, iterations)).padStart(12) + ' (fork only)')
|
|
461
|
+
results.push({ name: forkBench.name, forkOnly: true })
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
try {
|
|
466
|
+
// All times (warmup + samples) - used for improvement calculation
|
|
467
|
+
const stockAllTimes = []
|
|
468
|
+
const forkAllTimes = []
|
|
469
|
+
|
|
470
|
+
// Warmup runs (timed, included in improvement calc)
|
|
471
|
+
for (let w = 0; w < WARMUP_RUNS; w++) {
|
|
472
|
+
stockAllTimes.push(timeOnce(stockBench.fn, iterations))
|
|
473
|
+
forkAllTimes.push(timeOnce(forkBench.fn, iterations))
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Sample runs (used for both improvement and variance)
|
|
477
|
+
const stockSampleTimes = []
|
|
478
|
+
const forkSampleTimes = []
|
|
479
|
+
for (let s = 0; s < SAMPLES; s++) {
|
|
480
|
+
if (s % 2 === 0) {
|
|
481
|
+
const st = timeOnce(stockBench.fn, iterations)
|
|
482
|
+
const ft = timeOnce(forkBench.fn, iterations)
|
|
483
|
+
stockSampleTimes.push(st)
|
|
484
|
+
forkSampleTimes.push(ft)
|
|
485
|
+
stockAllTimes.push(st)
|
|
486
|
+
forkAllTimes.push(ft)
|
|
487
|
+
} else {
|
|
488
|
+
const ft = timeOnce(forkBench.fn, iterations)
|
|
489
|
+
const st = timeOnce(stockBench.fn, iterations)
|
|
490
|
+
forkSampleTimes.push(ft)
|
|
491
|
+
stockSampleTimes.push(st)
|
|
492
|
+
forkAllTimes.push(ft)
|
|
493
|
+
stockAllTimes.push(st)
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Variance from samples only (stable runs)
|
|
498
|
+
const stockSampleMedian = median(stockSampleTimes)
|
|
499
|
+
const forkSampleMedian = median(forkSampleTimes)
|
|
500
|
+
const stockStdDev = Math.sqrt(stockSampleTimes.reduce((sum, t) => sum + Math.pow(t - stockSampleMedian, 2), 0) / stockSampleTimes.length)
|
|
501
|
+
const forkStdDev = Math.sqrt(forkSampleTimes.reduce((sum, t) => sum + Math.pow(t - forkSampleMedian, 2), 0) / forkSampleTimes.length)
|
|
502
|
+
const avgCV = ((stockStdDev / stockSampleMedian + forkStdDev / forkSampleMedian) / 2) * 100
|
|
503
|
+
|
|
504
|
+
// Improvement from all runs (warmup + samples)
|
|
505
|
+
const stockMedian = median(stockAllTimes)
|
|
506
|
+
const forkMedian = median(forkAllTimes)
|
|
507
|
+
const change = ((forkMedian - stockMedian) / stockMedian) * 100
|
|
508
|
+
const changeStr = (change >= 0 ? '+' : '') + change.toFixed(1) + '%'
|
|
509
|
+
|
|
510
|
+
const threshold = Math.max(5, avgCV * 2)
|
|
511
|
+
|
|
512
|
+
let status
|
|
513
|
+
if (change < -threshold) {
|
|
514
|
+
status = 'FASTER'
|
|
515
|
+
improvements++
|
|
516
|
+
} else if (change > threshold) {
|
|
517
|
+
status = 'SLOWER'
|
|
518
|
+
regressions++
|
|
519
|
+
} else {
|
|
520
|
+
status = '~same'
|
|
521
|
+
unchanged++
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
console.log(
|
|
525
|
+
stockMedian.toFixed(2).padStart(12) +
|
|
526
|
+
forkMedian.toFixed(2).padStart(12) +
|
|
527
|
+
changeStr.padStart(10) +
|
|
528
|
+
`±${avgCV.toFixed(1)}%`.padStart(10) +
|
|
529
|
+
status.padStart(10)
|
|
530
|
+
)
|
|
531
|
+
|
|
532
|
+
results.push({
|
|
533
|
+
name: forkBench.name,
|
|
534
|
+
iterations,
|
|
535
|
+
stock: { median: stockMedian, min: Math.min(...stockAllTimes), max: Math.max(...stockAllTimes), stdDev: stockStdDev },
|
|
536
|
+
fork: { median: forkMedian, min: Math.min(...forkAllTimes), max: Math.max(...forkAllTimes), stdDev: forkStdDev },
|
|
537
|
+
change,
|
|
538
|
+
coefficientOfVariation: avgCV,
|
|
539
|
+
status
|
|
540
|
+
})
|
|
541
|
+
} catch (err) {
|
|
542
|
+
console.log('ERROR'.padStart(12) + ` ${err.message}`)
|
|
543
|
+
results.push({ name: forkBench.name, error: err.message })
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
console.log('─'.repeat(100))
|
|
548
|
+
console.log(`\nSummary: ${improvements} faster, ${regressions} slower, ${unchanged} unchanged`)
|
|
549
|
+
console.log(`(threshold: 5% or 2× coefficient of variation, whichever is larger)`)
|
|
550
|
+
|
|
551
|
+
const validResults = results.filter(r => r.stock && r.fork)
|
|
552
|
+
if (validResults.length > 0) {
|
|
553
|
+
const totalStock = validResults.reduce((sum, r) => sum + r.stock.median, 0)
|
|
554
|
+
const totalFork = validResults.reduce((sum, r) => sum + r.fork.median, 0)
|
|
555
|
+
const overallChange = ((totalFork - totalStock) / totalStock) * 100
|
|
556
|
+
console.log(`Overall: ${overallChange >= 0 ? '+' : ''}${overallChange.toFixed(1)}% total time (${formatTime(totalStock)} stock → ${formatTime(totalFork)} fork)`)
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return results
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Get git info for the current repo
|
|
564
|
+
*/
|
|
565
|
+
const getGitInfo = () => {
|
|
566
|
+
try {
|
|
567
|
+
const hash = execSync('git rev-parse HEAD', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim()
|
|
568
|
+
const shortHash = hash.slice(0, 8)
|
|
569
|
+
const dirty = execSync('git status --porcelain', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() !== ''
|
|
570
|
+
return { hash, shortHash, dirty }
|
|
571
|
+
} catch (e) {
|
|
572
|
+
return { hash: 'unknown', shortHash: 'unknown', dirty: true }
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Save results to JSON file and append to NDJSON log
|
|
578
|
+
*/
|
|
579
|
+
const saveResults = (results, forkVersion, stockVersion) => {
|
|
580
|
+
const outputDir = path.join(__dirname, 'results')
|
|
581
|
+
if (!fs.existsSync(outputDir)) {
|
|
582
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const git = getGitInfo()
|
|
586
|
+
const timestamp = new Date().toISOString()
|
|
587
|
+
const validResults = results.filter(r => r.stock && r.fork)
|
|
588
|
+
const totalStock = validResults.reduce((sum, r) => sum + r.stock.median, 0)
|
|
589
|
+
const totalFork = validResults.reduce((sum, r) => sum + r.fork.median, 0)
|
|
590
|
+
const overallChange = ((totalFork - totalStock) / totalStock) * 100
|
|
591
|
+
|
|
592
|
+
const output = {
|
|
593
|
+
timestamp,
|
|
594
|
+
git,
|
|
595
|
+
node: process.version,
|
|
596
|
+
samples: SAMPLES,
|
|
597
|
+
warmup: WARMUP_RUNS,
|
|
598
|
+
fork: { package: '@jbroll/jscad-modeling', version: forkVersion },
|
|
599
|
+
stock: { package: '@jscad/modeling', version: stockVersion },
|
|
600
|
+
summary: {
|
|
601
|
+
totalStock,
|
|
602
|
+
totalFork,
|
|
603
|
+
overallChange,
|
|
604
|
+
faster: results.filter(r => r.status === 'FASTER').length,
|
|
605
|
+
slower: results.filter(r => r.status === 'SLOWER').length,
|
|
606
|
+
unchanged: results.filter(r => r.status === '~same').length
|
|
607
|
+
},
|
|
608
|
+
results
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Save detailed JSON file
|
|
612
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, '-').slice(0, 19)
|
|
613
|
+
const outputFile = path.join(outputDir, `compare-${fileTimestamp}.json`)
|
|
614
|
+
fs.writeFileSync(outputFile, JSON.stringify(output, null, 2))
|
|
615
|
+
|
|
616
|
+
// Append to NDJSON log (one line per run)
|
|
617
|
+
const logFile = path.join(outputDir, 'benchmark-log.ndjson')
|
|
618
|
+
const logEntry = {
|
|
619
|
+
timestamp,
|
|
620
|
+
git,
|
|
621
|
+
node: process.version,
|
|
622
|
+
forkVersion,
|
|
623
|
+
stockVersion,
|
|
624
|
+
overallChange: Math.round(overallChange * 10) / 10,
|
|
625
|
+
totalStock: Math.round(totalStock),
|
|
626
|
+
totalFork: Math.round(totalFork),
|
|
627
|
+
faster: output.summary.faster,
|
|
628
|
+
slower: output.summary.slower
|
|
629
|
+
}
|
|
630
|
+
fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n')
|
|
631
|
+
|
|
632
|
+
console.log(`\nResults saved: ${outputFile}`)
|
|
633
|
+
console.log(`Log appended: ${logFile} (git: ${git.shortHash}${git.dirty ? ' dirty' : ''})`)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Cleanup temp directory
|
|
638
|
+
*/
|
|
639
|
+
const cleanup = (tempDir) => {
|
|
640
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
641
|
+
fs.rmSync(tempDir, { recursive: true, force: true })
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Main
|
|
647
|
+
*/
|
|
648
|
+
const main = () => {
|
|
649
|
+
console.log('JSCAD Modeling Benchmark Comparison')
|
|
650
|
+
console.log('═'.repeat(60))
|
|
651
|
+
|
|
652
|
+
const forkPath = path.resolve(__dirname, '..')
|
|
653
|
+
const forkVersion = getPackageVersion(forkPath)
|
|
654
|
+
console.log(`\nFork: @jbroll/jscad-modeling v${forkVersion}`)
|
|
655
|
+
const forkJscad = require(path.join(forkPath, 'src'))
|
|
656
|
+
|
|
657
|
+
const { tempDir, stockPath } = installStockPackage(STOCK_PACKAGE)
|
|
658
|
+
const stockVersion = getPackageVersion(stockPath)
|
|
659
|
+
console.log(`Stock: @jscad/modeling v${stockVersion}`)
|
|
660
|
+
const stockJscad = require(stockPath)
|
|
661
|
+
|
|
662
|
+
try {
|
|
663
|
+
const results = runComparison(forkJscad, stockJscad, forkVersion, stockVersion)
|
|
664
|
+
saveResults(results, forkVersion, stockVersion)
|
|
665
|
+
} finally {
|
|
666
|
+
console.log('\nCleaning up temp directory...')
|
|
667
|
+
cleanup(tempDir)
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
console.log('Done!')
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
main()
|