brepjs-cad 0.175.0 → 0.176.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/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.176.0](https://github.com/andymai/brepjs/compare/brepjs-cad-v0.175.0...brepjs-cad-v0.176.0) (2026-08-26)
4
+
5
+
6
+ ### Features
7
+
8
+ * **brepjs-cad:** brep preview — element-aware model viewer with live watch ([#2231](https://github.com/andymai/brepjs/issues/2231)) ([002a20f](https://github.com/andymai/brepjs/commit/002a20f9ad8d38cb812f00a875583bdb850a004d))
9
+
3
10
  ## [0.175.0](https://github.com/andymai/brepjs/compare/brepjs-cad-v0.174.0...brepjs-cad-v0.175.0) (2026-08-26)
4
11
 
5
12
 
@@ -1,14 +1,15 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_diff = require("./diff-DX9zgrss.cjs");
3
- exports.DEFAULT_TOLERANCE_PCT = require_diff.DEFAULT_TOLERANCE_PCT;
4
- exports.TYPECHECK_CODE = require_diff.TYPECHECK_CODE;
5
- exports.emptyReport = require_diff.emptyReport;
6
- exports.evaluateExpected = require_diff.evaluateExpected;
7
- exports.isExpectedDims = require_diff.isExpectedDims;
8
- exports.pctDelta = require_diff.pctDelta;
9
- exports.runChecks = require_diff.runChecks;
2
+ const require_usingCtx = require("./usingCtx-DNbyRoEg.cjs");
3
+ const require_diff = require("./diff-CmIV_qOi.cjs");
4
+ exports.DEFAULT_TOLERANCE_PCT = require_usingCtx.DEFAULT_TOLERANCE_PCT;
5
+ exports.TYPECHECK_CODE = require_usingCtx.TYPECHECK_CODE;
6
+ exports.emptyReport = require_usingCtx.emptyReport;
7
+ exports.evaluateExpected = require_usingCtx.evaluateExpected;
8
+ exports.isExpectedDims = require_usingCtx.isExpectedDims;
9
+ exports.pctDelta = require_usingCtx.pctDelta;
10
+ exports.runChecks = require_usingCtx.runChecks;
10
11
  exports.runDiff = require_diff.runDiff;
11
12
  exports.runMeasure = require_diff.runMeasure;
12
- exports.runPart = require_diff.runPart;
13
- exports.serializeReport = require_diff.serializeReport;
14
- exports.typecheckPart = require_diff.typecheckPart;
13
+ exports.runPart = require_usingCtx.runPart;
14
+ exports.serializeReport = require_usingCtx.serializeReport;
15
+ exports.typecheckPart = require_usingCtx.typecheckPart;
@@ -1,2 +1,3 @@
1
- import { a as typecheckPart, c as isExpectedDims, d as emptyReport, i as TYPECHECK_CODE, l as pctDelta, m as serializeReport, n as runMeasure, o as DEFAULT_TOLERANCE_PCT, r as runPart, s as evaluateExpected, t as runDiff, u as runChecks } from "./diff-B1sSWjLa.js";
1
+ import { c as evaluateExpected, d as runChecks, f as emptyReport, h as serializeReport, i as TYPECHECK_CODE, l as isExpectedDims, o as typecheckPart, r as runPart, s as DEFAULT_TOLERANCE_PCT, u as pctDelta } from "./usingCtx-DOzK2eJh.js";
2
+ import { n as runMeasure, t as runDiff } from "./diff-C2vDUt9F.js";
2
3
  export { DEFAULT_TOLERANCE_PCT, TYPECHECK_CODE, emptyReport, evaluateExpected, isExpectedDims, pctDelta, runChecks, runDiff, runMeasure, runPart, serializeReport, typecheckPart };
package/dist/cli/main.cjs CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const require_diff = require("../diff-DX9zgrss.cjs");
3
+ const require_usingCtx = require("../usingCtx-DNbyRoEg.cjs");
4
+ const require_diff = require("../diff-CmIV_qOi.cjs");
5
+ const require_openBrowser = require("../openBrowser-C1AiX6cC.cjs");
4
6
  let node_url = require("node:url");
5
7
  let node_fs = require("node:fs");
6
8
  let node_path = require("node:path");
7
9
  let commander = require("commander");
8
10
  let node_os = require("node:os");
9
- let node_child_process = require("node:child_process");
10
11
  //#region src/cli/scaffold.ts
11
12
  function partTemplate(name) {
12
13
  return `import { box, cut, unwrap } from 'brepjs';
@@ -97,114 +98,20 @@ function scaffoldPart(name, dir) {
97
98
  files
98
99
  };
99
100
  }
100
- function debounce(fn, delayMs = 150) {
101
- let timer;
102
- const cancel = () => {
103
- if (timer) {
104
- clearTimeout(timer);
105
- timer = void 0;
106
- }
107
- };
108
- const trigger = () => {
109
- cancel();
110
- timer = setTimeout(() => {
111
- timer = void 0;
112
- fn();
113
- }, delayMs);
114
- };
115
- return {
116
- trigger,
117
- cancel
118
- };
119
- }
120
- var SOURCE_FILE_RE = /\.(?:m?[jt]s|[jt]sx)$/;
121
- function isWatchRelevant(filename) {
122
- if (filename === void 0 || filename === null) return true;
123
- const name = filename.toString();
124
- if (/(?:^|[\\/])tsconfig(?:\..+)?\.json$/.test(name)) return true;
125
- return SOURCE_FILE_RE.test(name);
126
- }
127
- /**
128
- * Root the watcher at the project, not the entry's own directory: with the common
129
- * `src/main.ts` layout, tsconfig.json (whose JSX options change how sources transpile)
130
- * and `../` imports live one level up. Nearest package.json or tsconfig.json walking up
131
- * wins; entries with no project marker fall back to their own directory.
132
- */
133
- function watchRootFor(entryPath) {
134
- const start = (0, node_path.dirname)(entryPath);
135
- let dir = start;
136
- for (;;) {
137
- if ((0, node_fs.existsSync)((0, node_path.join)(dir, "package.json")) || (0, node_fs.existsSync)((0, node_path.join)(dir, "tsconfig.json"))) return dir;
138
- const parent = (0, node_path.dirname)(dir);
139
- if (parent === dir) return start;
140
- dir = parent;
141
- }
142
- }
143
- var IGNORED_DIR_NAMES = /* @__PURE__ */ new Set([
144
- "node_modules",
145
- "dist",
146
- ".git"
147
- ]);
148
- /**
149
- * Watch a directory tree with one non-recursive `fs.watch` per directory, skipping
150
- * node_modules/dist/.git and never following symlinks. `fs.watch({recursive})` has no
151
- * exclusion API, so on a real project it would descend into node_modules — tens of
152
- * thousands of inotify watches. Whenever an event fires in a directory it is re-scanned,
153
- * which picks up newly created subdirectories. Returns a stop function.
154
- */
155
- function watchTree(root, onEvent) {
156
- const watchers = /* @__PURE__ */ new Map();
157
- const add = (dir) => {
158
- if (watchers.has(dir)) return;
159
- try {
160
- watchers.set(dir, (0, node_fs.watch)(dir, (_event, filename) => {
161
- scan(dir);
162
- onEvent(filename);
163
- }));
164
- } catch {}
165
- };
166
- const scan = (dir) => {
167
- let entries;
168
- try {
169
- entries = (0, node_fs.readdirSync)(dir, { withFileTypes: true });
170
- } catch {
171
- return;
172
- }
173
- for (const entry of entries) {
174
- if (!entry.isDirectory() || IGNORED_DIR_NAMES.has(entry.name)) continue;
175
- const child = (0, node_path.join)(dir, entry.name);
176
- if (watchers.has(child)) continue;
177
- add(child);
178
- scan(child);
179
- }
180
- };
181
- add(root);
182
- scan(root);
183
- return () => {
184
- for (const watcher of watchers.values()) watcher.close();
185
- watchers.clear();
186
- };
187
- }
188
- //#endregion
189
- //#region src/disposeShape.ts
190
- function disposeShape(shape) {
191
- const disposer = shape?.[Symbol.dispose];
192
- if (typeof disposer === "function") disposer.call(shape);
193
- }
194
101
  //#endregion
195
102
  //#region src/cli/exportPart.ts
196
103
  function stem(file) {
197
104
  return (0, node_path.basename)(file).replace(/\.brep\.ts$/, "").replace(/\.ts$/, "");
198
105
  }
199
106
  async function exportPart(modulePath, formats, outDir) {
200
- const { shape, report, step, glb } = await require_diff.runPart(modulePath, {
107
+ const { shape, report, step, glb } = await require_usingCtx.runPart(modulePath, {
201
108
  step: Boolean(formats.step),
202
109
  glb: Boolean(formats.glb)
203
110
  });
204
111
  const errors = [];
205
112
  const written = [];
206
- if (!(require_diff.reportOk(report) && shape !== null)) {
207
- disposeShape(shape);
113
+ if (!(require_usingCtx.reportOk(report) && shape !== null)) {
114
+ require_openBrowser.disposeShape(shape);
208
115
  const failures = report.errorInfos.map((e) => e.message);
209
116
  return {
210
117
  ok: false,
@@ -231,7 +138,7 @@ async function exportPart(modulePath, formats, outDir) {
231
138
  } else errors.push("GLB export produced no data");
232
139
  }
233
140
  if (formats.stl) {
234
- const { exportSTL, isOk } = await require_diff.loadBrep();
141
+ const { exportSTL, isOk } = await require_usingCtx.loadBrep();
235
142
  const r = exportSTL(shape);
236
143
  if (isOk(r)) {
237
144
  const p = (0, node_path.join)(outDir, `${base}.stl`);
@@ -240,7 +147,7 @@ async function exportPart(modulePath, formats, outDir) {
240
147
  } else errors.push(`STL export: ${r.error.message}`);
241
148
  }
242
149
  } finally {
243
- disposeShape(shape);
150
+ require_openBrowser.disposeShape(shape);
244
151
  }
245
152
  return {
246
153
  ok: errors.length === 0,
@@ -250,43 +157,6 @@ async function exportPart(modulePath, formats, outDir) {
250
157
  };
251
158
  }
252
159
  //#endregion
253
- //#region src/cli/openBrowser.ts
254
- /**
255
- * Whether `--serve` should auto-open the browser for the current environment.
256
- *
257
- * Opens only for an interactive session — suppressed under CI, when stderr is
258
- * not a TTY (agent/piped runs), or on Linux without a display server — so
259
- * automation never spawns a browser unexpectedly. An explicit `--no-open` is a
260
- * separate, always-on override handled by the caller.
261
- */
262
- function shouldAutoOpen({ env = process.env, platform = process.platform, isTTY = Boolean(process.stderr.isTTY) } = {}) {
263
- if (env["CI"]) return false;
264
- if (!isTTY) return false;
265
- if (platform === "linux" && !env["DISPLAY"] && !env["WAYLAND_DISPLAY"]) return false;
266
- return true;
267
- }
268
- /** The platform-specific command that opens `url` in the default browser. */
269
- function browserCommand(url, platform) {
270
- if (platform === "darwin") return ["open", [url]];
271
- if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
272
- return ["xdg-open", [url]];
273
- }
274
- /**
275
- * Best-effort open of `url` in the default browser. Never throws and never
276
- * blocks — a missing opener just leaves the printed URL as the fallback.
277
- */
278
- function openBrowser(url, platform = process.platform) {
279
- try {
280
- const [cmd, args] = browserCommand(url, platform);
281
- const child = (0, node_child_process.spawn)(cmd, args, {
282
- stdio: "ignore",
283
- detached: true
284
- });
285
- child.on("error", () => {});
286
- child.unref();
287
- } catch {}
288
- }
289
- //#endregion
290
160
  //#region src/snapshot/aiming.ts
291
161
  /**
292
162
  * Kernel-anchored marks for the judge: `B<index>` at each body's bbox centroid (only for multi-body
@@ -372,14 +242,14 @@ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) o
372
242
  if (opts.step || opts.serve || opts.snapshot || opts.glb || opts.expectCode || opts.expectInvalid) process.stderr.write("batch mode: --step/--glb/--serve/--snapshot/--expect-code/--expect-invalid ignored (single-file only)\n");
373
243
  const results = [];
374
244
  for (const f of targets) {
375
- const { report, shape } = await require_diff.runPart(f, {
245
+ const { report, shape } = await require_usingCtx.runPart(f, {
376
246
  check: Boolean(opts.check),
377
247
  metrics: Boolean(opts.metrics)
378
248
  });
379
- disposeShape(shape);
249
+ require_openBrowser.disposeShape(shape);
380
250
  results.push({
381
251
  file: f,
382
- ok: require_diff.reportOk(report),
252
+ ok: require_usingCtx.reportOk(report),
383
253
  report
384
254
  });
385
255
  }
@@ -391,7 +261,7 @@ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) o
391
261
  }
392
262
  const file = targets[0] ?? files[0] ?? "";
393
263
  const wantStep = Boolean(opts.step) || Boolean(opts.snapshot) || Boolean(opts.serve);
394
- const { report, step, glb, shape } = await require_diff.runPart((0, node_path.resolve)(file), {
264
+ const { report, step, glb, shape } = await require_usingCtx.runPart((0, node_path.resolve)(file), {
395
265
  step: wantStep,
396
266
  glb: Boolean(opts.glb),
397
267
  check: Boolean(opts.check),
@@ -419,27 +289,27 @@ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) o
419
289
  }
420
290
  } else if (opts.snapshot) process.stderr.write("snapshot skipped: STEP export produced no artifact\n");
421
291
  } catch (e) {
422
- require_diff.pushError(report, { message: `artifact write failed: ${e.message}` });
292
+ require_usingCtx.pushError(report, { message: `artifact write failed: ${e.message}` });
423
293
  } finally {
424
- disposeShape(shape);
294
+ require_openBrowser.disposeShape(shape);
425
295
  }
426
- const json = require_diff.serializeReport(report);
296
+ const json = require_usingCtx.serializeReport(report);
427
297
  if (opts.json && opts.json !== "-") (0, node_fs.writeFileSync)(opts.json, json);
428
298
  process.stdout.write(json + "\n");
429
- if (!require_diff.reportOk(report)) process.exitCode = 1;
299
+ if (!require_usingCtx.reportOk(report)) process.exitCode = 1;
430
300
  if (opts.expectInvalid || opts.expectCode) {
431
301
  const codes = [...new Set(report.errorInfos.map((e) => e.code).filter((c) => Boolean(c)))];
432
- const valid = require_diff.reportOk(report);
302
+ const valid = require_usingCtx.reportOk(report);
433
303
  const pass = (!opts.expectInvalid || !valid) && (!opts.expectCode || codes.includes(opts.expectCode));
434
304
  process.exitCode = pass ? 0 : 1;
435
305
  process.stderr.write(`expect: ${pass ? "PASS" : "FAIL"}` + (opts.expectCode ? ` code=${opts.expectCode}` : "") + (opts.expectInvalid ? " invalid" : "") + ` (ok=${valid}, codes=${codes.join(",") || "none"})\n`);
436
306
  return;
437
307
  }
438
- if (Boolean(opts.serve) && stepPath !== void 0 && require_diff.reportOk(report) && stepPath) {
308
+ if (Boolean(opts.serve) && stepPath !== void 0 && require_usingCtx.reportOk(report) && stepPath) {
439
309
  const { serve } = await Promise.resolve().then(() => require("../snapshot/serve.cjs"));
440
310
  const { url, reused } = await serve({ file: stepPath });
441
311
  process.stderr.write(`viewer: ${url}\n`);
442
- if (!reused && opts.open && shouldAutoOpen()) openBrowser(url);
312
+ if (!reused && opts.open && require_openBrowser.shouldAutoOpen()) require_openBrowser.openBrowser(url);
443
313
  }
444
314
  });
445
315
  program.command("measure").argument("<a>", "path to a .brep.ts module").argument("[b]", "optional second module; if given, measures distance between the two parts").action(async (a, b) => {
@@ -462,14 +332,14 @@ program.command("snapshot").argument("<file>", "path to a .brep.ts module; rende
462
332
  const resolved = (0, node_path.resolve)(file);
463
333
  const base = opts.out ? (0, node_path.resolve)(opts.out) : `${resolved}-shots`;
464
334
  const outDir = opts.label ? (0, node_path.join)(base, opts.label) : base;
465
- const { report, step, shape } = await require_diff.runPart(resolved, {
335
+ const { report, step, shape } = await require_usingCtx.runPart(resolved, {
466
336
  step: true,
467
337
  metrics: true
468
338
  });
469
339
  try {
470
340
  if (!step) {
471
341
  process.stderr.write("snapshot: part produced no STEP — nothing to render\n");
472
- process.stdout.write(require_diff.serializeReport(report) + "\n");
342
+ process.stdout.write(require_usingCtx.serializeReport(report) + "\n");
473
343
  process.exitCode = 1;
474
344
  return;
475
345
  }
@@ -477,7 +347,7 @@ program.command("snapshot").argument("<file>", "path to a .brep.ts module; rende
477
347
  (0, node_fs.writeFileSync)(stepPath, Buffer.from(step));
478
348
  const shoot = await loadSnapshotShoot();
479
349
  if (!shoot) {
480
- process.stdout.write(require_diff.serializeReport(report) + "\n");
350
+ process.stdout.write(require_usingCtx.serializeReport(report) + "\n");
481
351
  return;
482
352
  }
483
353
  const section = aimedSection(report.manufacturability?.bores ?? [], report.measurements.bounds);
@@ -491,7 +361,7 @@ program.command("snapshot").argument("<file>", "path to a .brep.ts module; rende
491
361
  });
492
362
  for (const p of pngs) process.stdout.write(`${p}\n`);
493
363
  } finally {
494
- disposeShape(shape);
364
+ require_openBrowser.disposeShape(shape);
495
365
  }
496
366
  });
497
367
  program.command("init").argument("<name>", "part name; scaffolds <name>.brep.ts + tsconfig.json + README.md").option("--out <dir>", "target directory (defaults to ./<name>)").action((name, opts) => {
@@ -506,40 +376,22 @@ program.command("watch").argument("<file>", "path to a .brep.ts module; re-verif
506
376
  const path = (0, node_path.resolve)(file);
507
377
  const run = async (fresh) => {
508
378
  try {
509
- const { report, shape } = await require_diff.runPart(path, { freshImport: fresh });
379
+ const { report, shape } = await require_usingCtx.runPart(path, { freshImport: fresh });
510
380
  try {
511
- process.stdout.write(require_diff.serializeReport(report) + "\n");
381
+ process.stdout.write(require_usingCtx.serializeReport(report) + "\n");
512
382
  } finally {
513
- disposeShape(shape);
383
+ require_openBrowser.disposeShape(shape);
514
384
  }
515
385
  } catch (e) {
516
386
  process.stderr.write(`watch run failed: ${e.message}\n`);
517
387
  }
518
388
  };
519
- let inFlight = false;
520
- let rerunQueued = false;
521
- const start = (fresh) => {
522
- inFlight = true;
523
- run(fresh).finally(() => {
524
- inFlight = false;
525
- if (rerunQueued) {
526
- rerunQueued = false;
527
- start(true);
528
- }
529
- });
530
- };
531
- const schedule = () => {
532
- if (inFlight) {
533
- rerunQueued = true;
534
- return;
535
- }
536
- start(true);
537
- };
538
- const { trigger } = debounce(schedule, 150);
389
+ const runner = require_openBrowser.createSerialRunner(run);
390
+ const { trigger } = require_openBrowser.debounce(runner.trigger, 150);
539
391
  process.stderr.write(`watching ${path} (Ctrl-C to stop)\n`);
540
- start(false);
541
- const stopWatching = watchTree(watchRootFor(path), (filename) => {
542
- if (isWatchRelevant(filename)) trigger();
392
+ runner.start(false);
393
+ const stopWatching = require_openBrowser.watchTree(require_openBrowser.watchRootFor(path), (filename) => {
394
+ if (require_openBrowser.isWatchRelevant(filename)) trigger();
543
395
  });
544
396
  const stop = () => {
545
397
  stopWatching();
@@ -548,6 +400,15 @@ program.command("watch").argument("<file>", "path to a .brep.ts module; re-verif
548
400
  process.on("SIGINT", stop);
549
401
  process.on("SIGTERM", stop);
550
402
  });
403
+ program.command("preview").argument("<file>", "path to a model module (.ts/.tsx); a families element tree renders per element").option("--watch", "re-evaluate on save and push updates to the open viewer tab").option("--write-only", "write artifacts and exit without starting the viewer server").option("--glb <out>", "write a merged GLB of the whole model").option("--json <out>", "write the JSON summary to this path").option("--port <n>", "viewer server port (default: OS-assigned)", (v) => parseInt(v, 10)).option("--no-open", "do not auto-open the browser").action(async (file, opts) => {
404
+ const { runPreview } = await Promise.resolve().then(() => require("../preview/preview.cjs"));
405
+ try {
406
+ await runPreview((0, node_path.resolve)(file), opts);
407
+ } catch (e) {
408
+ process.stderr.write(`preview failed: ${e.message}\n`);
409
+ process.exitCode = 1;
410
+ }
411
+ });
551
412
  program.command("export").argument("<file>", "path to a .brep.ts module").option("--step", "write a STEP artifact").option("--glb", "write a GLB artifact").option("--stl", "write an STL artifact").option("--all", "write STEP + GLB + STL").option("--out <dir>", "output directory", ".").action(async (file, opts) => {
552
413
  const formats = opts.all ? {
553
414
  step: true,
package/dist/cli/main.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
- import { f as pushError, h as loadBrep, m as serializeReport, n as runMeasure, p as reportOk, r as runPart, t as runDiff } from "../diff-B1sSWjLa.js";
2
+ import { _ as loadBrep, h as serializeReport, m as reportOk, p as pushError, r as runPart } from "../usingCtx-DOzK2eJh.js";
3
+ import { n as runMeasure, t as runDiff } from "../diff-C2vDUt9F.js";
4
+ import { a as debounce, c as watchTree, i as createSerialRunner, n as shouldAutoOpen, o as isWatchRelevant, r as disposeShape, s as watchRootFor, t as openBrowser } from "../openBrowser-BBYw6VVb.js";
3
5
  import { fileURLToPath } from "node:url";
4
- import { existsSync, globSync, mkdirSync, readdirSync, realpathSync, watch, writeFileSync } from "node:fs";
5
- import { basename, dirname, join, resolve } from "node:path";
6
+ import { existsSync, globSync, mkdirSync, realpathSync, writeFileSync } from "node:fs";
7
+ import { basename, join, resolve } from "node:path";
6
8
  import { Command } from "commander";
7
9
  import { tmpdir } from "node:os";
8
- import { spawn } from "node:child_process";
9
10
  //#region src/cli/scaffold.ts
10
11
  function partTemplate(name) {
11
12
  return `import { box, cut, unwrap } from 'brepjs';
@@ -96,100 +97,6 @@ function scaffoldPart(name, dir) {
96
97
  files
97
98
  };
98
99
  }
99
- function debounce(fn, delayMs = 150) {
100
- let timer;
101
- const cancel = () => {
102
- if (timer) {
103
- clearTimeout(timer);
104
- timer = void 0;
105
- }
106
- };
107
- const trigger = () => {
108
- cancel();
109
- timer = setTimeout(() => {
110
- timer = void 0;
111
- fn();
112
- }, delayMs);
113
- };
114
- return {
115
- trigger,
116
- cancel
117
- };
118
- }
119
- var SOURCE_FILE_RE = /\.(?:m?[jt]s|[jt]sx)$/;
120
- function isWatchRelevant(filename) {
121
- if (filename === void 0 || filename === null) return true;
122
- const name = filename.toString();
123
- if (/(?:^|[\\/])tsconfig(?:\..+)?\.json$/.test(name)) return true;
124
- return SOURCE_FILE_RE.test(name);
125
- }
126
- /**
127
- * Root the watcher at the project, not the entry's own directory: with the common
128
- * `src/main.ts` layout, tsconfig.json (whose JSX options change how sources transpile)
129
- * and `../` imports live one level up. Nearest package.json or tsconfig.json walking up
130
- * wins; entries with no project marker fall back to their own directory.
131
- */
132
- function watchRootFor(entryPath) {
133
- const start = dirname(entryPath);
134
- let dir = start;
135
- for (;;) {
136
- if (existsSync(join(dir, "package.json")) || existsSync(join(dir, "tsconfig.json"))) return dir;
137
- const parent = dirname(dir);
138
- if (parent === dir) return start;
139
- dir = parent;
140
- }
141
- }
142
- var IGNORED_DIR_NAMES = /* @__PURE__ */ new Set([
143
- "node_modules",
144
- "dist",
145
- ".git"
146
- ]);
147
- /**
148
- * Watch a directory tree with one non-recursive `fs.watch` per directory, skipping
149
- * node_modules/dist/.git and never following symlinks. `fs.watch({recursive})` has no
150
- * exclusion API, so on a real project it would descend into node_modules — tens of
151
- * thousands of inotify watches. Whenever an event fires in a directory it is re-scanned,
152
- * which picks up newly created subdirectories. Returns a stop function.
153
- */
154
- function watchTree(root, onEvent) {
155
- const watchers = /* @__PURE__ */ new Map();
156
- const add = (dir) => {
157
- if (watchers.has(dir)) return;
158
- try {
159
- watchers.set(dir, watch(dir, (_event, filename) => {
160
- scan(dir);
161
- onEvent(filename);
162
- }));
163
- } catch {}
164
- };
165
- const scan = (dir) => {
166
- let entries;
167
- try {
168
- entries = readdirSync(dir, { withFileTypes: true });
169
- } catch {
170
- return;
171
- }
172
- for (const entry of entries) {
173
- if (!entry.isDirectory() || IGNORED_DIR_NAMES.has(entry.name)) continue;
174
- const child = join(dir, entry.name);
175
- if (watchers.has(child)) continue;
176
- add(child);
177
- scan(child);
178
- }
179
- };
180
- add(root);
181
- scan(root);
182
- return () => {
183
- for (const watcher of watchers.values()) watcher.close();
184
- watchers.clear();
185
- };
186
- }
187
- //#endregion
188
- //#region src/disposeShape.ts
189
- function disposeShape(shape) {
190
- const disposer = shape?.[Symbol.dispose];
191
- if (typeof disposer === "function") disposer.call(shape);
192
- }
193
100
  //#endregion
194
101
  //#region src/cli/exportPart.ts
195
102
  function stem(file) {
@@ -249,43 +156,6 @@ async function exportPart(modulePath, formats, outDir) {
249
156
  };
250
157
  }
251
158
  //#endregion
252
- //#region src/cli/openBrowser.ts
253
- /**
254
- * Whether `--serve` should auto-open the browser for the current environment.
255
- *
256
- * Opens only for an interactive session — suppressed under CI, when stderr is
257
- * not a TTY (agent/piped runs), or on Linux without a display server — so
258
- * automation never spawns a browser unexpectedly. An explicit `--no-open` is a
259
- * separate, always-on override handled by the caller.
260
- */
261
- function shouldAutoOpen({ env = process.env, platform = process.platform, isTTY = Boolean(process.stderr.isTTY) } = {}) {
262
- if (env["CI"]) return false;
263
- if (!isTTY) return false;
264
- if (platform === "linux" && !env["DISPLAY"] && !env["WAYLAND_DISPLAY"]) return false;
265
- return true;
266
- }
267
- /** The platform-specific command that opens `url` in the default browser. */
268
- function browserCommand(url, platform) {
269
- if (platform === "darwin") return ["open", [url]];
270
- if (platform === "win32") return ["rundll32", ["url.dll,FileProtocolHandler", url]];
271
- return ["xdg-open", [url]];
272
- }
273
- /**
274
- * Best-effort open of `url` in the default browser. Never throws and never
275
- * blocks — a missing opener just leaves the printed URL as the fallback.
276
- */
277
- function openBrowser(url, platform = process.platform) {
278
- try {
279
- const [cmd, args] = browserCommand(url, platform);
280
- const child = spawn(cmd, args, {
281
- stdio: "ignore",
282
- detached: true
283
- });
284
- child.on("error", () => {});
285
- child.unref();
286
- } catch {}
287
- }
288
- //#endregion
289
159
  //#region src/snapshot/aiming.ts
290
160
  /**
291
161
  * Kernel-anchored marks for the judge: `B<index>` at each body's bbox centroid (only for multi-body
@@ -515,28 +385,10 @@ program.command("watch").argument("<file>", "path to a .brep.ts module; re-verif
515
385
  process.stderr.write(`watch run failed: ${e.message}\n`);
516
386
  }
517
387
  };
518
- let inFlight = false;
519
- let rerunQueued = false;
520
- const start = (fresh) => {
521
- inFlight = true;
522
- run(fresh).finally(() => {
523
- inFlight = false;
524
- if (rerunQueued) {
525
- rerunQueued = false;
526
- start(true);
527
- }
528
- });
529
- };
530
- const schedule = () => {
531
- if (inFlight) {
532
- rerunQueued = true;
533
- return;
534
- }
535
- start(true);
536
- };
537
- const { trigger } = debounce(schedule, 150);
388
+ const runner = createSerialRunner(run);
389
+ const { trigger } = debounce(runner.trigger, 150);
538
390
  process.stderr.write(`watching ${path} (Ctrl-C to stop)\n`);
539
- start(false);
391
+ runner.start(false);
540
392
  const stopWatching = watchTree(watchRootFor(path), (filename) => {
541
393
  if (isWatchRelevant(filename)) trigger();
542
394
  });
@@ -547,6 +399,15 @@ program.command("watch").argument("<file>", "path to a .brep.ts module; re-verif
547
399
  process.on("SIGINT", stop);
548
400
  process.on("SIGTERM", stop);
549
401
  });
402
+ program.command("preview").argument("<file>", "path to a model module (.ts/.tsx); a families element tree renders per element").option("--watch", "re-evaluate on save and push updates to the open viewer tab").option("--write-only", "write artifacts and exit without starting the viewer server").option("--glb <out>", "write a merged GLB of the whole model").option("--json <out>", "write the JSON summary to this path").option("--port <n>", "viewer server port (default: OS-assigned)", (v) => parseInt(v, 10)).option("--no-open", "do not auto-open the browser").action(async (file, opts) => {
403
+ const { runPreview } = await import("../preview/preview.js");
404
+ try {
405
+ await runPreview(resolve(file), opts);
406
+ } catch (e) {
407
+ process.stderr.write(`preview failed: ${e.message}\n`);
408
+ process.exitCode = 1;
409
+ }
410
+ });
550
411
  program.command("export").argument("<file>", "path to a .brep.ts module").option("--step", "write a STEP artifact").option("--glb", "write a GLB artifact").option("--stl", "write an STL artifact").option("--all", "write STEP + GLB + STL").option("--out <dir>", "output directory", ".").action(async (file, opts) => {
551
412
  const formats = opts.all ? {
552
413
  step: true,
@@ -19,3 +19,12 @@ export declare function watchRootFor(entryPath: string): string;
19
19
  * which picks up newly created subdirectories. Returns a stop function.
20
20
  */
21
21
  export declare function watchTree(root: string, onEvent: (filename: string | Buffer | null | undefined) => void): () => void;
22
+ /**
23
+ * Serialize an async job: while one run is in flight, triggers coalesce into exactly
24
+ * one trailing rerun (verify/preview runs share one kernel — overlapping runs would
25
+ * interleave output and reorder reports).
26
+ */
27
+ export declare function createSerialRunner(run: (fresh: boolean) => Promise<void>): {
28
+ start: (fresh: boolean) => void;
29
+ trigger: () => void;
30
+ };