@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.
@@ -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()