@vcad/mcp 0.9.4-main.26 → 0.9.4-main.28

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 178040b8a9aaccb709684c5b95e4fb42a00ae9e2 at 2026-07-25T23:14:00.956Z. Source: https://github.com/ecto/vcad
9
+ Built from 301abfbddc2a05c29fabc9f655031e554eab1d04 at 2026-07-26T00:32:29.039Z. Source: https://github.com/ecto/vcad
package/index.mjs CHANGED
@@ -10367,6 +10367,20 @@ async function airgapFluxDensity(spec) {
10367
10367
  return null;
10368
10368
  }
10369
10369
  }
10370
+ async function airgapSolve(spec) {
10371
+ const wasm3 = await loadEcadWasm();
10372
+ if (!wasm3)
10373
+ return null;
10374
+ try {
10375
+ const fn = wasm3.ecadAirgapSolve;
10376
+ if (typeof fn !== "function")
10377
+ return null;
10378
+ return fn(JSON.stringify(spec));
10379
+ } catch (e) {
10380
+ console.warn("[ECAD] airgapSolve failed:", e);
10381
+ return null;
10382
+ }
10383
+ }
10370
10384
  async function generateNetlist(sheet) {
10371
10385
  const wasm3 = await loadEcadWasm();
10372
10386
  if (!wasm3)
@@ -11229,6 +11243,7 @@ __export(dist_exports, {
11229
11243
  PhysicsEnv: () => PhysicsEnv,
11230
11244
  SolidCache: () => SolidCache,
11231
11245
  airgapFluxDensity: () => airgapFluxDensity,
11246
+ airgapSolve: () => airgapSolve,
11232
11247
  applyForwardKinematics: () => applyForwardKinematics,
11233
11248
  buildMoleculeReceipt: () => buildReceipt2,
11234
11249
  buildPartDocument: () => buildPartDocument,
@@ -40898,6 +40913,26 @@ var init_CHANGELOG = __esm({
40898
40913
  "sheet_metal_create"
40899
40914
  ]
40900
40915
  },
40916
+ {
40917
+ id: "2026-07-25-shell-and-booleans-on-filleted-solids",
40918
+ version: "0.9.4",
40919
+ date: "2026-07-25",
40920
+ category: "fix",
40921
+ title: "Shelling and cutting a filleted solid no longer leaks",
40922
+ summary: "Hollowing a filleted part scattered its cavity and a pocket cut collapsed the neighboring blends flat; both are now exact and watertight, and the kernel is reproducible run to run.",
40923
+ features: [
40924
+ "kernel",
40925
+ "fillet",
40926
+ "shell",
40927
+ "booleans",
40928
+ "tessellation"
40929
+ ],
40930
+ mcpTools: [
40931
+ "create_cad_loon",
40932
+ "inspect_cad",
40933
+ "export_cad"
40934
+ ]
40935
+ },
40901
40936
  {
40902
40937
  id: "2026-07-25-router-window-repair-commit-parity",
40903
40938
  version: "0.9.4",
@@ -41079,6 +41114,28 @@ var init_CHANGELOG = __esm({
41079
41114
  "build_receipt"
41080
41115
  ]
41081
41116
  },
41117
+ {
41118
+ id: "2026-07-25-gym-session-first",
41119
+ version: "0.9.4",
41120
+ date: "2026-07-25",
41121
+ category: "feat",
41122
+ title: "Sim from a session id, not a re-sent document",
41123
+ summary: "create_robot_env and batch_create_envs now take document_id, binding the env to the live session instead of a re-sent IR copy, and observations come back keyed by joint / instance id.",
41124
+ features: [
41125
+ "physics",
41126
+ "simulation",
41127
+ "mcp"
41128
+ ],
41129
+ mcpTools: [
41130
+ "create_robot_env",
41131
+ "batch_create_envs",
41132
+ "gym_reset",
41133
+ "gym_observe",
41134
+ "gym_step",
41135
+ "batch_reset",
41136
+ "batch_step"
41137
+ ]
41138
+ },
41082
41139
  {
41083
41140
  id: "2026-07-25-gerber-pad-rotation",
41084
41141
  version: "0.9.4",
@@ -41147,6 +41204,22 @@ var init_CHANGELOG = __esm({
41147
41204
  "quote_manufacturing"
41148
41205
  ]
41149
41206
  },
41207
+ {
41208
+ id: "2026-07-25-calc-motor-tooth-saturation",
41209
+ version: "0.9.4",
41210
+ date: "2026-07-25",
41211
+ category: "fix",
41212
+ title: "calc_motor sees saturating stator teeth",
41213
+ summary: "calc_motor's MEC model treated iron as linear and silently over-predicted Kt on machines whose teeth saturate; it now reports tooth flux, warns past the knee, and can solve the saturating network.",
41214
+ features: [
41215
+ "motor",
41216
+ "magnetics",
41217
+ "simulation"
41218
+ ],
41219
+ mcpTools: [
41220
+ "calc_motor"
41221
+ ]
41222
+ },
41150
41223
  {
41151
41224
  id: "2026-07-24-visionos-app",
41152
41225
  version: "0.9.4",
@@ -64103,6 +64176,7 @@ async function calcMotor(args) {
64103
64176
  let bGap = num2(args.airgap_flux_tesla);
64104
64177
  let bGapSource = "supplied";
64105
64178
  let magnetSpec;
64179
+ let solution = null;
64106
64180
  let fringing;
64107
64181
  if (!Number.isFinite(bGap)) {
64108
64182
  const m = args.magnet ?? {};
@@ -64116,15 +64190,55 @@ async function calcMotor(args) {
64116
64190
  gapAreaMm2: mnum(m.gap_area_mm2, 1),
64117
64191
  ironMuRel: typeof m.iron_mu_rel === "number" ? m.iron_mu_rel : null,
64118
64192
  ironPathMm: mnum(m.iron_path_mm, 0),
64119
- ironAreaMm2: mnum(m.iron_area_mm2, 1)
64120
- };
64121
- const computed = await airgapFluxDensity(magnetSpec);
64122
- if (computed == null) {
64123
- return fail2(
64124
- "air-gap flux is required: pass airgap_flux_tesla, or `magnet` params (ECAD WASM must be available to compute B_gap)."
64125
- );
64193
+ ironAreaMm2: mnum(m.iron_area_mm2, 1),
64194
+ ironJsT: typeof m.iron_js_t === "number" ? m.iron_js_t : null
64195
+ };
64196
+ if (m.slots !== void 0) {
64197
+ const slots = num2(m.slots);
64198
+ if (!(slots > 0)) return fail2("magnet.slots must be > 0");
64199
+ const meanR = Number.isFinite(num2(m.mean_radius_mm)) ? num2(m.mean_radius_mm) : (innerR + outerR) / 2;
64200
+ const pitch = 2 * Math.PI * meanR / slots;
64201
+ let toothW = num2(m.tooth_width_mm);
64202
+ if (!Number.isFinite(toothW)) {
64203
+ const frac = num2(m.tooth_fraction);
64204
+ if (!Number.isFinite(frac)) {
64205
+ return fail2("magnet.slots requires magnet.tooth_width_mm or magnet.tooth_fraction");
64206
+ }
64207
+ if (!(frac > 0 && frac <= 1)) return fail2("magnet.tooth_fraction must be in (0, 1]");
64208
+ toothW = frac * pitch;
64209
+ }
64210
+ if (!(toothW > 0)) return fail2("magnet.tooth_width_mm must be > 0");
64211
+ magnetSpec.teeth = {
64212
+ slots,
64213
+ toothWidthMm: toothW,
64214
+ meanRadiusMm: meanR,
64215
+ toothPathMm: mnum(m.tooth_path_mm, 0)
64216
+ };
64217
+ }
64218
+ solution = await airgapSolve(magnetSpec);
64219
+ if (solution == null) {
64220
+ const computed = await airgapFluxDensity(magnetSpec);
64221
+ if (computed == null) {
64222
+ return fail2(
64223
+ "air-gap flux is required: pass airgap_flux_tesla, or `magnet` params (ECAD WASM must be available to compute B_gap)."
64224
+ );
64225
+ }
64226
+ const t2 = magnetSpec.teeth;
64227
+ const k = t2 ? Math.max(1, 2 * Math.PI * t2.meanRadiusMm / t2.slots / t2.toothWidthMm) : null;
64228
+ solution = {
64229
+ bGapTesla: computed,
64230
+ bToothTesla: k == null ? null : computed * k,
64231
+ bIronTesla: null,
64232
+ toothConcentration: k,
64233
+ nonlinear: false,
64234
+ iterations: 0,
64235
+ converged: true,
64236
+ warnings: k != null && computed * k > SILICON_STEEL_KNEE_T ? [
64237
+ `tooth flux density ${(computed * k).toFixed(2)} T exceeds the ${SILICON_STEEL_KNEE_T.toFixed(2)} T knee \u2014 the LINEAR iron model over-predicts B_gap and Kt here; pass magnet.iron_js_t to solve the saturating network`
64238
+ ] : []
64239
+ };
64126
64240
  }
64127
- bGap = computed;
64241
+ bGap = solution.bGapTesla;
64128
64242
  bGapSource = "computed";
64129
64243
  if (m.pole_width_mm !== void 0) {
64130
64244
  const poleW = num2(m.pole_width_mm);
@@ -64196,6 +64310,24 @@ async function calcMotor(args) {
64196
64310
  })
64197
64311
  );
64198
64312
  }
64313
+ if (solution?.bToothTesla != null && magnetSpec.teeth) {
64314
+ claims.push(
64315
+ emClaim(
64316
+ "tooth_flux_density",
64317
+ r4(solution.bToothTesla),
64318
+ "T",
64319
+ solution.nonlinear ? "mec-saturating-iron" : "mec-tooth-concentration",
64320
+ {
64321
+ ...mecInputs,
64322
+ slots: magnetSpec.teeth.slots,
64323
+ tooth_width_mm: r4(magnetSpec.teeth.toothWidthMm),
64324
+ mean_radius_mm: r4(magnetSpec.teeth.meanRadiusMm),
64325
+ tooth_concentration: r4(solution.toothConcentration ?? 1),
64326
+ ...magnetSpec.ironJsT != null ? { iron_js_t: magnetSpec.ironJsT } : {}
64327
+ }
64328
+ )
64329
+ );
64330
+ }
64199
64331
  }
64200
64332
  return {
64201
64333
  content: [
@@ -64211,6 +64343,16 @@ async function calcMotor(args) {
64211
64343
  fringing_derate: r4(fringing.derate),
64212
64344
  fringing_note: "Carter-like first-order derate: B under the pole drops by w/(w+2g) as flux fringes ~one gap length past each pole edge. Kt/Ke and the curve use the DERATED flux. Honest for pole width \u2273 2\xD7 gap; below that treat it as a lower bound."
64213
64345
  } : {},
64346
+ ...solution?.bToothTesla != null ? {
64347
+ tooth_flux_tesla: r4(solution.bToothTesla),
64348
+ tooth_concentration: r4(solution.toothConcentration ?? 1),
64349
+ // Fringing redistributes flux under the pole; it does not remove
64350
+ // it, so the tooth carries the RAW gap flux either way.
64351
+ tooth_note: "B_tooth = raw B_gap \xB7 (tooth pitch / tooth width) \u2014 the gap flux of a whole tooth pitch funnels into one tooth body. Compare against your steel's knee (~1.5-1.7 T for M19-class silicon steel)."
64352
+ } : {},
64353
+ ...solution?.bIronTesla != null ? { yoke_flux_tesla: r4(solution.bIronTesla) } : {},
64354
+ ...solution?.nonlinear ? { iron_model: "saturating (arctangent B-H)" } : {},
64355
+ ...solution && solution.warnings.length > 0 ? { warnings: solution.warnings } : {},
64214
64356
  winding_factor: windingFactor,
64215
64357
  kt_nm_per_a: r4(perf.ktNmPerA),
64216
64358
  ke_v_s_per_rad: r4(perf.keVSPerRad),
@@ -64221,7 +64363,7 @@ async function calcMotor(args) {
64221
64363
  speed_rad_s: r4(p.speedRadS),
64222
64364
  torque_nm: r4(p.torqueNm)
64223
64365
  })),
64224
- note: fringing ? "First-order steady-state estimate (no slotting/saturation/losses; pole-edge fringing derated via magnet.pole_width_mm)." : "First-order steady-state estimate (no slotting/fringing/saturation/losses; pass magnet.pole_width_mm to derate for fringing).",
64366
+ note: "First-order steady-state estimate (no slotting, no losses)" + (fringing ? "; pole-edge fringing derated via magnet.pole_width_mm" : "; pass magnet.pole_width_mm to derate for fringing") + (solution?.nonlinear ? "; iron solved with its saturating B-H law" : "; iron is LINEAR, so Kt is OPTIMISTIC on a machine whose teeth saturate \u2014 " + (solution?.bToothTesla != null ? "see tooth_flux_tesla, and pass magnet.iron_js_t to solve the saturating network" : "pass magnet.slots + tooth_width_mm to see the tooth field")) + ".",
64225
64367
  claims
64226
64368
  })
64227
64369
  }
@@ -65693,7 +65835,7 @@ async function fixDrc(args) {
65693
65835
  };
65694
65836
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
65695
65837
  }
65696
- var PCB_LAYER_SET, COPPER_LAYERS, COPPER_LAYER_SET, POSITION_TOLERANCE, PIN_SEP, pinKey, createSchematicSchema, docInputProperties, placeComponentsSchema, routeNetsSchema, runDrcSchema, runErcSchema, critiqueRouteSchema, routeDiffPairSchema, lengthMatchTracesSchema, exportGerberSchema, validateForFabSchema, fabPrepSchema, exportKicadSchema, addCoilSchema, boardFromSolidSchema, addCoilArraySchema, windingLayoutSchema, calcImpedanceSchema, sizeImpedanceSchema, sizePdnSchema, calcCoilSchema, sizeCoilSchema, calcRfSchema, COIL_GEOMETRY, MU0, RAIL_PIN_NAMES, PIN_TYPES, CHIP_SIZE_CODES, CONNECTIVITY_GUARD_MAX_NETS, POWER_NET_RE, DRC_CATEGORY, DRC_DELTA_SAMPLE_CAP, DRC_DELTA_FULL_BOARD_MAX, LINT_CRYSTAL_MAX_MM, LINT_DECAP_MAX_MM, LINT_CONNECTOR_EDGE_MAX_MM, LINT_HIGH_CURRENT_SEP_MIN_MM, LINT_GROUND_NET_RE, LINT_SUPPLY_NET_RE, SENSITIVE_NET_RE, HIGH_CURRENT_CLASS_RE, refPrefix, dist2d, round2, sumSq, round33, vec2Schema, setBoardOutlineSchema, FOOTPRINT_FAMILIES, listFootprintsSchema, searchFootprintsSchema, getPadPositionsSchema, getFootprintSchema, ROTATION_CONVENTION, describePcbSchema, DESCRIBE_NET_NAME_CAP, addTraceSchema, addViaSchema, OZ_TO_MM, setStackupSchema, setPlacementSchema, addZoneSchema, deleteElementProps, deleteZoneSchema, deleteTraceSchema, deleteViaSchema, COPPER_KINDS, GET_COPPER_CAP, getCopperSchema, TIE_POSITION_TOL, addNetTieSchema, deleteNetTieSchema, undoSchema, setDesignRulesSchema, sizeTraceForCurrentSchema, addViaArraySchema, MAX_VIA_ARRAY, addMotorWindingSchema, calcMotorSchema, sig6, BEARING_FRICTION_MNM, checkSelfStartSchema, solidFromBoardSchema, FR4_DENSITY_KG_M3, COPPER_DENSITY_KG_M3, DEFAULT_COPPER_THICKNESS_MM, loopArea, MOUNTING_FP_RE, KEEPOUT_ECHO_CAP, INLINE_DOC_ECHO_CAP, searchElectronicPartsSchema, resolvePartSchema, findAlternativesSchema, verifySubstitutionSchema, buildReceiptSchema, verifyReceiptSchema, FIX_EPS, FIX_DRC_NEVER, fixDrcSchema, toolDefs20;
65838
+ var PCB_LAYER_SET, COPPER_LAYERS, COPPER_LAYER_SET, POSITION_TOLERANCE, PIN_SEP, pinKey, createSchematicSchema, docInputProperties, placeComponentsSchema, routeNetsSchema, runDrcSchema, runErcSchema, critiqueRouteSchema, routeDiffPairSchema, lengthMatchTracesSchema, exportGerberSchema, validateForFabSchema, fabPrepSchema, exportKicadSchema, addCoilSchema, boardFromSolidSchema, addCoilArraySchema, windingLayoutSchema, calcImpedanceSchema, sizeImpedanceSchema, sizePdnSchema, calcCoilSchema, sizeCoilSchema, calcRfSchema, COIL_GEOMETRY, MU0, RAIL_PIN_NAMES, PIN_TYPES, CHIP_SIZE_CODES, CONNECTIVITY_GUARD_MAX_NETS, POWER_NET_RE, DRC_CATEGORY, DRC_DELTA_SAMPLE_CAP, DRC_DELTA_FULL_BOARD_MAX, LINT_CRYSTAL_MAX_MM, LINT_DECAP_MAX_MM, LINT_CONNECTOR_EDGE_MAX_MM, LINT_HIGH_CURRENT_SEP_MIN_MM, LINT_GROUND_NET_RE, LINT_SUPPLY_NET_RE, SENSITIVE_NET_RE, HIGH_CURRENT_CLASS_RE, refPrefix, dist2d, round2, sumSq, round33, vec2Schema, setBoardOutlineSchema, FOOTPRINT_FAMILIES, listFootprintsSchema, searchFootprintsSchema, getPadPositionsSchema, getFootprintSchema, ROTATION_CONVENTION, describePcbSchema, DESCRIBE_NET_NAME_CAP, addTraceSchema, addViaSchema, OZ_TO_MM, setStackupSchema, setPlacementSchema, addZoneSchema, deleteElementProps, deleteZoneSchema, deleteTraceSchema, deleteViaSchema, COPPER_KINDS, GET_COPPER_CAP, getCopperSchema, TIE_POSITION_TOL, addNetTieSchema, deleteNetTieSchema, undoSchema, setDesignRulesSchema, sizeTraceForCurrentSchema, addViaArraySchema, MAX_VIA_ARRAY, addMotorWindingSchema, calcMotorSchema, SILICON_STEEL_KNEE_T, sig6, BEARING_FRICTION_MNM, checkSelfStartSchema, solidFromBoardSchema, FR4_DENSITY_KG_M3, COPPER_DENSITY_KG_M3, DEFAULT_COPPER_THICKNESS_MM, loopArea, MOUNTING_FP_RE, KEEPOUT_ECHO_CAP, INLINE_DOC_ECHO_CAP, searchElectronicPartsSchema, resolvePartSchema, findAlternativesSchema, verifySubstitutionSchema, buildReceiptSchema, verifyReceiptSchema, FIX_EPS, FIX_DRC_NEVER, fixDrcSchema, toolDefs20;
65697
65839
  var init_ecad2 = __esm({
65698
65840
  "src/tools/ecad.ts"() {
65699
65841
  "use strict";
@@ -67105,7 +67247,7 @@ var init_ecad2 = __esm({
67105
67247
  },
67106
67248
  magnet: {
67107
67249
  type: "object",
67108
- description: "PM mode: optional magnet/geometry to compute B_gap when airgap_flux_tesla is omitted (NdFeB defaults). Fields: remanence_tesla, magnet_thickness_mm, airgap_mm, recoil_mu_rel, magnet_area_mm2, gap_area_mm2, iron_mu_rel. Add pole_width_mm (magnet pole face width across the fringing direction) to also apply the first-order Carter-like fringing derate w/(w+2g) \u2014 the tool then reports raw AND derated B and uses the derated value for Kt."
67250
+ description: "PM mode: optional magnet/geometry to compute B_gap when airgap_flux_tesla is omitted (NdFeB defaults). Fields: remanence_tesla, magnet_thickness_mm, airgap_mm, recoil_mu_rel, magnet_area_mm2, gap_area_mm2, iron_mu_rel. Add pole_width_mm (magnet pole face width across the fringing direction) to also apply the first-order Carter-like fringing derate w/(w+2g) \u2014 the tool then reports raw AND derated B and uses the derated value for Kt. SATURATION: the MEC iron is LINEAR unless you say otherwise, so it over-predicts Kt on a machine whose teeth saturate. Pass slots + (tooth_width_mm or tooth_fraction) to make the teeth visible \u2014 the tool then reports tooth_flux_density (B_gap \xB7 pitch/width, routinely 2x B_gap) and warns when it passes the ~1.5 T silicon-steel knee. Add iron_js_t (saturation polarization, silicon steel \u2248 2.0 T) with iron_mu_rel to actually SOLVE the saturating network instead of merely warning; tooth_path_mm puts the teeth in the reluctance loop."
67109
67251
  },
67110
67252
  phase_current_a: { type: "number", description: "Induction mode (required): phase current, A RMS (balanced 3-phase drive)." },
67111
67253
  electrical_freq_hz: { type: "number", description: "Induction mode (required): electrical drive frequency, Hz." },
@@ -67124,6 +67266,7 @@ var init_ecad2 = __esm({
67124
67266
  },
67125
67267
  required: ["pole_pairs", "turns_per_phase"]
67126
67268
  };
67269
+ SILICON_STEEL_KNEE_T = 1.5;
67127
67270
  sig6 = (v) => v === 0 || !Number.isFinite(v) ? v : Number(v.toPrecision(6));
67128
67271
  BEARING_FRICTION_MNM = {
67129
67272
  "608-2RS": {
@@ -74088,6 +74231,41 @@ function getSimulation(envId) {
74088
74231
  function getEnvRecord(envId) {
74089
74232
  return envRecords.get(envId) ?? null;
74090
74233
  }
74234
+ function resolveEnvDocument(args) {
74235
+ const id = typeof args.document_id === "string" ? args.document_id : "";
74236
+ const inline = args.document && typeof args.document === "object" ? args.document : null;
74237
+ if (id && inline) {
74238
+ throw new Error(
74239
+ `Pass either \`document_id\` or \`document\`, not both \u2014 they resolve to different sessions and the inline copy may be stale. Drop \`document\` to simulate session "${id}" in place.`
74240
+ );
74241
+ }
74242
+ if (!id && !inline) {
74243
+ throw new Error(
74244
+ "Pass `document_id` (from open_document) to simulate a resident session \u2014 or an inline `document` object for the stateless flow. Exactly one is required."
74245
+ );
74246
+ }
74247
+ if (id) {
74248
+ return { doc: getSession(id), documentId: id, source: "session" };
74249
+ }
74250
+ return { doc: inline, documentId: null, source: "inline" };
74251
+ }
74252
+ function labelObservation(obs, jointIds, endEffectorIds) {
74253
+ const labeled = { ...obs, joint_ids: jointIds };
74254
+ if (jointIds && jointIds.length === obs.joint_positions.length) {
74255
+ labeled.joints = jointIds.map((id, i) => ({
74256
+ id,
74257
+ position: obs.joint_positions[i],
74258
+ velocity: obs.joint_velocities[i]
74259
+ }));
74260
+ }
74261
+ if (endEffectorIds.length === obs.end_effector_poses.length) {
74262
+ labeled.end_effectors = endEffectorIds.map((id, i) => ({
74263
+ id,
74264
+ pose: obs.end_effector_poses[i]
74265
+ }));
74266
+ }
74267
+ return labeled;
74268
+ }
74091
74269
  async function createRobotEnv(input) {
74092
74270
  const args = input;
74093
74271
  const available = await isPhysicsAvailable2();
@@ -74106,17 +74284,19 @@ async function createRobotEnv(input) {
74106
74284
  }
74107
74285
  try {
74108
74286
  const envId = `sim_${randomBytes4(12).toString("base64url")}`;
74109
- const env = await PhysicsEnv.create(args.document, {
74287
+ const { doc, documentId: sessionId, source } = resolveEnvDocument(args);
74288
+ const env = await PhysicsEnv.create(doc, {
74110
74289
  endEffectorIds: args.end_effector_ids,
74111
74290
  dt: args.dt,
74112
74291
  substeps: args.substeps,
74113
74292
  maxSteps: args.max_steps
74114
74293
  });
74115
74294
  simulations.set(envId, env);
74116
- const documentId = registerSession(args.document);
74295
+ const documentId = sessionId ?? registerSession(doc);
74117
74296
  envRecords.set(envId, {
74118
- document: args.document,
74297
+ document: doc,
74119
74298
  documentId,
74299
+ endEffectorIds: args.end_effector_ids,
74120
74300
  trajectory: [],
74121
74301
  rewards: [],
74122
74302
  dones: [],
@@ -74128,7 +74308,19 @@ async function createRobotEnv(input) {
74128
74308
  });
74129
74309
  const info = {
74130
74310
  env_id: envId,
74311
+ // Binding contract, spelled out because two handles come back:
74312
+ // · env_id → gym_step / gym_reset / gym_observe / gym_close
74313
+ // · document_id → the replay viewer, get_preview_glb, and every other
74314
+ // session tool (render_view, inspect_cad, update, …)
74315
+ // On the session path document_id IS the id you passed — the env and the
74316
+ // document you keep editing are the same session. On the inline path it
74317
+ // is a NEW session minted from the IR you sent.
74131
74318
  document_id: documentId,
74319
+ document_source: source,
74320
+ binds: {
74321
+ env_id: "gym_step, gym_reset, gym_observe, gym_close",
74322
+ document_id: "replay viewer, get_preview_glb, and session tools (render_view, inspect_cad, update)"
74323
+ },
74132
74324
  num_joints: env.numJoints,
74133
74325
  // Observation ordering contract: joint_positions[i] and
74134
74326
  // joint_velocities[i] refer to joint_ids[i]. Null when the loaded
@@ -74185,8 +74377,16 @@ function gymStep(input) {
74185
74377
  record2.dones.shift();
74186
74378
  }
74187
74379
  }
74380
+ const labeled = {
74381
+ ...result,
74382
+ observation: labelObservation(
74383
+ result.observation,
74384
+ env.jointIds,
74385
+ record2?.endEffectorIds ?? []
74386
+ )
74387
+ };
74188
74388
  return {
74189
- content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
74389
+ content: [{ type: "text", text: JSON.stringify(labeled, null, 2) }]
74190
74390
  };
74191
74391
  } catch (err7) {
74192
74392
  const message = err7 instanceof Error ? err7.message : String(err7);
@@ -74218,7 +74418,20 @@ function gymReset(input) {
74218
74418
  record2.resetEpoch += 1;
74219
74419
  }
74220
74420
  return {
74221
- content: [{ type: "text", text: JSON.stringify(observation, null, 2) }]
74421
+ content: [
74422
+ {
74423
+ type: "text",
74424
+ text: JSON.stringify(
74425
+ labelObservation(
74426
+ observation,
74427
+ env.jointIds,
74428
+ record2?.endEffectorIds ?? []
74429
+ ),
74430
+ null,
74431
+ 2
74432
+ )
74433
+ }
74434
+ ]
74222
74435
  };
74223
74436
  } catch (err7) {
74224
74437
  const message = err7 instanceof Error ? err7.message : String(err7);
@@ -74241,8 +74454,22 @@ function gymObserve(input) {
74241
74454
  }
74242
74455
  try {
74243
74456
  const observation = env.observe();
74457
+ const record2 = envRecords.get(args.env_id);
74244
74458
  return {
74245
- content: [{ type: "text", text: JSON.stringify(observation, null, 2) }]
74459
+ content: [
74460
+ {
74461
+ type: "text",
74462
+ text: JSON.stringify(
74463
+ labelObservation(
74464
+ observation,
74465
+ env.jointIds,
74466
+ record2?.endEffectorIds ?? []
74467
+ ),
74468
+ null,
74469
+ 2
74470
+ )
74471
+ }
74472
+ ]
74246
74473
  };
74247
74474
  } catch (err7) {
74248
74475
  const message = err7 instanceof Error ? err7.message : String(err7);
@@ -74288,6 +74515,7 @@ async function batchCreateEnvs(input) {
74288
74515
  }
74289
74516
  try {
74290
74517
  const batchId = `batch_${randomBytes4(12).toString("base64url")}`;
74518
+ const { doc, documentId, source } = resolveEnvDocument(args);
74291
74519
  const envOptions = {
74292
74520
  endEffectorIds: args.end_effector_ids,
74293
74521
  dt: args.dt,
@@ -74296,22 +74524,33 @@ async function batchCreateEnvs(input) {
74296
74524
  };
74297
74525
  const envPromises = Array.from(
74298
74526
  { length: args.n_envs },
74299
- () => PhysicsEnv.create(args.document, envOptions)
74527
+ () => PhysicsEnv.create(doc, envOptions)
74300
74528
  );
74301
74529
  const envs = await Promise.all(envPromises);
74302
74530
  const actionDim = envs[0].actionDim;
74303
74531
  batchGroups.set(batchId, {
74304
74532
  envs,
74305
74533
  actionDim,
74306
- document: args.document,
74307
- options: envOptions
74534
+ document: doc,
74535
+ options: envOptions,
74536
+ jointIds: envs[0].jointIds
74308
74537
  });
74309
74538
  const info = {
74310
74539
  batch_id: batchId,
74540
+ // batch_step / batch_reset bind to batch_id. No document session is
74541
+ // minted here — a batch has no viewer; `document_id` echoes the session
74542
+ // read from, and is null on the inline path.
74543
+ document_id: documentId,
74544
+ document_source: source,
74311
74545
  n_envs: args.n_envs,
74312
74546
  action_dim: actionDim,
74313
74547
  observation_dim: envs[0].observationDim,
74314
- num_joints: envs[0].numJoints
74548
+ num_joints: envs[0].numJoints,
74549
+ // Observation ordering contract, echoed so batch callers get the same
74550
+ // labeling the single-env path returns per observation.
74551
+ joint_ids: envs[0].jointIds,
74552
+ actuated_joint_ids: envs[0].actuatedJointIds,
74553
+ end_effector_ids: args.end_effector_ids
74315
74554
  };
74316
74555
  return {
74317
74556
  content: [{ type: "text", text: JSON.stringify(info, null, 2) }]
@@ -74353,7 +74592,13 @@ function batchStep(input) {
74353
74592
  (env, i) => env.step(args.action_type, args.actions[i])
74354
74593
  );
74355
74594
  const summary = {
74356
- observations: results.map((r) => r.observation),
74595
+ observations: results.map(
74596
+ (r) => labelObservation(
74597
+ r.observation,
74598
+ group.jointIds,
74599
+ group.options.endEffectorIds
74600
+ )
74601
+ ),
74357
74602
  rewards: results.map((r) => r.reward),
74358
74603
  dones: results.map((r) => r.done)
74359
74604
  };
@@ -74380,7 +74625,13 @@ function batchReset(input) {
74380
74625
  };
74381
74626
  }
74382
74627
  try {
74383
- const observations = group.envs.map((env) => env.reset());
74628
+ const observations = group.envs.map(
74629
+ (env) => labelObservation(
74630
+ env.reset(),
74631
+ group.jointIds,
74632
+ group.options.endEffectorIds
74633
+ )
74634
+ );
74384
74635
  return {
74385
74636
  content: [{ type: "text", text: JSON.stringify({ observations }, null, 2) }]
74386
74637
  };
@@ -74392,7 +74643,7 @@ function batchReset(input) {
74392
74643
  };
74393
74644
  }
74394
74645
  }
74395
- var simulations, MAX_TRAJECTORY, envRecords, batchGroups, createRobotEnvSchema, gymStepSchema, gymResetSchema, gymObserveSchema, gymCloseSchema, batchCreateEnvsSchema, batchStepSchema, batchResetSchema, toolDefs45;
74646
+ var simulations, MAX_TRAJECTORY, envRecords, batchGroups, INLINE_DOC_DESC, SESSION_DOC_DESC, createRobotEnvSchema, gymStepSchema, gymResetSchema, gymObserveSchema, gymCloseSchema, batchCreateEnvsSchema, batchStepSchema, batchResetSchema, toolDefs45;
74396
74647
  var init_gym = __esm({
74397
74648
  "src/tools/gym.ts"() {
74398
74649
  "use strict";
@@ -74403,12 +74654,18 @@ var init_gym = __esm({
74403
74654
  MAX_TRAJECTORY = 600;
74404
74655
  envRecords = /* @__PURE__ */ new Map();
74405
74656
  batchGroups = /* @__PURE__ */ new Map();
74657
+ INLINE_DOC_DESC = "Inline Document IR describing the robot assembly, used instead of a session. Use this stateless path when no `document_id` is resident (e.g. a cold serverless instance). Exactly one of `document_id` or `document` must be given.";
74658
+ SESSION_DOC_DESC = "Session id of the assembly to simulate (from open_document / create_cad_loon). Preferred: the env binds to this same session, so no IR round-trip is needed and the sim can never run against a stale copy.";
74406
74659
  createRobotEnvSchema = {
74407
74660
  type: "object",
74408
74661
  properties: {
74662
+ document_id: {
74663
+ type: "string",
74664
+ description: SESSION_DOC_DESC
74665
+ },
74409
74666
  document: {
74410
74667
  type: "object",
74411
- description: "vcad IR Document describing the robot assembly"
74668
+ description: INLINE_DOC_DESC
74412
74669
  },
74413
74670
  end_effector_ids: {
74414
74671
  type: "array",
@@ -74428,7 +74685,7 @@ var init_gym = __esm({
74428
74685
  description: "Maximum episode length (default: 1000)"
74429
74686
  }
74430
74687
  },
74431
- required: ["document", "end_effector_ids"]
74688
+ required: ["end_effector_ids"]
74432
74689
  };
74433
74690
  gymStepSchema = {
74434
74691
  type: "object",
@@ -74483,9 +74740,13 @@ var init_gym = __esm({
74483
74740
  batchCreateEnvsSchema = {
74484
74741
  type: "object",
74485
74742
  properties: {
74743
+ document_id: {
74744
+ type: "string",
74745
+ description: SESSION_DOC_DESC
74746
+ },
74486
74747
  document: {
74487
74748
  type: "object",
74488
- description: "vcad IR Document describing the robot assembly"
74749
+ description: INLINE_DOC_DESC
74489
74750
  },
74490
74751
  n_envs: {
74491
74752
  type: "number",
@@ -74509,7 +74770,7 @@ var init_gym = __esm({
74509
74770
  description: "Maximum episode length (default: 1000)"
74510
74771
  }
74511
74772
  },
74512
- required: ["document", "n_envs", "end_effector_ids"]
74773
+ required: ["n_envs", "end_effector_ids"]
74513
74774
  };
74514
74775
  batchStepSchema = {
74515
74776
  type: "object",
@@ -74548,7 +74809,7 @@ var init_gym = __esm({
74548
74809
  {
74549
74810
  name: "create_robot_env",
74550
74811
  pack: "physics",
74551
- description: "Create a physics simulation environment from a vcad assembly. Returns an environment ID that can be used with gym_step, gym_reset, and gym_observe. The environment provides a gym-style interface for RL training. Mounts the inline 3D viewer with a play button \u2014 gym_step rollouts replay right in the chat.",
74812
+ description: "Create a physics simulation environment from a vcad assembly. Session-first: pass the `document_id` of the assembly already open \u2014 the env binds to that same session, so there is no IR round-trip and the sim can't run against a stale copy. Inline `document` IR is the stateless fallback; exactly one of the two is required. Returns env_id (for gym_step / gym_reset / gym_observe / gym_close) and document_id (what the replay viewer and the other session tools bind to). Mounts the inline 3D viewer with a play button \u2014 gym_step rollouts replay right in the chat.",
74552
74813
  inputSchema: createRobotEnvSchema,
74553
74814
  handler: (a) => createRobotEnv(a),
74554
74815
  // writesDoc: the registered session must persist durably (signed-in hosted
@@ -74559,7 +74820,7 @@ var init_gym = __esm({
74559
74820
  {
74560
74821
  name: "gym_step",
74561
74822
  pack: "physics",
74562
- description: "Step the physics simulation with an action. action_type can be 'torque' (Nm), 'position' (degrees/mm), or 'velocity' (deg/s or mm/s). Returns observation (joint positions/velocities, end effector poses), reward, and done flag.",
74823
+ description: "Step the physics simulation with an action. action_type can be 'torque' (Nm), 'position' (degrees/mm), or 'velocity' (deg/s or mm/s). Returns observation (joint positions/velocities, end effector poses), reward, and done flag. The observation carries both the bare positional arrays and id-keyed views: `joints[i] = {id, position, velocity}` and `end_effectors[i] = {id, pose}`, so no ordering has to be remembered.",
74563
74824
  inputSchema: gymStepSchema,
74564
74825
  handler: (a) => gymStep(a),
74565
74826
  behavior: behavior({})
@@ -74567,7 +74828,7 @@ var init_gym = __esm({
74567
74828
  {
74568
74829
  name: "gym_reset",
74569
74830
  pack: "physics",
74570
- description: "Reset the simulation environment to its initial state. Returns the initial observation.",
74831
+ description: "Reset the simulation environment to its initial state. Returns the initial observation, with joint values keyed by joint id (`joints`) and end effector poses keyed by instance id (`end_effectors`) alongside the bare arrays.",
74571
74832
  inputSchema: gymResetSchema,
74572
74833
  handler: (a) => gymReset(a),
74573
74834
  behavior: behavior({})
@@ -74575,7 +74836,7 @@ var init_gym = __esm({
74575
74836
  {
74576
74837
  name: "gym_observe",
74577
74838
  pack: "physics",
74578
- description: "Get the current observation from the simulation without stepping. Returns joint positions, velocities, and end effector poses.",
74839
+ description: "Get the current observation from the simulation without stepping. Returns joint positions, velocities, and end effector poses \u2014 both as bare arrays and keyed by joint / instance id.",
74579
74840
  inputSchema: gymObserveSchema,
74580
74841
  handler: (a) => gymObserve(a),
74581
74842
  behavior: behavior({})
@@ -74591,7 +74852,7 @@ var init_gym = __esm({
74591
74852
  {
74592
74853
  name: "batch_create_envs",
74593
74854
  pack: "physics",
74594
- description: "Create N parallel simulation environments from a single robot assembly. Returns a batch_id for use with batch_step and batch_reset. Enables parallel RL training across multiple environments.",
74855
+ description: "Create N parallel simulation environments from a single robot assembly. Session-first: pass the `document_id` of the open assembly, or inline `document` IR as the stateless fallback \u2014 exactly one is required. Returns a batch_id for use with batch_step and batch_reset. Enables parallel RL training across multiple environments.",
74595
74856
  inputSchema: batchCreateEnvsSchema,
74596
74857
  handler: (a) => batchCreateEnvs(a),
74597
74858
  behavior: behavior({})
@@ -77878,6 +78139,8 @@ function buildInstructions(kernelPrompt) {
77878
78139
  "",
77879
78140
  "Sessions and server restarts: a `document_id` is a handle to server-side state, NOT storage. If ANY preview goes dark, a widget stops updating, or a call fails with an unknown `document_id`, check `server_info` FIRST \u2014 a low `uptime_s` or a changed `instance_id` means the SERVER RESTARTED and the feature is fine. That is the fastest way to tell a restart from a bug, and it should be the first check for any preview-shaped complaint. On a restart every live document_id dies at once, so previously-working widgets all go dark together \u2014 that is not a renderer bug. When `server_info` reports `durable:false`, sessions are in-memory only and cannot be recovered: keep the authoring source (your `create_cad_loon` call or record of edits) so a document can be rebuilt, and snapshot long-lived work with `checkpoint_document` / `save_document`. Every tool result also carries `_meta['io.vcad/build']` with `boot_token` + `instance_id`, so a changed value flags a restart without polling.",
77880
78141
  "",
78142
+ "Physics/RL workflow: `create_robot_env` (or `batch_create_envs`) \u2192 `gym_reset` \u2192 `gym_step` \u2192 `gym_close`. Both env creators are session-first like everything else: pass the `document_id` of the open assembly \u2014 never re-send its IR. `create_robot_env` binds the env to that same session, so a `document_id` env and the document you keep editing can't drift apart; inline `document` IR stays available as the stateless fallback (it mints a NEW session, returned as `document_id`) and passing both is refused. `env_id` drives gym_step/gym_reset/gym_observe/gym_close; `document_id` is what the replay viewer, `get_preview_glb`, and the other session tools bind to. Observations come back both as bare positional arrays and keyed by id (`joints[i] = {id, position, velocity}`, `end_effectors[i] = {id, pose}`) \u2014 read the keyed views and no ordering has to be remembered.",
78143
+ "",
77881
78144
  "PCB workflow: `create_schematic` (declare connectivity as data via `nets`) \u2192 `place_components` \u2192 `route_nets` / `add_coil` / `add_coil_array` \u2192 `run_drc` \u2192 `validate_for_fab` \u2192 `export_gerber`. All take the `document_id` from create_schematic and mutate that session \u2014 never re-send the document. `validate_for_fab` is the single 'is this board ready?' gate (DRC + renderability + Gerber serialization + blockers, all fail-closed); `export_gerber` enforces a clean DRC by default and blocks a dirty board. `board_from_solid` turns a solid part (e.g. an enclosure or stator disc in a CAD session) into an outline polygon for `place_components`. For motors, plan the winding first with `winding_layout` (slots + poles \u2192 per-coil phase/polarity/winding-factor, as data \u2014 it touches no board), then realize it with `add_coil_array`. `run_drc` returns a summary by default (counts by rule + net-pair, worst clearance, a capped sample); pass `detail:'full'` for every violation. Surgical copper edits: `get_copper` lists existing traces/vias/zones (filtered by layer/net/bbox/kind) with the same indices `delete_trace`/`delete_via`/`delete_zone` accept \u2014 discover, then delete, without exporting the document. Where two nets must touch on purpose (wye neutral, split ground, shunt tap), declare it with `add_net_tie` \u2014 prefer a region-scoped tie (position+radius) so DRC stays honest away from the junction; `delete_net_tie` takes it back."
77882
78145
  ].join("\n");
77883
78146
  if (!kernelPrompt) return header;
@@ -78727,8 +78990,8 @@ var init_server3 = __esm({
78727
78990
  init_order_feed();
78728
78991
  init_animate();
78729
78992
  PKG_VERSION = (() => {
78730
- if ("0.9.4-main.26") {
78731
- return "0.9.4-main.26";
78993
+ if ("0.9.4-main.28") {
78994
+ return "0.9.4-main.28";
78732
78995
  }
78733
78996
  try {
78734
78997
  const req = createRequire2(import.meta.url);
@@ -78737,8 +79000,8 @@ var init_server3 = __esm({
78737
79000
  return "0.0.0";
78738
79001
  }
78739
79002
  })();
78740
- BUILD_SHA = "178040b8a9aaccb709684c5b95e4fb42a00ae9e2";
78741
- BUILD_TIME = "2026-07-25T23:14:00.956Z";
79003
+ BUILD_SHA = "301abfbddc2a05c29fabc9f655031e554eab1d04";
79004
+ BUILD_TIME = "2026-07-26T00:32:29.039Z";
78742
79005
  SHORT_SHA = BUILD_SHA === "unknown" ? "unknown" : BUILD_SHA.slice(0, 7);
78743
79006
  VERSION_WITH_BUILD = SHORT_SHA === "unknown" ? PKG_VERSION : `${PKG_VERSION}+${SHORT_SHA}`;
78744
79007
  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.26",
3
+ "version": "0.9.4-main.28",
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": "178040b8a9aaccb709684c5b95e4fb42a00ae9e2",
23
- "builtAt": "2026-07-25T23:14:00.956Z"
22
+ "sha": "301abfbddc2a05c29fabc9f655031e554eab1d04",
23
+ "builtAt": "2026-07-26T00:32:29.039Z"
24
24
  }
25
25
  }
Binary file