@vcad/mcp 0.9.4-main.35 → 0.9.4-main.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,4 +6,4 @@ vcad's MCP server as a self-contained bundle (server + BRep kernel WASM).
6
6
  { "mcpServers": { "vcad": { "command": "npx", "args": ["-y", "@vcad/mcp"] } } }
7
7
  ```
8
8
 
9
- Built from 05b902db41f28989ab2bae377db68a55dd2a7121 at 2026-07-26T13:07:16.613Z. Source: https://github.com/ecto/vcad
9
+ Built from 3b9087f300b569c70aa24cebc52af06d15e59187 at 2026-07-26T13:20:01.737Z. Source: https://github.com/ecto/vcad
package/index.mjs CHANGED
@@ -40914,6 +40914,25 @@ var init_CHANGELOG = __esm({
40914
40914
  CHANGELOG_default = {
40915
40915
  $schema: "./changelog.schema.json",
40916
40916
  entries: [
40917
+ {
40918
+ id: "2026-07-26-swept-and-all-pairs-clearance",
40919
+ version: "0.9.4",
40920
+ date: "2026-07-26",
40921
+ category: "feat",
40922
+ title: "check_clearance sweeps the range of motion",
40923
+ summary: "Pass `sweep` to drive joints through their travel and get the worst pose, or omit both groups to audit every part pair in the document for interference.",
40924
+ features: [
40925
+ "clearance",
40926
+ "assembly",
40927
+ "kinematics",
40928
+ "mcp"
40929
+ ],
40930
+ mcpTools: [
40931
+ "check_clearance",
40932
+ "build_receipt",
40933
+ "verify_receipt"
40934
+ ]
40935
+ },
40917
40936
  {
40918
40937
  id: "2026-07-26-render-loon-directly",
40919
40938
  version: "0.9.4",
@@ -55576,10 +55595,23 @@ function clearanceVerdict(distanceMm) {
55576
55595
  if (distanceMm <= CONTACT_EPS_MM) return "touching";
55577
55596
  return "clear";
55578
55597
  }
55579
- function clearanceHolds(distanceMm, minMm, allowContact) {
55598
+ function clearanceHolds(distanceMm, minMm, allowContact, intersecting = false) {
55599
+ if (intersecting) return false;
55580
55600
  return distanceMm >= minMm || allowContact && clearanceVerdict(distanceMm) === "touching";
55581
55601
  }
55582
- function partCandidates(doc, engine) {
55602
+ function worseThan(a, b) {
55603
+ if (a.intersecting !== b.intersecting) return a.intersecting;
55604
+ return a.distance < b.distance;
55605
+ }
55606
+ function placeMesh(mesh, transform2) {
55607
+ if (!transform2) return mesh;
55608
+ return transformMesh(mesh, {
55609
+ translate: transform2.translation,
55610
+ rotate: transform2.rotation,
55611
+ scale: transform2.scale
55612
+ });
55613
+ }
55614
+ function poseableParts(doc, engine) {
55583
55615
  const scene = engine.evaluate(doc);
55584
55616
  const visibleRoots = doc.roots.filter((e) => e.visible !== false);
55585
55617
  const out = [];
@@ -55588,23 +55620,30 @@ function partCandidates(doc, engine) {
55588
55620
  const node = doc.nodes[String(rootId)];
55589
55621
  const mesh = scene.parts[i].mesh;
55590
55622
  if (!mesh || mesh.positions.length === 0) continue;
55591
- out.push({ id: String(rootId), name: node?.name ?? void 0, mesh });
55623
+ out.push({ id: String(rootId), name: node?.name ?? void 0, localMesh: mesh });
55592
55624
  }
55593
55625
  for (const inst of scene.instances ?? []) {
55594
- const mesh = inst.transform ? transformMesh(inst.mesh, {
55595
- translate: inst.transform.translation,
55596
- rotate: inst.transform.rotation,
55597
- scale: inst.transform.scale
55598
- }) : inst.mesh;
55599
- if (!mesh || mesh.positions.length === 0) continue;
55626
+ if (!inst.mesh || inst.mesh.positions.length === 0) continue;
55600
55627
  out.push({
55601
55628
  id: inst.instanceId,
55602
55629
  name: inst.name ?? void 0,
55603
- mesh
55630
+ localMesh: inst.mesh,
55631
+ transform: inst.transform ?? void 0,
55632
+ instanceId: inst.instanceId
55604
55633
  });
55605
55634
  }
55606
55635
  return out;
55607
55636
  }
55637
+ function placeParts(parts, worldTransforms) {
55638
+ const out = [];
55639
+ for (const p of parts) {
55640
+ const transform2 = (p.instanceId ? worldTransforms?.get(p.instanceId) : void 0) ?? p.transform;
55641
+ const mesh = placeMesh(p.localMesh, transform2);
55642
+ if (!mesh || mesh.positions.length === 0) continue;
55643
+ out.push({ id: p.id, ...p.name ? { name: p.name } : {}, mesh });
55644
+ }
55645
+ return out;
55646
+ }
55608
55647
  function resolveGroup(candidates, ids) {
55609
55648
  const parts = /* @__PURE__ */ new Map();
55610
55649
  const missing = [];
@@ -55616,16 +55655,115 @@ function resolveGroup(candidates, ids) {
55616
55655
  }
55617
55656
  return { parts: [...parts.values()], missing };
55618
55657
  }
55619
- function computeGroupClearance(doc, engine, groupA, groupB) {
55658
+ function resolveSweepAxes(doc, axes) {
55659
+ const joints = doc.joints ?? [];
55660
+ const resolved = [];
55661
+ for (const axis of axes) {
55662
+ const wanted = String(axis.joint);
55663
+ const found = joints.find((j) => j.id === wanted) ?? joints.find((j) => j.name === wanted);
55664
+ if (!found) {
55665
+ const available = joints.map((j) => `${j.id}${j.name ? ` (${j.name})` : ""}`).join(", ");
55666
+ return { error: `No joint with id or name "${wanted}". Available: ${available || "none"}` };
55667
+ }
55668
+ if (!Number.isFinite(axis.from) || !Number.isFinite(axis.to)) {
55669
+ return { error: `Sweep axis "${wanted}" needs finite \`from\` and \`to\`.` };
55670
+ }
55671
+ const steps = Math.trunc(axis.steps);
55672
+ if (!Number.isFinite(steps) || steps < 1) {
55673
+ return { error: `Sweep axis "${wanted}" needs \`steps\` >= 1.` };
55674
+ }
55675
+ resolved.push({ joint: found.id, from: axis.from, to: axis.to, steps });
55676
+ }
55677
+ if (resolved.length === 0) return { error: "`sweep` needs at least one joint axis." };
55678
+ const total = resolved.reduce((n, a) => n * (a.steps + 1), 1);
55679
+ if (total > MAX_SWEEP_POSES) {
55680
+ return {
55681
+ error: `Sweep grid is ${total} poses (limit ${MAX_SWEEP_POSES}). Lower \`steps\` or sweep fewer joints at once.`
55682
+ };
55683
+ }
55684
+ return { axes: resolved };
55685
+ }
55686
+ function poseGrid(axes) {
55687
+ let poses = [[]];
55688
+ for (const axis of axes) {
55689
+ const n = Math.trunc(axis.steps);
55690
+ const next = [];
55691
+ for (const pose of poses) {
55692
+ for (let i = 0; i <= n; i++) {
55693
+ const state = axis.from + (axis.to - axis.from) * i / n;
55694
+ next.push([...pose, { joint: axis.joint, state }]);
55695
+ }
55696
+ }
55697
+ poses = next;
55698
+ }
55699
+ return poses;
55700
+ }
55701
+ function poseTransforms(doc, poses) {
55702
+ const joints = doc.joints ?? [];
55703
+ const byId = new Map(joints.map((j) => [j.id, j]));
55704
+ const saved = joints.map((j) => j.state);
55705
+ try {
55706
+ return poses.map((pose) => {
55707
+ for (const { joint, state } of pose) {
55708
+ const j = byId.get(joint);
55709
+ if (j) j.state = state;
55710
+ }
55711
+ return solveForwardKinematics2(doc);
55712
+ });
55713
+ } finally {
55714
+ joints.forEach((j, i) => {
55715
+ j.state = saved[i];
55716
+ });
55717
+ }
55718
+ }
55719
+ function computeGroupClearance(doc, engine, groupA, groupB, sweep) {
55620
55720
  if (groupA.length === 0 || groupB.length === 0) {
55621
55721
  return { error: "Both `group_a` and `group_b` need at least one part id." };
55622
55722
  }
55623
- let candidates;
55723
+ let base;
55724
+ try {
55725
+ base = poseableParts(doc, engine);
55726
+ } catch (e) {
55727
+ return { error: e instanceof Error ? e.message : String(e) };
55728
+ }
55729
+ if (!sweep || sweep.length === 0) {
55730
+ return measureGroups(engine, placeParts(base), groupA, groupB);
55731
+ }
55732
+ const { axes, error: sweepError } = resolveSweepAxes(doc, sweep);
55733
+ if (sweepError || !axes) return { error: sweepError ?? "sweep could not be resolved" };
55734
+ const poses = poseGrid(axes);
55735
+ let transforms;
55624
55736
  try {
55625
- candidates = partCandidates(doc, engine);
55737
+ transforms = poseTransforms(doc, poses);
55626
55738
  } catch (e) {
55627
55739
  return { error: e instanceof Error ? e.message : String(e) };
55628
55740
  }
55741
+ let worst2;
55742
+ for (let i = 0; i < poses.length; i++) {
55743
+ const { result, error: error2 } = measureGroups(
55744
+ engine,
55745
+ placeParts(base, transforms[i]),
55746
+ groupA,
55747
+ groupB
55748
+ );
55749
+ if (error2 || !result) return { error: error2 };
55750
+ if (!worst2 || worseThan(
55751
+ { distance: result.distance_mm, intersecting: result.intersecting },
55752
+ { distance: worst2.result.distance_mm, intersecting: worst2.result.intersecting }
55753
+ )) {
55754
+ worst2 = { result, pose: poses[i] };
55755
+ }
55756
+ }
55757
+ if (!worst2) return { error: "Sweep produced no poses to measure." };
55758
+ return {
55759
+ result: {
55760
+ ...worst2.result,
55761
+ worst_pose: worst2.pose.map((p) => ({ joint: p.joint, state: round6(p.state) })),
55762
+ poses_checked: poses.length
55763
+ }
55764
+ };
55765
+ }
55766
+ function measureGroups(engine, candidates, groupA, groupB) {
55629
55767
  const a = resolveGroup(candidates, groupA);
55630
55768
  const b = resolveGroup(candidates, groupB);
55631
55769
  const missing = [...a.missing, ...b.missing];
@@ -55652,7 +55790,7 @@ function computeGroupClearance(doc, engine, groupA, groupB) {
55652
55790
  return { error: e instanceof Error ? e.message : String(e) };
55653
55791
  }
55654
55792
  pairs += 1;
55655
- if (!worst2 || r.distance < worst2.r.distance) {
55793
+ if (!worst2 || worseThan(r, worst2.r)) {
55656
55794
  worst2 = { a: pa, b: pb, r };
55657
55795
  }
55658
55796
  }
@@ -55677,6 +55815,132 @@ function computeGroupClearance(doc, engine, groupA, groupB) {
55677
55815
  }
55678
55816
  };
