dsh-cad 0.2.0 → 0.7.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,228 @@ 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 cadPattern = defineTool({
601
+ name: 'cad_pattern',
602
+ 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.',
603
+ parameters: {
604
+ target: bodyTarget,
605
+ mode: { type: 'string', required: true, enum: ['linear', 'circular'], description: 'Pattern mode.' },
606
+ count: { type: 'number', required: true, description: 'Total placements (≥2).' },
607
+ delta: { type: 'array', items: { type: 'number' }, description: 'linear: spacing [dx,dy,dz] per copy (mm).' },
608
+ axis: { type: 'array', items: { type: 'number' }, description: 'circular: principal axis [x,y,z] (default +Z).' },
609
+ at: { type: 'array', items: { type: 'number' }, description: 'circular: axis point [x,y,z] (default origin).' },
610
+ angle: { type: 'number', description: 'circular: total sweep in degrees (default 360).' },
611
+ },
612
+ output: {
613
+ schema: {
614
+ type: 'object',
615
+ additionalProperties: false,
616
+ properties: {
617
+ bodyId: { type: 'string', required: true },
618
+ created: { type: 'array', items: { type: 'string' }, required: true, description: 'Ids of the newly created copy bodies.' },
619
+ ...requiredCounts,
620
+ ...commonOptional,
621
+ },
622
+ },
623
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) + '\ncreated: ' + value.created.join(', ') }],
624
+ presentationMeta: (_args, value) => metaOf(value),
625
+ },
626
+ isConcurrencySafe: () => false,
627
+ async execute(args, exec) {
628
+ await resolveDoc(exec);
629
+ if (args.mode === 'linear' && !Array.isArray(args.delta))
630
+ throw new Error('linear patterns need delta: [dx,dy,dz]');
631
+ const op = {
632
+ kind: 'pattern', target: args.target, mode: args.mode, count: args.count,
633
+ ...(Array.isArray(args.delta) ? { delta: args.delta } : {}),
634
+ ...(Array.isArray(args.axis) ? { axis: args.axis } : {}),
635
+ ...(Array.isArray(args.at) ? { at: args.at } : {}),
636
+ ...(args.angle !== undefined ? { angle: args.angle } : {}),
637
+ };
638
+ const result = await runModelOp(op);
639
+ const created = (result.created ?? []).map((entry) => {
640
+ if (entry.mesh !== undefined)
641
+ meshCache.set(entry.bodyId, mirrorMesh(entry.mesh, entry.bodyId));
642
+ return entry.bodyId;
643
+ });
644
+ await document.record(op, null);
645
+ const meshes = [...meshCache.values()];
646
+ const sceneUrlBase = deps.ensureSceneRoute();
647
+ if (sceneUrlBase !== null && meshes.length > 0) {
648
+ await deps.store.publish(document.doc.docId, meshes);
649
+ }
650
+ const value = {
651
+ bodyId: args.target,
652
+ created,
653
+ triangles: meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0),
654
+ bodies: meshes.length,
655
+ version: document.doc.version,
656
+ ...(sceneUrlBase !== null ? { sceneUrl: `${sceneUrlBase.replace('/scene', '/bin')}/${document.doc.docId}?v=${document.doc.version}` } : {}),
657
+ };
658
+ return value;
659
+ },
660
+ presentCall: (args) => ({ card: 'generic', title: `CAD ${String(args.mode ?? '')} pattern ×${String(args.count ?? '')}`, kind: 'other' }),
661
+ presentResult: () => ({ card: 'generic', title: 'CAD pattern' }),
662
+ });
378
663
  const cadBoolean = defineTool({
379
664
  name: 'cad_boolean',
380
665
  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 +682,8 @@ export function createModelTools(deps) {
397
682
  presentationMeta: (_args, value) => metaOf(value),
398
683
  },
399
684
  isConcurrencySafe: () => false,
400
- async execute(args) {
401
- await restoreOnce();
685
+ async execute(args, exec) {
686
+ await resolveDoc(exec);
402
687
  const op = { kind: 'boolean', op: args.op, target: args.target, tools: args.tools };
403
688
  const result = await runModelOp(op);
404
689
  return syncScene(op, result);
@@ -427,8 +712,8 @@ export function createModelTools(deps) {
427
712
  presentationMeta: (_args, value) => metaOf(value),
428
713
  },
429
714
  isConcurrencySafe: () => false,
430
- async execute(args) {
431
- await restoreOnce();
715
+ async execute(args, exec) {
716
+ await resolveDoc(exec);
432
717
  const op = { kind: 'fillet', target: args.target, radius: args.radius };
433
718
  const result = await runModelOp(op);
434
719
  return syncScene(op, result);
@@ -459,8 +744,8 @@ export function createModelTools(deps) {
459
744
  presentationMeta: (_args, value) => metaOf(value),
460
745
  },
461
746
  isConcurrencySafe: () => false,
462
- async execute(args) {
463
- await restoreOnce();
747
+ async execute(args, exec) {
748
+ await resolveDoc(exec);
464
749
  const op = {
465
750
  kind: 'transform',
466
751
  target: args.target,
@@ -514,8 +799,8 @@ export function createModelTools(deps) {
514
799
  },
515
800
  },
516
801
  isConcurrencySafe: () => true,
517
- async execute(args) {
518
- await restoreOnce();
802
+ async execute(args, exec) {
803
+ await resolveDoc(exec);
519
804
  const resolved = resolveWorkspacePath(args.path, deps.workspaceRoot);
520
805
  const lower = args.path.toLowerCase();
521
806
  // Assembly export: compound of transformed instances via the worker.
@@ -611,8 +896,8 @@ export function createModelTools(deps) {
611
896
  presentationMeta: (_args, value) => metaOf(value),
612
897
  },
613
898
  isConcurrencySafe: () => false,
614
- async execute(args) {
615
- await restoreOnce();
899
+ async execute(args, exec) {
900
+ await resolveDoc(exec);
616
901
  const op = { kind: 'delete', target: args.target };
617
902
  const result = await runModelOp(op);
618
903
  return syncScene(op, result);
@@ -640,8 +925,8 @@ export function createModelTools(deps) {
640
925
  presentationMeta: (_args, value) => metaOf(value),
641
926
  },
642
927
  isConcurrencySafe: () => true,
643
- async execute(args) {
644
- await restoreOnce();
928
+ async execute(args, exec) {
929
+ await resolveDoc(exec);
645
930
  const op = { kind: 'volume', target: args.target };
646
931
  const result = await runModelOp(op);
647
932
  return syncScene(op, result);
@@ -668,8 +953,8 @@ export function createModelTools(deps) {
668
953
  presentationMeta: (_args, value) => drawingMetaOf(value),
669
954
  },
670
955
  isConcurrencySafe: () => false,
671
- async execute(args) {
672
- await restoreOnce();
956
+ async execute(args, exec) {
957
+ await resolveDoc(exec);
673
958
  const target = args.target ?? lastBodyId();
674
959
  if (target === undefined)
675
960
  throw new Error('nothing to draw — create a body first');
@@ -717,8 +1002,8 @@ export function createModelTools(deps) {
717
1002
  presentationMeta: (_args, value) => assemblyMetaOf(value),
718
1003
  },
719
1004
  isConcurrencySafe: () => false,
720
- async execute(args) {
721
- await restoreOnce();
1005
+ async execute(args, exec) {
1006
+ await resolveDoc(exec);
722
1007
  if (!meshCache.has(args.bodyId))
723
1008
  throw new Error(`unknown body: ${args.bodyId}`);
724
1009
  const instanceId = nextInstanceId();
@@ -750,8 +1035,8 @@ export function createModelTools(deps) {
750
1035
  presentationMeta: (_args, value) => assemblyMetaOf(value),
751
1036
  },
752
1037
  isConcurrencySafe: () => false,
753
- async execute(args) {
754
- await restoreOnce();
1038
+ async execute(args, exec) {
1039
+ await resolveDoc(exec);
755
1040
  const op = {
756
1041
  kind: 'assembly_transform',
757
1042
  instanceId: args.instanceId,
@@ -776,8 +1061,8 @@ export function createModelTools(deps) {
776
1061
  presentationMeta: (_args, value) => assemblyMetaOf(value),
777
1062
  },
778
1063
  isConcurrencySafe: () => false,
779
- async execute(args) {
780
- await restoreOnce();
1064
+ async execute(args, exec) {
1065
+ await resolveDoc(exec);
781
1066
  const op = { kind: 'assembly_remove', instanceId: args.instanceId };
782
1067
  const result = await runModelOp(op);
783
1068
  return syncAssembly(op, result);
@@ -785,9 +1070,242 @@ export function createModelTools(deps) {
785
1070
  presentCall: (args) => ({ card: 'generic', title: `移除 ${String(args.instanceId)}`, kind: 'delete' }),
786
1071
  presentResult: () => ({ card: 'generic', title: '装配移除' }),
787
1072
  });
1073
+ // ── document (file space) tools ───────────────────────────────────────────
1074
+ const docRefParam = { type: 'string', required: true, description: 'Document id or exact name (list with cad_docs).' };
1075
+ const renderDocs = (value) => {
1076
+ const docs = Array.isArray(value.docs) ? value.docs : [];
1077
+ const active = typeof value.activeDoc === 'string' ? value.activeDoc : '';
1078
+ 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)}`);
1079
+ return lines.length > 0 ? ['工作区文档:', ...lines].join('\n') : '工作区还没有建模文档';
1080
+ };
1081
+ const cadDocs = defineTool({
1082
+ name: 'cad_docs',
1083
+ description: 'List the workspace modeling documents (the file space): id, name, body count and update time, marking the one active for this session. ' +
1084
+ 'Call before cad_doc_open / cad_doc_delete to discover ids and names.',
1085
+ parameters: {},
1086
+ output: {
1087
+ schema: {
1088
+ type: 'object',
1089
+ additionalProperties: false,
1090
+ properties: {
1091
+ docs: {
1092
+ type: 'array',
1093
+ required: true,
1094
+ items: {
1095
+ type: 'object',
1096
+ additionalProperties: false,
1097
+ properties: {
1098
+ id: { type: 'string', required: true },
1099
+ name: { type: 'string', required: true },
1100
+ bodies: { type: 'number', required: true },
1101
+ ops: { type: 'number', required: true },
1102
+ updatedAt: { type: 'string', required: true },
1103
+ active: { type: 'boolean' },
1104
+ },
1105
+ },
1106
+ },
1107
+ activeDoc: { type: 'string', description: 'Active document id for this session (absent: none).' },
1108
+ },
1109
+ },
1110
+ render: (_args, value) => [{ type: 'text', text: renderDocs(value) }],
1111
+ },
1112
+ isConcurrencySafe: () => true,
1113
+ async execute(_args, exec) {
1114
+ const sessionId = sessionKeyOf(exec);
1115
+ const docs = await deps.registry.list();
1116
+ const activeDoc = await deps.registry.bindingOf(sessionId);
1117
+ return {
1118
+ docs: docs.map((doc) => ({ id: doc.id, name: doc.name, bodies: doc.bodyCount, ops: doc.opCount, updatedAt: doc.updatedAt, ...(doc.id === activeDoc ? { active: true } : {}) })),
1119
+ ...(activeDoc === null ? {} : { activeDoc }),
1120
+ };
1121
+ },
1122
+ presentCall: () => ({ card: 'generic', title: '文档列表', kind: 'read' }),
1123
+ presentResult: () => ({ card: 'generic', title: '文档列表' }),
1124
+ });
1125
+ const cadDocNew = defineTool({
1126
+ name: 'cad_doc_new',
1127
+ description: 'Create a new modeling document and make it this session\'s active modeling target (previous documents stay in the file space). ' +
1128
+ '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.',
1129
+ parameters: {
1130
+ name: { type: 'string', description: 'Document name (default: 未命名 N).' },
1131
+ },
1132
+ output: {
1133
+ schema: {
1134
+ type: 'object',
1135
+ additionalProperties: false,
1136
+ properties: {
1137
+ docId: { type: 'string', required: true, description: 'New document id.' },
1138
+ name: { type: 'string', required: true, description: 'Document name.' },
1139
+ bodies: { type: 'number', required: true, description: 'Bodies (0 for a fresh document).' },
1140
+ version: { type: 'number', required: true, description: 'Document version.' },
1141
+ },
1142
+ },
1143
+ render: (_args, value) => [{ type: 'text', text: `新建文档 ${String(value.name)}(后续建模操作都写入该文档)` }],
1144
+ },
1145
+ isConcurrencySafe: () => false,
1146
+ async execute(args, exec) {
1147
+ const sessionId = sessionKeyOf(exec);
1148
+ const doc = await deps.registry.create(args.name);
1149
+ await deps.registry.bind(sessionId, doc.doc.docId);
1150
+ await activateDocument(doc);
1151
+ const name = (await deps.registry.list()).find((meta) => meta.id === doc.doc.docId)?.name ?? doc.doc.docId;
1152
+ return { docId: doc.doc.docId, name, bodies: 0, version: doc.doc.version };
1153
+ },
1154
+ presentCall: (args) => ({ card: 'generic', title: `新建文档 ${String(args.name ?? '')}`.trim(), kind: 'other' }),
1155
+ presentResult: () => ({ card: 'generic', title: '新建文档' }),
1156
+ });
1157
+ const cadDocOpen = defineTool({
1158
+ name: 'cad_doc_open',
1159
+ description: 'Open an existing modeling document and make it this session\'s active modeling target (its bodies load back exactly). ' +
1160
+ 'Use when the user asks to continue an earlier model (e.g. “打开发动机文档”) — resolve the id/name with cad_docs first when unsure.',
1161
+ parameters: {
1162
+ doc: docRefParam,
1163
+ },
1164
+ output: {
1165
+ schema: {
1166
+ type: 'object',
1167
+ additionalProperties: false,
1168
+ properties: {
1169
+ docId: { type: 'string', required: true, description: 'Opened document id.' },
1170
+ name: { type: 'string', required: true, description: 'Document name.' },
1171
+ bodies: { type: 'number', required: true, description: 'Bodies in the document.' },
1172
+ triangles: { type: 'number', required: true, description: 'Document triangle count.' },
1173
+ version: { type: 'number', required: true, description: 'Document version.' },
1174
+ sceneUrl: { type: 'string', description: 'Versioned viewer URL (web compositions).' },
1175
+ },
1176
+ },
1177
+ render: (_args, value) => [{ type: 'text', text: `打开文档 ${String(value.name)} · ${Number(value.bodies)} 体 (version ${Number(value.version)})` }],
1178
+ presentationMeta: (_args, value) => metaOf(value),
1179
+ },
1180
+ isConcurrencySafe: () => false,
1181
+ async execute(args, exec) {
1182
+ const sessionId = sessionKeyOf(exec);
1183
+ const meta = await deps.registry.resolve(args.doc);
1184
+ if (meta === null) {
1185
+ const available = (await deps.registry.list()).map((doc) => doc.name).join(' / ');
1186
+ throw new Error(`no document matches "${String(args.doc)}" — available: ${available === '' ? '(none)' : available}`);
1187
+ }
1188
+ await deps.registry.bind(sessionId, meta.id);
1189
+ const doc = await deps.registry.open(meta.id);
1190
+ if (doc === null)
1191
+ throw new Error(`document file missing: ${meta.id}`);
1192
+ await activateDocument(doc);
1193
+ const meshes = [...meshCache.values()];
1194
+ const triangles = meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0);
1195
+ const sceneUrlBase = deps.ensureSceneRoute();
1196
+ const value = {
1197
+ docId: meta.id,
1198
+ name: meta.name,
1199
+ bodies: meshes.length,
1200
+ triangles,
1201
+ version: document.doc.version,
1202
+ };
1203
+ if (meshes.length > 0)
1204
+ await deps.store.publish(meta.id, meshes);
1205
+ if (meshes.length > 0 && sceneUrlBase !== null) {
1206
+ value.sceneUrl = `${sceneUrlBase.replace('/scene', '/bin')}/${meta.id}?v=${document.doc.version}`;
1207
+ }
1208
+ return value;
1209
+ },
1210
+ presentCall: (args) => ({ card: 'generic', title: `打开文档 ${String(args.doc)}`, kind: 'other' }),
1211
+ presentResult: () => ({ card: 'generic', title: '打开文档' }),
1212
+ });
1213
+ const cadDocRename = defineTool({
1214
+ name: 'cad_doc_rename',
1215
+ description: 'Rename a modeling document in the file space.',
1216
+ parameters: {
1217
+ doc: docRefParam,
1218
+ name: { type: 'string', required: true, description: 'New document name.' },
1219
+ },
1220
+ output: {
1221
+ schema: {
1222
+ type: 'object',
1223
+ additionalProperties: false,
1224
+ properties: {
1225
+ docId: { type: 'string', required: true, description: 'Document id.' },
1226
+ name: { type: 'string', required: true, description: 'New name.' },
1227
+ },
1228
+ },
1229
+ render: (_args, value) => [{ type: 'text', text: `文档已重命名为 ${String(value.name)}` }],
1230
+ },
1231
+ isConcurrencySafe: () => false,
1232
+ async execute(args, exec) {
1233
+ void exec;
1234
+ const meta = await deps.registry.resolve(args.doc);
1235
+ if (meta === null)
1236
+ throw new Error(`no document matches "${String(args.doc)}"`);
1237
+ const renamed = await deps.registry.rename(meta.id, args.name);
1238
+ if (renamed === null)
1239
+ throw new Error(`rename failed: ${meta.id}`);
1240
+ return { docId: renamed.id, name: renamed.name };
1241
+ },
1242
+ presentCall: (args) => ({ card: 'generic', title: `重命名文档 ${String(args.doc)}`, kind: 'other' }),
1243
+ presentResult: () => ({ card: 'generic', title: '重命名文档' }),
1244
+ });
1245
+ const cadDocDelete = defineTool({
1246
+ name: 'cad_doc_delete',
1247
+ description: 'Permanently delete a modeling document from the file space (op log + bodies). Requires confirm=true — ask the user before calling. ' +
1248
+ 'To delete a single body inside a document, use cad_delete instead.',
1249
+ parameters: {
1250
+ doc: docRefParam,
1251
+ confirm: { type: 'boolean', required: true, description: 'Must be explicitly true to delete.' },
1252
+ },
1253
+ output: {
1254
+ schema: {
1255
+ type: 'object',
1256
+ additionalProperties: false,
1257
+ properties: {
1258
+ deleted: { type: 'string', required: true, description: 'Deleted document id.' },
1259
+ name: { type: 'string', required: true, description: 'Deleted document name.' },
1260
+ },
1261
+ },
1262
+ render: (_args, value) => [{ type: 'text', text: `已删除文档 ${String(value.name)}` }],
1263
+ },
1264
+ isConcurrencySafe: () => false,
1265
+ async execute(args, exec) {
1266
+ void exec;
1267
+ if (args.confirm !== true) {
1268
+ throw new Error('pass confirm=true to delete — this permanently removes the document and its bodies');
1269
+ }
1270
+ const meta = await deps.registry.resolve(args.doc);
1271
+ if (meta === null)
1272
+ throw new Error(`no document matches "${String(args.doc)}"`);
1273
+ const removed = await deps.registry.remove(meta.id);
1274
+ if (!removed)
1275
+ throw new Error(`delete failed: ${meta.id}`);
1276
+ // Drop the active-document state if it was the deleted one; the next
1277
+ // modeling op lazily creates a fresh document for its session.
1278
+ if (document.doc.docId === meta.id) {
1279
+ document = new ModelDocument(deps.workspaceRoot);
1280
+ meshCache.clear();
1281
+ drawingSheets = new Map();
1282
+ lastDrawingId = null;
1283
+ syncedEpoch = -1;
1284
+ }
1285
+ return { deleted: meta.id, name: meta.name };
1286
+ },
1287
+ presentCall: (args) => ({ card: 'generic', title: `删除文档 ${String(args.doc)}`, kind: 'delete' }),
1288
+ presentResult: () => ({ card: 'generic', title: '删除文档' }),
1289
+ });
1290
+ // ── constraint tools (Ansatz solver) ──────────────────────────────────────
1291
+ const constraintTools = createConstraintTools({
1292
+ deps,
1293
+ // Read the ACTIVE document per call: multi-document sessions swap it.
1294
+ getDocument: () => document,
1295
+ meshCache,
1296
+ constraintState,
1297
+ resolveDoc,
1298
+ syncAssembly,
1299
+ assemblyMetaOf,
1300
+ });
788
1301
  return [
789
1302
  cadCreatePrim,
790
1303
  cadExtrude,
1304
+ cadRevolve,
1305
+ cadChamfer,
1306
+ cadPattern,
1307
+ cadLoft,
1308
+ cadSweep,
791
1309
  cadBoolean,
792
1310
  cadFillet,
793
1311
  cadTransform,
@@ -798,11 +1316,22 @@ export function createModelTools(deps) {
798
1316
  cadAssemblyInsert,
799
1317
  cadAssemblyMove,
800
1318
  cadAssemblyRemove,
1319
+ cadDocs,
1320
+ cadDocNew,
1321
+ cadDocOpen,
1322
+ cadDocRename,
1323
+ cadDocDelete,
1324
+ ...constraintTools,
801
1325
  ];
802
1326
  }
803
1327
  export const MODEL_TOOL_NAMES = [
804
1328
  'cad_create_prim',
805
1329
  'cad_extrude_profile',
1330
+ 'cad_revolve',
1331
+ 'cad_chamfer',
1332
+ 'cad_pattern',
1333
+ 'cad_loft',
1334
+ 'cad_sweep',
806
1335
  'cad_boolean',
807
1336
  'cad_fillet',
808
1337
  'cad_transform',
@@ -813,4 +1342,12 @@ export const MODEL_TOOL_NAMES = [
813
1342
  'cad_assembly_insert',
814
1343
  'cad_assembly_move',
815
1344
  'cad_assembly_remove',
1345
+ 'cad_docs',
1346
+ 'cad_doc_new',
1347
+ 'cad_doc_open',
1348
+ 'cad_doc_rename',
1349
+ 'cad_doc_delete',
1350
+ 'cad_constraint',
1351
+ 'cad_solve',
1352
+ 'cad_motion',
816
1353
  ];