forgecad 0.9.2 → 0.9.3

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.
Files changed (79) hide show
  1. package/LICENSE +7 -5
  2. package/README.md +1 -1
  3. package/README.public.md +24 -2
  4. package/dist/assets/{AdminPage-Bs4PiK00.js → AdminPage-4jihcEk_.js} +1 -1
  5. package/dist/assets/{BlogPage-DVmgN0ma.js → BlogPage-BvzruKtw.js} +1 -1
  6. package/dist/assets/{DocsPage-BP6wlsBN.js → DocsPage-DHbd-WS-.js} +13 -13
  7. package/dist/assets/{EditorApp-Arw2NnGJ.js → EditorApp-C5P2rBfh.js} +433 -84
  8. package/dist/assets/{EditorApp-VY9lXx0N.css → EditorApp-DS0AIUrZ.css} +25 -0
  9. package/dist/assets/{EmbedViewer-qgQiOahL.js → EmbedViewer-B70wQwlE.js} +2 -2
  10. package/dist/assets/{LandingPageProofDriven-DvhtmWOz.js → LandingPageProofDriven-DIsYTnep.js} +1 -1
  11. package/dist/assets/{PricingPage-Ck3CP2ti.css → PricingPage-BMedqFef.css} +48 -0
  12. package/dist/assets/{PricingPage-657oLvWh.js → PricingPage-YPOr12pP.js} +34 -6
  13. package/dist/assets/{SettingsPage-wNy3_2yn.js → SettingsPage-rntoyJ3b.js} +10 -13
  14. package/dist/assets/{app-BdBoMQeO.js → app-CWucmnLZ.js} +801 -1208
  15. package/dist/assets/cli/{render-Ci3jjyT1.js → render-DZHmUySW.js} +214 -23
  16. package/dist/assets/copy-CQKQppF-.js +8 -0
  17. package/dist/assets/{evalWorker-CMCAbK8r.js → evalWorker-C3dKxi9Y.js} +1117 -95
  18. package/dist/assets/{manifold-BMn-8Vf8.js → manifold-CQ3FhfWB.js} +1 -1
  19. package/dist/assets/{manifold-jlYQ6E5R.js → manifold-CU0G1yYL.js} +1 -1
  20. package/dist/assets/{manifold-DbyILno4.js → manifold-CYWZMfjB.js} +2 -2
  21. package/dist/assets/{renderSceneState-DAnqvxSt.js → renderSceneState-BBUrnsUN.js} +1 -1
  22. package/dist/assets/{reportWorker-BcRVMHK-.js → reportWorker-BhZ7DjxQ.js} +1091 -95
  23. package/dist/assets/{sectionPlaneMath-DXJ_TdIW.js → sectionPlaneMath-BxfokaJE.js} +1091 -95
  24. package/dist/cli/render.html +1 -1
  25. package/dist/docs/index.html +2 -2
  26. package/dist/docs-raw/AI/usage.md +182 -89
  27. package/dist/docs-raw/API/core/concepts.md +26 -0
  28. package/dist/docs-raw/CLI.md +58 -37
  29. package/dist/docs-raw/INDEX.md +81 -64
  30. package/dist/docs-raw/cli-monetization.md +9 -8
  31. package/dist/docs-raw/generated/concepts.md +111 -4
  32. package/dist/docs-raw/generated/core.md +2 -0
  33. package/dist/docs-raw/generated/curves.md +480 -1
  34. package/dist/docs-raw/generated/output.md +1 -0
  35. package/dist/docs-raw/generated/sketch.md +2 -0
  36. package/dist/docs-raw/generated/viewport.md +81 -3
  37. package/dist/docs-raw/product/user-outreach-email-templates.md +159 -0
  38. package/dist/docs-raw/skills/forgecad-image-replicator.md +1 -1
  39. package/dist/docs-raw/skills/forgecad-make-a-model.md +33 -4
  40. package/dist/docs-raw/skills/forgecad-prepare-prompt.md +1 -1
  41. package/dist/docs-raw/skills/forgecad-project.md +1 -1
  42. package/dist/docs-raw/skills/forgecad-render-inspect.md +1 -1
  43. package/dist/docs-raw/skills/forgecad.md +2 -1
  44. package/dist/docs-raw/welcome.md +85 -137
  45. package/dist/index.html +1 -1
  46. package/dist/llms.txt +4 -3
  47. package/dist/sitemap.xml +6 -6
  48. package/dist-cli/forgecad.js +1413 -219
  49. package/dist-cli/forgecad.js.map +1 -1
  50. package/dist-skill/CONTEXT.md +594 -5
  51. package/dist-skill/SKILL-dev.md +2 -1
  52. package/dist-skill/SKILL.md +2 -1
  53. package/dist-skill/docs/API/core/concepts.md +26 -0
  54. package/dist-skill/docs/CLI.md +58 -37
  55. package/dist-skill/docs/generated/core.md +2 -0
  56. package/dist-skill/docs/generated/curves.md +480 -1
  57. package/dist-skill/docs/generated/output.md +1 -0
  58. package/dist-skill/docs/generated/sketch.md +2 -0
  59. package/dist-skill/docs/generated/viewport.md +81 -3
  60. package/dist-skill/docs-dev/API/core/concepts.md +26 -0
  61. package/dist-skill/docs-dev/CLI.md +58 -37
  62. package/dist-skill/docs-dev/generated/core.md +2 -0
  63. package/dist-skill/docs-dev/generated/curves.md +480 -1
  64. package/dist-skill/docs-dev/generated/output.md +1 -0
  65. package/dist-skill/docs-dev/generated/sketch.md +2 -0
  66. package/dist-skill/docs-dev/generated/viewport.md +81 -3
  67. package/dist-skill/library/README.md +0 -1
  68. package/dist-skill/library/forgecad-image-replicator/SKILL.md +1 -1
  69. package/dist-skill/library/forgecad-make-a-model/SKILL.md +33 -4
  70. package/dist-skill/library/forgecad-prepare-prompt/SKILL.md +1 -1
  71. package/dist-skill/library/forgecad-project/SKILL.md +1 -1
  72. package/dist-skill/library/forgecad-render-inspect/SKILL.md +1 -1
  73. package/examples/api/conformal-product-ribbon.forge.js +77 -0
  74. package/examples/api/render-labels.forge.js +33 -0
  75. package/examples/api/text2d-basics.forge.js +6 -3
  76. package/package.json +1 -1
  77. package/dist-skill/library/forgecad-deep-dive/SKILL.md +0 -120
  78. package/dist-skill/library/forgecad-deep-dive/agents/openai.yaml +0 -4
  79. package/dist-skill/library/forgecad-deep-dive/references/output-shape.md +0 -64