55679
55817
  }
55818
+ function meshAabb(mesh) {
55819
+ const p = mesh.positions;
55820
+ const min = [Infinity, Infinity, Infinity];
55821
+ const max = [-Infinity, -Infinity, -Infinity];
55822
+ for (let i = 0; i + 2 < p.length; i += 3) {
55823
+ for (let k = 0; k < 3; k++) {
55824
+ const v = p[i + k];
55825
+ if (v < min[k]) min[k] = v;
55826
+ if (v > max[k]) max[k] = v;
55827
+ }
55828
+ }
55829
+ return { min, max };
55830
+ }
55831
+ function aabbNear(a, b, margin) {
55832
+ for (let k = 0; k < 3; k++) {
55833
+ if (a.min[k] - margin > b.max[k]) return false;
55834
+ if (b.min[k] - margin > a.max[k]) return false;
55835
+ }
55836
+ return true;
55837
+ }
55838
+ function computeInterferenceAudit(doc, engine, opts) {
55839
+ let base;
55840
+ try {
55841
+ base = poseableParts(doc, engine);
55842
+ } catch (e) {
55843
+ return { error: e instanceof Error ? e.message : String(e) };
55844
+ }
55845
+ let poses = [];
55846
+ let transforms = [void 0];
55847
+ if (opts.sweep && opts.sweep.length > 0) {
55848
+ const { axes, error: error2 } = resolveSweepAxes(doc, opts.sweep);
55849
+ if (error2 || !axes) return { error: error2 ?? "sweep could not be resolved" };
55850
+ poses = poseGrid(axes);
55851
+ try {
55852
+ transforms = poseTransforms(doc, poses);
55853
+ } catch (e) {
55854
+ return { error: e instanceof Error ? e.message : String(e) };
55855
+ }
55856
+ }
55857
+ const first = placeParts(base, transforms[0]);
55858
+ if (first.length < 2) {
55859
+ return { error: "Fewer than two measurable parts \u2014 nothing to audit." };
55860
+ }
55861
+ const ignore = /* @__PURE__ */ new Set();
55862
+ const unresolved = [];
55863
+ const resolveId = (raw) => {
55864
+ const wanted = String(raw);
55865
+ return (first.find((c) => c.id === wanted) ?? first.find((c) => c.name === wanted))?.id;
55866
+ };
55867
+ for (const [rawA, rawB] of opts.ignorePairs) {
55868
+ const a = resolveId(rawA);
55869
+ const b = resolveId(rawB);
55870
+ if (!a) unresolved.push(String(rawA));
55871
+ if (!b) unresolved.push(String(rawB));
55872
+ if (a && b) ignore.add(pairKey(a, b));
55873
+ }
55874
+ for (const joint of doc.joints ?? []) {
55875
+ const parent = joint.parentInstanceId;
55876
+ if (!parent) continue;
55877
+ const fixed = joint.kind?.type === "Fixed";
55878
+ if (opts.ignoreAdjacent || fixed && opts.ignoreFixedJoints) {
55879
+ ignore.add(pairKey(parent, joint.childInstanceId));
55880
+ }
55881
+ }
55882
+ const margin = Math.max(opts.minMm, 0);
55883
+ const worstByPair = /* @__PURE__ */ new Map();
55884
+ const broadphase = /* @__PURE__ */ new Set();
55885
+ let queries = 0;
55886
+ let ignored = 0;
55887
+ for (let p = 0; p < transforms.length; p++) {
55888
+ const parts = p === 0 ? first : placeParts(base, transforms[p]);
55889
+ const boxes = parts.map((c) => meshAabb(c.mesh));
55890
+ for (let i = 0; i < parts.length; i++) {
55891
+ for (let j = i + 1; j < parts.length; j++) {
55892
+ const key = pairKey(parts[i].id, parts[j].id);
55893
+ if (ignore.has(key)) {
55894
+ if (p === 0) ignored += 1;
55895
+ continue;
55896
+ }
55897
+ if (!aabbNear(boxes[i], boxes[j], margin + CONTACT_EPS_MM)) continue;
55898
+ broadphase.add(key);
55899
+ let r;
55900
+ try {
55901
+ r = engine.meshClearance(parts[i].mesh, parts[j].mesh);
55902
+ } catch (e) {
55903
+ return { error: e instanceof Error ? e.message : String(e) };
55904
+ }
55905
+ queries += 1;
55906
+ const prev = worstByPair.get(key);
55907
+ if (!prev || worseThan(r, prev.r)) {
55908
+ worstByPair.set(key, {
55909
+ a: parts[i],
55910
+ b: parts[j],
55911
+ r,
55912
+ ...poses.length > 0 ? { pose: poses[p] } : {}
55913
+ });
55914
+ }
55915
+ }
55916
+ }
55917
+ }
55918
+ const named = (p) => ({ id: p.id, ...p.name ? { name: p.name } : {} });
55919
+ const findings = [...worstByPair.values()].filter((w) => !clearanceHolds(round6(w.r.distance), opts.minMm, false, w.r.intersecting)).map((w) => ({
55920
+ a: named(w.a),
55921
+ b: named(w.b),
55922
+ distance_mm: round6(w.r.distance),
55923
+ verdict: w.r.intersecting ? "intersecting" : clearanceVerdict(round6(w.r.distance)),
55924
+ point_a: w.r.pointA.map(round6),
55925
+ point_b: w.r.pointB.map(round6),
55926
+ ...w.pose ? { worst_pose: w.pose.map((s) => ({ joint: s.joint, state: round6(s.state) })) } : {}
55927
+ })).sort(
55928
+ (x, y) => Number(y.verdict === "intersecting") - Number(x.verdict === "intersecting") || x.distance_mm - y.distance_mm
55929
+ );
55930
+ const n = first.length;
55931
+ return {
55932
+ result: {
55933
+ parts_checked: n,
55934
+ pairs_total: n * (n - 1) / 2,
55935
+ pairs_ignored: ignored,
55936
+ pairs_broadphase: broadphase.size,
55937
+ queries,
55938
+ ...poses.length > 0 ? { poses_checked: poses.length } : {},
55939
+ findings,
55940
+ unresolved_ignores: [...new Set(unresolved)]
55941
+ }
55942
+ };
55943
+ }
55680
55944
  function upsertClearanceSpec(doc, spec) {
55681
55945
  const specs = doc.clearance_specs ?? [];
55682
55946
  const idx = specs.findIndex((s) => s.label === spec.label);
@@ -55689,16 +55953,13 @@ async function checkClearance(args, engine) {
55689
55953
  if (!documentId) return err3("Pass `document_id` (the CAD session).");
55690
55954
  const groupA = stringArray(args.group_a);
55691
55955
  const groupB = stringArray(args.group_b);
55692
- if (!groupA || !groupB) {
55693
- return err3("Pass `group_a` and `group_b` as arrays of part ids (or part names).");
55694
- }
55695
- const minMm = typeof args.min_mm === "number" ? args.min_mm : NaN;
55696
- if (!Number.isFinite(minMm)) return err3("Pass `min_mm`, the required minimum separation in mm.");
55697
55956
  const label = typeof args.label === "string" && args.label.trim() ? args.label.trim() : void 0;
55698
55957
  const allowContact = asBool(args.allow_contact);
55958
+ const { sweep, error: sweepParseError } = parseSweep(args.sweep);
55959
+ if (sweepParseError) return err3(sweepParseError);
55699
55960
  if (label && args.joint_state !== void 0 && args.joint_state !== null) {
55700
55961
  return err3(
55701
- "`label` and `joint_state` cannot be combined: a persisted clearance spec is re-measured at the document's stored joint states, so an assertion captured at an ad-hoc pose could not be re-verified. Either drop `label` (one-off pose measurement), or set the joint states on the document and assert there."
55962
+ "`label` and `joint_state` cannot be combined: a persisted clearance spec is re-measured at the document's stored joint states, so an assertion captured at an ad-hoc pose could not be re-verified. Either drop `label` (one-off pose measurement), use `sweep` (persisted with the spec and re-verified over the same range), or set the joint states on the document and assert there."
55702
55963
  );
55703
55964
  }
55704
55965
  const stored = getSession(documentId);
@@ -55709,10 +55970,20 @@ async function checkClearance(args, engine) {
55709
55970
  } catch (e) {
55710
55971
  return err3(e instanceof Error ? e.message : String(e));
55711
55972
  }
55712
- const { result, error: error2 } = computeGroupClearance(doc, engine, groupA, groupB);
55973
+ if (!groupA && !groupB) {
55974
+ return auditClearance(documentId, doc, engine, args, sweep, label, pose);
55975
+ }
55976
+ if (!groupA || !groupB) {
55977
+ return err3(
55978
+ "Pass both `group_a` and `group_b` as arrays of part ids (or part names) \u2014 or neither, to audit every pair in the document."
55979
+ );
55980
+ }
55981
+ const minMm = typeof args.min_mm === "number" ? args.min_mm : NaN;
55982
+ if (!Number.isFinite(minMm)) return err3("Pass `min_mm`, the required minimum separation in mm.");
55983
+ const { result, error: error2 } = computeGroupClearance(doc, engine, groupA, groupB, sweep);
55713
55984
  if (error2 || !result) return err3(error2 ?? "Clearance could not be computed.");
55714
55985
  const verdict = clearanceVerdict(result.distance_mm);
55715
- const pass2 = clearanceHolds(result.distance_mm, minMm, allowContact);
55986
+ const pass2 = clearanceHolds(result.distance_mm, minMm, allowContact, result.intersecting);
55716
55987
  let specSaved = false;
55717
55988
  if (label) {
55718
55989
  upsertClearanceSpec(stored, {
@@ -55720,7 +55991,8 @@ async function checkClearance(args, engine) {
55720
55991
  group_a: result.group_a.map((p) => p.id),
55721
55992
  group_b: result.group_b.map((p) => p.id),
55722
55993
  min_mm: minMm,
55723
- ...allowContact ? { allow_contact: true } : {}
55994
+ ...allowContact ? { allow_contact: true } : {},
55995
+ ...sweep && sweep.length > 0 ? { sweep } : {}
55724
55996
  });
55725
55997
  specSaved = true;
55726
55998
  }
@@ -55736,6 +56008,8 @@ async function checkClearance(args, engine) {
55736
56008
  ...pose ? { pose } : {},
55737
56009
  intersecting: result.intersecting,
55738
56010
  worst_pair: result.worst_pair,
56011
+ ...result.worst_pose ? { worst_pose: result.worst_pose } : {},
56012
+ ...result.poses_checked !== void 0 ? { poses_checked: result.poses_checked } : {},
55739
56013
  pairs_checked: result.pairs_checked,
55740
56014
  group_a: result.group_a,
55741
56015
  group_b: result.group_b,
@@ -55752,11 +56026,92 @@ async function checkClearance(args, engine) {
55752
56026
  }
55753
56027
  };
55754
56028
  }
56029
+ function parseSweep(raw) {
56030
+ if (raw === void 0 || raw === null) return {};
56031
+ if (!Array.isArray(raw)) {
56032
+ return { error: "`sweep` must be an array of {joint, from, to, steps} axes." };
56033
+ }
56034
+ const sweep = [];
56035
+ for (const entry of raw) {
56036
+ if (!entry || typeof entry !== "object") {
56037
+ return { error: "Each `sweep` axis must be an object {joint, from, to, steps}." };
56038
+ }
56039
+ const e = entry;
56040
+ const joint = typeof e.joint === "string" ? e.joint.trim() : "";
56041
+ const from = Number(e.from);
56042
+ const to = Number(e.to);
56043
+ const steps = Number(e.steps);
56044
+ if (!joint || !Number.isFinite(from) || !Number.isFinite(to) || !Number.isFinite(steps)) {
56045
+ return {
56046
+ error: "Each `sweep` axis needs a `joint` id/name plus numeric `from`, `to`, and `steps`."
56047
+ };
56048
+ }
56049
+ sweep.push({ joint, from, to, steps });
56050
+ }
56051
+ return sweep.length > 0 ? { sweep } : {};
56052
+ }
56053
+ function parseIgnorePairs(raw) {
56054
+ if (raw === void 0 || raw === null) return { pairs: [] };
56055
+ if (!Array.isArray(raw)) {
56056
+ return { pairs: [], error: "`ignore_pairs` must be an array of [part_a, part_b] pairs." };
56057
+ }
56058
+ const pairs = [];
56059
+ for (const entry of raw) {
56060
+ if (!Array.isArray(entry) || entry.length !== 2) {
56061
+ return { pairs: [], error: "Each `ignore_pairs` entry must be a two-element array." };
56062
+ }
56063
+ pairs.push([String(entry[0]), String(entry[1])]);
56064
+ }
56065
+ return { pairs };
56066
+ }
56067
+ function auditClearance(documentId, doc, engine, args, sweep, label, pose) {
56068
+ const minMm = typeof args.min_mm === "number" && Number.isFinite(args.min_mm) ? args.min_mm : 0;
56069
+ const { pairs: ignorePairs, error: ignoreError } = parseIgnorePairs(args.ignore_pairs);
56070
+ if (ignoreError) return err3(ignoreError);
56071
+ const ignoreFixedJoints = args.ignore_fixed_joints === void 0 ? true : asBool(args.ignore_fixed_joints);
56072
+ const ignoreAdjacent = asBool(args.ignore_adjacent);
56073
+ const { result, error: error2 } = computeInterferenceAudit(doc, engine, {
56074
+ minMm,
56075
+ ignorePairs,
56076
+ ignoreFixedJoints,
56077
+ ignoreAdjacent,
56078
+ sweep
56079
+ });
56080
+ if (error2 || !result) return err3(error2 ?? "Interference audit could not be computed.");
56081
+ const payload = {
56082
+ success: true,
56083
+ document_id: documentId,
56084
+ mode: "audit",
56085
+ required_mm: minMm,
56086
+ ...pose ? { pose } : {},
56087
+ pass: result.findings.length === 0,
56088
+ findings: result.findings,
56089
+ interference_count: result.findings.length,
56090
+ parts_checked: result.parts_checked,
56091
+ pairs_total: result.pairs_total,
56092
+ pairs_ignored: result.pairs_ignored,
56093
+ pairs_broadphase: result.pairs_broadphase,
56094
+ queries: result.queries,
56095
+ ...result.poses_checked !== void 0 ? { poses_checked: result.poses_checked } : {},
56096
+ ...result.unresolved_ignores.length > 0 ? {
56097
+ unresolved_ignores: result.unresolved_ignores,
56098
+ note: `ignore_pairs named parts that do not exist: ${result.unresolved_ignores.join(", ")} \u2014 those pairs were NOT whitelisted.`
56099
+ } : {},
56100
+ ...label ? {
56101
+ spec_saved: false,
56102
+ label_note: "`label` persists only pairwise assertions; an audit is a whole-document scan, so nothing was saved. Name the offending pair and re-run with group_a/group_b to persist it."
56103
+ } : {}
56104
+ };
56105
+ return {
56106
+ content: [{ type: "text", text: JSON.stringify(payload) }],
56107
+ structuredContent: { clearance: payload, document_id: documentId }
56108
+ };
56109
+ }
55755
56110
  function clearanceReceiptClaims(doc, engine) {
55756
56111
  const specs = doc.clearance_specs ?? [];
55757
56112
  return specs.map((spec) => {
55758
56113
  const id = `${CLEARANCE_CLAIM_PREFIX}${spec.label}`;
55759
- const description = `clearance "${spec.label}" at least ${spec.min_mm} mm`;
56114
+ const description = spec.sweep?.length ? `clearance "${spec.label}" at least ${spec.min_mm} mm across the swept range of motion` : `clearance "${spec.label}" at least ${spec.min_mm} mm`;
55760
56115
  const subject = `${spec.group_a.join("+")} vs ${spec.group_b.join("+")}`;
55761
56116
  if (!engine) {
55762
56117
  return {
@@ -55770,7 +56125,13 @@ function clearanceReceiptClaims(doc, engine) {
55770
56125
  subject
55771
56126
  };
55772
56127
  }
55773
- const { result, error: error2 } = computeGroupClearance(doc, engine, spec.group_a, spec.group_b);
56128
+ const { result, error: error2 } = computeGroupClearance(
56129
+ doc,
56130
+ engine,
56131
+ spec.group_a,
56132
+ spec.group_b,
56133
+ spec.sweep
56134
+ );
55774
56135
  if (error2 || !result) {
55775
56136
  return {
55776
56137
  ...unverifiableClaim(
@@ -55790,8 +56151,15 @@ function clearanceReceiptClaims(doc, engine) {
55790
56151
  group_b: spec.group_b,
55791
56152
  required_mm: spec.min_mm,
55792
56153
  measured_mm: result.distance_mm,
55793
- holds: clearanceHolds(result.distance_mm, spec.min_mm, allowContact),
55794
- ...allowContact ? { allow_contact: true } : {}
56154
+ holds: clearanceHolds(result.distance_mm, spec.min_mm, allowContact, result.intersecting),
56155
+ ...allowContact ? { allow_contact: true } : {},
56156
+ // A swept claim carries its grid so verify_receipt re-checks the same
56157
+ // range of motion — re-verifying at one pose would silently weaken it.
56158
+ ...spec.sweep?.length ? {
56159
+ sweep: spec.sweep,
56160
+ worst_pose: result.worst_pose ?? [],
56161
+ poses_checked: result.poses_checked ?? 0
56162
+ } : {}
55795
56163
  };
55796
56164
  return {
55797
56165
  id,
@@ -55820,7 +56188,13 @@ function verifyClearanceClaims(doc, engine, receipt) {
55820
56188
  });
55821
56189
  continue;
55822
56190
  }
55823
- const { result, error: error2 } = computeGroupClearance(doc, engine, stored.group_a, stored.group_b);
56191
+ const { result, error: error2 } = computeGroupClearance(
56192
+ doc,
56193
+ engine,
56194
+ stored.group_a,
56195
+ stored.group_b,
56196
+ stored.sweep
56197
+ );
55824
56198
  if (error2 || !result) {
55825
56199
  checks.push({
55826
56200
  label,
@@ -55832,32 +56206,28 @@ function verifyClearanceClaims(doc, engine, receipt) {
55832
56206
  continue;
55833
56207
  }
55834
56208
  const measured = result.distance_mm;
55835
- if (!clearanceHolds(measured, stored.required_mm, stored.allow_contact === true)) {
56209
+ const sweptFields = result.poses_checked ? { worst_pose: result.worst_pose, poses_checked: result.poses_checked } : {};
56210
+ const common = {
56211
+ label,
56212
+ required_mm: stored.required_mm,
56213
+ stored_mm: stored.measured_mm,
56214
+ measured_mm: measured,
56215
+ ...sweptFields
56216
+ };
56217
+ if (!clearanceHolds(measured, stored.required_mm, stored.allow_contact === true, result.intersecting)) {
55836
56218
  checks.push({
55837
- label,
56219
+ ...common,
55838
56220
  status: "Violated",
55839
- required_mm: stored.required_mm,
55840
- stored_mm: stored.measured_mm,
55841
- measured_mm: measured,
55842
- reason: `measured ${measured} mm is below the required ${stored.required_mm} mm`
56221
+ reason: result.poses_checked ? `measured ${measured} mm is below the required ${stored.required_mm} mm somewhere in the swept range of motion` : `measured ${measured} mm is below the required ${stored.required_mm} mm`
55843
56222
  });
55844
56223
  } else if (Math.abs(measured - stored.measured_mm) > STALE_EPS_MM) {
55845
56224
  checks.push({
55846
- label,
56225
+ ...common,
55847
56226
  status: "Stale",
55848
- required_mm: stored.required_mm,
55849
- stored_mm: stored.measured_mm,
55850
- measured_mm: measured,
55851
56227
  reason: "geometry changed since the receipt was built, but the clearance still holds"
55852
56228
  });
55853
56229
  } else {
55854
- checks.push({
55855
- label,
55856
- status: "Holds",
55857
- required_mm: stored.required_mm,
55858
- stored_mm: stored.measured_mm,
55859
- measured_mm: measured
55860
- });
56230
+ checks.push({ ...common, status: "Holds" });
55861
56231
  }
55862
56232
  }
55863
56233
  const status = checks.some((c) => c.status === "Violated") ? "Violated" : checks.some((c) => c.status === "Stale") ? "Stale" : "Holds";
@@ -55887,7 +56257,7 @@ function err3(text) {
55887
56257
  isError: true
55888
56258
  };
55889
56259
  }
55890
- var CLEARANCE_CLAIM_PREFIX, MECH_DOMAIN2, CLEARANCE_ORACLE, STALE_EPS_MM, CONTACT_EPS_MM, round6, checkClearanceSchema, toolDefs11;
56260
+ var CLEARANCE_CLAIM_PREFIX, MECH_DOMAIN2, CLEARANCE_ORACLE, STALE_EPS_MM, CONTACT_EPS_MM, round6, checkClearanceSchema, MAX_SWEEP_POSES, pairKey, toolDefs11;
55891
56261
  var init_clearance = __esm({
55892
56262
  "src/tools/clearance.ts"() {
55893
56263
  "use strict";
@@ -55935,15 +56305,47 @@ var init_clearance = __esm({
55935
56305
  type: "boolean",
55936
56306
  description: "Treat exact surface contact (measured distance within 0.001 mm of zero) as passing even though it is below `min_mm` \u2014 for parts designed to touch, e.g. a stage bolted flush to the chamber floor. Penetration beyond the tolerance still fails. Persisted with the spec when `label` is given."
55937
56307
  },
55938
- joint_state: jointStateSchemaProp
56308
+ joint_state: jointStateSchemaProp,
56309
+ sweep: {
56310
+ type: "array",
56311
+ description: "Range-of-motion sweep: drive these joints across their travel and report the WORST pose, not the authored one. Each axis is {joint, from, to, steps}; multiple axes form a Cartesian grid (capped at 4096 poses). Joint states are restored afterwards \u2014 the sweep never edits the document.",
56312
+ items: {
56313
+ type: "object",
56314
+ properties: {
56315
+ joint: { type: "string", description: "Joint id or name to drive." },
56316
+ from: { type: "number", description: "Start of travel (degrees for revolute, mm for prismatic)." },
56317
+ to: { type: "number", description: "End of travel." },
56318
+ steps: { type: "number", description: "Number of intervals; steps + 1 poses are sampled." }
56319
+ },
56320
+ required: ["joint", "from", "to", "steps"]
56321
+ }
56322
+ },
56323
+ ignore_pairs: {
56324
+ type: "array",
56325
+ description: "Audit mode: part id/name pairs whose contact is intended (a bolt in its own hole, a bearing pressed into its bore). Each entry is a two-element array.",
56326
+ items: {
56327
+ type: "array",
56328
+ items: { type: "string" }
56329
+ }
56330
+ },
56331
+ ignore_fixed_joints: {
56332
+ type: "boolean",
56333
+ description: "Audit mode: skip pairs joined by a Fixed joint \u2014 they are bolted together and always 'interfere'. Default true."
56334
+ },
56335
+ ignore_adjacent: {
56336
+ type: "boolean",
56337
+ description: "Audit mode: skip every directly-jointed parent/child pair, not just Fixed ones. Default false \u2014 adjacent links can and do clash away from the joint axis."
56338
+ }
55939
56339
  },
55940
- required: ["document_id", "group_a", "group_b", "min_mm"]
56340
+ required: ["document_id"]
55941
56341
  };
56342
+ MAX_SWEEP_POSES = 4096;
56343
+ pairKey = (a, b) => a < b ? `${a}\0${b}` : `${b}\0${a}`;
55942
56344
  toolDefs11 = [
55943
56345
  {
55944
56346
  name: "check_clearance",
55945
56347
  pack: null,
55946
- description: "Measure the minimum distance between two groups of parts in a CAD session and assert it stays above `min_mm` \u2014 air gaps, press fits, screw-head clearances. Reports the measured minimum (negative = penetration depth), a clear/touching/intersecting verdict, the worst part pair, and pass/fail. Pass `allow_contact: true` for parts designed to touch (e.g. bolted flush) so exact contact passes instead of reading as an intersection. Pass `joint_state` to measure at a real pose (joint id or name \u2192 degrees, or mm for sliders) instead of the stored zero pose \u2014 that is how you check a mechanism clears through its travel. Give it a `label` to persist the assertion on the document: build_receipt then emits it as a mech.clearance claim and verify_receipt re-verifies it as Holds / Stale / Violated when geometry changes.",
56348
+ description: "Measure the minimum distance between two groups of parts in a CAD session and assert it stays above `min_mm` \u2014 air gaps, press fits, screw-head clearances. Reports the measured minimum (negative = penetration depth), a clear/touching/intersecting verdict, the worst part pair, and pass/fail. Pass `allow_contact: true` for parts designed to touch (e.g. bolted flush) so exact contact passes instead of reading as an intersection. Pass `joint_state` to measure at one real pose (joint id or name \u2192 degrees, or mm for sliders) instead of the stored one. Give it a `label` to persist the assertion on the document: build_receipt then emits it as a mech.clearance claim and verify_receipt re-verifies it as Holds / Stale / Violated when geometry changes. Two modes beyond the single-pair snapshot: (1) `sweep` \u2014 pass [{joint, from, to, steps}] to drive the mechanism through its range of motion and report the WORST pose, not the one it was authored in (a linkage modelled at mid-travel routinely clears there and collides at both ends); persisted with the label, so the assertion re-verifies over the same range. (2) audit \u2014 omit `group_a`/`group_b` entirely to broadphase EVERY part pair in the document and list everything that interpenetrates, with penetration depth; whitelist intended contact with `ignore_pairs` (Fixed-joint pairs are skipped by default). Combine both for 'does this machine ever hit itself anywhere in its workspace'.",
55947
56349
  inputSchema: checkClearanceSchema,
55948
56350
  handler: (a, c) => checkClearance(a, c.engine),
55949
56351
  behavior: behavior({ writesDoc: true })
@@ -79647,8 +80049,8 @@ var init_server3 = __esm({
79647
80049
  init_order_feed();
79648
80050
  init_animate();
79649
80051
  PKG_VERSION = (() => {
79650
- if ("0.9.4-main.35") {
79651
- return "0.9.4-main.35";
80052
+ if ("0.9.4-main.36") {
80053
+ return "0.9.4-main.36";
79652
80054
  }
79653
80055
  try {
79654
80056
  const req = createRequire2(import.meta.url);
@@ -79657,8 +80059,8 @@ var init_server3 = __esm({
79657
80059
  return "0.0.0";
79658
80060
  }
79659
80061
  })();
79660
- BUILD_SHA = "05b902db41f28989ab2bae377db68a55dd2a7121";
79661
- BUILD_TIME = "2026-07-26T13:07:16.613Z";
80062
+ BUILD_SHA = "3b9087f300b569c70aa24cebc52af06d15e59187";
80063
+ BUILD_TIME = "2026-07-26T13:20:01.737Z";
79662
80064
  SHORT_SHA = BUILD_SHA === "unknown" ? "unknown" : BUILD_SHA.slice(0, 7);
79663
80065
  VERSION_WITH_BUILD = SHORT_SHA === "unknown" ? PKG_VERSION : `${PKG_VERSION}+${SHORT_SHA}`;
79664
80066
  INSTANCE_ID = randomUUID6().slice(0, 8);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vcad/mcp",
3
- "version": "0.9.4-main.35",
3
+ "version": "0.9.4-main.36",
4
4
  "description": "vcad MCP server — parametric CAD + PCB design tools for AI agents (self-contained: bundled server + kernel WASM)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -19,7 +19,7 @@
19
19
  },
20
20
  "homepage": "https://vcad.io",
21
21
  "vcadBuild": {
22
- "sha": "05b902db41f28989ab2bae377db68a55dd2a7121",
23
- "builtAt": "2026-07-26T13:07:16.613Z"
22
+ "sha": "3b9087f300b569c70aa24cebc52af06d15e59187",
23
+ "builtAt": "2026-07-26T13:20:01.737Z"
24
24
  }
25
25
  }
Binary file