dsh-cad 0.2.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,6 +6,7 @@ import { composeAssemblyMeshes } from '../modeling/assembly.js';
6
6
  import { buildDrawingSheet, drawingToDxf, drawingToSvg } from '../modeling/drawing.js';
7
7
  import { resolveWorkspacePath } from './util.js';
8
8
  import { toDcPrtDocument } from '../feature_script/dc_prt.js';
9
+ import { createConstraintTools } from './cad-constraint.js';
9
10
  /** Mirror a worker mesh with its raw typed arrays (binary-transport ready). */
10
11
  function mirrorMesh(mesh, bodyId) {
11
12
  return {
@@ -15,17 +16,22 @@ function mirrorMesh(mesh, bodyId) {
15
16
  indices: mesh.indices,
16
17
  };
17
18
  }
18
- /** Build the whole tool family over one document + worker + scene store. */
19
+ /** Build the whole tool family over the document registry + worker + stores. */
19
20
  export function createModelTools(deps) {
20
- const document = new ModelDocument(deps.workspaceRoot);
21
+ // The active document and its derived caches are swapped as a unit whenever
22
+ // a session's binding points elsewhere (see resolveDoc). meshCache /
23
+ // drawingSheets are cleared and rebuilt by replay.
24
+ let document = new ModelDocument(deps.workspaceRoot);
21
25
  /** bodyId → raw worker mesh mirror (binary scene source, zero encoding). */
22
26
  const meshCache = new Map();
23
27
  /** Reset epoch after the last sync — out-of-band replays (cad_view on a
24
28
  * .dcprt) bump the epoch and force a re-replay before the next op. */
25
29
  let syncedEpoch = -1;
26
30
  /** drawingId → rebuilt sheet (backing cad_export .svg/.dxf). */
27
- const drawingSheets = new Map();
31
+ let drawingSheets = new Map();
28
32
  let lastDrawingId = null;
33
+ /** Live constraint-model state (rebuilt from the op log on replay). */
34
+ const constraintState = { model: null, lastSuggestions: [] };
29
35
  /**
30
36
  * Standard drawing views (GB first-angle): 主视图 front, 俯视图 top,
31
37
  * 左视图 left, 轴测 iso. Same constants as the worker's fallback.
@@ -46,34 +52,44 @@ export function createModelTools(deps) {
46
52
  const salted = (hex.slice(0, 12) + salt + hex.slice(13)).slice(0, 32);
47
53
  return `${salted.slice(0, 8)}-${salted.slice(8, 12)}-${salted.slice(12, 16)}-${salted.slice(16, 20)}-${salted.slice(20, 32)}`;
48
54
  };
49
- async function restoreOnce() {
50
- if (syncedEpoch === workerResetEpoch())
51
- return;
52
- await document.restore();
53
- if (document.doc.ops.length > 0) {
54
- await runModelOp({ kind: 'reset' });
55
- for (const op of document.doc.ops) {
56
- try {
57
- const result = await runModelOp(op);
58
- // A replayed drawing re-generates its sheet and re-publishes the
59
- // scene so the stable viewId keeps serving after restarts.
60
- if (op.kind === 'drawing' && op.sceneViewId !== undefined && result.views !== undefined) {
61
- const sheet = buildDrawingSheet({ partName: op.name ?? op.target, views: result.views, paper: op.paper });
62
- drawingSheets.set(op.sceneViewId, { sheet, partName: op.name ?? op.target });
63
- lastDrawingId = op.sceneViewId;
64
- await deps.sceneStore.putAt(op.sceneViewId, {
65
- kind: '2d',
66
- format: 'drawing',
67
- entities: sheet.entities,
68
- bounds: sheet.bounds,
69
- layers: sheet.layers,
70
- });
71
- }
55
+ /**
56
+ * Replay a document's ops into the worker and rebuild the derived caches
57
+ * (mesh mirror, drawing sheets, constraint model). The worker is always
58
+ * reset first — also for empty documents, so a previous document's shapes
59
+ * never leak in. Absorbs the old restoreOnce body.
60
+ */
61
+ async function replayActiveDoc() {
62
+ await runModelOp({ kind: 'reset' });
63
+ // Derived state belongs to the outgoing document: clear before replay so
64
+ // a document without constraints/drawings never inherits the previous one's.
65
+ constraintState.model = null;
66
+ for (const op of document.doc.ops) {
67
+ try {
68
+ const result = await runModelOp(op);
69
+ // A replayed constraint model returns to live state.
70
+ if (op.kind === 'constraints') {
71
+ constraintState.model = op.model;
72
72
  }
73
- catch {
74
- // A single stale op must not block recovery; later ops may be independent.
73
+ // A replayed drawing re-generates its sheet and re-publishes the
74
+ // scene so the stable viewId keeps serving after restarts.
75
+ if (op.kind === 'drawing' && op.sceneViewId !== undefined && result.views !== undefined) {
76
+ const sheet = buildDrawingSheet({ partName: op.name ?? op.target, views: result.views, paper: op.paper });
77
+ drawingSheets.set(op.sceneViewId, { sheet, partName: op.name ?? op.target });
78
+ lastDrawingId = op.sceneViewId;
79
+ await deps.sceneStore.putAt(op.sceneViewId, {
80
+ kind: '2d',
81
+ format: 'drawing',
82
+ entities: sheet.entities,
83
+ bounds: sheet.bounds,
84
+ layers: sheet.layers,
85
+ });
75
86
  }
76
87
  }
88
+ catch {
89
+ // A single stale op must not block recovery; later ops may be independent.
90
+ }
91
+ }
92
+ if (document.doc.ops.length > 0) {
77
93
  const all = await runModelOp({ kind: 'tessellate_all' });
78
94
  meshCache.clear();
79
95
  for (const mesh of all.meshes ?? []) {
@@ -82,6 +98,62 @@ export function createModelTools(deps) {
82
98
  }
83
99
  syncedEpoch = workerResetEpoch();
84
100
  }
101
+ /** Swap the active document: restore from disk, reset the worker, replay. */
102
+ async function activateDocument(next) {
103
+ await next.restore();
104
+ document = next;
105
+ meshCache.clear();
106
+ drawingSheets = new Map();
107
+ lastDrawingId = null;
108
+ await replayActiveDoc();
109
+ }
110
+ /** Session key from the tool run context (`_default` outside a session). */
111
+ const sessionKeyOf = (exec) => {
112
+ const agent = exec?.agent;
113
+ return typeof agent?.id === 'string' && agent.id !== '' ? agent.id : '_default';
114
+ };
115
+ /**
116
+ * Per-op entry point (replaces restoreOnce): resolve the calling session's
117
+ * active document, switching documents (reset + replay) when its binding
118
+ * points elsewhere, then re-sync when the worker was reset out of band.
119
+ */
120
+ async function resolveDoc(exec) {
121
+ const sessionId = sessionKeyOf(exec);
122
+ const bound = await deps.registry.bindingOf(sessionId);
123
+ if (bound === null) {
124
+ // Upgrade continuity: the first unbound session inherits the migrated
125
+ // legacy document (if any) instead of starting from scratch.
126
+ const legacy = await deps.registry.claimLegacyFor(sessionId);
127
+ if (legacy !== null) {
128
+ const doc = await deps.registry.open(legacy);
129
+ if (doc !== null) {
130
+ await activateDocument(doc);
131
+ return;
132
+ }
133
+ }
134
+ // Unbound session (new conversation): start on a fresh empty document.
135
+ const fresh = await deps.registry.create();
136
+ await deps.registry.bind(sessionId, fresh.doc.docId);
137
+ await activateDocument(fresh);
138
+ return;
139
+ }
140
+ if (bound !== document.doc.docId) {
141
+ const next = await deps.registry.open(bound);
142
+ if (next === null) {
143
+ // Manifest entry without a document file — recover with a fresh doc.
144
+ const fresh = await deps.registry.create();
145
+ await deps.registry.bind(sessionId, fresh.doc.docId);
146
+ await activateDocument(fresh);
147
+ return;
148
+ }
149
+ await activateDocument(next);
150
+ return;
151
+ }
152
+ if (syncedEpoch !== workerResetEpoch()) {
153
+ await document.restore();
154
+ await replayActiveDoc();
155
+ }
156
+ }
85
157
  async function syncScene(op, result, filePath) {
86
158
  if (result.mesh !== undefined && result.bodyId !== undefined) {
87
159
  meshCache.set(result.bodyId, mirrorMesh(result.mesh, result.bodyId));
@@ -94,6 +166,7 @@ export function createModelTools(deps) {
94
166
  meshCache.delete(id);
95
167
  const nameEntry = result.bodyId !== undefined && result.name !== undefined ? { bodyId: result.bodyId, name: result.name } : null;
96
168
  await document.record(op, nameEntry);
169
+ await deps.registry.touch(document.doc.docId, { opCount: document.doc.version, bodyCount: meshCache.size });
97
170
  const meshes = [...meshCache.values()];
98
171
  const triangles = meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0);
99
172
  const sceneUrlBase = deps.ensureSceneRoute();
@@ -134,6 +207,7 @@ export function createModelTools(deps) {
134
207
  const instances = result.instances ?? [];
135
208
  const meshes = composeAssemblyMeshes(meshCache, instances);
136
209
  await document.record(op, null);
210
+ await deps.registry.touch(document.doc.docId, { opCount: document.doc.version, bodyCount: meshCache.size });
137
211
  const sceneUrlBase = deps.ensureSceneRoute();
138
212
  if (sceneUrlBase !== null) {
139
213
  // Publish even when empty — removing the last instance must refresh the tab.
@@ -248,7 +322,7 @@ export function createModelTools(deps) {
248
322
  };
249
323
  const renderDrawing = (value) => {
250
324
  const lines = [
251
- `工程图 ${String(value.target)}: 主视图/俯视图/左视图/轴测 · 比例 ${String(value.scaleText ?? '')} · ${String(value.entities)} 实体 (version ${String(value.version)})`,
325
+ `工程图 ${String(value.target)}: 主视图/俯视图/左视图/轴测 · OCCT 真实消隐 · 比例 ${String(value.scaleText ?? '')} · ${String(value.entities)} 实体 (version ${String(value.version)})`,
252
326
  ];
253
327
  if (value.filePath !== undefined)
254
328
  lines.push(`written: ${String(value.filePath)}`);
@@ -328,8 +402,8 @@ export function createModelTools(deps) {
328
402
  presentationMeta: (_args, value) => metaOf(value),
329
403
  },
330
404
  isConcurrencySafe: () => false,
331
- async execute(args) {
332
- await restoreOnce();
405
+ async execute(args, exec) {
406
+ await resolveDoc(exec);
333
407
  const bodyId = nextBodyId();
334
408
  const params = args;
335
409
  const op = { kind: 'create_prim', bodyId, prim: args.kind, params, name: args.name };
@@ -341,9 +415,11 @@ export function createModelTools(deps) {
341
415
  });
342
416
  const cadExtrude = defineTool({
343
417
  name: 'cad_extrude_profile',
344
- description: 'Create a solid by extruding a closed polygon profile in the XY plane along +Z (mm). `points` is a flat [x0,y0, x1,y1, …] loop (≥3 points, auto-closed). Use cad_boolean with cylinders for holes.',
418
+ description: 'Create a solid by extruding a closed profile in the XY plane along +Z (mm). `profile` accepts CURVE SEGMENTS — {start: [x,y], segments: [{type: "line"|"arc"|"bspline", …}]} (arc: to/center/ccw; bspline: through-points, sampled smooth curve) — or {circle: {center, radius}} for a round profile. ' +
419
+ 'The legacy flat `points` polygon loop (≥3 points, auto-closed) still works. Use cad_boolean for holes.',
345
420
  parameters: {
346
- points: { type: 'array', required: true, items: { type: 'number' }, description: 'Flat [x0,y0,x1,y1,…] loop (mm).' },
421
+ points: { type: 'array', items: { type: 'number' }, description: 'Legacy flat [x0,y0,x1,y1,…] polygon loop (mm).' },
422
+ profile: { type: 'json', description: 'Curve-segment profile: {start:[x,y], segments:[…]} or {circle:{center:[x,y], radius}}.' },
347
423
  height: { type: 'number', description: 'Extrusion height (mm, default 10).' },
348
424
  base: { type: 'number', description: 'Z of the profile plane (mm, default 0).' },
349
425
  name: { type: 'string', description: 'Optional display name.' },
@@ -362,19 +438,292 @@ export function createModelTools(deps) {
362
438
  presentationMeta: (_args, value) => metaOf(value),
363
439
  },
364
440
  isConcurrencySafe: () => false,
365
- async execute(args) {
366
- await restoreOnce();
367
- if (args.points.length < 6 || args.points.length % 2 !== 0) {
368
- throw new Error('points must be a flat array of ≥3 [x,y] pairs (≥6 numbers)');
441
+ async execute(args, exec) {
442
+ await resolveDoc(exec);
443
+ if (args.profile === undefined && (args.points === undefined || args.points.length < 6 || args.points.length % 2 !== 0)) {
444
+ throw new Error('provide a curve-segment `profile` object or a flat points array of ≥3 [x,y] pairs');
369
445
  }
370
446
  const bodyId = nextBodyId();
371
- const op = { kind: 'extrude_profile', bodyId, points: args.points, height: args.height, base: args.base, name: args.name };
447
+ const op = { kind: 'extrude_profile', bodyId, ...(args.profile !== undefined ? { profile: args.profile } : { points: args.points }), height: args.height, base: args.base, name: args.name };
372
448
  const result = await runModelOp(op);
373
449
  return syncScene(op, result);
374
450
  },
375
451
  presentCall: () => ({ card: 'generic', title: 'CAD extrude profile', kind: 'other' }),
376
452
  presentResult: () => ({ card: 'generic', title: 'CAD extrude' }),
377
453
  });
454
+ const cadLoft = defineTool({
455
+ name: 'cad_loft',
456
+ description: 'Loft (放样): skin a solid through successive closed sections. `sections` is a list of ≥2 loops, each a flat [x0,y0,z0, x1,y1,z1, …] triplet list in its own plane (≥3 points, auto-closed), ordered along the loft. ' +
457
+ 'Sections may differ in shape and point count (e.g. a square lofted to a hexagon). `ruled` keeps the sides straight instead of smoothing. ' +
458
+ 'Each result passes a BRepCheck validity gate: a self-intersecting or degenerate loft is rejected with a clear message instead of storing an unusable body.',
459
+ parameters: {
460
+ sections: {
461
+ type: 'array',
462
+ required: true,
463
+ description: 'Ordered sections, each a flat [x,y,z,…] loop (≥9 numbers).',
464
+ items: { type: 'array', items: { type: 'number' }, description: 'One closed section loop.' },
465
+ },
466
+ ruled: { type: 'boolean', description: 'Straight (ruled) sides instead of a smoothed surface (default false).' },
467
+ solid: { type: 'boolean', description: 'Cap the ends into a solid (default true).' },
468
+ name: { type: 'string', description: 'Optional display name.' },
469
+ },
470
+ output: {
471
+ schema: {
472
+ type: 'object',
473
+ additionalProperties: false,
474
+ properties: {
475
+ bodyId: { type: 'string', required: true },
476
+ ...requiredCounts,
477
+ ...commonOptional,
478
+ },
479
+ },
480
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
481
+ presentationMeta: (_args, value) => metaOf(value),
482
+ },
483
+ isConcurrencySafe: () => false,
484
+ async execute(args, exec) {
485
+ await resolveDoc(exec);
486
+ if (!Array.isArray(args.sections) || args.sections.length < 2)
487
+ throw new Error('sections must list at least 2 closed loops');
488
+ const bodyId = nextBodyId();
489
+ const op = { kind: 'loft', bodyId, sections: args.sections, solid: args.solid, ruled: args.ruled, name: args.name };
490
+ const result = await runModelOp(op);
491
+ return syncScene(op, result);
492
+ },
493
+ presentCall: () => ({ card: 'generic', title: 'CAD loft', kind: 'other' }),
494
+ presentResult: () => ({ card: 'generic', title: 'CAD loft' }),
495
+ });
496
+ const cadSweep = defineTool({
497
+ name: 'cad_sweep',
498
+ description: 'Sweep (扫掠): pipe a closed 2D profile along a 3D path. `profile` is a flat [x0,y0, x1,y1, …] outline (≥3 points, auto-closed) placed in the plane PERPENDICULAR TO THE PATH\'S START TANGENT, so the outline\'s 2D axes map onto that plane — no manual orientation needed. ' +
499
+ '`path` is a flat [x0,y0,z0, …] polyline. Straight, collinear and gently curved paths give exact solids; a SHARP direction change with a section large relative to the corner self-intersects — such a result is REJECTED by the BRepCheck validity gate (the error names the cause), so round or chamfer the corners in the path.',
500
+ parameters: {
501
+ profile: { type: 'json', required: true, description: 'Closed outline on the start plane: flat [x,y,…] array, {start, segments:[…]} curve chain, or {circle:{center,radius}}.' },
502
+ path: { type: 'array', required: true, items: { type: 'number' }, description: 'Sweep path as [x,y,z,…] triplets (≥6 numbers).' },
503
+ name: { type: 'string', description: 'Optional display name.' },
504
+ },
505
+ output: {
506
+ schema: {
507
+ type: 'object',
508
+ additionalProperties: false,
509
+ properties: {
510
+ bodyId: { type: 'string', required: true },
511
+ ...requiredCounts,
512
+ ...commonOptional,
513
+ },
514
+ },
515
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
516
+ presentationMeta: (_args, value) => metaOf(value),
517
+ },
518
+ isConcurrencySafe: () => false,
519
+ async execute(args, exec) {
520
+ await resolveDoc(exec);
521
+ const bodyId = nextBodyId();
522
+ const op = { kind: 'sweep', bodyId, profile: args.profile, path: args.path, name: args.name };
523
+ const result = await runModelOp(op);
524
+ return syncScene(op, result);
525
+ },
526
+ presentCall: () => ({ card: 'generic', title: 'CAD sweep', kind: 'other' }),
527
+ presentResult: () => ({ card: 'generic', title: 'CAD sweep' }),
528
+ });
529
+ const cadRevolve = defineTool({
530
+ name: 'cad_revolve',
531
+ description: 'Revolve (旋转): sweep a closed 2D profile around an axis into a solid of revolution — shafts, wheels, vases, cones, spheres. Profile coordinates are (radius, height) relative to the axis: with the default +Z axis, profile [x,y] means radius x at height y. ' +
532
+ 'Accepts the same curve-segment profiles as cad_extrude_profile (lines/arcs/bspline/circle), so rounded rims are exact. `angle` in degrees (default 360).',
533
+ parameters: {
534
+ profile: { type: 'json', required: true, description: 'Profile in (radius, height): flat [r0,h0, r1,h1,…] loop, {start, segments:[…]}, or {circle:{center,radius}}.' },
535
+ angle: { type: 'number', description: 'Sweep angle in degrees (default 360).' },
536
+ axis: { type: 'array', items: { type: 'number' }, description: 'Revolve axis direction [x,y,z] (default +Z).' },
537
+ at: { type: 'array', items: { type: 'number' }, description: 'A point the axis passes through [x,y,z] (default origin).' },
538
+ name: { type: 'string', description: 'Optional display name.' },
539
+ },
540
+ output: {
541
+ schema: {
542
+ type: 'object',
543
+ additionalProperties: false,
544
+ properties: {
545
+ bodyId: { type: 'string', required: true },
546
+ ...requiredCounts,
547
+ ...commonOptional,
548
+ },
549
+ },
550
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
551
+ presentationMeta: (_args, value) => metaOf(value),
552
+ },
553
+ isConcurrencySafe: () => false,
554
+ async execute(args, exec) {
555
+ await resolveDoc(exec);
556
+ const bodyId = nextBodyId();
557
+ const op = {
558
+ kind: 'revolve', bodyId, profile: args.profile,
559
+ ...(args.angle !== undefined ? { angle: (args.angle * Math.PI) / 180 } : {}),
560
+ ...(Array.isArray(args.axis) ? { axis: args.axis } : {}),
561
+ ...(Array.isArray(args.at) ? { at: args.at } : {}),
562
+ ...(args.name !== undefined ? { name: args.name } : {}),
563
+ };
564
+ const result = await runModelOp(op);
565
+ return syncScene(op, result);
566
+ },
567
+ presentCall: () => ({ card: 'generic', title: 'CAD revolve', kind: 'other' }),
568
+ presentResult: () => ({ card: 'generic', title: 'CAD revolve' }),
569
+ });
570
+ const cadChamfer = defineTool({
571
+ name: 'cad_chamfer',
572
+ description: 'Chamfer (倒角): bevel every sharp edge of a body with one equal distance (mm). Fails when the distance exceeds the adjacent faces.',
573
+ parameters: {
574
+ target: bodyTarget,
575
+ distance: { type: 'number', required: true, description: 'Chamfer distance (mm).' },
576
+ },
577
+ output: {
578
+ schema: {
579
+ type: 'object',
580
+ additionalProperties: false,
581
+ properties: {
582
+ bodyId: { type: 'string', required: true },
583
+ ...requiredCounts,
584
+ ...commonOptional,
585
+ },
586
+ },
587
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
588
+ presentationMeta: (_args, value) => metaOf(value),
589
+ },
590
+ isConcurrencySafe: () => false,
591
+ async execute(args, exec) {
592
+ await resolveDoc(exec);
593
+ const op = { kind: 'chamfer', target: args.target, distance: args.distance };
594
+ const result = await runModelOp(op);
595
+ return syncScene(op, result);
596
+ },
597
+ presentCall: (args) => ({ card: 'generic', title: `CAD chamfer ${String(args.target)}`, kind: 'other' }),
598
+ presentResult: () => ({ card: 'generic', title: 'CAD chamfer' }),
599
+ });
600
+ const cadShell = defineTool({
601
+ name: 'cad_shell',
602
+ description: 'Shell (抽壳): hollow a solid into a wall of `thickness` mm (the wall grows inward, the outer skin is preserved). `open` lists outward NORMALS [x,y,z] whose faces become openings (e.g. [[0,0,1]] opens the top of a box; empty = sealed hollow). ' +
603
+ 'Runs on the occt.ts kernel (>= 0.3.0): the result becomes a HOSTED body — volume/export(.step)/display keep working, but opencascade.js-only edits (fillet/chamfer/loft/sweep/revolve/boolean) on it are refused, so do those before shelling.',
604
+ parameters: {
605
+ target: bodyTarget,
606
+ thickness: { type: 'number', required: true, description: 'Wall thickness (mm, positive).' },
607
+ open: { type: 'array', items: { type: 'array', items: { type: 'number' } }, description: 'Outward normals of the faces to open, e.g. [[0,0,1]].' },
608
+ },
609
+ output: {
610
+ schema: {
611
+ type: 'object',
612
+ additionalProperties: false,
613
+ properties: {
614
+ bodyId: { type: 'string', required: true },
615
+ ...requiredCounts,
616
+ ...commonOptional,
617
+ },
618
+ },
619
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
620
+ presentationMeta: (_args, value) => metaOf(value),
621
+ },
622
+ isConcurrencySafe: () => false,
623
+ async execute(args, exec) {
624
+ await resolveDoc(exec);
625
+ const op = { kind: 'shell', target: args.target, thickness: args.thickness, ...(Array.isArray(args.open) ? { openNormals: args.open } : {}) };
626
+ const result = await runModelOp(op);
627
+ return syncScene(op, result);
628
+ },
629
+ presentCall: (args) => ({ card: 'generic', title: `CAD shell ${String(args.target)}`, kind: 'other' }),
630
+ presentResult: () => ({ card: 'generic', title: 'CAD shell' }),
631
+ });
632
+ const cadDraft = defineTool({
633
+ name: 'cad_draft',
634
+ description: 'Draft (拔模): tilt the walls of a solid by `angle` degrees for mold release — faces pivot about a neutral plane, dimensions there unchanged. `direction` is the pull direction (default +Z); by default the faces parallel to it (the walls) are drafted. ' +
635
+ 'Runs on the occt.ts kernel (>= 0.3.0): the result becomes a HOSTED body (same caveats as cad_shell).',
636
+ parameters: {
637
+ target: bodyTarget,
638
+ angle: { type: 'number', required: true, description: 'Draft angle in degrees (positive opens the walls toward the pull direction).' },
639
+ direction: { type: 'array', items: { type: 'number' }, description: 'Pull direction [x,y,z] (default +Z).' },
640
+ },
641
+ output: {
642
+ schema: {
643
+ type: 'object',
644
+ additionalProperties: false,
645
+ properties: {
646
+ bodyId: { type: 'string', required: true },
647
+ ...requiredCounts,
648
+ ...commonOptional,
649
+ },
650
+ },
651
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
652
+ presentationMeta: (_args, value) => metaOf(value),
653
+ },
654
+ isConcurrencySafe: () => false,
655
+ async execute(args, exec) {
656
+ await resolveDoc(exec);
657
+ const op = { kind: 'draft', target: args.target, angle: args.angle, ...(Array.isArray(args.direction) ? { direction: args.direction } : {}) };
658
+ const result = await runModelOp(op);
659
+ return syncScene(op, result);
660
+ },
661
+ presentCall: (args) => ({ card: 'generic', title: `CAD draft ${String(args.target)} ${String(args.angle ?? '')}°`, kind: 'other' }),
662
+ presentResult: () => ({ card: 'generic', title: 'CAD draft' }),
663
+ });
664
+ const cadPattern = defineTool({
665
+ name: 'cad_pattern',
666
+ description: 'Pattern (阵列): replicate a body into `count` total placements. Linear: `delta` [dx,dy,dz] spacing per copy. Circular: rotate copies around a principal axis (+X/+Y/+Z) through `at` over `angle` degrees (default 360). Creates new bodies (target·1 … target·(count−1)); fuse afterwards for a single body.',
667
+ parameters: {
668
+ target: bodyTarget,
669
+ mode: { type: 'string', required: true, enum: ['linear', 'circular'], description: 'Pattern mode.' },
670
+ count: { type: 'number', required: true, description: 'Total placements (≥2).' },
671
+ delta: { type: 'array', items: { type: 'number' }, description: 'linear: spacing [dx,dy,dz] per copy (mm).' },
672
+ axis: { type: 'array', items: { type: 'number' }, description: 'circular: principal axis [x,y,z] (default +Z).' },
673
+ at: { type: 'array', items: { type: 'number' }, description: 'circular: axis point [x,y,z] (default origin).' },
674
+ angle: { type: 'number', description: 'circular: total sweep in degrees (default 360).' },
675
+ },
676
+ output: {
677
+ schema: {
678
+ type: 'object',
679
+ additionalProperties: false,
680
+ properties: {
681
+ bodyId: { type: 'string', required: true },
682
+ created: { type: 'array', items: { type: 'string' }, required: true, description: 'Ids of the newly created copy bodies.' },
683
+ ...requiredCounts,
684
+ ...commonOptional,
685
+ },
686
+ },
687
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) + '\ncreated: ' + value.created.join(', ') }],
688
+ presentationMeta: (_args, value) => metaOf(value),
689
+ },
690
+ isConcurrencySafe: () => false,
691
+ async execute(args, exec) {
692
+ await resolveDoc(exec);
693
+ if (args.mode === 'linear' && !Array.isArray(args.delta))
694
+ throw new Error('linear patterns need delta: [dx,dy,dz]');
695
+ const op = {
696
+ kind: 'pattern', target: args.target, mode: args.mode, count: args.count,
697
+ ...(Array.isArray(args.delta) ? { delta: args.delta } : {}),
698
+ ...(Array.isArray(args.axis) ? { axis: args.axis } : {}),
699
+ ...(Array.isArray(args.at) ? { at: args.at } : {}),
700
+ ...(args.angle !== undefined ? { angle: args.angle } : {}),
701
+ };
702
+ const result = await runModelOp(op);
703
+ const created = (result.created ?? []).map((entry) => {
704
+ if (entry.mesh !== undefined)
705
+ meshCache.set(entry.bodyId, mirrorMesh(entry.mesh, entry.bodyId));
706
+ return entry.bodyId;
707
+ });
708
+ await document.record(op, null);
709
+ const meshes = [...meshCache.values()];
710
+ const sceneUrlBase = deps.ensureSceneRoute();
711
+ if (sceneUrlBase !== null && meshes.length > 0) {
712
+ await deps.store.publish(document.doc.docId, meshes);
713
+ }
714
+ const value = {
715
+ bodyId: args.target,
716
+ created,
717
+ triangles: meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0),
718
+ bodies: meshes.length,
719
+ version: document.doc.version,
720
+ ...(sceneUrlBase !== null ? { sceneUrl: `${sceneUrlBase.replace('/scene', '/bin')}/${document.doc.docId}?v=${document.doc.version}` } : {}),
721
+ };
722
+ return value;
723
+ },
724
+ presentCall: (args) => ({ card: 'generic', title: `CAD ${String(args.mode ?? '')} pattern ×${String(args.count ?? '')}`, kind: 'other' }),
725
+ presentResult: () => ({ card: 'generic', title: 'CAD pattern' }),
726
+ });
378
727
  const cadBoolean = defineTool({
379
728
  name: 'cad_boolean',
380
729
  description: 'Boolean-combine bodies: fuse (union), cut (subtract tools from target), common (intersection). Consumed tool bodies are removed; the result keeps the target bodyId. Classic pattern for holes: cut a cylinder from a plate.',
@@ -397,8 +746,8 @@ export function createModelTools(deps) {
397
746
  presentationMeta: (_args, value) => metaOf(value),
398
747
  },
399
748
  isConcurrencySafe: () => false,
400
- async execute(args) {
401
- await restoreOnce();
749
+ async execute(args, exec) {
750
+ await resolveDoc(exec);
402
751
  const op = { kind: 'boolean', op: args.op, target: args.target, tools: args.tools };
403
752
  const result = await runModelOp(op);
404
753
  return syncScene(op, result);
@@ -427,8 +776,8 @@ export function createModelTools(deps) {
427
776
  presentationMeta: (_args, value) => metaOf(value),
428
777
  },
429
778
  isConcurrencySafe: () => false,
430
- async execute(args) {
431
- await restoreOnce();
779
+ async execute(args, exec) {
780
+ await resolveDoc(exec);
432
781
  const op = { kind: 'fillet', target: args.target, radius: args.radius };
433
782
  const result = await runModelOp(op);
434
783
  return syncScene(op, result);
@@ -459,8 +808,8 @@ export function createModelTools(deps) {
459
808
  presentationMeta: (_args, value) => metaOf(value),
460
809
  },
461
810
  isConcurrencySafe: () => false,
462
- async execute(args) {
463
- await restoreOnce();
811
+ async execute(args, exec) {
812
+ await resolveDoc(exec);
464
813
  const op = {
465
814
  kind: 'transform',
466
815
  target: args.target,
@@ -514,8 +863,8 @@ export function createModelTools(deps) {
514
863
  },
515
864
  },
516
865
  isConcurrencySafe: () => true,
517
- async execute(args) {
518
- await restoreOnce();
866
+ async execute(args, exec) {
867
+ await resolveDoc(exec);
519
868
  const resolved = resolveWorkspacePath(args.path, deps.workspaceRoot);
520
869
  const lower = args.path.toLowerCase();
521
870
  // Assembly export: compound of transformed instances via the worker.
@@ -611,8 +960,8 @@ export function createModelTools(deps) {
611
960
  presentationMeta: (_args, value) => metaOf(value),
612
961
  },
613
962
  isConcurrencySafe: () => false,
614
- async execute(args) {
615
- await restoreOnce();
963
+ async execute(args, exec) {
964
+ await resolveDoc(exec);
616
965
  const op = { kind: 'delete', target: args.target };
617
966
  const result = await runModelOp(op);
618
967
  return syncScene(op, result);
@@ -640,8 +989,8 @@ export function createModelTools(deps) {
640
989
  presentationMeta: (_args, value) => metaOf(value),
641
990
  },
642
991
  isConcurrencySafe: () => true,
643
- async execute(args) {
644
- await restoreOnce();
992
+ async execute(args, exec) {
993
+ await resolveDoc(exec);
645
994
  const op = { kind: 'volume', target: args.target };
646
995
  const result = await runModelOp(op);
647
996
  return syncScene(op, result);
@@ -668,8 +1017,8 @@ export function createModelTools(deps) {
668
1017
  presentationMeta: (_args, value) => drawingMetaOf(value),
669
1018
  },
670
1019
  isConcurrencySafe: () => false,
671
- async execute(args) {
672
- await restoreOnce();
1020
+ async execute(args, exec) {
1021
+ await resolveDoc(exec);
673
1022
  const target = args.target ?? lastBodyId();
674
1023
  if (target === undefined)
675
1024
  throw new Error('nothing to draw — create a body first');
@@ -717,8 +1066,8 @@ export function createModelTools(deps) {
717
1066
  presentationMeta: (_args, value) => assemblyMetaOf(value),
718
1067
  },
719
1068
  isConcurrencySafe: () => false,
720
- async execute(args) {
721
- await restoreOnce();
1069
+ async execute(args, exec) {
1070
+ await resolveDoc(exec);
722
1071
  if (!meshCache.has(args.bodyId))
723
1072
  throw new Error(`unknown body: ${args.bodyId}`);
724
1073
  const instanceId = nextInstanceId();
@@ -750,8 +1099,8 @@ export function createModelTools(deps) {
750
1099
  presentationMeta: (_args, value) => assemblyMetaOf(value),
751
1100
  },
752
1101
  isConcurrencySafe: () => false,
753
- async execute(args) {
754
- await restoreOnce();
1102
+ async execute(args, exec) {
1103
+ await resolveDoc(exec);
755
1104
  const op = {
756
1105
  kind: 'assembly_transform',
757
1106
  instanceId: args.instanceId,
@@ -776,8 +1125,8 @@ export function createModelTools(deps) {
776
1125
  presentationMeta: (_args, value) => assemblyMetaOf(value),
777
1126
  },
778
1127
  isConcurrencySafe: () => false,
779
- async execute(args) {
780
- await restoreOnce();
1128
+ async execute(args, exec) {
1129
+ await resolveDoc(exec);
781
1130
  const op = { kind: 'assembly_remove', instanceId: args.instanceId };
782
1131
  const result = await runModelOp(op);
783
1132
  return syncAssembly(op, result);
@@ -785,9 +1134,244 @@ export function createModelTools(deps) {
785
1134
  presentCall: (args) => ({ card: 'generic', title: `移除 ${String(args.instanceId)}`, kind: 'delete' }),
786
1135
  presentResult: () => ({ card: 'generic', title: '装配移除' }),
787
1136
  });
1137
+ // ── document (file space) tools ───────────────────────────────────────────
1138
+ const docRefParam = { type: 'string', required: true, description: 'Document id or exact name (list with cad_docs).' };
1139
+ const renderDocs = (value) => {
1140
+ const docs = Array.isArray(value.docs) ? value.docs : [];
1141
+ const active = typeof value.activeDoc === 'string' ? value.activeDoc : '';
1142
+ const lines = docs.map((doc) => `${doc.id === active ? '● ' : ' '}${String(doc.name)} · ${Number(doc.bodies)} 体 · ${String(doc.updatedAt).slice(0, 16).replace('T', ' ')} · ${String(doc.id)}`);
1143
+ return lines.length > 0 ? ['工作区文档:', ...lines].join('\n') : '工作区还没有建模文档';
1144
+ };
1145
+ const cadDocs = defineTool({
1146
+ name: 'cad_docs',
1147
+ description: 'List the workspace modeling documents (the file space): id, name, body count and update time, marking the one active for this session. ' +
1148
+ 'Call before cad_doc_open / cad_doc_delete to discover ids and names.',
1149
+ parameters: {},
1150
+ output: {
1151
+ schema: {
1152
+ type: 'object',
1153
+ additionalProperties: false,
1154
+ properties: {
1155
+ docs: {
1156
+ type: 'array',
1157
+ required: true,
1158
+ items: {
1159
+ type: 'object',
1160
+ additionalProperties: false,
1161
+ properties: {
1162
+ id: { type: 'string', required: true },
1163
+ name: { type: 'string', required: true },
1164
+ bodies: { type: 'number', required: true },
1165
+ ops: { type: 'number', required: true },
1166
+ updatedAt: { type: 'string', required: true },
1167
+ active: { type: 'boolean' },
1168
+ },
1169
+ },
1170
+ },
1171
+ activeDoc: { type: 'string', description: 'Active document id for this session (absent: none).' },
1172
+ },
1173
+ },
1174
+ render: (_args, value) => [{ type: 'text', text: renderDocs(value) }],
1175
+ },
1176
+ isConcurrencySafe: () => true,
1177
+ async execute(_args, exec) {
1178
+ const sessionId = sessionKeyOf(exec);
1179
+ const docs = await deps.registry.list();
1180
+ const activeDoc = await deps.registry.bindingOf(sessionId);
1181
+ return {
1182
+ docs: docs.map((doc) => ({ id: doc.id, name: doc.name, bodies: doc.bodyCount, ops: doc.opCount, updatedAt: doc.updatedAt, ...(doc.id === activeDoc ? { active: true } : {}) })),
1183
+ ...(activeDoc === null ? {} : { activeDoc }),
1184
+ };
1185
+ },
1186
+ presentCall: () => ({ card: 'generic', title: '文档列表', kind: 'read' }),
1187
+ presentResult: () => ({ card: 'generic', title: '文档列表' }),
1188
+ });
1189
+ const cadDocNew = defineTool({
1190
+ name: 'cad_doc_new',
1191
+ description: 'Create a new modeling document and make it this session\'s active modeling target (previous documents stay in the file space). ' +
1192
+ 'Name it when the task has a clear subject (e.g. 发动机 / 齿轮箱) — start multi-part projects with this tool so each project keeps its own document.',
1193
+ parameters: {
1194
+ name: { type: 'string', description: 'Document name (default: 未命名 N).' },
1195
+ },
1196
+ output: {
1197
+ schema: {
1198
+ type: 'object',
1199
+ additionalProperties: false,
1200
+ properties: {
1201
+ docId: { type: 'string', required: true, description: 'New document id.' },
1202
+ name: { type: 'string', required: true, description: 'Document name.' },
1203
+ bodies: { type: 'number', required: true, description: 'Bodies (0 for a fresh document).' },
1204
+ version: { type: 'number', required: true, description: 'Document version.' },
1205
+ },
1206
+ },
1207
+ render: (_args, value) => [{ type: 'text', text: `新建文档 ${String(value.name)}(后续建模操作都写入该文档)` }],
1208
+ },
1209
+ isConcurrencySafe: () => false,
1210
+ async execute(args, exec) {
1211
+ const sessionId = sessionKeyOf(exec);
1212
+ const doc = await deps.registry.create(args.name);
1213
+ await deps.registry.bind(sessionId, doc.doc.docId);
1214
+ await activateDocument(doc);
1215
+ const name = (await deps.registry.list()).find((meta) => meta.id === doc.doc.docId)?.name ?? doc.doc.docId;
1216
+ return { docId: doc.doc.docId, name, bodies: 0, version: doc.doc.version };
1217
+ },
1218
+ presentCall: (args) => ({ card: 'generic', title: `新建文档 ${String(args.name ?? '')}`.trim(), kind: 'other' }),
1219
+ presentResult: () => ({ card: 'generic', title: '新建文档' }),
1220
+ });
1221
+ const cadDocOpen = defineTool({
1222
+ name: 'cad_doc_open',
1223
+ description: 'Open an existing modeling document and make it this session\'s active modeling target (its bodies load back exactly). ' +
1224
+ 'Use when the user asks to continue an earlier model (e.g. “打开发动机文档”) — resolve the id/name with cad_docs first when unsure.',
1225
+ parameters: {
1226
+ doc: docRefParam,
1227
+ },
1228
+ output: {
1229
+ schema: {
1230
+ type: 'object',
1231
+ additionalProperties: false,
1232
+ properties: {
1233
+ docId: { type: 'string', required: true, description: 'Opened document id.' },
1234
+ name: { type: 'string', required: true, description: 'Document name.' },
1235
+ bodies: { type: 'number', required: true, description: 'Bodies in the document.' },
1236
+ triangles: { type: 'number', required: true, description: 'Document triangle count.' },
1237
+ version: { type: 'number', required: true, description: 'Document version.' },
1238
+ sceneUrl: { type: 'string', description: 'Versioned viewer URL (web compositions).' },
1239
+ },
1240
+ },
1241
+ render: (_args, value) => [{ type: 'text', text: `打开文档 ${String(value.name)} · ${Number(value.bodies)} 体 (version ${Number(value.version)})` }],
1242
+ presentationMeta: (_args, value) => metaOf(value),
1243
+ },
1244
+ isConcurrencySafe: () => false,
1245
+ async execute(args, exec) {
1246
+ const sessionId = sessionKeyOf(exec);
1247
+ const meta = await deps.registry.resolve(args.doc);
1248
+ if (meta === null) {
1249
+ const available = (await deps.registry.list()).map((doc) => doc.name).join(' / ');
1250
+ throw new Error(`no document matches "${String(args.doc)}" — available: ${available === '' ? '(none)' : available}`);
1251
+ }
1252
+ await deps.registry.bind(sessionId, meta.id);
1253
+ const doc = await deps.registry.open(meta.id);
1254
+ if (doc === null)
1255
+ throw new Error(`document file missing: ${meta.id}`);
1256
+ await activateDocument(doc);
1257
+ const meshes = [...meshCache.values()];
1258
+ const triangles = meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0);
1259
+ const sceneUrlBase = deps.ensureSceneRoute();
1260
+ const value = {
1261
+ docId: meta.id,
1262
+ name: meta.name,
1263
+ bodies: meshes.length,
1264
+ triangles,
1265
+ version: document.doc.version,
1266
+ };
1267
+ if (meshes.length > 0)
1268
+ await deps.store.publish(meta.id, meshes);
1269
+ if (meshes.length > 0 && sceneUrlBase !== null) {
1270
+ value.sceneUrl = `${sceneUrlBase.replace('/scene', '/bin')}/${meta.id}?v=${document.doc.version}`;
1271
+ }
1272
+ return value;
1273
+ },
1274
+ presentCall: (args) => ({ card: 'generic', title: `打开文档 ${String(args.doc)}`, kind: 'other' }),
1275
+ presentResult: () => ({ card: 'generic', title: '打开文档' }),
1276
+ });
1277
+ const cadDocRename = defineTool({
1278
+ name: 'cad_doc_rename',
1279
+ description: 'Rename a modeling document in the file space.',
1280
+ parameters: {
1281
+ doc: docRefParam,
1282
+ name: { type: 'string', required: true, description: 'New document name.' },
1283
+ },
1284
+ output: {
1285
+ schema: {
1286
+ type: 'object',
1287
+ additionalProperties: false,
1288
+ properties: {
1289
+ docId: { type: 'string', required: true, description: 'Document id.' },
1290
+ name: { type: 'string', required: true, description: 'New name.' },
1291
+ },
1292
+ },
1293
+ render: (_args, value) => [{ type: 'text', text: `文档已重命名为 ${String(value.name)}` }],
1294
+ },
1295
+ isConcurrencySafe: () => false,
1296
+ async execute(args, exec) {
1297
+ void exec;
1298
+ const meta = await deps.registry.resolve(args.doc);
1299
+ if (meta === null)
1300
+ throw new Error(`no document matches "${String(args.doc)}"`);
1301
+ const renamed = await deps.registry.rename(meta.id, args.name);
1302
+ if (renamed === null)
1303
+ throw new Error(`rename failed: ${meta.id}`);
1304
+ return { docId: renamed.id, name: renamed.name };
1305
+ },
1306
+ presentCall: (args) => ({ card: 'generic', title: `重命名文档 ${String(args.doc)}`, kind: 'other' }),
1307
+ presentResult: () => ({ card: 'generic', title: '重命名文档' }),
1308
+ });
1309
+ const cadDocDelete = defineTool({
1310
+ name: 'cad_doc_delete',
1311
+ description: 'Permanently delete a modeling document from the file space (op log + bodies). Requires confirm=true — ask the user before calling. ' +
1312
+ 'To delete a single body inside a document, use cad_delete instead.',
1313
+ parameters: {
1314
+ doc: docRefParam,
1315
+ confirm: { type: 'boolean', required: true, description: 'Must be explicitly true to delete.' },
1316
+ },
1317
+ output: {
1318
+ schema: {
1319
+ type: 'object',
1320
+ additionalProperties: false,
1321
+ properties: {
1322
+ deleted: { type: 'string', required: true, description: 'Deleted document id.' },
1323
+ name: { type: 'string', required: true, description: 'Deleted document name.' },
1324
+ },
1325
+ },
1326
+ render: (_args, value) => [{ type: 'text', text: `已删除文档 ${String(value.name)}` }],
1327
+ },
1328
+ isConcurrencySafe: () => false,
1329
+ async execute(args, exec) {
1330
+ void exec;
1331
+ if (args.confirm !== true) {
1332
+ throw new Error('pass confirm=true to delete — this permanently removes the document and its bodies');
1333
+ }
1334
+ const meta = await deps.registry.resolve(args.doc);
1335
+ if (meta === null)
1336
+ throw new Error(`no document matches "${String(args.doc)}"`);
1337
+ const removed = await deps.registry.remove(meta.id);
1338
+ if (!removed)
1339
+ throw new Error(`delete failed: ${meta.id}`);
1340
+ // Drop the active-document state if it was the deleted one; the next
1341
+ // modeling op lazily creates a fresh document for its session.
1342
+ if (document.doc.docId === meta.id) {
1343
+ document = new ModelDocument(deps.workspaceRoot);
1344
+ meshCache.clear();
1345
+ drawingSheets = new Map();
1346
+ lastDrawingId = null;
1347
+ syncedEpoch = -1;
1348
+ }
1349
+ return { deleted: meta.id, name: meta.name };
1350
+ },
1351
+ presentCall: (args) => ({ card: 'generic', title: `删除文档 ${String(args.doc)}`, kind: 'delete' }),
1352
+ presentResult: () => ({ card: 'generic', title: '删除文档' }),
1353
+ });
1354
+ // ── constraint tools (Ansatz solver) ──────────────────────────────────────
1355
+ const constraintTools = createConstraintTools({
1356
+ deps,
1357
+ // Read the ACTIVE document per call: multi-document sessions swap it.
1358
+ getDocument: () => document,
1359
+ meshCache,
1360
+ constraintState,
1361
+ resolveDoc,
1362
+ syncAssembly,
1363
+ assemblyMetaOf,
1364
+ });
788
1365
  return [
789
1366
  cadCreatePrim,
790
1367
  cadExtrude,
1368
+ cadRevolve,
1369
+ cadChamfer,
1370
+ cadShell,
1371
+ cadDraft,
1372
+ cadPattern,
1373
+ cadLoft,
1374
+ cadSweep,
791
1375
  cadBoolean,
792
1376
  cadFillet,
793
1377
  cadTransform,
@@ -798,11 +1382,24 @@ export function createModelTools(deps) {
798
1382
  cadAssemblyInsert,
799
1383
  cadAssemblyMove,
800
1384
  cadAssemblyRemove,
1385
+ cadDocs,
1386
+ cadDocNew,
1387
+ cadDocOpen,
1388
+ cadDocRename,
1389
+ cadDocDelete,
1390
+ ...constraintTools,
801
1391
  ];
802
1392
  }
803
1393
  export const MODEL_TOOL_NAMES = [
804
1394
  'cad_create_prim',
805
1395
  'cad_extrude_profile',
1396
+ 'cad_revolve',
1397
+ 'cad_chamfer',
1398
+ 'cad_shell',
1399
+ 'cad_draft',
1400
+ 'cad_pattern',
1401
+ 'cad_loft',
1402
+ 'cad_sweep',
806
1403
  'cad_boolean',
807
1404
  'cad_fillet',
808
1405
  'cad_transform',
@@ -813,4 +1410,12 @@ export const MODEL_TOOL_NAMES = [
813
1410
  'cad_assembly_insert',
814
1411
  'cad_assembly_move',
815
1412
  'cad_assembly_remove',
1413
+ 'cad_docs',
1414
+ 'cad_doc_new',
1415
+ 'cad_doc_open',
1416
+ 'cad_doc_rename',
1417
+ 'cad_doc_delete',
1418
+ 'cad_constraint',
1419
+ 'cad_solve',
1420
+ 'cad_motion',
816
1421
  ];