@@ -4172,11 +4172,11 @@ function extractEdgeSegments(mesh) {
4172
4172
  const revKey = useNumeric ? vb * MAX_NUMERIC + va : `${vb},${va}`;
4173
4173
  const adjTri = halfEdges.get(revKey);
4174
4174
  if (adjTri !== void 0) {
4175
- const dot8 = faceNx[t] * faceNx[adjTri] + faceNy[t] * faceNy[adjTri] + faceNz[t] * faceNz[adjTri];
4176
- if (dot8 <= EDGE_THRESHOLD_DOT) {
4175
+ const dot9 = faceNx[t] * faceNx[adjTri] + faceNy[t] * faceNy[adjTri] + faceNz[t] * faceNz[adjTri];
4176
+ if (dot9 <= EDGE_THRESHOLD_DOT) {
4177
4177
  const origVa = triVerts[t * 3 + e];
4178
4178
  const origVb = triVerts[t * 3 + (e + 1) % 3];
4179
- const seg = buildEdgeSegment(edgeIndex++, origVa, origVb, vertProperties, numProp, t, adjTri, faceNx, faceNy, faceNz, dot8, false);
4179
+ const seg = buildEdgeSegment(edgeIndex++, origVa, origVb, vertProperties, numProp, t, adjTri, faceNx, faceNy, faceNz, dot9, false);
4180
4180
  edges.push(seg);
4181
4181
  }
4182
4182
  halfEdges.delete(revKey);
@@ -4206,7 +4206,7 @@ function extractEdgeSegments(mesh) {
4206
4206
  }
4207
4207
  return edges;
4208
4208
  }
4209
- function buildEdgeSegment(index, origVa, origVb, vertProperties, numProp, triA, triB, faceNx, faceNy, faceNz, dot8, boundary) {
4209
+ function buildEdgeSegment(index, origVa, origVb, vertProperties, numProp, triA, triB, faceNx, faceNy, faceNz, dot9, boundary) {
4210
4210
  const sx = vertProperties[origVa * numProp];
4211
4211
  const sy = vertProperties[origVa * numProp + 1];
4212
4212
  const sz = vertProperties[origVa * numProp + 2];
@@ -4218,7 +4218,7 @@ function buildEdgeSegment(index, origVa, origVb, vertProperties, numProp, triA,
4218
4218
  const invLen = length5 > 1e-12 ? 1 / length5 : 0;
4219
4219
  const nA = [faceNx[triA], faceNy[triA], faceNz[triA]];
4220
4220
  const nB = [faceNx[triB], faceNy[triB], faceNz[triB]];
4221
- const clampedDot = Math.max(-1, Math.min(1, dot8));
4221
+ const clampedDot = Math.max(-1, Math.min(1, dot9));
4222
4222
  const dihedralAngle = boundary ? 0 : 180 - Math.acos(clampedDot) * (180 / Math.PI);
4223
4223
  const crossX = nA[1] * nB[2] - nA[2] * nB[1];
4224
4224
  const crossY = nA[2] * nB[0] - nA[0] * nB[2];
@@ -5467,20 +5467,20 @@ function resolvePlaneOriginNormal(plane) {
5467
5467
  }
5468
5468
  function rotationToPlaneSpace(normal) {
5469
5469
  const n = normalize2(normal);
5470
- const dot8 = n[2];
5471
- if (dot8 > 1 - EPS4) {
5470
+ const dot9 = n[2];
5471
+ if (dot9 > 1 - EPS4) {
5472
5472
  return [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1];
5473
5473
  }
5474
5474
  let axis;
5475
5475
  let angle;
5476
- if (dot8 < -1 + EPS4) {
5476
+ if (dot9 < -1 + EPS4) {
5477
5477
  axis = [1, 0, 0];
5478
5478
  angle = Math.PI;
5479
5479
  } else {
5480
5480
  axis = [n[1], -n[0], 0];
5481
5481
  const axisLen = length(axis);
5482
5482
  axis = [axis[0] / axisLen, axis[1] / axisLen, axis[2] / axisLen];
5483
- angle = Math.acos(dot8);
5483
+ angle = Math.acos(dot9);
5484
5484
  }
5485
5485
  const [x, y, z] = axis;
5486
5486
  const c = Math.cos(angle);
@@ -10560,8 +10560,8 @@ function isFlat(p0, pMid, p1, cosThreshold) {
10560
10560
  const len1sq = d1x * d1x + d1y * d1y + d1z * d1z;
10561
10561
  const len2sq = d2x * d2x + d2y * d2y + d2z * d2z;
10562
10562
  if (len1sq < 1e-20 || len2sq < 1e-20) return true;
10563
- const dot8 = d1x * d2x + d1y * d2y + d1z * d2z;
10564
- const cosAngle = dot8 / Math.sqrt(len1sq * len2sq);
10563
+ const dot9 = d1x * d2x + d1y * d2y + d1z * d2z;
10564
+ const cosAngle = dot9 / Math.sqrt(len1sq * len2sq);
10565
10565
  return cosAngle >= cosThreshold;
10566
10566
  }
10567
10567
  function extractBoundingBox(oc, shape) {
@@ -12535,8 +12535,8 @@ Faces on this shape (${allFaces.length}):`);
12535
12535
  lines.push(` ... and ${allFaces.length - 10} more`);
12536
12536
  }
12537
12537
  if (query.normal) {
12538
- const dot8 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
12539
- const sorted = [...allFaces].map((f3) => ({ face: f3, sim: dot8(f3.normal, query.normal) })).sort((a, b) => b.sim - a.sim).slice(0, 3);
12538
+ const dot9 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
12539
+ const sorted = [...allFaces].map((f3) => ({ face: f3, sim: dot9(f3.normal, query.normal) })).sort((a, b) => b.sim - a.sim).slice(0, 3);
12540
12540
  if (sorted.length > 0 && sorted[0].sim < 0.9998) {
12541
12541
  lines.push(`
12542
12542
  Nearest normals to query:`);
@@ -18313,7 +18313,7 @@ function extractEdgesWithFaces(mesh, triCluster) {
18313
18313
  }
18314
18314
  return edges;
18315
18315
  }
18316
- function buildEdgeWithFaces(index, origVa, origVb, vertProperties, numProp, triA, triB, faceNx, faceNy, faceNz, dot8, boundary, clusterA, clusterB) {
18316
+ function buildEdgeWithFaces(index, origVa, origVb, vertProperties, numProp, triA, triB, faceNx, faceNy, faceNz, dot9, boundary, clusterA, clusterB) {
18317
18317
  const sx = vertProperties[origVa * numProp];
18318
18318
  const sy = vertProperties[origVa * numProp + 1];
18319
18319
  const sz = vertProperties[origVa * numProp + 2];
@@ -18325,7 +18325,7 @@ function buildEdgeWithFaces(index, origVa, origVb, vertProperties, numProp, triA
18325
18325
  const invLen = length5 > 1e-12 ? 1 / length5 : 0;
18326
18326
  const nA = [faceNx[triA], faceNy[triA], faceNz[triA]];
18327
18327
  const nB = [faceNx[triB], faceNy[triB], faceNz[triB]];
18328
- const clampedDot = Math.max(-1, Math.min(1, dot8));
18328
+ const clampedDot = Math.max(-1, Math.min(1, dot9));
18329
18329
  const dihedralAngle = boundary ? 0 : 180 - Math.acos(clampedDot) * (180 / Math.PI);
18330
18330
  const crossX = nA[1] * nB[2] - nA[2] * nB[1];
18331
18331
  const crossY = nA[2] * nB[0] - nA[0] * nB[2];
@@ -20162,6 +20162,7 @@ Fix: pass a path like [[0,0,0], [20,0,8,4], [40,0,0,2]].`
20162
20162
  const curveMode = shouldSmoothCurve(options, defaultCurve) && sourcePoints.length >= 3;
20163
20163
  const normalized = compactPathPoints(resampleCurve(sourcePoints, options, defaultCurve));
20164
20164
  const minRadius = Math.min(...normalized.map((point2) => point2.radius));
20165
+ const hasExplicitBlend = options.blend !== void 0;
20165
20166
  const blendRadius = positiveFinite(options.blend, "Sculpt.tube() blend", Math.max(0.5, minRadius));
20166
20167
  let segmentCount = 0;
20167
20168
  for (let i = 0; i < normalized.length - 1; i += 1) {
@@ -20170,8 +20171,8 @@ Fix: pass a path like [[0,0,0], [20,0,8,4], [40,0,0,2]].`
20170
20171
  if (segmentCount === 0) {
20171
20172
  throw new Error("Sculpt.tube() points must include at least one non-zero-length segment.");
20172
20173
  }
20173
- const curveBlendRadius = curveMode ? Math.max(blendRadius, minRadius * 1.5) : Math.min(blendRadius, Math.max(0.1, minRadius * 0.8));
20174
- let out = polylineSweep(normalized, curveMode ? curveBlendRadius : blendRadius);
20174
+ const effectiveBlendRadius = curveMode && !hasExplicitBlend ? Math.max(blendRadius, minRadius * 1.5) : blendRadius;
20175
+ let out = polylineSweep(normalized, effectiveBlendRadius);
20175
20176
  if (options.polish !== void 0) out = out.polish(options.polish);
20176
20177
  return out;
20177
20178
  }
@@ -24422,6 +24423,11 @@ function jointsView(options = {}) {
24422
24423
  }
24423
24424
 
24424
24425
  // src/forge/assembly/assembly.ts
24426
+ var SWEEP_JOINT_DEFAULT_STEP_LIMIT = {
24427
+ live: 1,
24428
+ default: 4,
24429
+ high: Number.POSITIVE_INFINITY
24430
+ };
24425
24431
  var collectedAssemblies = [];
24426
24432
  function resetCollectedAssemblies() {
24427
24433
  collectedAssemblies = [];
@@ -24465,6 +24471,33 @@ function collisionShape(part) {
24465
24471
  if (shapes.length === 1) return shapes[0];
24466
24472
  return union(...shapes);
24467
24473
  }
24474
+ function boundsOverlap(a, b) {
24475
+ for (let axis = 0; axis < 3; axis++) {
24476
+ if (a.max[axis] <= b.min[axis] || b.max[axis] <= a.min[axis]) return false;
24477
+ }
24478
+ return true;
24479
+ }
24480
+ function readAssemblyPerfEnv(name) {
24481
+ return typeof process !== "undefined" ? process.env?.[name] : void 0;
24482
+ }
24483
+ function resolveSweepJointStepLimit() {
24484
+ if (readAssemblyPerfEnv("FORGECAD_ALLOW_FULL_SWEEP_JOINT") === "1") return Number.POSITIVE_INFINITY;
24485
+ const override = readAssemblyPerfEnv("FORGECAD_SWEEP_JOINT_STEP_LIMIT");
24486
+ if (override != null && override.trim() !== "") {
24487
+ const parsed = Number(override);
24488
+ if (Number.isFinite(parsed) && parsed >= 1) return Math.floor(parsed);
24489
+ }
24490
+ return SWEEP_JOINT_DEFAULT_STEP_LIMIT[getForgeQualityPreset()];
24491
+ }
24492
+ function boundSweepJointSteps(jointName, requestedSteps) {
24493
+ const limit = resolveSweepJointStepLimit();
24494
+ if (!Number.isFinite(limit) || requestedSteps <= limit) return requestedSteps;
24495
+ const bounded = Math.max(1, Math.floor(limit));
24496
+ emitRuntimeWarning(
24497
+ `sweepJoint("${jointName}") requested ${requestedSteps} step(s), exceeding the ${getForgeQualityPreset()} quality sweep limit (${bounded}). Using ${bounded} step(s) for responsiveness. Use high quality/export, set FORGECAD_SWEEP_JOINT_STEP_LIMIT, or set FORGECAD_ALLOW_FULL_SWEEP_JOINT=1 for the full sweep.`
24498
+ );
24499
+ return bounded;
24500
+ }
24468
24501
  var FASTENER_PATTERN = /\b(bolt|screw|nut|washer|pin|rivet|fastener|standoff|insert)\b/i;
24469
24502
  function isFastenerName(name) {
24470
24503
  return FASTENER_PATTERN.test(name);
@@ -24793,16 +24826,23 @@ var SolvedAssembly = class {
24793
24826
  const minOverlap = options.minOverlapVolume ?? 0.1;
24794
24827
  const ignore = new Set((options.ignorePairs ?? []).map(([a, b]) => [a, b].sort().join("|")));
24795
24828
  const findings = [];
24796
- for (let i = 0; i < names.length; i++) {
24797
- for (let j = i + 1; j < names.length; j++) {
24798
- const aName = names[i];
24799
- const bName = names[j];
24829
+ const entries = [];
24830
+ for (const name of names) {
24831
+ const shape = collisionShape(this.getPart(name));
24832
+ if (!shape) continue;
24833
+ try {
24834
+ entries.push({ name, shape, bounds: shape.boundingBox() });
24835
+ } catch {
24836
+ }
24837
+ }
24838
+ for (let i = 0; i < entries.length; i++) {
24839
+ for (let j = i + 1; j < entries.length; j++) {
24840
+ const aName = entries[i].name;
24841
+ const bName = entries[j].name;
24800
24842
  if (ignore.has([aName, bName].sort().join("|"))) continue;
24801
- const a = collisionShape(this.getPart(aName));
24802
- const b = collisionShape(this.getPart(bName));
24803
- if (!a || !b) continue;
24843
+ if (!boundsOverlap(entries[i].bounds, entries[j].bounds)) continue;
24804
24844
  try {
24805
- const hit = a.intersect(b);
24845
+ const hit = entries[i].shape.intersect(entries[j].shape);
24806
24846
  if (hit.isEmpty()) continue;
24807
24847
  const vol = hit.volume();
24808
24848
  if (vol > minOverlap) {
@@ -25779,7 +25819,7 @@ var Assembly = class _Assembly {
25779
25819
  if (this.jointCouplings.has(jointName)) {
25780
25820
  throw new Error(`Cannot sweep coupled joint "${jointName}". Sweep one of its source joints instead.`);
25781
25821
  }
25782
- const n = Math.max(1, Math.floor(steps));
25822
+ const n = boundSweepJointSteps(jointName, Math.max(1, Math.floor(steps)));
25783
25823
  const frames = [];
25784
25824
  for (let i = 0; i <= n; i++) {
25785
25825
  const t = n === 0 ? 0 : i / n;
@@ -29280,9 +29320,9 @@ var PathBuilder = class {
29280
29320
  const [d1x, d1y] = this.getSegDirAt(prev, "end");
29281
29321
  const [d2x, d2y] = this.getSegDirAt(curr, "start");
29282
29322
  const cross7 = d1x * d2y - d1y * d2x;
29283
- const dot8 = d1x * d2x + d1y * d2y;
29323
+ const dot9 = d1x * d2x + d1y * d2y;
29284
29324
  if (Math.abs(cross7) < 1e-9) return { trimA: [cx, cy], trimB: [cx, cy], arcSeg: null };
29285
- const halfAngle = Math.atan2(Math.abs(cross7), dot8) / 2;
29325
+ const halfAngle = Math.atan2(Math.abs(cross7), dot9) / 2;
29286
29326
  const trimDist = radius / Math.tan(halfAngle);
29287
29327
  const trimA = [cx - d1x * trimDist, cy - d1y * trimDist];
29288
29328
  const trimB = [cx + d2x * trimDist, cy + d2y * trimDist];
@@ -29419,8 +29459,8 @@ var PathBuilder = class {
29419
29459
  const reflect = (px, py) => {
29420
29460
  const dx = px - pivotX;
29421
29461
  const dy = py - pivotY;
29422
- const dot8 = dx * nx + dy * ny;
29423
- return [px - 2 * dot8 * nx, py - 2 * dot8 * ny];
29462
+ const dot9 = dx * nx + dy * ny;
29463
+ return [px - 2 * dot9 * nx, py - 2 * dot9 * ny];
29424
29464
  };
29425
29465
  const toMirror = this.segs.slice(1).reverse();
29426
29466
  for (const seg of toMirror) {
@@ -30533,8 +30573,8 @@ function resolveExplodeDirection(mode, center, originCenter, seed) {
30533
30573
  return [0, 0, 1];
30534
30574
  }
30535
30575
  function explodeProjectPerpendicular(vec, axis) {
30536
- const dot8 = explodeDot(vec, axis);
30537
- return [vec[0] - axis[0] * dot8, vec[1] - axis[1] * dot8, vec[2] - axis[2] * dot8];
30576
+ const dot9 = explodeDot(vec, axis);
30577
+ return [vec[0] - axis[0] * dot9, vec[1] - axis[1] * dot9, vec[2] - axis[2] * dot9];
30538
30578
  }
30539
30579
  function applyExplodeTreeBias(direction2, mode, inheritedDirection, seed) {
30540
30580
  if (!inheritedDirection || mode !== "radial") return direction2;
@@ -44520,6 +44560,132 @@ function shapeToGeometryFallback(shape) {
44520
44560
  return { solid, edges, hasSmoothNormals: false };
44521
44561
  }
44522
44562
 
44563
+ // src/forge/renderLabel.ts
44564
+ var _collected5 = [];
44565
+ var _nextId = 1;
44566
+ function resetRenderLabels() {
44567
+ _collected5 = [];
44568
+ _nextId = 1;
44569
+ }
44570
+ function getCollectedRenderLabels() {
44571
+ return _collected5.map((label) => ({ ...label, at: [...label.at], offset: [...label.offset] }));
44572
+ }
44573
+ function requireFinite2(value, label) {
44574
+ if (typeof value !== "number" || !Number.isFinite(value)) {
44575
+ throw new Error(`${label} must be a finite number`);
44576
+ }
44577
+ return value;
44578
+ }
44579
+ function requireVec3(value, label) {
44580
+ if (!Array.isArray(value) || value.length !== 3) {
44581
+ throw new Error(`${label} must be [x, y, z]`);
44582
+ }
44583
+ return [requireFinite2(value[0], `${label}[0]`), requireFinite2(value[1], `${label}[1]`), requireFinite2(value[2], `${label}[2]`)];
44584
+ }
44585
+ function optionalColor(value, label) {
44586
+ if (value === void 0) return void 0;
44587
+ if (typeof value !== "string" || value.trim().length === 0) {
44588
+ throw new Error(`${label} must be a non-empty CSS color string`);
44589
+ }
44590
+ return value.trim();
44591
+ }
44592
+ var VALID_ANCHORS = /* @__PURE__ */ new Set([
44593
+ "center",
44594
+ "top",
44595
+ "bottom",
44596
+ "left",
44597
+ "right",
44598
+ "top-left",
44599
+ "top-right",
44600
+ "bottom-left",
44601
+ "bottom-right"
44602
+ ]);
44603
+ function normalizeOptions(options) {
44604
+ if (options === void 0) {
44605
+ return { offset: [0, 0, 0], anchor: "center", alwaysOnTop: true };
44606
+ }
44607
+ if (!options || typeof options !== "object" || Array.isArray(options)) {
44608
+ throw new Error("Viewport.label options must be an object");
44609
+ }
44610
+ const out = {
44611
+ offset: [0, 0, 0],
44612
+ anchor: "center",
44613
+ alwaysOnTop: true
44614
+ };
44615
+ const color = optionalColor(options.color, "Viewport.label options.color");
44616
+ if (color !== void 0) out.color = color;
44617
+ const background = optionalColor(options.background, "Viewport.label options.background");
44618
+ if (background !== void 0) out.background = background;
44619
+ if (options.size !== void 0) {
44620
+ out.size = requireFinite2(options.size, "Viewport.label options.size");
44621
+ if (out.size <= 0) throw new Error("Viewport.label options.size must be positive");
44622
+ }
44623
+ if (options.offset !== void 0) out.offset = requireVec3(options.offset, "Viewport.label options.offset");
44624
+ if (options.anchor !== void 0) {
44625
+ if (!VALID_ANCHORS.has(options.anchor)) {
44626
+ throw new Error(`Viewport.label options.anchor must be one of: ${Array.from(VALID_ANCHORS).join(", ")}`);
44627
+ }
44628
+ out.anchor = options.anchor;
44629
+ }
44630
+ if (options.alwaysOnTop !== void 0) {
44631
+ if (typeof options.alwaysOnTop !== "boolean") throw new Error("Viewport.label options.alwaysOnTop must be a boolean");
44632
+ out.alwaysOnTop = options.alwaysOnTop;
44633
+ }
44634
+ return out;
44635
+ }
44636
+ function collectRenderLabel(text, at, options) {
44637
+ if (typeof text !== "string" || text.trim().length === 0) {
44638
+ throw new Error("Viewport.label text must be a non-empty string");
44639
+ }
44640
+ const normalizedAt = requireVec3(at, "Viewport.label at");
44641
+ const normalizedOptions = normalizeOptions(options);
44642
+ _collected5.push({
44643
+ id: `render-label-${_nextId++}`,
44644
+ text: text.trim(),
44645
+ at: normalizedAt,
44646
+ ...normalizedOptions
44647
+ });
44648
+ }
44649
+ var Viewport = {
44650
+ /**
44651
+ * Add a render-only viewport label at a world-space point.
44652
+ *
44653
+ * **Details**
44654
+ *
44655
+ * `Viewport.label()` is for explanatory text that helps a viewer understand
44656
+ * the model. It does not create sketches, meshes, B-rep topology, exported
44657
+ * text, or face labels, so it stays off the OCCT path. Use `text2d()` only
44658
+ * when the letters should become manufactured geometry, such as raised
44659
+ * lettering, engraved serial numbers, or exported nameplates.
44660
+ *
44661
+ * Labels are collected during script execution and rendered by the viewport
44662
+ * as lightweight overlay annotations. They are ignored by exports and do not
44663
+ * appear in `objects`.
44664
+ *
44665
+ * **Example**
44666
+ *
44667
+ * ```js
44668
+ * Viewport.label('Bearing bore', [0, 0, 18], {
44669
+ * color: '#f8fafc',
44670
+ * background: '#0f172acc',
44671
+ * offset: [0, 0, 8],
44672
+ * anchor: 'bottom',
44673
+ * });
44674
+ *
44675
+ * return box(40, 30, 12);
44676
+ * ```
44677
+ *
44678
+ * @param text - Label text to display in the viewport
44679
+ * @param at - World-space anchor point `[x, y, z]`
44680
+ * @param options - Visual label options
44681
+ * @returns void
44682
+ * @category Viewport Labels
44683
+ */
44684
+ label(text, at, options) {
44685
+ collectRenderLabel(text, at, options);
44686
+ }
44687
+ };
44688
+
44523
44689
  // src/forge/renderStyle.ts
44524
44690
  var RENDER_STYLE_OPTIONS = [
44525
44691
  {
@@ -44666,17 +44832,17 @@ var rail = {
44666
44832
  };
44667
44833
 
44668
44834
  // src/forge/scene/scene.ts
44669
- function requireFinite2(value, label) {
44835
+ function requireFinite3(value, label) {
44670
44836
  if (typeof value !== "number" || !Number.isFinite(value)) {
44671
44837
  throw new Error(`${label} must be a finite number`);
44672
44838
  }
44673
44839
  return value;
44674
44840
  }
44675
- function requireVec3(value, label) {
44841
+ function requireVec32(value, label) {
44676
44842
  if (!Array.isArray(value) || value.length !== 3) {
44677
44843
  throw new Error(`${label} must be [x, y, z]`);
44678
44844
  }
44679
- return [requireFinite2(value[0], `${label}[0]`), requireFinite2(value[1], `${label}[1]`), requireFinite2(value[2], `${label}[2]`)];
44845
+ return [requireFinite3(value[0], `${label}[0]`), requireFinite3(value[1], `${label}[1]`), requireFinite3(value[2], `${label}[2]`)];
44680
44846
  }
44681
44847
  function requireColor(value, label) {
44682
44848
  if (typeof value !== "string" || !value.trim()) {
@@ -44700,11 +44866,11 @@ var VALID_ENVIRONMENT_PRESETS = /* @__PURE__ */ new Set([
44700
44866
  ]);
44701
44867
  function validateCamera(cam, label) {
44702
44868
  const out = {};
44703
- if (cam.position !== void 0) out.position = requireVec3(cam.position, `${label}.position`);
44704
- if (cam.target !== void 0) out.target = requireVec3(cam.target, `${label}.target`);
44705
- if (cam.up !== void 0) out.up = requireVec3(cam.up, `${label}.up`);
44869
+ if (cam.position !== void 0) out.position = requireVec32(cam.position, `${label}.position`);
44870
+ if (cam.target !== void 0) out.target = requireVec32(cam.target, `${label}.target`);
44871
+ if (cam.up !== void 0) out.up = requireVec32(cam.up, `${label}.up`);
44706
44872
  if (cam.fov !== void 0) {
44707
- out.fov = requireFinite2(cam.fov, `${label}.fov`);
44873
+ out.fov = requireFinite3(cam.fov, `${label}.fov`);
44708
44874
  if (out.fov <= 0 || out.fov >= 180) throw new Error(`${label}.fov must be between 0 and 180`);
44709
44875
  }
44710
44876
  if (cam.type !== void 0) {
@@ -44715,6 +44881,120 @@ function validateCamera(cam, label) {
44715
44881
  }
44716
44882
  return out;
44717
44883
  }
44884
+ function validateViewCamera(cam, label) {
44885
+ const validated = validateCamera(cam, label);
44886
+ if (!validated.position) {
44887
+ throw new Error(`${label}.position is required for named render views`);
44888
+ }
44889
+ if (!validated.target) {
44890
+ throw new Error(`${label}.target is required for named render views`);
44891
+ }
44892
+ return {
44893
+ ...validated,
44894
+ position: validated.position,
44895
+ target: validated.target
44896
+ };
44897
+ }
44898
+ function validateViews(views, label) {
44899
+ if (!views || typeof views !== "object" || Array.isArray(views)) {
44900
+ throw new Error(`${label} must be an object mapping view names to cameras`);
44901
+ }
44902
+ const out = {};
44903
+ for (const [name, view] of Object.entries(views)) {
44904
+ if (!name.trim()) {
44905
+ throw new Error(`${label} names must be non-empty strings`);
44906
+ }
44907
+ const viewLabel = `${label}.${name}`;
44908
+ if (!view || typeof view !== "object" || Array.isArray(view)) {
44909
+ throw new Error(`${viewLabel} must be a camera object or an object with a camera property`);
44910
+ }
44911
+ const hasExplicitCamera = Object.prototype.hasOwnProperty.call(view, "camera");
44912
+ if (hasExplicitCamera) {
44913
+ const camera = view.camera;
44914
+ if (!camera || typeof camera !== "object" || Array.isArray(camera)) {
44915
+ throw new Error(`${viewLabel}.camera must be an object`);
44916
+ }
44917
+ out[name] = { camera: validateViewCamera(camera, `${viewLabel}.camera`) };
44918
+ continue;
44919
+ }
44920
+ out[name] = { camera: validateViewCamera(view, viewLabel) };
44921
+ }
44922
+ return out;
44923
+ }
44924
+ function requireString(value, label) {
44925
+ if (typeof value !== "string" || !value.trim()) {
44926
+ throw new Error(`${label} must be a non-empty string`);
44927
+ }
44928
+ return value.trim();
44929
+ }
44930
+ function optionalString(value, label) {
44931
+ if (value === void 0) return void 0;
44932
+ return requireString(value, label);
44933
+ }
44934
+ function validateJourneyStep(step, label) {
44935
+ if (!step || typeof step !== "object" || Array.isArray(step)) {
44936
+ throw new Error(`${label} must be an object`);
44937
+ }
44938
+ const out = {
44939
+ id: requireString(step.id, `${label}.id`)
44940
+ };
44941
+ const title = optionalString(step.title, `${label}.title`);
44942
+ if (title !== void 0) out.title = title;
44943
+ const focus = optionalString(step.focus, `${label}.focus`);
44944
+ if (focus !== void 0) out.focus = focus;
44945
+ const caption = optionalString(step.caption, `${label}.caption`);
44946
+ if (caption !== void 0) out.caption = caption;
44947
+ if (step.camera !== void 0) {
44948
+ if (!step.camera || typeof step.camera !== "object" || Array.isArray(step.camera)) {
44949
+ throw new Error(`${label}.camera must be an object`);
44950
+ }
44951
+ out.camera = validateViewCamera(step.camera, `${label}.camera`);
44952
+ }
44953
+ return out;
44954
+ }
44955
+ function validateJourney(journey, label) {
44956
+ if (!journey || typeof journey !== "object" || Array.isArray(journey)) {
44957
+ throw new Error(`${label} must be an object`);
44958
+ }
44959
+ if (!Array.isArray(journey.steps) || journey.steps.length === 0) {
44960
+ throw new Error(`${label}.steps must be a non-empty array`);
44961
+ }
44962
+ const out = {
44963
+ steps: journey.steps.map((step, index) => validateJourneyStep(step, `${label}.steps[${index}]`))
44964
+ };
44965
+ const title = optionalString(journey.title, `${label}.title`);
44966
+ if (title !== void 0) out.title = title;
44967
+ const startsAt = optionalString(journey.startsAt, `${label}.startsAt`);
44968
+ if (startsAt !== void 0) out.startsAt = startsAt;
44969
+ if (journey.behavior !== void 0) {
44970
+ if (journey.behavior !== "opt-in" && journey.behavior !== "auto") {
44971
+ throw new Error(`${label}.behavior must be "opt-in" or "auto"`);
44972
+ }
44973
+ out.behavior = journey.behavior;
44974
+ }
44975
+ const seen = /* @__PURE__ */ new Set();
44976
+ for (const step of out.steps) {
44977
+ if (seen.has(step.id)) {
44978
+ throw new Error(`${label}.steps contains duplicate step id "${step.id}"`);
44979
+ }
44980
+ seen.add(step.id);
44981
+ }
44982
+ if (out.startsAt && !seen.has(out.startsAt)) {
44983
+ throw new Error(`${label}.startsAt "${out.startsAt}" does not match any step id`);
44984
+ }
44985
+ return out;
44986
+ }
44987
+ function validateJourneys(journeys, label) {
44988
+ if (!journeys || typeof journeys !== "object" || Array.isArray(journeys)) {
44989
+ throw new Error(`${label} must be an object mapping journey ids to journey configs`);
44990
+ }
44991
+ const out = {};
44992
+ for (const [id, journey] of Object.entries(journeys)) {
44993
+ const normalizedId = requireString(id, `${label} journey id`);
44994
+ out[normalizedId] = validateJourney(journey, `${label}.${normalizedId}`);
44995
+ }
44996
+ return out;
44997
+ }
44718
44998
  function validateLight(light, label) {
44719
44999
  if (!light || typeof light !== "object") throw new Error(`${label} must be an object`);
44720
45000
  if (!VALID_LIGHT_TYPES.has(light.type)) {
@@ -44722,15 +45002,15 @@ function validateLight(light, label) {
44722
45002
  }
44723
45003
  const out = { type: light.type };
44724
45004
  if (light.color !== void 0) out.color = requireColor(light.color, `${label}.color`);
44725
- if (light.intensity !== void 0) out.intensity = requireFinite2(light.intensity, `${label}.intensity`);
44726
- if (light.position !== void 0) out.position = requireVec3(light.position, `${label}.position`);
44727
- if (light.target !== void 0) out.target = requireVec3(light.target, `${label}.target`);
45005
+ if (light.intensity !== void 0) out.intensity = requireFinite3(light.intensity, `${label}.intensity`);
45006
+ if (light.position !== void 0) out.position = requireVec32(light.position, `${label}.position`);
45007
+ if (light.target !== void 0) out.target = requireVec32(light.target, `${label}.target`);
44728
45008
  if (light.groundColor !== void 0) out.groundColor = requireColor(light.groundColor, `${label}.groundColor`);
44729
45009
  if (light.skyColor !== void 0) out.skyColor = requireColor(light.skyColor, `${label}.skyColor`);
44730
- if (light.angle !== void 0) out.angle = requireFinite2(light.angle, `${label}.angle`);
44731
- if (light.penumbra !== void 0) out.penumbra = requireFinite2(light.penumbra, `${label}.penumbra`);
44732
- if (light.decay !== void 0) out.decay = requireFinite2(light.decay, `${label}.decay`);
44733
- if (light.distance !== void 0) out.distance = requireFinite2(light.distance, `${label}.distance`);
45010
+ if (light.angle !== void 0) out.angle = requireFinite3(light.angle, `${label}.angle`);
45011
+ if (light.penumbra !== void 0) out.penumbra = requireFinite3(light.penumbra, `${label}.penumbra`);
45012
+ if (light.decay !== void 0) out.decay = requireFinite3(light.decay, `${label}.decay`);
45013
+ if (light.distance !== void 0) out.distance = requireFinite3(light.distance, `${label}.distance`);
44734
45014
  if (light.castShadow !== void 0) {
44735
45015
  if (typeof light.castShadow !== "boolean") throw new Error(`${label}.castShadow must be a boolean`);
44736
45016
  out.castShadow = light.castShadow;
@@ -44745,7 +45025,7 @@ function validateEnvironment(env, label) {
44745
45025
  }
44746
45026
  out.preset = env.preset;
44747
45027
  }
44748
- if (env.intensity !== void 0) out.intensity = requireFinite2(env.intensity, `${label}.intensity`);
45028
+ if (env.intensity !== void 0) out.intensity = requireFinite3(env.intensity, `${label}.intensity`);
44749
45029
  if (env.background !== void 0) {
44750
45030
  if (typeof env.background !== "boolean") throw new Error(`${label}.background must be a boolean`);
44751
45031
  out.background = env.background;
@@ -44755,9 +45035,9 @@ function validateEnvironment(env, label) {
44755
45035
  function validateFog(fog, label) {
44756
45036
  const out = {};
44757
45037
  if (fog.color !== void 0) out.color = requireColor(fog.color, `${label}.color`);
44758
- if (fog.near !== void 0) out.near = requireFinite2(fog.near, `${label}.near`);
44759
- if (fog.far !== void 0) out.far = requireFinite2(fog.far, `${label}.far`);
44760
- if (fog.density !== void 0) out.density = requireFinite2(fog.density, `${label}.density`);
45038
+ if (fog.near !== void 0) out.near = requireFinite3(fog.near, `${label}.near`);
45039
+ if (fog.far !== void 0) out.far = requireFinite3(fog.far, `${label}.far`);
45040
+ if (fog.density !== void 0) out.density = requireFinite3(fog.density, `${label}.density`);
44761
45041
  return out;
44762
45042
  }
44763
45043
  function validatePostProcessing(pp, label) {
@@ -44765,23 +45045,23 @@ function validatePostProcessing(pp, label) {
44765
45045
  if (pp.bloom !== void 0) {
44766
45046
  if (!pp.bloom || typeof pp.bloom !== "object") throw new Error(`${label}.bloom must be an object`);
44767
45047
  out.bloom = {};
44768
- if (pp.bloom.intensity !== void 0) out.bloom.intensity = requireFinite2(pp.bloom.intensity, `${label}.bloom.intensity`);
44769
- if (pp.bloom.threshold !== void 0) out.bloom.threshold = requireFinite2(pp.bloom.threshold, `${label}.bloom.threshold`);
44770
- if (pp.bloom.radius !== void 0) out.bloom.radius = requireFinite2(pp.bloom.radius, `${label}.bloom.radius`);
45048
+ if (pp.bloom.intensity !== void 0) out.bloom.intensity = requireFinite3(pp.bloom.intensity, `${label}.bloom.intensity`);
45049
+ if (pp.bloom.threshold !== void 0) out.bloom.threshold = requireFinite3(pp.bloom.threshold, `${label}.bloom.threshold`);
45050
+ if (pp.bloom.radius !== void 0) out.bloom.radius = requireFinite3(pp.bloom.radius, `${label}.bloom.radius`);
44771
45051
  }
44772
45052
  if (pp.vignette !== void 0) {
44773
45053
  if (!pp.vignette || typeof pp.vignette !== "object") throw new Error(`${label}.vignette must be an object`);
44774
45054
  out.vignette = {};
44775
- if (pp.vignette.darkness !== void 0) out.vignette.darkness = requireFinite2(pp.vignette.darkness, `${label}.vignette.darkness`);
44776
- if (pp.vignette.offset !== void 0) out.vignette.offset = requireFinite2(pp.vignette.offset, `${label}.vignette.offset`);
45055
+ if (pp.vignette.darkness !== void 0) out.vignette.darkness = requireFinite3(pp.vignette.darkness, `${label}.vignette.darkness`);
45056
+ if (pp.vignette.offset !== void 0) out.vignette.offset = requireFinite3(pp.vignette.offset, `${label}.vignette.offset`);
44777
45057
  }
44778
45058
  if (pp.grain !== void 0) {
44779
45059
  if (!pp.grain || typeof pp.grain !== "object") throw new Error(`${label}.grain must be an object`);
44780
45060
  out.grain = {};
44781
- if (pp.grain.intensity !== void 0) out.grain.intensity = requireFinite2(pp.grain.intensity, `${label}.grain.intensity`);
45061
+ if (pp.grain.intensity !== void 0) out.grain.intensity = requireFinite3(pp.grain.intensity, `${label}.grain.intensity`);
44782
45062
  }
44783
45063
  if (pp.toneMappingExposure !== void 0) {
44784
- out.toneMappingExposure = requireFinite2(pp.toneMappingExposure, `${label}.toneMappingExposure`);
45064
+ out.toneMappingExposure = requireFinite3(pp.toneMappingExposure, `${label}.toneMappingExposure`);
44785
45065
  }
44786
45066
  return out;
44787
45067
  }
@@ -44792,7 +45072,7 @@ function validateGround(ground, label) {
44792
45072
  out.visible = ground.visible;
44793
45073
  }
44794
45074
  if (ground.color !== void 0) out.color = requireColor(ground.color, `${label}.color`);
44795
- if (ground.offset !== void 0) out.offset = requireFinite2(ground.offset, `${label}.offset`);
45075
+ if (ground.offset !== void 0) out.offset = requireFinite3(ground.offset, `${label}.offset`);
44796
45076
  if (ground.receiveShadow !== void 0) {
44797
45077
  if (typeof ground.receiveShadow !== "boolean") throw new Error(`${label}.receiveShadow must be a boolean`);
44798
45078
  out.receiveShadow = ground.receiveShadow;
@@ -44802,31 +45082,31 @@ function validateGround(ground, label) {
44802
45082
  function validateCapture(cap, label) {
44803
45083
  const out = {};
44804
45084
  if (cap.framesPerTurn !== void 0) {
44805
- out.framesPerTurn = requireFinite2(cap.framesPerTurn, `${label}.framesPerTurn`);
45085
+ out.framesPerTurn = requireFinite3(cap.framesPerTurn, `${label}.framesPerTurn`);
44806
45086
  if (out.framesPerTurn < 12 || out.framesPerTurn > 720) {
44807
45087
  throw new Error(`${label}.framesPerTurn must be between 12 and 720`);
44808
45088
  }
44809
45089
  }
44810
45090
  if (cap.holdFrames !== void 0) {
44811
- out.holdFrames = requireFinite2(cap.holdFrames, `${label}.holdFrames`);
45091
+ out.holdFrames = requireFinite3(cap.holdFrames, `${label}.holdFrames`);
44812
45092
  if (out.holdFrames < 0 || out.holdFrames > 300) {
44813
45093
  throw new Error(`${label}.holdFrames must be between 0 and 300`);
44814
45094
  }
44815
45095
  }
44816
45096
  if (cap.pitchDeg !== void 0) {
44817
- out.pitchDeg = requireFinite2(cap.pitchDeg, `${label}.pitchDeg`);
45097
+ out.pitchDeg = requireFinite3(cap.pitchDeg, `${label}.pitchDeg`);
44818
45098
  if (out.pitchDeg < -80 || out.pitchDeg > 80) {
44819
45099
  throw new Error(`${label}.pitchDeg must be between -80 and 80`);
44820
45100
  }
44821
45101
  }
44822
45102
  if (cap.fps !== void 0) {
44823
- out.fps = requireFinite2(cap.fps, `${label}.fps`);
45103
+ out.fps = requireFinite3(cap.fps, `${label}.fps`);
44824
45104
  if (out.fps < 1 || out.fps > 60) {
44825
45105
  throw new Error(`${label}.fps must be between 1 and 60`);
44826
45106
  }
44827
45107
  }
44828
45108
  if (cap.size !== void 0) {
44829
- out.size = requireFinite2(cap.size, `${label}.size`);
45109
+ out.size = requireFinite3(cap.size, `${label}.size`);
44830
45110
  if (out.size < 1) {
44831
45111
  throw new Error(`${label}.size must be positive`);
44832
45112
  }
@@ -44847,18 +45127,29 @@ function validateBackground(bg, label) {
44847
45127
  }
44848
45128
  throw new Error(`${label} must be a color string or { top, bottom } gradient`);
44849
45129
  }
44850
- var _collected5 = null;
45130
+ var _collected6 = null;
44851
45131
  function resetScene() {
44852
- _collected5 = null;
45132
+ _collected6 = null;
44853
45133
  }
44854
45134
  function getCollectedScene() {
44855
- return _collected5 ? { ..._collected5 } : null;
45135
+ return _collected6 ? { ..._collected6 } : null;
44856
45136
  }
44857
45137
  function scene(options) {
44858
45138
  if (!options || typeof options !== "object") {
44859
45139
  throw new Error("scene(options) expects an options object");
44860
45140
  }
44861
- const current = _collected5 ? { ..._collected5 } : { background: null, camera: null, lights: null, environment: null, fog: null, postProcessing: null, ground: null, capture: null };
45141
+ const current = _collected6 ? { ..._collected6 } : {
45142
+ background: null,
45143
+ camera: null,
45144
+ views: null,
45145
+ journeys: null,
45146
+ lights: null,
45147
+ environment: null,
45148
+ fog: null,
45149
+ postProcessing: null,
45150
+ ground: null,
45151
+ capture: null
45152
+ };
44862
45153
  if (options.background !== void 0) {
44863
45154
  current.background = validateBackground(options.background, "scene.background");
44864
45155
  }
@@ -44869,6 +45160,14 @@ function scene(options) {
44869
45160
  const validated = validateCamera(options.camera, "scene.camera");
44870
45161
  current.camera = current.camera ? { ...current.camera, ...validated } : validated;
44871
45162
  }
45163
+ if (options.views !== void 0) {
45164
+ const validated = validateViews(options.views, "scene.views");
45165
+ current.views = current.views ? { ...current.views, ...validated } : validated;
45166
+ }
45167
+ if (options.journeys !== void 0) {
45168
+ const validated = validateJourneys(options.journeys, "scene.journeys");
45169
+ current.journeys = current.journeys ? { ...current.journeys, ...validated } : validated;
45170
+ }
44872
45171
  if (options.lights !== void 0) {
44873
45172
  if (!Array.isArray(options.lights)) {
44874
45173
  throw new Error("scene.lights must be an array");
@@ -44907,7 +45206,74 @@ function scene(options) {
44907
45206
  const validated = validateCapture(options.capture, "scene.capture");
44908
45207
  current.capture = current.capture ? { ...current.capture, ...validated } : validated;
44909
45208
  }
44910
- _collected5 = current;
45209
+ _collected6 = current;
45210
+ }
45211
+ var targetPath = (target) => {
45212
+ const path4 = target.treePath?.filter((entry) => entry.trim());
45213
+ return path4 && path4.length > 0 ? path4.join("/") : target.name;
45214
+ };
45215
+ var hasErrorDiagnostic = (diagnostics) => diagnostics.some((diagnostic) => diagnostic.level === "error");
45216
+ function resolveJourneyFocus(focus, targets) {
45217
+ return targets.filter((target) => target.name === focus || targetPath(target) === focus);
45218
+ }
45219
+ function formatAvailableTargets(targets) {
45220
+ return targets.map((target) => targetPath(target)).filter((value, index, values) => values.indexOf(value) === index).sort().slice(0, 8);
45221
+ }
45222
+ function resolveSceneJourneyTargets(config, targets) {
45223
+ if (!config?.journeys) return config;
45224
+ const journeys = {};
45225
+ for (const [journeyId, journey] of Object.entries(config.journeys)) {
45226
+ const journeyDiagnostics = [...journey.diagnostics ?? []];
45227
+ const steps = journey.steps.map((step) => {
45228
+ const stepDiagnostics = [...step.diagnostics ?? []];
45229
+ let resolvedFocusId = null;
45230
+ let resolvedFocusPath = null;
45231
+ if (step.focus) {
45232
+ const matches = resolveJourneyFocus(step.focus, targets);
45233
+ if (matches.length === 1) {
45234
+ resolvedFocusId = matches[0].id;
45235
+ resolvedFocusPath = targetPath(matches[0]);
45236
+ } else if (matches.length === 0) {
45237
+ stepDiagnostics.push({
45238
+ level: "error",
45239
+ stepId: step.id,
45240
+ message: `focus "${step.focus}" did not match any returned object by name or tree path.`,
45241
+ suggestions: formatAvailableTargets(targets)
45242
+ });
45243
+ } else {
45244
+ stepDiagnostics.push({
45245
+ level: "error",
45246
+ stepId: step.id,
45247
+ message: `focus "${step.focus}" matched ${matches.length} objects. Use a slash-separated tree path.`,
45248
+ suggestions: matches.map((match) => targetPath(match))
45249
+ });
45250
+ }
45251
+ } else if (!step.camera) {
45252
+ stepDiagnostics.push({
45253
+ level: "warning",
45254
+ stepId: step.id,
45255
+ message: "step has no focus or explicit camera, so the viewer can show the caption but cannot move the camera."
45256
+ });
45257
+ }
45258
+ journeyDiagnostics.push(...stepDiagnostics);
45259
+ return {
45260
+ ...step,
45261
+ resolvedFocusId,
45262
+ resolvedFocusPath,
45263
+ diagnostics: stepDiagnostics.length > 0 ? stepDiagnostics : void 0
45264
+ };
45265
+ });
45266
+ journeys[journeyId] = {
45267
+ ...journey,
45268
+ steps,
45269
+ valid: !hasErrorDiagnostic(journeyDiagnostics),
45270
+ diagnostics: journeyDiagnostics.length > 0 ? journeyDiagnostics : void 0
45271
+ };
45272
+ }
45273
+ return {
45274
+ ...config,
45275
+ journeys
45276
+ };
44911
45277
  }
44912
45278
 
44913
45279
  // src/forge/scene/viewConfig.ts
@@ -45065,25 +45431,25 @@ var DEFAULT_JOINT_OVERLAY_VIEW_CONFIG = {
45065
45431
  var DEFAULT_VIEW_CONFIG = {
45066
45432
  jointOverlay: cloneJointOverlay(DEFAULT_JOINT_OVERLAY_VIEW_CONFIG)
45067
45433
  };
45068
- var _collected6 = null;
45434
+ var _collected7 = null;
45069
45435
  function resetViewConfig() {
45070
- _collected6 = null;
45436
+ _collected7 = null;
45071
45437
  }
45072
45438
  function getCollectedViewConfig() {
45073
- return _collected6 ? cloneViewConfig(_collected6) : null;
45439
+ return _collected7 ? cloneViewConfig(_collected7) : null;
45074
45440
  }
45075
45441
  function viewConfig(options = {}) {
45076
45442
  if (!options || typeof options !== "object") {
45077
45443
  throw new Error("viewConfig(options) expects an options object");
45078
45444
  }
45079
- const next = _collected6 ? cloneViewConfig(_collected6) : cloneViewConfig(DEFAULT_VIEW_CONFIG);
45445
+ const next = _collected7 ? cloneViewConfig(_collected7) : cloneViewConfig(DEFAULT_VIEW_CONFIG);
45080
45446
  if (options.jointOverlay !== void 0) {
45081
45447
  if (!options.jointOverlay || typeof options.jointOverlay !== "object") {
45082
45448
  throw new Error("viewConfig.jointOverlay must be an object");
45083
45449
  }
45084
45450
  next.jointOverlay = patchJointOverlay(next.jointOverlay, options.jointOverlay, "viewConfig.jointOverlay");
45085
45451
  }
45086
- _collected6 = next;
45452
+ _collected7 = next;
45087
45453
  }
45088
45454
 
45089
45455
  // src/forge/intent/compilerDiagnostics.ts
@@ -46239,7 +46605,7 @@ function hermiteTransitionG2(a, b) {
46239
46605
  }
46240
46606
 
46241
46607
  // src/forge/sketch/nurbsCurve.ts
46242
- function requireFinite3(v, label) {
46608
+ function requireFinite4(v, label) {
46243
46609
  if (!Number.isFinite(v)) throw new Error(`nurbs3d: ${label} must be finite, got ${v}`);
46244
46610
  }
46245
46611
  var NurbsCurve3D = class {
@@ -46254,14 +46620,14 @@ var NurbsCurve3D = class {
46254
46620
  if (degree < 1) throw new Error("nurbs3d: degree must be \u2265 1");
46255
46621
  if (n < degree + 1) throw new Error(`nurbs3d: need at least ${degree + 1} control points for degree ${degree}, got ${n}`);
46256
46622
  for (let i = 0; i < n; i++) {
46257
- requireFinite3(points[i][0], `controlPoints[${i}][0]`);
46258
- requireFinite3(points[i][1], `controlPoints[${i}][1]`);
46259
- requireFinite3(points[i][2], `controlPoints[${i}][2]`);
46623
+ requireFinite4(points[i][0], `controlPoints[${i}][0]`);
46624
+ requireFinite4(points[i][1], `controlPoints[${i}][1]`);
46625
+ requireFinite4(points[i][2], `controlPoints[${i}][2]`);
46260
46626
  }
46261
46627
  const weights = options.weights ?? new Array(n).fill(1);
46262
46628
  if (weights.length !== n) throw new Error(`nurbs3d: weights.length (${weights.length}) must equal controlPoints.length (${n})`);
46263
46629
  for (let i = 0; i < n; i++) {
46264
- requireFinite3(weights[i], `weights[${i}]`);
46630
+ requireFinite4(weights[i], `weights[${i}]`);
46265
46631
  if (weights[i] <= 0) throw new Error(`nurbs3d: weights[${i}] must be > 0, got ${weights[i]}`);
46266
46632
  }
46267
46633
  const expectedKnotLength = n + degree + 1;
@@ -46270,7 +46636,7 @@ var NurbsCurve3D = class {
46270
46636
  throw new Error(`nurbs3d: knots.length (${knots.length}) must be controlPoints.length + degree + 1 (${expectedKnotLength})`);
46271
46637
  }
46272
46638
  for (let i = 0; i < knots.length; i++) {
46273
- requireFinite3(knots[i], `knots[${i}]`);
46639
+ requireFinite4(knots[i], `knots[${i}]`);
46274
46640
  if (i > 0 && knots[i] < knots[i - 1]) {
46275
46641
  throw new Error(`nurbs3d: knot vector must be non-decreasing, but knots[${i - 1}]=${knots[i - 1]} > knots[${i}]=${knots[i]}`);
46276
46642
  }
@@ -46831,9 +47197,15 @@ function cross5(a, b) {
46831
47197
  function add4(a, b) {
46832
47198
  return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
46833
47199
  }
47200
+ function sub6(a, b) {
47201
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
47202
+ }
46834
47203
  function scale5(v, s) {
46835
47204
  return [v[0] * s, v[1] * s, v[2] * s];
46836
47205
  }
47206
+ function dot6(a, b) {
47207
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
47208
+ }
46837
47209
  function lerp2(a, b, t) {
46838
47210
  return a + (b - a) * t;
46839
47211
  }
@@ -46866,6 +47238,65 @@ function sideVectors(axis) {
46866
47238
  function cloneQuery(query) {
46867
47239
  return { side: query.side, u: query.u, v: query.v, offset: query.offset };
46868
47240
  }
47241
+ function normalizedSide(side) {
47242
+ return side === "back" ? "rear" : side;
47243
+ }
47244
+ function profileExponent(station) {
47245
+ if (station.profile.kind === "superEllipse") return station.profile.exponent ?? 3.2;
47246
+ if (station.profile.kind === "roundedRect") return 4.5;
47247
+ return 2;
47248
+ }
47249
+ function superEllipsePoint(rx, ry, exponent, angle) {
47250
+ const cos4 = Math.cos(angle);
47251
+ const sin4 = Math.sin(angle);
47252
+ const x = rx * Math.sign(cos4) * Math.abs(cos4) ** (2 / exponent);
47253
+ const y = ry * Math.sign(sin4) * Math.abs(sin4) ** (2 / exponent);
47254
+ const nx = Math.sign(x) * Math.abs(x / Math.max(rx, EPS7)) ** Math.max(exponent - 1, 1e-3);
47255
+ const ny = Math.sign(y) * Math.abs(y / Math.max(ry, EPS7)) ** Math.max(exponent - 1, 1e-3);
47256
+ const nLen = Math.hypot(nx, ny);
47257
+ return {
47258
+ point: [x, y],
47259
+ normal: nLen < EPS7 ? [Math.sign(cos4), Math.sign(sin4)] : [nx / nLen, ny / nLen]
47260
+ };
47261
+ }
47262
+ function angleForSide(side, u) {
47263
+ const t = clamp6(u, 0, 1);
47264
+ if (side === "right") return -Math.PI / 2 + t * Math.PI;
47265
+ if (side === "left") return Math.PI / 2 + t * Math.PI;
47266
+ if (side === "top") return Math.PI - t * Math.PI;
47267
+ if (side === "bottom") return Math.PI + t * Math.PI;
47268
+ return null;
47269
+ }
47270
+ function sideSpan(side, width, depth) {
47271
+ if (side === "left" || side === "right") return Math.max(depth, EPS7);
47272
+ if (side === "top" || side === "bottom") return Math.max(width, EPS7);
47273
+ return Math.max(width, depth, EPS7);
47274
+ }
47275
+ function interpolateQuery(a, b, t) {
47276
+ const sideA = normalizedSide(a.side);
47277
+ const sideB = normalizedSide(b.side);
47278
+ if (sideA !== sideB) {
47279
+ throw new Error(
47280
+ `Product.ribbon().on(...) currently samples one skin side per ribbon; got '${a.side}' then '${b.side}'. Split this into separate ribbons at the side transition.`
47281
+ );
47282
+ }
47283
+ return {
47284
+ side: sideA,
47285
+ u: lerp2(a.u ?? 0.5, b.u ?? 0.5, t),
47286
+ v: lerp2(a.v ?? 0.5, b.v ?? 0.5, t),
47287
+ offset: lerp2(a.offset ?? 0, b.offset ?? 0, t)
47288
+ };
47289
+ }
47290
+ function resolvePathQueries(points) {
47291
+ return points.map((point2) => point2 instanceof ProductSurfaceRef ? point2.querySpec() : cloneQuery(point2));
47292
+ }
47293
+ function orientGridToNormal(grid, desiredNormal) {
47294
+ if (grid.length < 2 || grid[0].length < 2) return grid;
47295
+ const widthEdge = sub6(grid[grid.length - 1][0], grid[0][0]);
47296
+ const lengthEdge = sub6(grid[0][grid[0].length - 1], grid[0][0]);
47297
+ const actual = norm2(cross5(widthEdge, lengthEdge));
47298
+ return dot6(actual, desiredNormal) < 0 ? [...grid].reverse() : grid;
47299
+ }
46869
47300
  function isStationBuilder(input) {
46870
47301
  return typeof input.toSpec === "function";
46871
47302
  }
@@ -46967,6 +47398,15 @@ var ProductSkin = class {
46967
47398
  curveOnSurface(name, points) {
46968
47399
  return points.map((point2, index) => new ProductSurfaceRef(this, { u: 0.5, v: 0.5, ...point2 }, `${name}/${index}`));
46969
47400
  }
47401
+ /**
47402
+ * Create a fluent surface helper for refs and conformal features on one side of this skin.
47403
+ *
47404
+ * Use this when several refs or ribbons share the same skin side; side-local helpers keep
47405
+ * path points concise and make it harder to mix sides accidentally.
47406
+ */
47407
+ surface(side) {
47408
+ return new ProductSurfaceBuilder(this, side);
47409
+ }
46970
47410
  /** Interpolate center, width, and depth at a normalized v or absolute axis value. */
46971
47411
  stationAt(vOrAxis) {
46972
47412
  const axisValue = vOrAxis >= 0 && vOrAxis <= 1 ? lerp2(this.axisMin, this.axisMax, vOrAxis) : clamp6(vOrAxis, this.axisMin, this.axisMax);
@@ -46985,7 +47425,9 @@ var ProductSkin = class {
46985
47425
  width: lerp2(a.profile.width, b.profile.width, t),
46986
47426
  depth: lerp2(a.profile.depth, b.profile.depth, t),
46987
47427
  dWidth: (b.profile.width - a.profile.width) / span,
46988
- dDepth: (b.profile.depth - a.profile.depth) / span
47428
+ dDepth: (b.profile.depth - a.profile.depth) / span,
47429
+ exponent: lerp2(profileExponent(a), profileExponent(b), t),
47430
+ kind: a.profile.kind === b.profile.kind ? a.profile.kind : "custom"
46989
47431
  };
46990
47432
  }
46991
47433
  const last = sorted[sorted.length - 1];
@@ -46995,12 +47437,14 @@ var ProductSkin = class {
46995
47437
  width: last.profile.width,
46996
47438
  depth: last.profile.depth,
46997
47439
  dWidth: 0,
46998
- dDepth: 0
47440
+ dDepth: 0,
47441
+ exponent: profileExponent(last),
47442
+ kind: last.profile.kind
46999
47443
  };
47000
47444
  }
47001
47445
  /** Build a local surface frame from a side/u/v query. */
47002
47446
  frame(query) {
47003
- const side = query.side === "back" ? "rear" : query.side;
47447
+ const side = normalizedSide(query.side);
47004
47448
  const offset2 = query.offset ?? 0;
47005
47449
  const basis = sideVectors(this.axis);
47006
47450
  const isFrontCap = side === "front";
@@ -47024,11 +47468,31 @@ var ProductSkin = class {
47024
47468
  }
47025
47469
  const station = this.stationAt(query.v ?? 0.5);
47026
47470
  const u = clamp6(query.u ?? 0.5, 0, 1) - 0.5;
47471
+ const sideAngle = angleForSide(side, query.u ?? 0.5);
47027
47472
  let crossA = 0;
47028
47473
  let crossB = 0;
47029
47474
  let normal = [0, 0, 1];
47030
47475
  let tangentU = basis.crossA;
47031
- if (side === "right") {
47476
+ if (sideAngle != null) {
47477
+ const section = superEllipsePoint(station.width / 2, station.depth / 2, station.exponent, sideAngle);
47478
+ crossA = section.point[0];
47479
+ crossB = section.point[1];
47480
+ normal = norm2(add4(scale5(basis.crossA, section.normal[0]), scale5(basis.crossB, section.normal[1])));
47481
+ const delta = 2e-3;
47482
+ const prev = superEllipsePoint(
47483
+ station.width / 2,
47484
+ station.depth / 2,
47485
+ station.exponent,
47486
+ angleForSide(side, clamp6((query.u ?? 0.5) - delta, 0, 1)) ?? sideAngle
47487
+ ).point;
47488
+ const next = superEllipsePoint(
47489
+ station.width / 2,
47490
+ station.depth / 2,
47491
+ station.exponent,
47492
+ angleForSide(side, clamp6((query.u ?? 0.5) + delta, 0, 1)) ?? sideAngle
47493
+ ).point;
47494
+ tangentU = norm2(add4(scale5(basis.crossA, next[0] - prev[0]), scale5(basis.crossB, next[1] - prev[1])));
47495
+ } else if (side === "right") {
47032
47496
  crossA = station.width / 2;
47033
47497
  crossB = u * station.depth;
47034
47498
  normal = basis.crossA;
@@ -47050,7 +47514,7 @@ var ProductSkin = class {
47050
47514
  tangentU = basis.crossA;
47051
47515
  }
47052
47516
  normal = norm2(normal);
47053
- tangentU = norm2(tangentU);
47517
+ tangentU = norm2(sub6(tangentU, scale5(normal, dot6(tangentU, normal))));
47054
47518
  const tangentV = norm2(cross5(normal, tangentU));
47055
47519
  const point2 = add4(add4(station.center, add4(scale5(basis.crossA, crossA), scale5(basis.crossB, crossB))), scale5(normal, offset2));
47056
47520
  return {
@@ -47370,6 +47834,303 @@ var ProductHandleBuilder = class {
47370
47834
  return new ProductHandleFeature(grip, upperPad, lowerPad);
47371
47835
  }
47372
47836
  };
47837
+ var ProductSurfaceBuilder = class {
47838
+ constructor(skin, side) {
47839
+ this.skin = skin;
47840
+ this.side = side;
47841
+ }
47842
+ /** Create a ref on this skin side. */
47843
+ ref(u = 0.5, v = 0.5, offset2) {
47844
+ return Product.ref(this.skin, {
47845
+ side: this.side,
47846
+ u,
47847
+ v,
47848
+ ...offset2 != null ? { offset: offset2 } : {}
47849
+ });
47850
+ }
47851
+ /** Create a side/u/v query on this skin side. */
47852
+ uv(u = 0.5, v = 0.5, offset2) {
47853
+ return {
47854
+ side: this.side,
47855
+ u,
47856
+ v,
47857
+ ...offset2 != null ? { offset: offset2 } : {}
47858
+ };
47859
+ }
47860
+ /**
47861
+ * Start a conformal ribbon on this skin side.
47862
+ *
47863
+ * Path points use side-local `u`/`v` coordinates; this builder supplies the side.
47864
+ * The returned ProductRibbonBuilder is already bound to the source skin and can be further
47865
+ * configured before build(). Use `widthSamples` >= 3 when the ribbon must visibly wrap over
47866
+ * curved product sections instead of behaving like a flat strip.
47867
+ */
47868
+ ribbon(name, points, options = {}) {
47869
+ if (points.length < 2) throw new Error("Product.surface(...).ribbon(name, points) requires at least two path points");
47870
+ const path4 = points.map((point2) => ({
47871
+ side: this.side,
47872
+ u: point2.u ?? 0.5,
47873
+ v: point2.v ?? 0.5,
47874
+ ...point2.offset != null ? { offset: point2.offset } : {}
47875
+ }));
47876
+ return new ProductRibbonBuilder(name).on(this.skin, path4, options);
47877
+ }
47878
+ };
47879
+ var ProductRibbonBuilder = class {
47880
+ constructor(name) {
47881
+ this.name = name;
47882
+ if (!name || !name.trim()) throw new Error("Product.ribbon(name) requires a non-empty name");
47883
+ }
47884
+ skinValue;
47885
+ queryPath = [];
47886
+ refPath = [];
47887
+ widthValue = 6;
47888
+ thicknessValue = 0.8;
47889
+ offsetValue = 0.25;
47890
+ samplesValue = 24;
47891
+ widthSamplesValue = 5;
47892
+ resolutionValue;
47893
+ materialValue;
47894
+ colorValue;
47895
+ lastDiagnosticsValue;
47896
+ /**
47897
+ * Follow a ProductSkin with side/u/v path queries or refs.
47898
+ *
47899
+ * This is the highest-fidelity mode because every interpolated sample is resolved through
47900
+ * ProductSkin.frame(), so the ribbon bends along the selected side as station width/depth changes.
47901
+ * All query path points must stay on one side; split side transitions into separate ribbons.
47902
+ */
47903
+ on(skin, points, options = {}) {
47904
+ if (points.length < 2) throw new Error("Product.ribbon().on(skin, points) requires at least two path points");
47905
+ this.skinValue = skin;
47906
+ this.queryPath = resolvePathQueries(points);
47907
+ this.refPath = [];
47908
+ return this.applyOptions(options);
47909
+ }
47910
+ /**
47911
+ * Follow explicit surface refs.
47912
+ *
47913
+ * Useful for named refs or paths assembled elsewhere. The builder resolves each ref frame and
47914
+ * interpolates between those frames; use on(skin, points) when you need full skin-side sampling
47915
+ * between sparse control points.
47916
+ */
47917
+ fromRefs(points, options = {}) {
47918
+ if (points.length < 2) throw new Error("Product.ribbon().fromRefs(points) requires at least two refs");
47919
+ this.skinValue = void 0;
47920
+ this.queryPath = [];
47921
+ this.refPath = [...points];
47922
+ return this.applyOptions(options);
47923
+ }
47924
+ /** Set ribbon width in millimeters. */
47925
+ width(width) {
47926
+ if (!Number.isFinite(width) || width <= 0) throw new Error("Product.ribbon().width(width) requires a positive finite number");
47927
+ this.widthValue = width;
47928
+ return this;
47929
+ }
47930
+ /** Set solid thickness outward from the source surface in millimeters. */
47931
+ thickness(thickness) {
47932
+ if (!Number.isFinite(thickness) || thickness <= 0)
47933
+ throw new Error("Product.ribbon().thickness(thickness) requires a positive finite number");
47934
+ this.thicknessValue = thickness;
47935
+ return this;
47936
+ }
47937
+ /** Set positive clearance between the source surface and the ribbon's inner face. */
47938
+ offset(offset2) {
47939
+ if (!Number.isFinite(offset2)) throw new Error("Product.ribbon().offset(offset) requires a finite number");
47940
+ this.offsetValue = offset2;
47941
+ return this;
47942
+ }
47943
+ /** Set samples along the path. */
47944
+ samples(samples) {
47945
+ if (!Number.isFinite(samples) || samples < 2) throw new Error("Product.ribbon().samples(samples) requires a value >= 2");
47946
+ this.samplesValue = Math.round(samples);
47947
+ return this;
47948
+ }
47949
+ /** Set samples across the width. Use 3+ to bend over curved cross-sections. */
47950
+ widthSamples(samples) {
47951
+ if (!Number.isFinite(samples) || samples < 2) throw new Error("Product.ribbon().widthSamples(samples) requires a value >= 2");
47952
+ this.widthSamplesValue = Math.round(samples);
47953
+ return this;
47954
+ }
47955
+ /** Set NURBS tessellation resolution. */
47956
+ resolution(resolution) {
47957
+ if (!Number.isFinite(resolution) || resolution < 2) throw new Error("Product.ribbon().resolution(resolution) requires a value >= 2");
47958
+ this.resolutionValue = Math.round(resolution);
47959
+ return this;
47960
+ }
47961
+ /** Apply a product material preset. */
47962
+ material(material) {
47963
+ this.materialValue = material;
47964
+ return this;
47965
+ }
47966
+ /** Apply a simple color override. */
47967
+ color(color) {
47968
+ this.colorValue = color;
47969
+ return this;
47970
+ }
47971
+ /** Build a conformal ribbon as a thin NURBS surface solid. */
47972
+ build(options = {}) {
47973
+ return this.buildWithDiagnostics(options).shape;
47974
+ }
47975
+ /**
47976
+ * Build a conformal ribbon and return surface-feature diagnostics.
47977
+ *
47978
+ * Use this while validating API usage or model fidelity; diagnostics report sampling counts,
47979
+ * side-span clamping, lowering mode, and warnings that should be visible in reviews.
47980
+ */
47981
+ buildWithDiagnostics(options = {}) {
47982
+ this.applyOptions(options);
47983
+ const gridResult = this.skinValue ? this.buildSkinGrid(this.skinValue, this.queryPath) : this.buildRefGrid(this.refPath);
47984
+ const desiredNormal = this.centerDesiredNormal();
47985
+ let ribbon = nurbsSurface(orientGridToNormal(gridResult.grid, desiredNormal), {
47986
+ degreeU: Math.min(3, this.widthSamplesValue - 1),
47987
+ degreeV: Math.min(3, this.samplesValue - 1),
47988
+ thickness: this.thicknessValue,
47989
+ resolution: this.resolutionValue ?? Math.max(this.samplesValue, this.widthSamplesValue, 12),
47990
+ approximate: true
47991
+ }).as(this.name);
47992
+ if (this.colorValue) ribbon = ribbon.color(this.colorValue);
47993
+ const shape = applyMaterial(ribbon, this.materialValue);
47994
+ this.lastDiagnosticsValue = gridResult.diagnostics;
47995
+ return { shape, diagnostics: this.cloneDiagnostics(gridResult.diagnostics) };
47996
+ }
47997
+ /** Return diagnostics from the most recent build, if this builder has been built. */
47998
+ diagnostics() {
47999
+ return this.lastDiagnosticsValue ? this.cloneDiagnostics(this.lastDiagnosticsValue) : void 0;
48000
+ }
48001
+ applyOptions(options) {
48002
+ if (options.width != null) this.width(options.width);
48003
+ if (options.thickness != null) this.thickness(options.thickness);
48004
+ if (options.offset != null) this.offset(options.offset);
48005
+ if (options.samples != null) this.samples(options.samples);
48006
+ if (options.widthSamples != null) this.widthSamples(options.widthSamples);
48007
+ if (options.resolution != null) this.resolution(options.resolution);
48008
+ if (options.material) this.material(options.material);
48009
+ if (options.color) this.color(options.color);
48010
+ return this;
48011
+ }
48012
+ centerDesiredNormal() {
48013
+ if (this.skinValue && this.queryPath.length > 0) {
48014
+ const mid = this.samplePathQuery(0.5);
48015
+ return this.skinValue.frame({ ...mid, offset: (mid.offset ?? 0) + this.offsetValue }).normal;
48016
+ }
48017
+ if (this.refPath.length > 0) return this.refPath[Math.floor(this.refPath.length / 2)].frame({ offset: this.offsetValue }).normal;
48018
+ return [0, 0, 1];
48019
+ }
48020
+ samplePathQuery(t) {
48021
+ if (this.queryPath.length < 2) throw new Error("Product.ribbon().on(...) must be called before .build()");
48022
+ const segmentCount = this.queryPath.length - 1;
48023
+ const scaled = clamp6(t, 0, 1) * segmentCount;
48024
+ const segment = Math.min(segmentCount - 1, Math.floor(scaled));
48025
+ const localT = scaled - segment;
48026
+ return interpolateQuery(this.queryPath[segment], this.queryPath[segment + 1], localT);
48027
+ }
48028
+ buildSkinGrid(skin, path4) {
48029
+ if (path4.length < 2) throw new Error("Product.ribbon().on(skin, points) must be called before .build()");
48030
+ const side = normalizedSide(path4[0].side);
48031
+ if (side === "front" || side === "rear") {
48032
+ throw new Error(
48033
+ "Product.ribbon().on(...) supports side ribbons on left/right/top/bottom surfaces. Use Product.panel() for front/rear caps."
48034
+ );
48035
+ }
48036
+ for (const point2 of path4) {
48037
+ if (normalizedSide(point2.side) !== side) {
48038
+ throw new Error("Product.ribbon().on(...) currently supports one side per ribbon. Split ribbons at side transitions.");
48039
+ }
48040
+ }
48041
+ const rows = Array.from({ length: this.widthSamplesValue }, () => []);
48042
+ let clampedUCount = 0;
48043
+ let maxUClampDistance = 0;
48044
+ for (let i = 0; i < this.samplesValue; i += 1) {
48045
+ const along = this.samplesValue === 1 ? 0 : i / (this.samplesValue - 1);
48046
+ const center = this.samplePathQuery(along);
48047
+ const station = skin.stationAt(center.v ?? 0.5);
48048
+ const span = sideSpan(side, station.width, station.depth);
48049
+ for (let j = 0; j < this.widthSamplesValue; j += 1) {
48050
+ const across = this.widthSamplesValue === 1 ? 0 : j / (this.widthSamplesValue - 1) - 0.5;
48051
+ const rawU = (center.u ?? 0.5) + across * this.widthValue / span;
48052
+ const u = clamp6(rawU, 0, 1);
48053
+ const clampDistance = Math.abs(rawU - u) * span;
48054
+ if (clampDistance > EPS7) {
48055
+ clampedUCount += 1;
48056
+ maxUClampDistance = Math.max(maxUClampDistance, clampDistance);
48057
+ }
48058
+ const query = {
48059
+ ...center,
48060
+ side,
48061
+ u,
48062
+ offset: (center.offset ?? 0) + this.offsetValue + this.thicknessValue
48063
+ };
48064
+ rows[j].push(skin.frame(query).point);
48065
+ }
48066
+ }
48067
+ return {
48068
+ grid: rows,
48069
+ diagnostics: this.makeDiagnostics({
48070
+ skin: skin.name,
48071
+ side,
48072
+ pathPointCount: path4.length,
48073
+ clampedUCount,
48074
+ maxUClampDistance
48075
+ })
48076
+ };
48077
+ }
48078
+ buildRefGrid(refs) {
48079
+ if (refs.length < 2) throw new Error("Product.ribbon().fromRefs(points) must be called before .build()");
48080
+ const frames = refs.map((ref) => ref.frame({ offset: this.offsetValue + this.thicknessValue }));
48081
+ const rows = Array.from({ length: this.widthSamplesValue }, () => []);
48082
+ for (const frame of frames) {
48083
+ for (let j = 0; j < this.widthSamplesValue; j += 1) {
48084
+ const across = this.widthSamplesValue === 1 ? 0 : j / (this.widthSamplesValue - 1) - 0.5;
48085
+ rows[j].push(add4(frame.point, scale5(frame.tangentU, across * this.widthValue)));
48086
+ }
48087
+ }
48088
+ this.samplesValue = refs.length;
48089
+ return {
48090
+ grid: rows,
48091
+ diagnostics: this.makeDiagnostics({
48092
+ pathPointCount: refs.length,
48093
+ clampedUCount: 0,
48094
+ maxUClampDistance: 0
48095
+ })
48096
+ };
48097
+ }
48098
+ makeDiagnostics(input) {
48099
+ const resolution = this.resolutionValue ?? Math.max(this.samplesValue, this.widthSamplesValue, 12);
48100
+ const warnings = [];
48101
+ if (input.clampedUCount > 0) {
48102
+ warnings.push(
48103
+ `Ribbon '${this.name}' was clipped to the ${input.side ?? "surface"} UV bounds at ${input.clampedUCount} sampled point(s).`
48104
+ );
48105
+ }
48106
+ if (this.samplesValue < 8) warnings.push(`Ribbon '${this.name}' uses low along-path sampling; increase samples() for smoother bends.`);
48107
+ if (this.widthSamplesValue < 3)
48108
+ warnings.push(`Ribbon '${this.name}' uses low width sampling; use widthSamples(3+) to show cross-surface curvature.`);
48109
+ return {
48110
+ name: this.name,
48111
+ ...input.skin ? { skin: input.skin } : {},
48112
+ ...input.side ? { side: input.side } : {},
48113
+ pathPointCount: input.pathPointCount,
48114
+ width: this.widthValue,
48115
+ thickness: this.thicknessValue,
48116
+ offset: this.offsetValue,
48117
+ samples: this.samplesValue,
48118
+ widthSamples: this.widthSamplesValue,
48119
+ resolution,
48120
+ lowering: "nurbsSurface",
48121
+ expectedFidelity: "mixed",
48122
+ clampedUCount: input.clampedUCount,
48123
+ maxUClampDistance: input.maxUClampDistance,
48124
+ warnings
48125
+ };
48126
+ }
48127
+ cloneDiagnostics(diagnostics) {
48128
+ return {
48129
+ ...diagnostics,
48130
+ warnings: [...diagnostics.warnings]
48131
+ };
48132
+ }
48133
+ };
47373
48134
  var Product = {
47374
48135
  /** Start a named product skin builder. */
47375
48136
  skin(name) {
@@ -47442,10 +48203,27 @@ var Product = {
47442
48203
  ref(skin, query) {
47443
48204
  return new ProductSurfaceRef(skin, query);
47444
48205
  },
48206
+ /**
48207
+ * Create a fluent surface helper for refs and conformal features on one side of a skin.
48208
+ *
48209
+ * Equivalent to skin.surface(side), useful when writing in Product.* namespace style.
48210
+ */
48211
+ surface(skin, side) {
48212
+ return skin.surface(side);
48213
+ },
47445
48214
  /** Start a panel feature builder. */
47446
48215
  panel(name) {
47447
48216
  return new ProductPanelBuilder(name);
47448
48217
  },
48218
+ /**
48219
+ * Start a conformal ribbon/trim builder for details that should bend with a ProductSkin.
48220
+ *
48221
+ * Call .on(skin, points) for side/u/v sampling or .fromRefs(points) for explicit surface refs,
48222
+ * then configure width, thickness, offset, sampling, material, and color before build().
48223
+ */
48224
+ ribbon(name) {
48225
+ return new ProductRibbonBuilder(name);
48226
+ },
47449
48227
  /** Start a spout/nozzle feature builder. */
47450
48228
  spout(name) {
47451
48229
  return new ProductSpoutBuilder(name);
@@ -47615,8 +48393,8 @@ function buildNameLookup(objects) {
47615
48393
  const bySuffix = /* @__PURE__ */ new Map();
47616
48394
  objects.forEach((obj) => {
47617
48395
  pushLookup(byExact, obj.name, obj.id);
47618
- for (let dot8 = obj.name.indexOf("."); dot8 >= 0; dot8 = obj.name.indexOf(".", dot8 + 1)) {
47619
- pushLookup(bySuffix, obj.name.slice(dot8 + 1), obj.id);
48396
+ for (let dot9 = obj.name.indexOf("."); dot9 >= 0; dot9 = obj.name.indexOf(".", dot9 + 1)) {
48397
+ pushLookup(bySuffix, obj.name.slice(dot9 + 1), obj.id);
47620
48398
  }
47621
48399
  });
47622
48400
  return { byExact, bySuffix };
@@ -49904,7 +50682,7 @@ var Constraint = {
49904
50682
  };
49905
50683
 
49906
50684
  // src/forge/points.ts
49907
- function requireVec32(v, label) {
50685
+ function requireVec33(v, label) {
49908
50686
  if (!Array.isArray(v) || v.length !== 3 || !Number.isFinite(v[0]) || !Number.isFinite(v[1]) || !Number.isFinite(v[2])) {
49909
50687
  throw new Error(`${label} must be a [number, number, number] with finite values, got ${JSON.stringify(v)}`);
49910
50688
  }
@@ -49917,24 +50695,24 @@ function requireFiniteNumber(n, label) {
49917
50695
  return n;
49918
50696
  }
49919
50697
  function distance(a, b) {
49920
- requireVec32(a, "a");
49921
- requireVec32(b, "b");
50698
+ requireVec33(a, "a");
50699
+ requireVec33(b, "b");
49922
50700
  return Math.hypot(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
49923
50701
  }
49924
50702
  function midpoint4(a, b) {
49925
- requireVec32(a, "a");
49926
- requireVec32(b, "b");
50703
+ requireVec33(a, "a");
50704
+ requireVec33(b, "b");
49927
50705
  return [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2, (a[2] + b[2]) / 2];
49928
50706
  }
49929
50707
  function lerp3(a, b, t) {
49930
- requireVec32(a, "a");
49931
- requireVec32(b, "b");
50708
+ requireVec33(a, "a");
50709
+ requireVec33(b, "b");
49932
50710
  requireFiniteNumber(t, "t");
49933
50711
  return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
49934
50712
  }
49935
50713
  function direction(a, b) {
49936
- requireVec32(a, "a");
49937
- requireVec32(b, "b");
50714
+ requireVec33(a, "a");
50715
+ requireVec33(b, "b");
49938
50716
  const dx = b[0] - a[0];
49939
50717
  const dy = b[1] - a[1];
49940
50718
  const dz = b[2] - a[2];
@@ -49945,8 +50723,8 @@ function direction(a, b) {
49945
50723
  return [dx / len2, dy / len2, dz / len2];
49946
50724
  }
49947
50725
  function offset(point2, dir, amount) {
49948
- requireVec32(point2, "point");
49949
- requireVec32(dir, "dir");
50726
+ requireVec33(point2, "point");
50727
+ requireVec33(dir, "dir");
49950
50728
  requireFiniteNumber(amount, "amount");
49951
50729
  return [point2[0] + dir[0] * amount, point2[1] + dir[1] * amount, point2[2] + dir[2] * amount];
49952
50730
  }
@@ -50055,14 +50833,14 @@ var WoodBoard = class _WoodBoard {
50055
50833
  };
50056
50834
 
50057
50835
  // src/forge/wood/joints.ts
50058
- function requireFinite4(value, name) {
50836
+ function requireFinite5(value, name) {
50059
50837
  if (!Number.isFinite(value)) {
50060
50838
  throw new Error(`${name} must be a finite number, got ${value}`);
50061
50839
  }
50062
50840
  return value;
50063
50841
  }
50064
50842
  function requirePositive3(value, name) {
50065
- requireFinite4(value, name);
50843
+ requireFinite5(value, name);
50066
50844
  if (value <= 0) {
50067
50845
  throw new Error(`${name} must be positive, got ${value}`);
50068
50846
  }
@@ -50097,10 +50875,10 @@ function dado(host, guest, opts) {
50097
50875
  }
50098
50876
  let fromBottom;
50099
50877
  if (opts.fromBottom != null) {
50100
- fromBottom = requireFinite4(opts.fromBottom, "fromBottom");
50878
+ fromBottom = requireFinite5(opts.fromBottom, "fromBottom");
50101
50879
  } else {
50102
50880
  fromBottom = host.height - opts.fromTop - channelWidth;
50103
- requireFinite4(fromBottom, "computed fromBottom");
50881
+ requireFinite5(fromBottom, "computed fromBottom");
50104
50882
  }
50105
50883
  let dadoLength = host.width;
50106
50884
  let xOffset = 0;
@@ -50157,7 +50935,7 @@ function mortiseAndTenon(mortiseBoard, tenonBoard, opts) {
50157
50935
  const style = o.style ?? "blind";
50158
50936
  const fit = o.fit ?? "snug";
50159
50937
  const clearance = clearanceForFit(fit);
50160
- const cornerRadius = o.cornerRadius != null ? requireFinite4(o.cornerRadius, "cornerRadius") : 0;
50938
+ const cornerRadius = o.cornerRadius != null ? requireFinite5(o.cornerRadius, "cornerRadius") : 0;
50161
50939
  const tenonThickness = o.tenonThickness != null ? requirePositive3(o.tenonThickness, "tenonThickness") : tenonBoard.thickness / 3;
50162
50940
  const tenonWidth = o.tenonWidth != null ? requirePositive3(o.tenonWidth, "tenonWidth") : Math.min(tenonBoard.height * 0.6, mortiseBoard.height * 0.8);
50163
50941
  const tenonLength = o.tenonLength != null ? requirePositive3(o.tenonLength, "tenonLength") : style === "through" ? mortiseBoard.thickness : mortiseBoard.thickness * 2 / 3;
@@ -50170,10 +50948,10 @@ function mortiseAndTenon(mortiseBoard, tenonBoard, opts) {
50170
50948
  throw new Error("mortiseAndTenon: specify position.fromTop or position.fromBottom, not both");
50171
50949
  }
50172
50950
  if (o.position.fromTop != null) {
50173
- requireFinite4(o.position.fromTop, "position.fromTop");
50951
+ requireFinite5(o.position.fromTop, "position.fromTop");
50174
50952
  mortiseCenterY = mortiseBoard.height / 2 - o.position.fromTop - mortiseH / 2;
50175
50953
  } else if (o.position.fromBottom != null) {
50176
- requireFinite4(o.position.fromBottom, "position.fromBottom");
50954
+ requireFinite5(o.position.fromBottom, "position.fromBottom");
50177
50955
  mortiseCenterY = -mortiseBoard.height / 2 + o.position.fromBottom + mortiseH / 2;
50178
50956
  }
50179
50957
  }
@@ -50249,12 +51027,12 @@ var Wood = {
50249
51027
  };
50250
51028
 
50251
51029
  // src/forge/cameraTrajectory.ts
50252
- var _collected7 = null;
51030
+ var _collected8 = null;
50253
51031
  function resetCameraTrajectory() {
50254
- _collected7 = null;
51032
+ _collected8 = null;
50255
51033
  }
50256
51034
  function getCollectedCameraTrajectory() {
50257
- return _collected7;
51035
+ return _collected8;
50258
51036
  }
50259
51037
  function isOrbitKeyframe(kf) {
50260
51038
  return "orbit" in kf;
@@ -50262,19 +51040,19 @@ function isOrbitKeyframe(kf) {
50262
51040
  function isCartesianKeyframe(kf) {
50263
51041
  return "position" in kf;
50264
51042
  }
50265
- function requireFinite5(value, label) {
51043
+ function requireFinite6(value, label) {
50266
51044
  if (!Number.isFinite(value)) {
50267
51045
  throw new Error(`cameraTrajectory(): ${label} must be a finite number, got ${value}`);
50268
51046
  }
50269
51047
  }
50270
51048
  function validateOrbitKeyframe(kf, index) {
50271
- requireFinite5(kf.at, `keyframes[${index}].at`);
50272
- requireFinite5(kf.orbit.angle, `keyframes[${index}].orbit.angle`);
50273
- requireFinite5(kf.orbit.pitch, `keyframes[${index}].orbit.pitch`);
50274
- requireFinite5(kf.orbit.distance, `keyframes[${index}].orbit.distance`);
51049
+ requireFinite6(kf.at, `keyframes[${index}].at`);
51050
+ requireFinite6(kf.orbit.angle, `keyframes[${index}].orbit.angle`);
51051
+ requireFinite6(kf.orbit.pitch, `keyframes[${index}].orbit.pitch`);
51052
+ requireFinite6(kf.orbit.distance, `keyframes[${index}].orbit.distance`);
50275
51053
  }
50276
51054
  function validateCartesianKeyframe(kf, index) {
50277
- requireFinite5(kf.at, `keyframes[${index}].at`);
51055
+ requireFinite6(kf.at, `keyframes[${index}].at`);
50278
51056
  if (!Array.isArray(kf.position) || kf.position.length !== 3) {
50279
51057
  throw new Error(`cameraTrajectory(): keyframes[${index}].position must be a 3-element array`);
50280
51058
  }
@@ -50282,8 +51060,8 @@ function validateCartesianKeyframe(kf, index) {
50282
51060
  throw new Error(`cameraTrajectory(): keyframes[${index}].target must be a 3-element array`);
50283
51061
  }
50284
51062
  for (let i = 0; i < 3; i++) {
50285
- requireFinite5(kf.position[i], `keyframes[${index}].position[${i}]`);
50286
- requireFinite5(kf.target[i], `keyframes[${index}].target[${i}]`);
51063
+ requireFinite6(kf.position[i], `keyframes[${index}].position[${i}]`);
51064
+ requireFinite6(kf.target[i], `keyframes[${index}].target[${i}]`);
50287
51065
  }
50288
51066
  }
50289
51067
  function validateKeyframeOrder(keyframes) {
@@ -50303,11 +51081,11 @@ function validateKeyframeOrder(keyframes) {
50303
51081
  }
50304
51082
  }
50305
51083
  function cameraTrajectory(defOrFn, options) {
50306
- if (_collected7 !== null) {
51084
+ if (_collected8 !== null) {
50307
51085
  console.warn("cameraTrajectory() called more than once \u2014 overwriting previous trajectory.");
50308
51086
  }
50309
51087
  if (typeof defOrFn === "function") {
50310
- _collected7 = {
51088
+ _collected8 = {
50311
51089
  kind: "parametric",
50312
51090
  parametricFn: defOrFn,
50313
51091
  duration: options?.duration,
@@ -50326,13 +51104,13 @@ function cameraTrajectory(defOrFn, options) {
50326
51104
  for (let i = 0; i < orbitKeyframes.length; i++) {
50327
51105
  validateOrbitKeyframe(orbitKeyframes[i], i);
50328
51106
  }
50329
- _collected7 = { kind: "orbit-keyframes", orbitKeyframes, duration, fps, easing };
51107
+ _collected8 = { kind: "orbit-keyframes", orbitKeyframes, duration, fps, easing };
50330
51108
  } else if (isCartesianKeyframe(first)) {
50331
51109
  const cartesianKeyframes = keyframes;
50332
51110
  for (let i = 0; i < cartesianKeyframes.length; i++) {
50333
51111
  validateCartesianKeyframe(cartesianKeyframes[i], i);
50334
51112
  }
50335
- _collected7 = { kind: "cartesian-keyframes", cartesianKeyframes, duration, fps, easing };
51113
+ _collected8 = { kind: "cartesian-keyframes", cartesianKeyframes, duration, fps, easing };
50336
51114
  } else {
50337
51115
  throw new Error('cameraTrajectory(): each keyframe must have either an "orbit" or "position" property');
50338
51116
  }
@@ -50360,6 +51138,76 @@ function isEdgeSegment(value) {
50360
51138
  function isEdgeReferenceLike(value) {
50361
51139
  return typeof value === "object" && value !== null && "edges" in value && typeof value.edges === "function";
50362
51140
  }
51141
+ var BROAD_EDGE_FEATURE_DEFAULT_BUDGET = {
51142
+ live: 0,
51143
+ default: 12,
51144
+ high: Number.POSITIVE_INFINITY
51145
+ };
51146
+ var broadEdgeFeatureBudget = null;
51147
+ function readBroadEdgeFeatureEnv(name) {
51148
+ return typeof process !== "undefined" ? process.env?.[name] : void 0;
51149
+ }
51150
+ function resolveBroadEdgeFeatureBudget() {
51151
+ if (readBroadEdgeFeatureEnv("FORGECAD_ALLOW_BROAD_EDGE_FEATURES") === "1") return Number.POSITIVE_INFINITY;
51152
+ const override = readBroadEdgeFeatureEnv("FORGECAD_BROAD_EDGE_FEATURE_BUDGET");
51153
+ if (override != null && override.trim() !== "") {
51154
+ const parsed = Number(override);
51155
+ if (Number.isFinite(parsed) && parsed >= 0) return parsed;
51156
+ }
51157
+ return BROAD_EDGE_FEATURE_DEFAULT_BUDGET[getForgeQualityPreset()];
51158
+ }
51159
+ function resetBroadEdgeFeatureBudget() {
51160
+ broadEdgeFeatureBudget = null;
51161
+ }
51162
+ function remainingBroadEdgeFeatureBudget() {
51163
+ if (broadEdgeFeatureBudget === null) broadEdgeFeatureBudget = resolveBroadEdgeFeatureBudget();
51164
+ return broadEdgeFeatureBudget;
51165
+ }
51166
+ function consumeBroadEdgeFeatureBudget(edgeCount) {
51167
+ const remaining = remainingBroadEdgeFeatureBudget();
51168
+ if (!Number.isFinite(remaining)) return true;
51169
+ if (edgeCount > remaining) return false;
51170
+ broadEdgeFeatureBudget = Math.max(0, remaining - edgeCount);
51171
+ return true;
51172
+ }
51173
+ function shouldSkipBroadEdgeFeature(operation, edgeCount) {
51174
+ if (consumeBroadEdgeFeatureBudget(edgeCount)) return false;
51175
+ const remaining = Math.max(0, remainingBroadEdgeFeatureBudget());
51176
+ emitRuntimeWarning(
51177
+ `${operation}() without an edge selector matched ${edgeCount} edge(s), exceeding the remaining broad edge-feature budget (${remaining}). Skipped this cosmetic edge finish for responsiveness. Pass an explicit edge selector, use high quality/export, or set FORGECAD_BROAD_EDGE_FEATURE_BUDGET to opt into more broad edge finishing.`
51178
+ );
51179
+ return true;
51180
+ }
51181
+ function shouldSkipExhaustedBroadEdgeFeature(operation) {
51182
+ const remaining = remainingBroadEdgeFeatureBudget();
51183
+ if (!Number.isFinite(remaining) || remaining > 0) return false;
51184
+ emitRuntimeWarning(
51185
+ `${operation}() without an edge selector was skipped because the broad edge-feature budget is exhausted. Skipped this cosmetic edge finish for responsiveness. Pass an explicit edge selector, use high quality/export, or set FORGECAD_BROAD_EDGE_FEATURE_BUDGET to opt into more broad edge finishing.`
51186
+ );
51187
+ return true;
51188
+ }
51189
+ function estimateSelectorlessEdgeCount(plan) {
51190
+ if (!plan) return null;
51191
+ switch (plan.kind) {
51192
+ case "box":
51193
+ return 12;
51194
+ case "queryOwner":
51195
+ case "transform":
51196
+ return estimateSelectorlessEdgeCount(plan.base);
51197
+ default:
51198
+ return null;
51199
+ }
51200
+ }
51201
+ function shouldSkipUnestimatedBroadEdgeFeature(operation, target) {
51202
+ const remaining = remainingBroadEdgeFeatureBudget();
51203
+ if (!Number.isFinite(remaining)) return false;
51204
+ const estimatedEdges = estimateSelectorlessEdgeCount(getShapeCompilePlan(target));
51205
+ if (estimatedEdges !== null && estimatedEdges <= remaining) return false;
51206
+ emitRuntimeWarning(
51207
+ `${operation}() without an edge selector was skipped before broad edge enumeration because this shape is too complex for the remaining broad edge-feature budget (${Math.max(0, remaining)}). Skipped this cosmetic edge finish for responsiveness. Pass an explicit edge selector, use high quality/export, or set FORGECAD_BROAD_EDGE_FEATURE_BUDGET to opt into more broad edge finishing.`
51208
+ );
51209
+ return true;
51210
+ }
50363
51211
  function edgesToTargets(edges) {
50364
51212
  return edges.map((e) => ({
50365
51213
  midpoint: [e.midpoint[0], e.midpoint[1], e.midpoint[2]],
@@ -50373,10 +51221,13 @@ function fillet(shape, radius, edges, segments = 16) {
50373
51221
  throw new Error("fillet() requires a positive finite radius.");
50374
51222
  }
50375
51223
  const target = shape;
51224
+ if (edges === void 0 && shouldSkipExhaustedBroadEdgeFeature("fillet")) return target;
51225
+ if (edges === void 0 && shouldSkipUnestimatedBroadEdgeFeature("fillet", target)) return target;
50376
51226
  const resolvedEdges = resolveEdges(target, edges);
50377
51227
  if (resolvedEdges.length === 0) {
50378
51228
  throw new Error("fillet(): no edges match the given selection.");
50379
51229
  }
51230
+ if (edges === void 0 && shouldSkipBroadEdgeFeature("fillet", resolvedEdges.length)) return target;
50380
51231
  const basePlan = getShapeCompilePlan(target);
50381
51232
  const plan = {
50382
51233
  kind: "filletEdges",
@@ -50396,10 +51247,13 @@ function chamfer(shape, size, edges) {
50396
51247
  throw new Error("chamfer() requires a positive finite size.");
50397
51248
  }
50398
51249
  const target = shape;
51250
+ if (edges === void 0 && shouldSkipExhaustedBroadEdgeFeature("chamfer")) return target;
51251
+ if (edges === void 0 && shouldSkipUnestimatedBroadEdgeFeature("chamfer", target)) return target;
50399
51252
  const resolvedEdges = resolveEdges(target, edges);
50400
51253
  if (resolvedEdges.length === 0) {
50401
51254
  throw new Error("chamfer(): no edges match the given selection.");
50402
51255
  }
51256
+ if (edges === void 0 && shouldSkipBroadEdgeFeature("chamfer", resolvedEdges.length)) return target;
50403
51257
  const basePlan = getShapeCompilePlan(target);
50404
51258
  const plan = {
50405
51259
  kind: "chamferEdges",
@@ -50455,10 +51309,10 @@ function offsetSolid(shape, thickness) {
50455
51309
 
50456
51310
  // src/forge/projectionCompile.ts
50457
51311
  var EPS8 = 1e-6;
50458
- function dot6(a, b) {
51312
+ function dot7(a, b) {
50459
51313
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
50460
51314
  }
50461
- function sub6(a, b) {
51315
+ function sub7(a, b) {
50462
51316
  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
50463
51317
  }
50464
51318
  function length2d(x, y) {
@@ -50583,14 +51437,14 @@ function mapProfileToPlane(profile, sourcePlacement, targetPlane) {
50583
51437
  const sourceU = sourceTransform.vector([1, 0, 0]);
50584
51438
  const sourceV = sourceTransform.vector([0, 1, 0]);
50585
51439
  const sourceNormal = sourceTransform.vector([0, 0, 1]);
50586
- const normalAlignment = Math.abs(dot6(normalize8(sourceNormal), targetPlane.normal));
51440
+ const normalAlignment = Math.abs(dot7(normalize8(sourceNormal), targetPlane.normal));
50587
51441
  if (!nearlyEqual(normalAlignment, 1, 1e-5)) {
50588
51442
  return { reason: "projection replay currently requires the target plane to stay parallel to the source workplane." };
50589
51443
  }
50590
- const a = dot6(sourceU, targetPlane.u);
50591
- const b = dot6(sourceV, targetPlane.u);
50592
- const c = dot6(sourceU, targetPlane.v);
50593
- const d = dot6(sourceV, targetPlane.v);
51444
+ const a = dot7(sourceU, targetPlane.u);
51445
+ const b = dot7(sourceV, targetPlane.u);
51446
+ const c = dot7(sourceU, targetPlane.v);
51447
+ const d = dot7(sourceV, targetPlane.v);
50594
51448
  const sx = length2d(a, c);
50595
51449
  const sy = length2d(b, d);
50596
51450
  if (sx < EPS8 || sy < EPS8) {
@@ -50624,9 +51478,9 @@ function mapProfileToPlane(profile, sourcePlacement, targetPlane) {
50624
51478
  next = appendProfileCompileTransform(next, { kind: "rotate", degrees: angle });
50625
51479
  }
50626
51480
  }
50627
- const delta = sub6(sourceOrigin, targetPlane.origin);
50628
- const tx = dot6(delta, targetPlane.u);
50629
- const ty = dot6(delta, targetPlane.v);
51481
+ const delta = sub7(sourceOrigin, targetPlane.origin);
51482
+ const tx = dot7(delta, targetPlane.u);
51483
+ const ty = dot7(delta, targetPlane.v);
50630
51484
  if (!nearlyEqual(tx, 0) || !nearlyEqual(ty, 0)) {
50631
51485
  next = appendProfileCompileTransform(next, { kind: "translate", x: tx, y: ty });
50632
51486
  }
@@ -56547,8 +57401,8 @@ function normalizeVector2(v) {
56547
57401
  function angleDegBetween(a, b) {
56548
57402
  const na = normalizeVector2(a);
56549
57403
  const nb = normalizeVector2(b);
56550
- const dot8 = Math.max(-1, Math.min(1, na[0] * nb[0] + na[1] * nb[1] + na[2] * nb[2]));
56551
- return Math.acos(dot8) * 180 / Math.PI;
57404
+ const dot9 = Math.max(-1, Math.min(1, na[0] * nb[0] + na[1] * nb[1] + na[2] * nb[2]));
57405
+ return Math.acos(dot9) * 180 / Math.PI;
56552
57406
  }
56553
57407
  function edgeEndpoint(edge, endpoint) {
56554
57408
  const source = endpoint === "start" ? edge.start : edge.end;
@@ -57466,9 +58320,9 @@ function sampleQuadratic2(p0, p1, p2, tolerance) {
57466
58320
  return out;
57467
58321
  }
57468
58322
  function vectorAngle(ux, uy, vx, vy) {
57469
- const dot8 = ux * vx + uy * vy;
58323
+ const dot9 = ux * vx + uy * vy;
57470
58324
  const det = ux * vy - uy * vx;
57471
- return Math.atan2(det, dot8);
58325
+ return Math.atan2(det, dot9);
57472
58326
  }
57473
58327
  function sampleArc2(x1, y1, rxInput, ryInput, xAxisRotation, largeArcFlag, sweepFlag, x2, y2, tolerance, arcSegmentsMin) {
57474
58328
  let rx = Math.abs(rxInput);
@@ -58567,8 +59421,8 @@ function fromSlicesImpl(slices, options = {}) {
58567
59421
  group2 = { normal: canonicalNormal(s.normal), slices: [] };
58568
59422
  groupMap.set(key, group2);
58569
59423
  }
58570
- const dot8 = s.normal[0] * group2.normal[0] + s.normal[1] * group2.normal[1] + s.normal[2] * group2.normal[2];
58571
- const sign = dot8 < 0 ? -1 : 1;
59424
+ const dot9 = s.normal[0] * group2.normal[0] + s.normal[1] * group2.normal[1] + s.normal[2] * group2.normal[2];
59425
+ const sign = dot9 < 0 ? -1 : 1;
58572
59426
  group2.slices.push({ offset: s.offset * sign, profile: s.profile });
58573
59427
  }
58574
59428
  const groups = [...groupMap.values()];
@@ -58933,14 +59787,14 @@ function sheetStock(width, height, description, opts) {
58933
59787
  }
58934
59788
 
58935
59789
  // src/forge/laserKit/joints.ts
58936
- function requireFinite6(value, name) {
59790
+ function requireFinite7(value, name) {
58937
59791
  if (!Number.isFinite(value)) {
58938
59792
  throw new Error(`${name} must be a finite number, got ${value}`);
58939
59793
  }
58940
59794
  return value;
58941
59795
  }
58942
59796
  function requirePositive4(value, name) {
58943
- requireFinite6(value, name);
59797
+ requireFinite7(value, name);
58944
59798
  if (value <= 0) {
58945
59799
  throw new Error(`${name} must be positive, got ${value}`);
58946
59800
  }
@@ -58955,8 +59809,8 @@ function requireOdd(value, name) {
58955
59809
  function fingerJointProfile(length5, thickness, options = {}) {
58956
59810
  requirePositive4(length5, "length");
58957
59811
  requirePositive4(thickness, "thickness");
58958
- const clearance = requireFinite6(options.clearance ?? 0, "clearance");
58959
- const kerf = requireFinite6(options.kerf ?? 0, "kerf");
59812
+ const clearance = requireFinite7(options.clearance ?? 0, "clearance");
59813
+ const kerf = requireFinite7(options.kerf ?? 0, "kerf");
58960
59814
  const endStyle = options.endStyle ?? "full";
58961
59815
  let fingers;
58962
59816
  if (options.fingers != null) {
@@ -59019,11 +59873,11 @@ function fingerJointProfile(length5, thickness, options = {}) {
59019
59873
  function tabSlotProfile(length5, thickness, options = {}) {
59020
59874
  requirePositive4(length5, "length");
59021
59875
  requirePositive4(thickness, "thickness");
59022
- const clearance = requireFinite6(options.clearance ?? 0, "clearance");
59023
- const kerf = requireFinite6(options.kerf ?? 0, "kerf");
59876
+ const clearance = requireFinite7(options.clearance ?? 0, "clearance");
59877
+ const kerf = requireFinite7(options.kerf ?? 0, "kerf");
59024
59878
  const inset = requirePositive4(options.inset ?? thickness, "inset");
59025
59879
  const tabWidth = requirePositive4(options.tabWidth ?? 2 * thickness, "tabWidth");
59026
- const tabCount = options.tabCount != null ? (requireFinite6(options.tabCount, "tabCount"), Math.max(1, Math.round(options.tabCount))) : Math.max(1, Math.round(length5 / (4 * thickness)));
59880
+ const tabCount = options.tabCount != null ? (requireFinite7(options.tabCount, "tabCount"), Math.max(1, Math.round(options.tabCount))) : Math.max(1, Math.round(length5 / (4 * thickness)));
59027
59881
  const halfKerf = kerf / 2;
59028
59882
  const usableLength = length5 - 2 * inset;
59029
59883
  const spacing = tabCount > 1 ? usableLength / (tabCount - 1) : 0;
@@ -59112,14 +59966,14 @@ function lookupKerf(material, thickness, laserType) {
59112
59966
  }
59113
59967
 
59114
59968
  // src/forge/laserKit/flatPart.ts
59115
- function requireFinite7(value, name) {
59969
+ function requireFinite8(value, name) {
59116
59970
  if (!Number.isFinite(value)) {
59117
59971
  throw new Error(`${name} must be a finite number, got ${value}`);
59118
59972
  }
59119
59973
  return value;
59120
59974
  }
59121
59975
  function requirePositive5(value, name) {
59122
- requireFinite7(value, name);
59976
+ requireFinite8(value, name);
59123
59977
  if (value <= 0) {
59124
59978
  throw new Error(`${name} must be positive, got ${value}`);
59125
59979
  }
@@ -59219,9 +60073,9 @@ function positionAlongEdge(profile, edge, inward = false) {
59219
60073
  const angle = Math.atan2(-nx, ny) * (180 / Math.PI);
59220
60074
  const edx = (edge.end[0] - edge.start[0]) / edge.length;
59221
60075
  const edy = (edge.end[1] - edge.start[1]) / edge.length;
59222
- const dot8 = ny * edx + -nx * edy;
60076
+ const dot9 = ny * edx + -nx * edy;
59223
60077
  const rotated = profile.rotateAround(angle, [0, 0]);
59224
- if (dot8 > 0) {
60078
+ if (dot9 > 0) {
59225
60079
  return rotated.translate(edge.start[0], edge.start[1]);
59226
60080
  } else {
59227
60081
  return rotated.translate(edge.end[0], edge.end[1]);
@@ -60363,12 +61217,12 @@ function rotatePoint(p2, axis, angleDeg2, pivot) {
60363
61217
  const s = Math.sin(rad);
60364
61218
  const k = v3normalize(axis);
60365
61219
  const v = v3sub(p2, pivot);
60366
- const dot8 = v3dot(k, v);
61220
+ const dot9 = v3dot(k, v);
60367
61221
  const cross7 = v3cross(k, v);
60368
61222
  const result = [
60369
- v[0] * c + cross7[0] * s + k[0] * dot8 * (1 - c),
60370
- v[1] * c + cross7[1] * s + k[1] * dot8 * (1 - c),
60371
- v[2] * c + cross7[2] * s + k[2] * dot8 * (1 - c)
61223
+ v[0] * c + cross7[0] * s + k[0] * dot9 * (1 - c),
61224
+ v[1] * c + cross7[1] * s + k[1] * dot9 * (1 - c),
61225
+ v[2] * c + cross7[2] * s + k[2] * dot9 * (1 - c)
60372
61226
  ];
60373
61227
  return v3add(result, pivot);
60374
61228
  }
@@ -60943,14 +61797,14 @@ function spec(name, checkFn) {
60943
61797
  return {
60944
61798
  name,
60945
61799
  check(...args) {
60946
- const before = _collected8.length;
61800
+ const before = _collected9.length;
60947
61801
  _activeGroup = name;
60948
61802
  try {
60949
61803
  checkFn(...args);
60950
61804
  } finally {
60951
61805
  _activeGroup = null;
60952
61806
  }
60953
- const added = _collected8.slice(before);
61807
+ const added = _collected9.slice(before);
60954
61808
  return {
60955
61809
  name,
60956
61810
  passed: added.filter((r) => r.status === "pass").length,
@@ -60960,15 +61814,15 @@ function spec(name, checkFn) {
60960
61814
  }
60961
61815
  };
60962
61816
  }
60963
- var _collected8 = [];
61817
+ var _collected9 = [];
60964
61818
  var _counter2 = 0;
60965
61819
  var _activeGroup = null;
60966
61820
  function resetVerifications() {
60967
- _collected8 = [];
61821
+ _collected9 = [];
60968
61822
  _counter2 = 0;
60969
61823
  }
60970
61824
  function getCollectedVerifications() {
60971
- return _collected8.slice();
61825
+ return _collected9.slice();
60972
61826
  }
60973
61827
  function nextId() {
60974
61828
  _counter2 += 1;
@@ -60993,7 +61847,7 @@ function captureSourceLine() {
60993
61847
  }
60994
61848
  function push(result) {
60995
61849
  if (_activeGroup) result.group = _activeGroup;
60996
- _collected8.push(result);
61850
+ _collected9.push(result);
60997
61851
  }
60998
61852
  function roundNum(n, digits = 4) {
60999
61853
  return Number.isFinite(n) ? n.toFixed(digits).replace(/\.?0+$/, "") : String(n);
@@ -61196,14 +62050,14 @@ var verify = {
61196
62050
  try {
61197
62051
  const na = faceA.normal;
61198
62052
  const nb = faceB.normal;
61199
- const dot8 = vec3Dot3(na, nb);
62053
+ const dot9 = vec3Dot3(na, nb);
61200
62054
  const lenA = vec3Len3(na);
61201
62055
  const lenB = vec3Len3(nb);
61202
62056
  if (lenA < 1e-9 || lenB < 1e-9) {
61203
62057
  push({ id: nextId(), label, status: "fail", message: "One or both faces have zero-length normals", line: line2 });
61204
62058
  return;
61205
62059
  }
61206
- const cosAngle = Math.abs(dot8) / (lenA * lenB);
62060
+ const cosAngle = Math.abs(dot9) / (lenA * lenB);
61207
62061
  const angleDeg2 = Math.acos(Math.min(1, cosAngle)) * 180 / Math.PI;
61208
62062
  const passed = angleDeg2 <= toleranceDeg;
61209
62063
  push({
@@ -61227,14 +62081,14 @@ var verify = {
61227
62081
  try {
61228
62082
  const na = faceA.normal;
61229
62083
  const nb = faceB.normal;
61230
- const dot8 = vec3Dot3(na, nb);
62084
+ const dot9 = vec3Dot3(na, nb);
61231
62085
  const lenA = vec3Len3(na);
61232
62086
  const lenB = vec3Len3(nb);
61233
62087
  if (lenA < 1e-9 || lenB < 1e-9) {
61234
62088
  push({ id: nextId(), label, status: "fail", message: "One or both faces have zero-length normals", line: line2 });
61235
62089
  return;
61236
62090
  }
61237
- const cosAngle = Math.abs(dot8) / (lenA * lenB);
62091
+ const cosAngle = Math.abs(dot9) / (lenA * lenB);
61238
62092
  const angleDeg2 = 90 - Math.acos(Math.min(1, cosAngle)) * 180 / Math.PI;
61239
62093
  const passed = Math.abs(angleDeg2) <= toleranceDeg;
61240
62094
  push({
@@ -61328,18 +62182,18 @@ var verify = {
61328
62182
  try {
61329
62183
  const na = faceA.normal;
61330
62184
  const nb = faceB.normal;
61331
- const dot8 = vec3Dot3(na, nb);
62185
+ const dot9 = vec3Dot3(na, nb);
61332
62186
  const lenA = vec3Len3(na);
61333
62187
  const lenB = vec3Len3(nb);
61334
62188
  if (lenA < 1e-9 || lenB < 1e-9) {
61335
62189
  push({ id: nextId(), label, status: "fail", message: "One or both faces have zero-length normals", line: line2 });
61336
62190
  return;
61337
62191
  }
61338
- if (dot8 <= 0) {
62192
+ if (dot9 <= 0) {
61339
62193
  push({ id: nextId(), label, status: "fail", message: "Face normals point in opposite directions", line: line2 });
61340
62194
  return;
61341
62195
  }
61342
- const cosAngle = dot8 / (lenA * lenB);
62196
+ const cosAngle = dot9 / (lenA * lenB);
61343
62197
  const angleDeg2 = Math.acos(Math.min(1, cosAngle)) * 180 / Math.PI;
61344
62198
  const passed = angleDeg2 <= toleranceDeg;
61345
62199
  push({
@@ -61556,6 +62410,7 @@ var verify = {
61556
62410
  // src/forge/script-runtime/executionSession.ts
61557
62411
  function resetExecutionSession(logs) {
61558
62412
  resetCollectedAssemblies();
62413
+ resetBroadEdgeFeatureBudget();
61559
62414
  resetParams();
61560
62415
  resetShapeQueryOwnerIds();
61561
62416
  resetDimensions();
@@ -61564,6 +62419,7 @@ function resetExecutionSession(logs) {
61564
62419
  resetSheetStock();
61565
62420
  resetRobotExport();
61566
62421
  resetCutPlanes();
62422
+ resetRenderLabels();
61567
62423
  resetCameraTrajectory();
61568
62424
  resetExplodeView();
61569
62425
  resetJointsView();
@@ -61627,6 +62483,7 @@ function collectSuccessfulExecutionSnapshot(args) {
61627
62483
  bom: getCollectedBom(),
61628
62484
  sheetStock: getCollectedSheetStock(),
61629
62485
  cutPlanes: getCollectedCutPlanes(),
62486
+ renderLabels: getCollectedRenderLabels(),
61630
62487
  cameraTrajectory: getCollectedCameraTrajectory(),
61631
62488
  explodeView: getCollectedExplodeView(),
61632
62489
  jointsView: getCollectedJointsView(),
@@ -61650,6 +62507,7 @@ function collectFailedExecutionSnapshot(args) {
61650
62507
  bom: getCollectedBom(),
61651
62508
  sheetStock: getCollectedSheetStock(),
61652
62509
  cutPlanes: getCollectedCutPlanes(),
62510
+ renderLabels: getCollectedRenderLabels(),
61653
62511
  cameraTrajectory: getCollectedCameraTrajectory(),
61654
62512
  explodeView: getCollectedExplodeView(),
61655
62513
  jointsView: getCollectedJointsView(),
@@ -61796,13 +62654,15 @@ function formatLogArg(value) {
61796
62654
  return `[Log serialization failed: ${formatLogError(error)}]`;
61797
62655
  }
61798
62656
  }
61799
- function makeSandboxConsole(collectedLogs) {
62657
+ function makeSandboxConsole(collectedLogs, mirror) {
61800
62658
  const capture = (level) => (...args) => {
62659
+ const formattedArgs = args.map(formatLogArg);
61801
62660
  collectedLogs.push({
61802
62661
  level,
61803
- args: args.map(formatLogArg),
62662
+ args: formattedArgs,
61804
62663
  timestamp: Date.now()
61805
62664
  });
62665
+ mirror?.(level, formattedArgs);
61806
62666
  };
61807
62667
  return { log: capture("log"), warn: capture("warn"), error: capture("error"), info: capture("info") };
61808
62668
  }
@@ -61966,6 +62826,18 @@ function buildFileIndex(allFiles) {
61966
62826
  }
61967
62827
  return fileIndex;
61968
62828
  }
62829
+ function hasPathExtension(path4) {
62830
+ const fileName = path4.split("/").pop() ?? path4;
62831
+ return /\.[^/.]+$/.test(fileName);
62832
+ }
62833
+ function explicitExtensionHint(requestedName, resolvedPath, fileIndex) {
62834
+ if (!resolvedPath || hasPathExtension(resolvedPath)) return "";
62835
+ const requestedBase = requestedName.trim();
62836
+ const suggestions = [".forge.js", ".js"].map((ext) => ({ ext, resolved: `${resolvedPath}${ext}` })).filter(({ resolved }) => fileIndex.has(resolved)).map(({ ext }) => `"${requestedBase}${ext}"`);
62837
+ if (suggestions.length === 0) return "";
62838
+ const joined = suggestions.length === 1 ? suggestions[0] : suggestions.join(" or ");
62839
+ return ` Did you mean ${joined}? ForgeCAD requires explicit file extensions in project imports.`;
62840
+ }
61969
62841
  function resolveImportSource(fromFile, requestedName, allFiles, options) {
61970
62842
  if (typeof requestedName !== "string" || requestedName.trim().length === 0) {
61971
62843
  throw new Error("Import path must be a non-empty string");
@@ -61974,7 +62846,8 @@ function resolveImportSource(fromFile, requestedName, allFiles, options) {
61974
62846
  const lookupKey = options.fileIndex.get(resolvedPath);
61975
62847
  if (!lookupKey) {
61976
62848
  const suffix = resolvedPath && resolvedPath !== requestedName ? ` (resolved to "${resolvedPath}" from "${fromFile}")` : ` (from "${fromFile}")`;
61977
- throw new Error(`File not found: "${requestedName}"${suffix}`);
62849
+ const hint = explicitExtensionHint(requestedName, resolvedPath, options.fileIndex);
62850
+ throw new Error(`File not found: "${requestedName}"${suffix}.${hint}`);
61978
62851
  }
61979
62852
  const source = allFiles[lookupKey];
61980
62853
  if (typeof source !== "string") {
@@ -62227,6 +63100,36 @@ function extractUnusedTopLevelVarNames(code) {
62227
63100
  }
62228
63101
  return declaredNames.filter((n) => topLevelNames.has(n) && (!usedByOthers.has(n) || explicitImplicitResultNames.has(n)));
62229
63102
  }
63103
+ function collectBindingNameLocations(node, sourceFile, names) {
63104
+ if (ts.isIdentifier(node)) {
63105
+ const { line: line2, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
63106
+ names.push({ name: node.text, line: line2 + 1, column: character + 1 });
63107
+ return;
63108
+ }
63109
+ for (const element of node.elements) {
63110
+ if (ts.isBindingElement(element)) {
63111
+ collectBindingNameLocations(element.name, sourceFile, names);
63112
+ }
63113
+ }
63114
+ }
63115
+ function findTopLevelRuntimeGlobalCollision(code, runtimeGlobalNames) {
63116
+ const runtimeGlobals = new Set(runtimeGlobalNames);
63117
+ const sourceFile = ts.createSourceFile("__runtime-globals.js", code, ts.ScriptTarget.ES2020, false, ts.ScriptKind.JS);
63118
+ const declarations = [];
63119
+ for (const statement of sourceFile.statements) {
63120
+ if (ts.isVariableStatement(statement)) {
63121
+ const isLexical = (statement.declarationList.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0;
63122
+ if (!isLexical) continue;
63123
+ for (const decl of statement.declarationList.declarations) {
63124
+ collectBindingNameLocations(decl.name, sourceFile, declarations);
63125
+ }
63126
+ } else if (ts.isClassDeclaration(statement) && statement.name) {
63127
+ const { line: line2, character } = sourceFile.getLineAndCharacterOfPosition(statement.name.getStart(sourceFile));
63128
+ declarations.push({ name: statement.name.text, line: line2 + 1, column: character + 1 });
63129
+ }
63130
+ }
63131
+ return declarations.find((declaration) => runtimeGlobals.has(declaration.name)) ?? null;
63132
+ }
62230
63133
  function createForgeRuntimeModule(bindings) {
62231
63134
  const runtime = { ...bindings };
62232
63135
  Object.defineProperty(runtime, "__esModule", { value: true });
@@ -62768,6 +63671,11 @@ function withConstructorChainLockdown(fn) {
62768
63671
  }
62769
63672
  }
62770
63673
  function executeFile(code, fileName, allFiles, visited, scope = {}, options, executionMode = "script", moduleCacheEntry) {
63674
+ options.debug?.("executeFile:start", {
63675
+ fileName,
63676
+ executionMode,
63677
+ scope: scope.namePrefix ?? fileName
63678
+ });
62771
63679
  const trackCircularImports = executionMode === "script";
62772
63680
  if (trackCircularImports) {
62773
63681
  if (visited.has(fileName)) {
@@ -62879,7 +63787,34 @@ function executeFile(code, fileName, allFiles, visited, scope = {}, options, exe
62879
63787
  setShapeTopology(shape, topo);
62880
63788
  return shape;
62881
63789
  };
62882
- const sandboxConsole = makeSandboxConsole(_collectedLogs);
63790
+ const sandboxConsole = makeSandboxConsole(_collectedLogs, options.debug ? (level, args) => options.debug?.("console", { level, args }) : void 0);
63791
+ const runtimeVerify = options.debug ? {
63792
+ ...verify,
63793
+ that(label, check, message) {
63794
+ options.debug?.("verify:that:start", { label });
63795
+ const verifyStart = performance.now();
63796
+ try {
63797
+ return verify.that(label, check, message);
63798
+ } finally {
63799
+ options.debug?.("verify:that:end", {
63800
+ label,
63801
+ ms: Number((performance.now() - verifyStart).toFixed(1))
63802
+ });
63803
+ }
63804
+ },
63805
+ equal(label, actual, expected, tolerance = 0, message) {
63806
+ options.debug?.("verify:equal:start", { label, actual, expected, tolerance });
63807
+ const verifyStart = performance.now();
63808
+ try {
63809
+ return verify.equal(label, actual, expected, tolerance, message);
63810
+ } finally {
63811
+ options.debug?.("verify:equal:end", {
63812
+ label,
63813
+ ms: Number((performance.now() - verifyStart).toFixed(1))
63814
+ });
63815
+ }
63816
+ }
63817
+ } : verify;
62883
63818
  setShowLabelsHighlight(highlight);
62884
63819
  const runtimeBindings = {
62885
63820
  box: trackedBox,
@@ -63020,7 +63955,8 @@ function executeFile(code, fileName, allFiles, visited, scope = {}, options, exe
63020
63955
  jointsView,
63021
63956
  viewConfig,
63022
63957
  scene,
63023
- verify,
63958
+ Viewport,
63959
+ verify: runtimeVerify,
63024
63960
  spec,
63025
63961
  mock,
63026
63962
  gcode,
@@ -63158,8 +64094,21 @@ function executeFile(code, fileName, allFiles, visited, scope = {}, options, exe
63158
64094
  throw error;
63159
64095
  }
63160
64096
  };
63161
- const compiled = compileScript(code, fileName, options);
63162
64097
  const bindingNames = Object.keys(runtimeBindings);
64098
+ const collision = findTopLevelRuntimeGlobalCollision(code, bindingNames);
64099
+ if (collision) {
64100
+ throw new Error(
64101
+ `Local declaration "${collision.name}" at ${fileName}:${collision.line}:${collision.column} collides with the built-in ForgeCAD runtime name "${collision.name}". Use the ForgeCAD API directly, or rename your local variable/import (for example, import the helper module object instead of destructuring "${collision.name}").`
64102
+ );
64103
+ }
64104
+ options.debug?.("executeFile:compile:start", { fileName, executionMode });
64105
+ const compileStart = performance.now();
64106
+ const compiled = compileScript(code, fileName, options);
64107
+ options.debug?.("executeFile:compile:end", {
64108
+ fileName,
64109
+ executionMode,
64110
+ ms: Number((performance.now() - compileStart).toFixed(1))
64111
+ });
63163
64112
  const bindingValues = bindingNames.map((name) => runtimeBindings[name]);
63164
64113
  let scriptCode = compiled.code;
63165
64114
  if (executionMode === "script") {
@@ -63185,12 +64134,20 @@ ${scriptCode}
63185
64134
  exports: executionMode === "module" && moduleCacheEntry ? moduleCacheEntry.exports : {}
63186
64135
  };
63187
64136
  const initialExportsRef = moduleValue.exports;
64137
+ options.debug?.("executeFile:invoke:start", { fileName, executionMode });
64138
+ const invokeStart = performance.now();
63188
64139
  const returnValue = withConstructorChainLockdown(
63189
64140
  () => runWithParamScope(
63190
64141
  scope,
63191
64142
  () => fn(moduleValue.exports, moduleValue, requireModule, fileName, dirnamePath(fileName), ...bindingValues)
63192
64143
  )
63193
64144
  );
64145
+ options.debug?.("executeFile:invoke:end", {
64146
+ fileName,
64147
+ executionMode,
64148
+ ms: Number((performance.now() - invokeStart).toFixed(1)),
64149
+ returned: returnValue === void 0 ? "undefined" : returnValue === null ? "null" : typeof returnValue
64150
+ });
63194
64151
  if (executionMode === "module") {
63195
64152
  const hasExports = hasExplicitModuleExports(moduleValue.exports, initialExportsRef);
63196
64153
  if (returnValue !== void 0 && hasExports) {
@@ -63229,6 +64186,7 @@ ${scriptCode}
63229
64186
  }
63230
64187
  return returnValue;
63231
64188
  } finally {
64189
+ options.debug?.("executeFile:end", { fileName, executionMode });
63232
64190
  if (trackCircularImports) {
63233
64191
  visited.delete(fileName);
63234
64192
  }
@@ -63243,14 +64201,24 @@ function runScript(code, fileName = "main.forge.js", allFiles = {}, options = {}
63243
64201
  fileIndex: buildFileIndex(allFiles),
63244
64202
  compiledFiles: persistentCompiledFiles,
63245
64203
  moduleCache: /* @__PURE__ */ new Map(),
63246
- readBinaryFile: options.readBinaryFile
64204
+ readBinaryFile: options.readBinaryFile,
64205
+ debug: options.debug
63247
64206
  };
63248
64207
  const quality = resolveForgeQualityPreset(options.quality);
64208
+ options.debug?.("runScript:start", { fileName, quality, fileCount: Object.keys(allFiles).length });
63249
64209
  try {
63250
64210
  return runWithForgeQuality(quality, () => {
64211
+ options.debug?.("runScript:execute:start", { fileName });
64212
+ const executeStart = performance.now();
63251
64213
  const result = executeFile(code, fileName, allFiles, /* @__PURE__ */ new Set(), {}, execOptions);
64214
+ options.debug?.("runScript:execute:end", {
64215
+ fileName,
64216
+ ms: Number((performance.now() - executeStart).toFixed(1)),
64217
+ resultType: result === void 0 ? "undefined" : result === null ? "null" : typeof result
64218
+ });
63252
64219
  const highlights = getCollectedHighlights();
63253
64220
  const mocks = getCollectedMocks();
64221
+ options.debug?.("runScript:map:start", { highlights: highlights.length, mocks: mocks.length });
63254
64222
  const mapped = mapScriptResultToScene({
63255
64223
  result,
63256
64224
  fileName,
@@ -63259,24 +64227,43 @@ function runScript(code, fileName = "main.forge.js", allFiles = {}, options = {}
63259
64227
  mocks,
63260
64228
  logs: _collectedLogs
63261
64229
  });
64230
+ options.debug?.("runScript:map:end", {
64231
+ objects: mapped.objects.length,
64232
+ hasShape: Boolean(mapped.shape),
64233
+ hasSketch: Boolean(mapped.sketch),
64234
+ hasError: Boolean(mapped.error)
64235
+ });
64236
+ options.debug?.("runScript:explodeHints:start", { objects: mapped.objects.length });
63262
64237
  autoFillExplodeHints(mapped.objects);
64238
+ options.debug?.("runScript:explodeHints:end");
64239
+ options.debug?.("runScript:snapshot:start", { objects: mapped.objects.length });
64240
+ const snapshot = collectSuccessfulExecutionSnapshot({
64241
+ quality,
64242
+ objects: mapped.objects,
64243
+ logs: _collectedLogs,
64244
+ extraDimensions: mapped.extraDimensions,
64245
+ highlights,
64246
+ mocks
64247
+ });
64248
+ options.debug?.("runScript:snapshot:end", {
64249
+ params: snapshot.params.length,
64250
+ cutPlanes: snapshot.cutPlanes.length,
64251
+ verifications: snapshot.verifications.length
64252
+ });
64253
+ options.debug?.("runScript:sceneTargets:start");
64254
+ snapshot.sceneConfig = resolveSceneJourneyTargets(snapshot.sceneConfig, mapped.objects);
64255
+ options.debug?.("runScript:sceneTargets:end");
63263
64256
  return {
63264
64257
  shape: mapped.shape,
63265
64258
  sketch: mapped.sketch,
63266
64259
  objects: mapped.objects,
63267
- ...collectSuccessfulExecutionSnapshot({
63268
- quality,
63269
- objects: mapped.objects,
63270
- logs: _collectedLogs,
63271
- extraDimensions: mapped.extraDimensions,
63272
- highlights,
63273
- mocks
63274
- }),
64260
+ ...snapshot,
63275
64261
  error: mapped.error,
63276
64262
  timeMs: performance.now() - t0
63277
64263
  };
63278
64264
  });
63279
64265
  } catch (e) {
64266
+ options.debug?.("runScript:error", { error: e?.message || String(e) });
63280
64267
  const msg = e.message || String(e);
63281
64268
  const stack = e.stack || "";
63282
64269
  let lineInfo = "";
@@ -69319,6 +70306,7 @@ Options:
69319
70306
  With distance: azimuth:elevation:distance (e.g. 45:30:200)
69320
70307
  Full spec: proj=perspective;pos=x,y,z;target=x,y,z;up=x,y,z;fov=45
69321
70308
  Default: iso
70309
+ --view <name> Use a named camera view declared with scene({ views })
69322
70310
  --scene <json> Scene state JSON copied from the viewport
69323
70311
  --size <px> Image size in pixels (default: ${DEFAULT_SIZE})
69324
70312
  --focus [names] Focus: no arg hides mocks; comma-separated names shows only those
@@ -69343,6 +70331,7 @@ Examples:
69343
70331
  forgecad render model.forge.js --camera 45:30 # 45\xB0 azimuth, 30\xB0 elevation
69344
70332
  forgecad render model.forge.js --camera 45:30:200 # same, at 200mm distance
69345
70333
  forgecad render model.forge.js --camera "proj=perspective;pos=200,-160,120;target=0,0,20;up=0,0,1;fov=38"
70334
+ forgecad render model.forge.js --view hero # camera from scene({ views: { hero: ... } })
69346
70335
  forgecad render model.forge.js --camera front --camera side # two views`;
69347
70336
  }
69348
70337
  function readValue(argv, idx, flag) {
@@ -69359,6 +70348,15 @@ function parseRenderStyle(value) {
69359
70348
  if (RENDER_STYLES.has(value)) return value;
69360
70349
  throw new Error(`--render-style must be 'classic', 'studio', 'fast', or 'glass' (got '${value}')`);
69361
70350
  }
70351
+ function sceneSpecHasCamera(sceneSpec) {
70352
+ let parsed;
70353
+ try {
70354
+ parsed = JSON.parse(sceneSpec);
70355
+ } catch (err2) {
70356
+ throw new Error(`--scene must be valid JSON when used with --view: ${err2 instanceof Error ? err2.message : String(err2)}`);
70357
+ }
70358
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) && Object.prototype.hasOwnProperty.call(parsed, "camera");
70359
+ }
69362
70360
  function parseFocusArg(argv, idx) {
69363
70361
  const next = argv[idx + 1];
69364
70362
  if (!next || next.startsWith("--")) {
@@ -69394,6 +70392,7 @@ function parseRenderCliOptions(argv) {
69394
70392
  let port = DEFAULT_PORT;
69395
70393
  let chromePath = process.env.CHROME_PATH;
69396
70394
  let cameraSpec;
70395
+ let viewName;
69397
70396
  let sceneSpec;
69398
70397
  let background;
69399
70398
  let focus = null;
@@ -69425,6 +70424,18 @@ function parseRenderCliOptions(argv) {
69425
70424
  i += 1;
69426
70425
  continue;
69427
70426
  }
70427
+ if (arg === "--view") {
70428
+ if (viewName) {
70429
+ throw new Error("Pass --view only once.");
70430
+ }
70431
+ const val = readValue(argv, i, arg).trim();
70432
+ if (!val) {
70433
+ throw new Error("--view requires a non-empty view name.");
70434
+ }
70435
+ viewName = val;
70436
+ i += 1;
70437
+ continue;
70438
+ }
69428
70439
  if (arg === "--angles") {
69429
70440
  const vals = readValue(argv, i, arg).split(",").map((s) => s.trim()).filter(Boolean);
69430
70441
  cameras.push(...vals);
@@ -69504,7 +70515,13 @@ function parseRenderCliOptions(argv) {
69504
70515
  if (!Number.isFinite(port) || port < 1 || port > 65535) {
69505
70516
  throw new Error(`--port must be between 1 and 65535 (got ${port})`);
69506
70517
  }
69507
- if (cameras.length === 0 && !cameraSpec && !sceneSpec) {
70518
+ if (viewName && (cameras.length > 0 || cameraSpec)) {
70519
+ throw new Error("Cannot use --view with --camera or --angles. Choose either a model-declared view or an explicit camera.");
70520
+ }
70521
+ if (viewName && sceneSpec && sceneSpecHasCamera(sceneSpec)) {
70522
+ throw new Error("Cannot use --view with --scene JSON that includes camera. Remove the camera field from --scene or use --camera.");
70523
+ }
70524
+ if (cameras.length === 0 && !cameraSpec && !sceneSpec && !viewName) {
69508
70525
  cameras.push("iso");
69509
70526
  }
69510
70527
  if (focus && hide) {
@@ -69518,6 +70535,7 @@ function parseRenderCliOptions(argv) {
69518
70535
  port,
69519
70536
  chromePath: resolveChromePath(chromePath),
69520
70537
  cameraSpec,
70538
+ viewName,
69521
70539
  sceneSpec,
69522
70540
  background,
69523
70541
  focus,
@@ -69668,7 +70686,9 @@ function parseInspectCli(argv) {
69668
70686
  throw new Error("Missing input .forge.js path");
69669
70687
  }
69670
70688
  if (!channels) {
69671
- throw new Error("Missing required --channels. Choose explicit channels, for example --channels rgb,mask or --channels rgb,mask,collisions.");
70689
+ throw new Error(
70690
+ "Missing required --channels. Choose explicit channels, for example --channels rgb,mask or --channels rgb,mask,collisions."
70691
+ );
69672
70692
  }
69673
70693
  if (flagOutputDir && positionalOutputDir) {
69674
70694
  throw new Error("Pass either positional output-dir or --output, not both.");
@@ -70279,6 +71299,7 @@ async function runRenderCli(argv = process.argv.slice(2)) {
70279
71299
  allFiles: files,
70280
71300
  fileName: scriptName,
70281
71301
  cameraSpec: renderOptions.cameraSpec,
71302
+ viewName: renderOptions.viewName,
70282
71303
  sceneSpec: renderOptions.sceneSpec,
70283
71304
  background: renderOptions.background,
70284
71305
  focus: renderOptions.focus,
@@ -70295,6 +71316,7 @@ async function runRenderCli(argv = process.argv.slice(2)) {
70295
71316
  cameras: options.cameras,
70296
71317
  size: options.size,
70297
71318
  cameraSpec: options.cameraSpec || null,
71319
+ viewName: options.viewName || null,
70298
71320
  sceneSpec: options.sceneSpec || null,
70299
71321
  background: options.background || null,
70300
71322
  focus: options.focus || null,
@@ -74816,7 +75838,7 @@ function inBounds(p2, min2, max3, pad = 1e-4) {
74816
75838
  function clamp13(v, lo, hi) {
74817
75839
  return Math.max(lo, Math.min(hi, v));
74818
75840
  }
74819
- function dot7(a, b) {
75841
+ function dot8(a, b) {
74820
75842
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
74821
75843
  }
74822
75844
  function cross6(a, b) {
@@ -74830,7 +75852,7 @@ function norm4(v) {
74830
75852
  function mul(v, s) {
74831
75853
  return [v[0] * s, v[1] * s, v[2] * s];
74832
75854
  }
74833
- function sub7(a, b) {
75855
+ function sub8(a, b) {
74834
75856
  return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
74835
75857
  }
74836
75858
  function makeViewFrame2(view) {
@@ -74847,12 +75869,12 @@ function makeViewFrame2(view) {
74847
75869
  return { id: view, right, up, forward };
74848
75870
  }
74849
75871
  function isDimensionVisibleInView2(dim2, frame, toleranceDeg) {
74850
- const dir = sub7(dim2.to, dim2.from);
75872
+ const dir = sub8(dim2.to, dim2.from);
74851
75873
  const len2 = Math.hypot(dir[0], dir[1], dir[2]);
74852
75874
  if (len2 < 1e-9) return false;
74853
75875
  const d = [dir[0] / len2, dir[1] / len2, dir[2] / len2];
74854
- const alignRight = clamp13(Math.abs(dot7(d, frame.right)), 0, 1);
74855
- const alignUp = clamp13(Math.abs(dot7(d, frame.up)), 0, 1);
75876
+ const alignRight = clamp13(Math.abs(dot8(d, frame.right)), 0, 1);
75877
+ const alignUp = clamp13(Math.abs(dot8(d, frame.up)), 0, 1);
74856
75878
  const angleRight = Math.acos(alignRight) * 180 / Math.PI;
74857
75879
  const angleUp = Math.acos(alignUp) * 180 / Math.PI;
74858
75880
  const minAngle = Math.min(angleRight, angleUp);
@@ -75367,6 +76389,9 @@ var DEFAULTS = {
75367
76389
  crf: parseIntEnv(["FORGE_CAPTURE_CRF"], 18),
75368
76390
  port: parseIntEnv(["FORGE_PORT"], 5173)
75369
76391
  };
76392
+ var PUPPETEER_PROTOCOL_TIMEOUT_MS2 = parseIntEnv(["FORGE_CAPTURE_PROTOCOL_TIMEOUT_MS"], 10 * 60 * 1e3);
76393
+ var CAPTURE_DEBUG = parseBooleanEnv(["FORGE_CAPTURE_DEBUG", "FORGE_CAPTURE_TRACE"], false);
76394
+ var CAPTURE_DEBUG_ALL_BROWSER = parseBooleanEnv(["FORGE_CAPTURE_DEBUG_ALL_BROWSER"], false);
75370
76395
  var repoRoot = packageRootFrom(import.meta.url);
75371
76396
  function parseIntEnv(names, fallback) {
75372
76397
  for (const name of names) {
@@ -75395,10 +76420,24 @@ function parseOptionalFloatEnv(names) {
75395
76420
  }
75396
76421
  return void 0;
75397
76422
  }
76423
+ function parseBooleanEnv(names, fallback) {
76424
+ for (const name of names) {
76425
+ const raw = process.env[name];
76426
+ if (raw == null) continue;
76427
+ const normalized = raw.trim().toLowerCase();
76428
+ if (["1", "true", "yes", "on"].includes(normalized)) return true;
76429
+ if (["0", "false", "no", "off"].includes(normalized)) return false;
76430
+ }
76431
+ return fallback;
76432
+ }
75398
76433
  function parseQualityEnv(raw) {
75399
76434
  if (raw === "default" || raw === "live" || raw === "high") return raw;
75400
76435
  return null;
75401
76436
  }
76437
+ function debugLog(message) {
76438
+ if (!CAPTURE_DEBUG) return;
76439
+ console.log(`[forge-capture:debug] ${message}`);
76440
+ }
75402
76441
  function parseSweepPlaneEnv(raw) {
75403
76442
  if (!raw) return null;
75404
76443
  const normalized = raw.toUpperCase();
@@ -76355,7 +77394,8 @@ async function captureAndEncode(page, options, framePlan, encoderMode, ffmpegPat
76355
77394
  imageFormat: ffmpegInputMode === "jpeg" ? "jpeg" : "png",
76356
77395
  jpegQuality: 0.9,
76357
77396
  showEdges: process.env.FORGE_CAPTURE_SHOW_EDGES !== "0",
76358
- profile
77397
+ profile,
77398
+ debug: CAPTURE_DEBUG
76359
77399
  }
76360
77400
  );
76361
77401
  timings.browserFrameMs += Date.now() - browserStart;
@@ -76580,10 +77620,26 @@ async function runCaptureCli(config, argv = process.argv.slice(2)) {
76580
77620
  }
76581
77621
  browser = await puppeteer2.launch({
76582
77622
  headless: true,
77623
+ protocolTimeout: PUPPETEER_PROTOCOL_TIMEOUT_MS2,
76583
77624
  executablePath: chromePath,
76584
77625
  args: chromeArgs
76585
77626
  });
76586
77627
  const page = await browser.newPage();
77628
+ if (CAPTURE_DEBUG) {
77629
+ page.on("console", (message) => {
77630
+ const text = message.text();
77631
+ if (text.includes("[forge-capture:debug]") || CAPTURE_DEBUG_ALL_BROWSER) {
77632
+ console.log(text.includes("[forge-capture:debug]") ? text : `[browser:${message.type()}] ${text}`);
77633
+ }
77634
+ });
77635
+ page.on("pageerror", (error) => {
77636
+ console.log(`[forge-capture:debug] browser page error: ${error.message}`);
77637
+ });
77638
+ page.on("requestfailed", (request) => {
77639
+ console.log(`[forge-capture:debug] browser request failed: ${request.url()} ${request.failure()?.errorText ?? ""}`.trim());
77640
+ });
77641
+ }
77642
+ debugLog(`loading capture runtime on :${activePort}`);
76587
77643
  let captureRuntimeReady = await loadCaptureRuntime(page, activePort, options.capture);
76588
77644
  if (!captureRuntimeReady && !viteProc) {
76589
77645
  const fallbackPort = await findFreePort(activePort + 1);
@@ -76597,6 +77653,8 @@ async function runCaptureCli(config, argv = process.argv.slice(2)) {
76597
77653
  if (!captureRuntimeReady) {
76598
77654
  throw new Error("Capture runtime did not initialize. Restart the Forge dev server and try again.");
76599
77655
  }
77656
+ debugLog(`capture runtime ready; starting browser init for ${basename4(input)}`);
77657
+ const initStart = Date.now();
76600
77658
  const init2 = await page.evaluate(
76601
77659
  (payload) => {
76602
77660
  return window.__forgeCaptureInit(payload.code, payload.options);
@@ -76617,6 +77675,7 @@ async function runCaptureCli(config, argv = process.argv.slice(2)) {
76617
77675
  paramOverrides: options.paramOverrides,
76618
77676
  animationName: options.animationName ?? null,
76619
77677
  capture: options.capture,
77678
+ debug: CAPTURE_DEBUG,
76620
77679
  sweep: options.capture === "section-sweep" ? {
76621
77680
  plane: options.sweepPlane,
76622
77681
  normal: options.sweepNormal ?? null,
@@ -76629,6 +77688,7 @@ async function runCaptureCli(config, argv = process.argv.slice(2)) {
76629
77688
  }
76630
77689
  }
76631
77690
  );
77691
+ debugLog(`browser init returned in ${Date.now() - initStart}ms`);
76632
77692
  if (!init2?.ok) {
76633
77693
  throw new Error(init2?.error || "Script failed to initialize in renderer");
76634
77694
  }
@@ -82355,29 +83415,39 @@ import { homedir as homedir7 } from "os";
82355
83415
  import { join as join12 } from "path";
82356
83416
  var VALID_TIERS = /* @__PURE__ */ new Set(["free", "pro", "team"]);
82357
83417
  var PRO_COMMANDS = /* @__PURE__ */ new Set([
82358
- // Exact CAD interchange formats
82359
- "export step",
82360
- "export brep",
82361
83418
  // Advanced rendering
82362
83419
  "render hq",
82363
83420
  // Animations
82364
83421
  "capture gif",
82365
- "capture mp4",
82366
- // Manufacturing outputs
82367
- "cut-list",
82368
- "export gcode",
82369
- "export report",
82370
- "export cutting-layout",
82371
- // Robotics exports
82372
- "export sdf",
82373
- "export urdf",
82374
- // Sketch PDF export
82375
- "export sketch-pdf"
83422
+ "capture mp4"
82376
83423
  ]);
82377
83424
  function requiredTierForCommand(commandPath) {
82378
83425
  const key = commandPath.join(" ");
82379
83426
  return PRO_COMMANDS.has(key) ? "pro" : "free";
82380
83427
  }
83428
+ var PRODUCTION_OUTPUT_COMMANDS = /* @__PURE__ */ new Map([
83429
+ ["cut-list", "sheet cut list"],
83430
+ ["export sketch-pdf", "dimensioned sketch PDF"],
83431
+ ["export step", "STEP exact CAD exchange"],
83432
+ ["export brep", "BREP exact geometry"],
83433
+ ["export gcode", "G-code toolpath"],
83434
+ ["export sdf", "SDF robotics package"],
83435
+ ["export urdf", "URDF robotics package"],
83436
+ ["export report", "model report PDF"],
83437
+ ["export cutting-layout", "sheet cutting layout PDF"]
83438
+ ]);
83439
+ function productionOutputNoticeForCommand(commandPath) {
83440
+ const key = commandPath.join(" ");
83441
+ const label = PRODUCTION_OUTPUT_COMMANDS.get(key);
83442
+ if (!label) return null;
83443
+ return {
83444
+ label,
83445
+ message: "Free to run. For client, employer, sale, funded product, or closed commercial IP work, ForgeCAD Pro covers commercial use and support."
83446
+ };
83447
+ }
83448
+ function productionOutputBadgeForCommand(commandPath) {
83449
+ return productionOutputNoticeForCommand(commandPath) ? "Production" : null;
83450
+ }
82381
83451
  function tierSatisfies(userTier, required) {
82382
83452
  if (required === "free") return true;
82383
83453
  return userTier === "pro" || userTier === "team";
@@ -82645,6 +83715,17 @@ function enforceFeatureGate(commandPath) {
82645
83715
  }
82646
83716
  process.exit(1);
82647
83717
  }
83718
+ function printProductionOutputNotice(commandPath) {
83719
+ const notice = productionOutputNoticeForCommand(commandPath);
83720
+ if (!notice) return;
83721
+ const license = getCurrentLicense();
83722
+ if (license.valid && tierSatisfies(license.tier, "pro")) return;
83723
+ const commandName = `forgecad ${commandPath.join(" ")}`;
83724
+ console.error(`\x1B[36mProduction output:\x1B[0m ${commandName} creates a ${notice.label}.`);
83725
+ console.error(` ${notice.message}`);
83726
+ console.error(" Upgrade: https://forgecad.io/pricing");
83727
+ console.error("");
83728
+ }
82648
83729
 
82649
83730
  // cli/forge-project.ts
82650
83731
  function slugify(name) {
@@ -85292,6 +86373,64 @@ async function writeSolverDebugIndex(root, scriptPath, bundles) {
85292
86373
  }
85293
86374
 
85294
86375
  // cli/test-run.ts
86376
+ var DEFAULT_EXACT_SPATIAL_OBJECT_LIMIT = 250;
86377
+ function parseSpatialMode(argv) {
86378
+ const consumed = /* @__PURE__ */ new Set();
86379
+ const idx = argv.indexOf("--spatial");
86380
+ if (idx === -1) return { mode: "bounded", consumed };
86381
+ consumed.add(idx);
86382
+ const raw = argv[idx + 1];
86383
+ if (!raw || raw.startsWith("-")) {
86384
+ console.error("Missing value for --spatial. Expected bounded, exact, or off.");
86385
+ process.exit(1);
86386
+ }
86387
+ consumed.add(idx + 1);
86388
+ if (raw === "bounded" || raw === "exact" || raw === "off") return { mode: raw, consumed };
86389
+ console.error(`Invalid --spatial value: ${raw}. Expected bounded, exact, or off.`);
86390
+ process.exit(1);
86391
+ }
86392
+ function buildJourneyInspection(journeys) {
86393
+ return {
86394
+ journeys: Object.entries(journeys ?? {}).map(([id, journey]) => ({
86395
+ id,
86396
+ title: journey.title ?? id,
86397
+ startsAt: journey.startsAt ?? journey.steps[0]?.id ?? null,
86398
+ behavior: journey.behavior ?? "opt-in",
86399
+ valid: journey.valid !== false,
86400
+ diagnostics: journey.diagnostics ?? [],
86401
+ steps: journey.steps.map((step) => ({
86402
+ id: step.id,
86403
+ title: step.title ?? step.id,
86404
+ focus: step.focus ?? null,
86405
+ resolvedFocusId: step.resolvedFocusId ?? null,
86406
+ resolvedFocusPath: step.resolvedFocusPath ?? null,
86407
+ caption: step.caption ?? null,
86408
+ camera: step.camera ?? null,
86409
+ diagnostics: step.diagnostics ?? []
86410
+ }))
86411
+ }))
86412
+ };
86413
+ }
86414
+ function printJourneySummary(journeys) {
86415
+ const inspected = buildJourneyInspection(journeys).journeys;
86416
+ console.log(`\u2713 Journeys: ${inspected.length}`);
86417
+ for (const journey of inspected) {
86418
+ const status = journey.valid ? "ok" : "has errors";
86419
+ console.log(` ${journey.id}: ${journey.title} (${journey.steps.length} step${journey.steps.length === 1 ? "" : "s"}, ${status})`);
86420
+ for (const diagnostic of journey.diagnostics) {
86421
+ const icon = diagnostic.level === "error" ? "\u2717" : "!";
86422
+ const stepTag = diagnostic.stepId ? `${diagnostic.stepId}: ` : "";
86423
+ console.log(` ${icon} ${stepTag}${diagnostic.message}`);
86424
+ if (diagnostic.suggestions && diagnostic.suggestions.length > 0) {
86425
+ console.log(` suggestions: ${diagnostic.suggestions.join(", ")}`);
86426
+ }
86427
+ }
86428
+ for (const [index, step] of journey.steps.entries()) {
86429
+ const target = step.resolvedFocusPath ?? step.focus ?? (step.camera ? "camera" : "no target");
86430
+ console.log(` ${index + 1}. ${step.id} -> ${target}`);
86431
+ }
86432
+ }
86433
+ }
85295
86434
  function tallyFeatures(plan) {
85296
86435
  const counts = /* @__PURE__ */ new Map();
85297
86436
  function inc(key) {
@@ -85390,7 +86529,7 @@ function formatFeatureSummary(counts) {
85390
86529
  function bboxOverlap2(a, b) {
85391
86530
  return [0, 1, 2].every((k) => a.min[k] < b.max[k] + 0.1 && a.max[k] > b.min[k] - 0.1);
85392
86531
  }
85393
- function analyzeSpatial(entries) {
86532
+ function analyzeSpatial(entries, mode) {
85394
86533
  const lines = [];
85395
86534
  const _axisLabel = ["X", "Y", "Z"];
85396
86535
  const dirLabels = {
@@ -85408,12 +86547,20 @@ function analyzeSpatial(entries) {
85408
86547
  }
85409
86548
  const sceneSize = Math.max(...allMax.map((v, i) => v - allMin[i]));
85410
86549
  const proximityThreshold = sceneSize * 0.15;
86550
+ const exactCollisionChecksEnabled = mode === "exact" || entries.length <= DEFAULT_EXACT_SPATIAL_OBJECT_LIMIT;
86551
+ let bboxOverlapPairs = 0;
86552
+ let skippedExactCollisionPairs = 0;
85411
86553
  for (let i = 0; i < entries.length; i++) {
85412
86554
  for (let j = i + 1; j < entries.length; j++) {
85413
86555
  const a = entries[i], b = entries[j];
85414
86556
  if (a.groupName && a.groupName === b.groupName) continue;
85415
86557
  if (a.mock && b.mock) continue;
85416
86558
  if (!bboxOverlap2(a, b)) continue;
86559
+ bboxOverlapPairs++;
86560
+ if (!exactCollisionChecksEnabled) {
86561
+ skippedExactCollisionPairs++;
86562
+ continue;
86563
+ }
85417
86564
  try {
85418
86565
  const hit = a.shape.intersect(b.shape);
85419
86566
  if (!hit.isEmpty()) {
@@ -85426,6 +86573,13 @@ function analyzeSpatial(entries) {
85426
86573
  }
85427
86574
  }
85428
86575
  }
86576
+ if (skippedExactCollisionPairs > 0) {
86577
+ lines.push(
86578
+ ` Exact collision checks skipped for ${skippedExactCollisionPairs} bbox-overlapping pair(s) in this ${entries.length}-object scene. Re-run with --spatial exact for exhaustive pairwise intersections.`
86579
+ );
86580
+ } else if (bboxOverlapPairs > 0 && exactCollisionChecksEnabled) {
86581
+ lines.push(` Exact collision checks: ${bboxOverlapPairs} bbox-overlapping pair(s) tested.`);
86582
+ }
85429
86583
  for (let i = 0; i < entries.length; i++) {
85430
86584
  const a = entries[i];
85431
86585
  const nearest = Array.from({ length: 6 }, () => ({ idx: -1, gap: Infinity }));
@@ -85538,7 +86692,7 @@ function parseParamFlags(argv) {
85538
86692
  }
85539
86693
  function usage15() {
85540
86694
  console.error(
85541
- "Usage: forgecad run <script.forge.js> [--connectivity] [--connectivity-tolerance <mm>] [--param Key=Value] [--debug-imports] [--verbose] [--backend manifold|occt] [--solver-debug-out <dir>]"
86695
+ "Usage: forgecad run <script.forge.js> [--connectivity] [--connectivity-tolerance <mm>] [--spatial bounded|exact|off] [--param Key=Value] [--debug-imports] [--verbose] [--backend manifold|occt] [--solver-debug-out <dir>]"
85542
86696
  );
85543
86697
  process.exit(1);
85544
86698
  }
@@ -85648,11 +86802,14 @@ async function runScriptCli(argv = process.argv.slice(2)) {
85648
86802
  consumed: connectivityConsumed
85649
86803
  } = parsePhysicalConnectivityFlags(argv);
85650
86804
  const debugImports = argv.includes("--debug-imports");
86805
+ const printJourneys = argv.includes("--journeys");
86806
+ const printJourneysJson = argv.includes("--journeys-json");
85651
86807
  const verbose = argv.includes("--verbose") || argv.includes("-v");
85652
86808
  const backend = parseBackendArg(argv);
85653
86809
  const solverDebugOut = parseRequiredArg(argv, "--solver-debug-out");
86810
+ const { mode: spatialMode, consumed: spatialConsumed } = parseSpatialMode(argv);
85654
86811
  const positional = argv.filter(
85655
- (arg, i) => !paramConsumed.has(i) && !focusConsumed.has(i) && !connectivityConsumed.has(i) && arg !== "--debug-imports" && arg !== "--backend" && argv[i - 1] !== "--backend" && arg !== "--solver-debug-out" && argv[i - 1] !== "--solver-debug-out"
86812
+ (arg, i) => !paramConsumed.has(i) && !focusConsumed.has(i) && !connectivityConsumed.has(i) && !spatialConsumed.has(i) && arg !== "--debug-imports" && arg !== "--journeys" && arg !== "--journeys-json" && arg !== "--backend" && argv[i - 1] !== "--backend" && arg !== "--solver-debug-out" && argv[i - 1] !== "--solver-debug-out"
85656
86813
  );
85657
86814
  const scriptPath = positional[0];
85658
86815
  if (!scriptPath) usage15();
@@ -85681,6 +86838,10 @@ async function runScriptCli(argv = process.argv.slice(2)) {
85681
86838
  }
85682
86839
  process.exit(1);
85683
86840
  }
86841
+ if (printJourneysJson) {
86842
+ console.log(JSON.stringify(buildJourneyInspection(result.sceneConfig?.journeys), null, 2));
86843
+ return;
86844
+ }
85684
86845
  const { visible: visibleObjects, summary: focusSummary } = applyFocusFilter(result.objects, focusFilter);
85685
86846
  let totalBodies = 0;
85686
86847
  for (const obj of visibleObjects) {
@@ -85741,6 +86902,10 @@ async function runScriptCli(argv = process.argv.slice(2)) {
85741
86902
  }
85742
86903
  }
85743
86904
  }
86905
+ if (printJourneys) {
86906
+ console.log();
86907
+ printJourneySummary(result.sceneConfig?.journeys);
86908
+ }
85744
86909
  for (const obj of result.objects) {
85745
86910
  if (!obj.shape) continue;
85746
86911
  const plan = getShapeCompilePlan(obj.shape);
@@ -85826,14 +86991,17 @@ async function runScriptCli(argv = process.argv.slice(2)) {
85826
86991
  const connectivity = analyzePhysicalConnectivity2(entries, connectivityOptions);
85827
86992
  for (const line2 of formatPhysicalConnectivity(connectivity)) console.log(line2);
85828
86993
  }
85829
- if (entries.length > 1) {
86994
+ if (entries.length > 1 && spatialMode !== "off") {
85830
86995
  console.log(`
85831
86996
  \u2713 Spatial analysis:`);
85832
- const spatialLines = analyzeSpatial(entries);
86997
+ const spatialLines = analyzeSpatial(entries, spatialMode);
85833
86998
  for (const line2 of spatialLines) console.log(line2);
85834
86999
  if (spatialLines.length === 0) {
85835
87000
  console.log(` (no collisions, all objects well-separated)`);
85836
87001
  }
87002
+ } else if (entries.length > 1 && spatialMode === "off") {
87003
+ console.log(`
87004
+ \u2713 Spatial analysis: skipped (--spatial off)`);
85837
87005
  }
85838
87006
  console.log(
85839
87007
  `\u2713 Params: ${result.params.map((p2) => {
@@ -86300,6 +87468,12 @@ var RENDER_OPTIONS = [
86300
87468
  valueLabel: "<front|back|side|right|top|iso|az:el|az:el:dist|spec>",
86301
87469
  values: RENDER_ANGLE_VALUES
86302
87470
  },
87471
+ {
87472
+ name: "--view",
87473
+ description: "Named camera view declared by the model with scene({ views })",
87474
+ argument: "required",
87475
+ valueLabel: "<name>"
87476
+ },
86303
87477
  { name: "--size", description: "Image size in pixels", argument: "required", valueLabel: "<px>" },
86304
87478
  { name: "--scene", description: "Viewport scene state JSON", argument: "required", valueLabel: "<json>" },
86305
87479
  { name: "--background", description: "Canvas background override", argument: "required", valueLabel: "<color>" },
@@ -86472,27 +87646,27 @@ var CAPTURE_COMMON_OPTIONS = [
86472
87646
  var commands = [
86473
87647
  {
86474
87648
  group: "Studio",
86475
- path: ["dev"],
86476
- summary: "Start the Vite dev server with live reload. No build step required \u2014 the preferred way to run ForgeCAD during active development.",
86477
- usage: ["forgecad dev <project-path> [project-path ...]"],
86478
- examples: ["forgecad dev ~/cad/gearbox", "forgecad dev ~/cad/gearbox ~/cad/fixture", "forgecad dev ~/cad/gearbox --port 4173"],
87649
+ path: ["studio"],
87650
+ summary: "Open the installed local editor around one or more project folders.",
87651
+ usage: ["forgecad studio <project-path> [project-path ...]"],
87652
+ examples: ["forgecad studio ~/cad/gearbox", "forgecad studio ~/cad/gearbox ~/cad/fixture", "forgecad studio ~/cad/gearbox --port 4173"],
86479
87653
  completion: {
86480
87654
  options: STUDIO_OPTIONS,
86481
87655
  positionals: [{ description: "project path", valueKind: "directory", repeatable: true }]
86482
87656
  },
86483
- run: runDevCli
87657
+ run: runStudioCli
86484
87658
  },
86485
87659
  {
86486
87660
  group: "Studio",
86487
- path: ["studio"],
86488
- summary: "Serve the production build of the studio (requires dist/ \u2014 run `npm run build` first).",
86489
- usage: ["forgecad studio <project-path> [project-path ...]"],
86490
- examples: ["forgecad studio ~/cad/gearbox", "forgecad studio ~/cad/gearbox ~/cad/fixture", "forgecad studio ~/cad/gearbox --port 4173"],
87661
+ path: ["dev"],
87662
+ summary: "Start the Vite dev server for ForgeCAD source development.",
87663
+ usage: ["forgecad dev <project-path> [project-path ...]"],
87664
+ examples: ["forgecad dev ~/cad/gearbox", "forgecad dev ~/cad/gearbox ~/cad/fixture", "forgecad dev ~/cad/gearbox --port 4173"],
86491
87665
  completion: {
86492
87666
  options: STUDIO_OPTIONS,
86493
87667
  positionals: [{ description: "project path", valueKind: "directory", repeatable: true }]
86494
87668
  },
86495
- run: runStudioCli
87669
+ run: runDevCli
86496
87670
  },
86497
87671
  {
86498
87672
  group: "Studio",
@@ -86606,9 +87780,9 @@ var commands = [
86606
87780
  group: "Modeling",
86607
87781
  path: ["run"],
86608
87782
  summary: "Execute a Forge script and print full geometry diagnostics \u2014 object summary, collision detection, spatial analysis, verification results, and solver profiling.",
86609
- description: "The primary validation command. Runs your script with the real geometry kernel (no browser needed) and prints a comprehensive report:\n\n**Object summary** \u2014 lists every named shape with its volume, bounding box, and body count. For constrained sketches, shows solver status (FULLY / UNDER / OVER constrained), DOF, and error residuals. Problematic constraints (conflicting, redundant, or high-residual) are flagged individually.\n\n**Construction history** \u2014 shows the build sequence for each shape (primitives, operations, modifications) so you can verify the modeling intent.\n\n**Feature summary** \u2014 tallies geometry features across all objects (e.g. `3 extrude, 2 fillet, 1 chamfer`).\n\n**Verification results** \u2014 runs any `verify.*` checks in the script and reports pass/fail with expected vs actual values.\n\n**Automatic collision detection** \u2014 performs an all-pairs collision check on every named shape. For each pair whose bounding boxes overlap, computes the boolean intersection and reports overlap above 0.1 mm\xB3:\n\n```\n\u26A0 COLLISION: bolt \u2229 base (shared vol: 42.3mm\xB3)\n```\n\nIntra-group pairs (same assembly group) and mock-to-mock pairs are skipped. If a part passes through a boolean-subtracted hole, no collision is reported \u2014 the material is gone.\n\n**Spatial analysis** \u2014 reports directional relationships and gap distances between nearby objects (e.g. `bracket is ABOVE base (gap: 5mm)`). When no issues are found: `(no collisions, all objects well-separated)`.\n\n**Physical connectivity** \u2014 pass `--connectivity` to list physically connected components across visible objects. Overlapping or touching bboxes are joined within `--connectivity-tolerance` (default `0.05` model units); use collision inspection for exact positive-volume overlaps. This helps answer whether the model is one continuous assembly or several separate islands.\n\n**Parameters** \u2014 lists all declared parameters with their current values. Overridden values are marked with `*`.\n\n**Solver profiling** \u2014 when constraint solving occurs, shows timing breakdown (clone, solve, redundancy detection, surface building) and solver internals.",
87783
+ description: "The primary validation command. Runs your script with the real geometry kernel (no browser needed) and prints a comprehensive report:\n\n**Object summary** \u2014 lists every named shape with its volume, bounding box, and body count. For constrained sketches, shows solver status (FULLY / UNDER / OVER constrained), DOF, and error residuals. Problematic constraints (conflicting, redundant, or high-residual) are flagged individually.\n\n**Construction history** \u2014 shows the build sequence for each shape (primitives, operations, modifications) so you can verify the modeling intent.\n\n**Feature summary** \u2014 tallies geometry features across all objects (e.g. `3 extrude, 2 fillet, 1 chamfer`).\n\n**Verification results** \u2014 runs any `verify.*` checks in the script and reports pass/fail with expected vs actual values.\n\n**Automatic collision detection** \u2014 performs an all-pairs collision check on every named shape. For each pair whose bounding boxes overlap, computes the boolean intersection and reports overlap above 0.1 mm\xB3:\n\n```\n\u26A0 COLLISION: bolt \u2229 base (shared vol: 42.3mm\xB3)\n```\n\nIntra-group pairs (same assembly group) and mock-to-mock pairs are skipped. If a part passes through a boolean-subtracted hole, no collision is reported \u2014 the material is gone.\n\n**Spatial analysis** \u2014 reports directional relationships and gap distances between nearby objects (e.g. `bracket is ABOVE base (gap: 5mm)`). Exact pairwise collision intersections run by default only for bounded scenes; use `--spatial exact` for exhaustive collision checks or `--spatial off` to skip this section.\n\n**Physical connectivity** \u2014 pass `--connectivity` to list physically connected components across visible objects. Overlapping or touching bboxes are joined within `--connectivity-tolerance` (default `0.05` model units); use collision inspection for exact positive-volume overlaps. This helps answer whether the model is one continuous assembly or several separate islands.\n\n**Parameters** \u2014 lists all declared parameters with their current values. Overridden values are marked with `*`.\n\n**Solver profiling** \u2014 when constraint solving occurs, shows timing breakdown (clone, solve, redundancy detection, surface building) and solver internals.",
86610
87784
  usage: [
86611
- "forgecad run <script.forge.js> [--connectivity] [--connectivity-tolerance <mm>] [--focus [names]] [--hide names] [--param Key=Value] [--debug-imports] [--backend manifold|occt] [--solver-debug-out <dir>]"
87785
+ "forgecad run <script.forge.js> [--connectivity] [--connectivity-tolerance <mm>] [--spatial bounded|exact|off] [--focus [names]] [--hide names] [--journeys] [--journeys-json] [--param Key=Value] [--debug-imports] [--backend manifold|occt] [--solver-debug-out <dir>]"
86612
87786
  ],
86613
87787
  examples: [
86614
87788
  "forgecad run examples/cup.forge.js",
@@ -86616,6 +87790,7 @@ var commands = [
86616
87790
  "forgecad run examples/cup.forge.js --focus bracket,hinge",
86617
87791
  'forgecad run examples/cup.forge.js --hide "wall,bolt"',
86618
87792
  "forgecad run examples/cup.forge.js --connectivity",
87793
+ "forgecad run examples/cup.forge.js --journeys",
86619
87794
  "forgecad run examples/cup.forge.js --backend occt",
86620
87795
  "forgecad run examples/cup.forge.js --debug-imports",
86621
87796
  'forgecad run examples/cup.forge.js -p "Wall Thickness=3" -p "Body Height=200"',
@@ -86637,7 +87812,20 @@ var commands = [
86637
87812
  argument: "required",
86638
87813
  valueLabel: "<mm>"
86639
87814
  },
87815
+ {
87816
+ name: "--spatial",
87817
+ description: "Spatial diagnostics mode",
87818
+ argument: "required",
87819
+ valueLabel: "<bounded|exact|off>",
87820
+ values: [
87821
+ { value: "bounded", description: "Default: exact checks only for bounded scene sizes" },
87822
+ { value: "exact", description: "Exhaustive pairwise exact collision intersections" },
87823
+ { value: "off", description: "Skip spatial diagnostics" }
87824
+ ]
87825
+ },
86640
87826
  { name: "--debug-imports", description: "Print the import trace" },
87827
+ { name: "--journeys", description: "Print model journey summaries declared with scene({ journeys })" },
87828
+ { name: "--journeys-json", description: "Emit machine-readable journey metadata and diagnostics only" },
86641
87829
  {
86642
87830
  name: "--solver-debug-out",
86643
87831
  description: "Write constructive transcripts and SVG snapshots to a directory",
@@ -86704,13 +87892,14 @@ var commands = [
86704
87892
  group: "Modeling",
86705
87893
  path: ["render", "3d"],
86706
87894
  summary: "Render a Forge scene to PNG using the real viewport renderer.",
86707
- description: "Launches a headless Chrome instance, renders the scene with the same WebGL viewport as the editor, and saves a PNG. The output path defaults to `<script-name>.png` next to the input file.\n\nUse `--focus` to isolate specific parts (hides everything else) or `--hide` to remove clutter like mock objects. The `--camera` flag accepts named views (`front`, `top`, `iso`), `azimuth:elevation` angles, or an exact `proj/pos/target/up/fov` camera spec \u2014 pass it multiple times to render several viewpoints in one run.\n\nUse `--edges=<off|thin|bold>` to control the edge overlay. For a pure wireframe look, use `render wireframe` instead.\n\nThis is the standard way to visually verify geometry from the CLI or in agent workflows. For higher quality (path-traced, materials, HDRI lighting), use `render hq` instead.",
87895
+ description: "Launches a headless Chrome instance, renders the scene with the same WebGL viewport as the editor, and saves a PNG. The output path defaults to `<script-name>.png` next to the input file.\n\nUse `--focus` to isolate specific parts (hides everything else) or `--hide` to remove clutter like mock objects. The `--view` flag selects a named camera declared in `scene({ views })`. The `--camera` flag accepts built-in views (`front`, `top`, `iso`), `azimuth:elevation` angles, or an exact `proj/pos/target/up/fov` camera spec \u2014 pass `--camera` multiple times to render several viewpoints in one run.\n\nUse `--edges=<off|thin|bold>` to control the edge overlay. For a pure wireframe look, use `render wireframe` instead.\n\nThis is the standard way to visually verify geometry from the CLI or in agent workflows. For higher quality (path-traced, materials, HDRI lighting), use `render hq` instead.",
86708
87896
  usage: ["forgecad render 3d <script.forge.js> [output.png] [--focus [names]] [--hide names] [--edges off|thin|bold] [options]"],
86709
87897
  examples: [
86710
87898
  "forgecad render 3d examples/cup.forge.js",
86711
87899
  "forgecad render 3d examples/cup.forge.js --focus",
86712
87900
  "forgecad render 3d examples/cup.forge.js --focus bracket",
86713
87901
  'forgecad render 3d examples/cup.forge.js --hide "wall,bolt"',
87902
+ "forgecad render 3d model.forge.js --view hero",
86714
87903
  "forgecad render 3d model.forge.js --camera 45:30",
86715
87904
  'forgecad render 3d model.forge.js --camera "proj=perspective;pos=200,-160,120;target=0,0,20;up=0,0,1;fov=38"',
86716
87905
  "forgecad render 3d model.forge.js --camera front --camera side",
@@ -88124,7 +89313,8 @@ Commands:`);
88124
89313
  ${group2}`);
88125
89314
  visibleCommands.filter((command) => command.group === group2).forEach((command) => {
88126
89315
  const tier = requiredTierForCommand(command.path);
88127
- const badge = tier === "pro" ? " \x1B[33m[Pro]\x1B[0m" : "";
89316
+ const productionBadge = productionOutputBadgeForCommand(command.path);
89317
+ const badge = tier === "pro" ? " \x1B[33m[Pro]\x1B[0m" : productionBadge ? ` \x1B[36m[${productionBadge}]\x1B[0m` : "";
88128
89318
  console.log(` ${padRight2(commandLabel(command), width + 2)}${command.summary}${badge}`);
88129
89319
  });
88130
89320
  }
@@ -88306,6 +89496,7 @@ async function runForgeCadCli(argv = process.argv.slice(2)) {
88306
89496
  }
88307
89497
  if (!shouldRequireAuth) {
88308
89498
  enforceFeatureGate(match.command.path);
89499
+ if (match.args.length > 0) printProductionOutputNotice(match.command.path);
88309
89500
  await match.command.run(match.args);
88310
89501
  return;
88311
89502
  }
@@ -88313,6 +89504,7 @@ async function runForgeCadCli(argv = process.argv.slice(2)) {
88313
89504
  const requiredTier = requiredTierForCommand(match.command.path);
88314
89505
  if (isLongRunningCommand(match.command.path)) {
88315
89506
  enforceFeatureGate(match.command.path);
89507
+ if (match.args.length > 0) printProductionOutputNotice(match.command.path);
88316
89508
  await recordCliCommandEvent({
88317
89509
  commandPath: match.command.path,
88318
89510
  args: match.args,
@@ -88328,6 +89520,7 @@ async function runForgeCadCli(argv = process.argv.slice(2)) {
88328
89520
  try {
88329
89521
  capturedExitCode = await runWithExitCapture(async () => {
88330
89522
  enforceFeatureGate(match.command.path);
89523
+ if (match.args.length > 0) printProductionOutputNotice(match.command.path);
88331
89524
  await match.command.run(match.args);
88332
89525
  });
88333
89526
  await recordCliCommandEvent({
@@ -88364,6 +89557,7 @@ if (isDirectCliRun(import.meta.url)) {
88364
89557
  export {
88365
89558
  commandRequiresCliAuth,
88366
89559
  commands,
89560
+ productionOutputBadgeForCommand,
88367
89561
  requiredTierForCommand,
88368
89562
  runForgeCadCli
88369
89563
  };