dsh-cad 0.2.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.
Files changed (113) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +190 -0
  3. package/README.zh-CN.md +173 -0
  4. package/cordis.patch.yml +3 -0
  5. package/lib/b64.js +12 -0
  6. package/lib/cad_connector/builtin-executor.js +55 -0
  7. package/lib/cad_connector/builtin.js +8 -0
  8. package/lib/cad_connector/executor.js +76 -0
  9. package/lib/cad_connector/freecad-executor.js +398 -0
  10. package/lib/cad_connector/freecad.js +8 -0
  11. package/lib/cad_connector/fusion360-executor.js +398 -0
  12. package/lib/cad_connector/fusion360.js +8 -0
  13. package/lib/cad_connector/gstarcad3d.js +8 -0
  14. package/lib/cad_connector/index.js +20 -0
  15. package/lib/cad_connector/onshape.js +8 -0
  16. package/lib/cad_connector/solidworks.js +8 -0
  17. package/lib/cad_connector/types.js +6 -0
  18. package/lib/cad_connector/zw3d.js +8 -0
  19. package/lib/client.js +4117 -0
  20. package/lib/client.js.map +7 -0
  21. package/lib/convert/dcprt.js +38 -0
  22. package/lib/convert/detect.js +22 -0
  23. package/lib/convert/dxf.js +124 -0
  24. package/lib/convert/index.js +70 -0
  25. package/lib/convert/obj.js +106 -0
  26. package/lib/convert/png.js +114 -0
  27. package/lib/convert/step-worker.mjs +61 -0
  28. package/lib/convert/step.js +66 -0
  29. package/lib/convert/stl.js +110 -0
  30. package/lib/convert/svg.js +101 -0
  31. package/lib/demo-bracket.brep +668 -0
  32. package/lib/demo-flange.brep +709 -0
  33. package/lib/demo-shaft.brep +516 -0
  34. package/lib/feature_script/dc_asm.js +17 -0
  35. package/lib/feature_script/dc_engineering.js +19 -0
  36. package/lib/feature_script/dc_prt.js +21 -0
  37. package/lib/feature_script/index.js +7 -0
  38. package/lib/image/profile.js +218 -0
  39. package/lib/index.js +77 -0
  40. package/lib/modeling/assembly.js +63 -0
  41. package/lib/modeling/bin-format.js +80 -0
  42. package/lib/modeling/bin-store.js +67 -0
  43. package/lib/modeling/client.js +117 -0
  44. package/lib/modeling/document.js +55 -0
  45. package/lib/modeling/drawing.js +324 -0
  46. package/lib/modeling/hlr.cjs +328 -0
  47. package/lib/modeling/modeling-worker.cjs +235 -0
  48. package/lib/modeling/occt-adapter.cjs +271 -0
  49. package/lib/routes.js +142 -0
  50. package/lib/store.js +68 -0
  51. package/lib/tools/cad-freecad.js +160 -0
  52. package/lib/tools/cad-fusion.js +138 -0
  53. package/lib/tools/cad-image.js +121 -0
  54. package/lib/tools/cad-info.js +92 -0
  55. package/lib/tools/cad-model.js +816 -0
  56. package/lib/tools/cad-view.js +112 -0
  57. package/lib/tools/util.js +25 -0
  58. package/lib/types/b64.d.ts +8 -0
  59. package/lib/types/cad/builtin.d.ts +3 -0
  60. package/lib/types/cad/freecad.d.ts +3 -0
  61. package/lib/types/cad/fusion360.d.ts +3 -0
  62. package/lib/types/cad/gstarcad3d.d.ts +3 -0
  63. package/lib/types/cad/index.d.ts +8 -0
  64. package/lib/types/cad/onshape.d.ts +3 -0
  65. package/lib/types/cad/solidworks.d.ts +3 -0
  66. package/lib/types/cad/types.d.ts +21 -0
  67. package/lib/types/cad/zw3d.d.ts +3 -0
  68. package/lib/types/cad_connector/builtin-executor.d.ts +9 -0
  69. package/lib/types/cad_connector/builtin.d.ts +3 -0
  70. package/lib/types/cad_connector/executor.d.ts +69 -0
  71. package/lib/types/cad_connector/freecad-executor.d.ts +55 -0
  72. package/lib/types/cad_connector/freecad.d.ts +3 -0
  73. package/lib/types/cad_connector/fusion360-executor.d.ts +11 -0
  74. package/lib/types/cad_connector/fusion360.d.ts +3 -0
  75. package/lib/types/cad_connector/gstarcad3d.d.ts +3 -0
  76. package/lib/types/cad_connector/index.d.ts +8 -0
  77. package/lib/types/cad_connector/onshape.d.ts +3 -0
  78. package/lib/types/cad_connector/solidworks.d.ts +3 -0
  79. package/lib/types/cad_connector/types.d.ts +21 -0
  80. package/lib/types/cad_connector/zw3d.d.ts +3 -0
  81. package/lib/types/convert/dcprt.d.ts +14 -0
  82. package/lib/types/convert/detect.d.ts +7 -0
  83. package/lib/types/convert/dxf.d.ts +3 -0
  84. package/lib/types/convert/index.d.ts +9 -0
  85. package/lib/types/convert/obj.d.ts +7 -0
  86. package/lib/types/convert/png.d.ts +8 -0
  87. package/lib/types/convert/step.d.ts +4 -0
  88. package/lib/types/convert/stl.d.ts +7 -0
  89. package/lib/types/convert/svg.d.ts +8 -0
  90. package/lib/types/feature_script/dc_asm.d.ts +40 -0
  91. package/lib/types/feature_script/dc_engineering.d.ts +55 -0
  92. package/lib/types/feature_script/dc_prt.d.ts +37 -0
  93. package/lib/types/feature_script/index.d.ts +7 -0
  94. package/lib/types/image/profile.d.ts +28 -0
  95. package/lib/types/index.d.ts +18 -0
  96. package/lib/types/modeling/assembly.d.ts +17 -0
  97. package/lib/types/modeling/bin-format.d.ts +42 -0
  98. package/lib/types/modeling/bin-store.d.ts +20 -0
  99. package/lib/types/modeling/client.d.ts +125 -0
  100. package/lib/types/modeling/document.d.ts +26 -0
  101. package/lib/types/modeling/drawing.d.ts +38 -0
  102. package/lib/types/routes.d.ts +27 -0
  103. package/lib/types/store.d.ts +18 -0
  104. package/lib/types/tools/cad-freecad.d.ts +10 -0
  105. package/lib/types/tools/cad-fusion.d.ts +8 -0
  106. package/lib/types/tools/cad-image.d.ts +8 -0
  107. package/lib/types/tools/cad-info.d.ts +6 -0
  108. package/lib/types/tools/cad-model.d.ts +13 -0
  109. package/lib/types/tools/cad-view.d.ts +10 -0
  110. package/lib/types/tools/util.d.ts +4 -0
  111. package/lib/types/types.d.ts +142 -0
  112. package/lib/types.js +25 -0
  113. package/package.json +91 -0
@@ -0,0 +1,816 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { defineTool } from '@deepseek-ai/dsh-tools';
3
+ import { runModelOp, workerResetEpoch } from '../modeling/client.js';
4
+ import { ModelDocument } from '../modeling/document.js';
5
+ import { composeAssemblyMeshes } from '../modeling/assembly.js';
6
+ import { buildDrawingSheet, drawingToDxf, drawingToSvg } from '../modeling/drawing.js';
7
+ import { resolveWorkspacePath } from './util.js';
8
+ import { toDcPrtDocument } from '../feature_script/dc_prt.js';
9
+ /** Mirror a worker mesh with its raw typed arrays (binary-transport ready). */
10
+ function mirrorMesh(mesh, bodyId) {
11
+ return {
12
+ name: mesh.name === '' ? bodyId : mesh.name,
13
+ positions: mesh.positions,
14
+ normals: mesh.normals,
15
+ indices: mesh.indices,
16
+ };
17
+ }
18
+ /** Build the whole tool family over one document + worker + scene store. */
19
+ export function createModelTools(deps) {
20
+ const document = new ModelDocument(deps.workspaceRoot);
21
+ /** bodyId → raw worker mesh mirror (binary scene source, zero encoding). */
22
+ const meshCache = new Map();
23
+ /** Reset epoch after the last sync — out-of-band replays (cad_view on a
24
+ * .dcprt) bump the epoch and force a re-replay before the next op. */
25
+ let syncedEpoch = -1;
26
+ /** drawingId → rebuilt sheet (backing cad_export .svg/.dxf). */
27
+ const drawingSheets = new Map();
28
+ let lastDrawingId = null;
29
+ /**
30
+ * Standard drawing views (GB first-angle): 主视图 front, 俯视图 top,
31
+ * 左视图 left, 轴测 iso. Same constants as the worker's fallback.
32
+ */
33
+ const DRAWING_VIEWS = [
34
+ { name: 'front', dir: [0, -1, 0], xDir: [1, 0, 0] },
35
+ { name: 'top', dir: [0, 0, 1], xDir: [1, 0, 0] },
36
+ { name: 'left', dir: [-1, 0, 0], xDir: [0, 1, 0] },
37
+ { name: 'iso', dir: [1, -1, 1], xDir: [1, 1, 0] },
38
+ ];
39
+ /**
40
+ * A second stable UUID derived from the document id (hex reversed + salted
41
+ * version nibble): SceneStore's viewId regex demands UUID shape, while the
42
+ * drawing scene must keep one stable viewId across re-draws/replays.
43
+ */
44
+ const siblingUuid = (salt) => {
45
+ const hex = document.doc.docId.replace(/-/g, '').split('').reverse().join('');
46
+ const salted = (hex.slice(0, 12) + salt + hex.slice(13)).slice(0, 32);
47
+ return `${salted.slice(0, 8)}-${salted.slice(8, 12)}-${salted.slice(12, 16)}-${salted.slice(16, 20)}-${salted.slice(20, 32)}`;
48
+ };
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
+ }
72
+ }
73
+ catch {
74
+ // A single stale op must not block recovery; later ops may be independent.
75
+ }
76
+ }
77
+ const all = await runModelOp({ kind: 'tessellate_all' });
78
+ meshCache.clear();
79
+ for (const mesh of all.meshes ?? []) {
80
+ meshCache.set(mesh.bodyId, mirrorMesh(mesh, mesh.bodyId));
81
+ }
82
+ }
83
+ syncedEpoch = workerResetEpoch();
84
+ }
85
+ async function syncScene(op, result, filePath) {
86
+ if (result.mesh !== undefined && result.bodyId !== undefined) {
87
+ meshCache.set(result.bodyId, mirrorMesh(result.mesh, result.bodyId));
88
+ }
89
+ for (const mesh of result.meshes ?? []) {
90
+ meshCache.set(mesh.bodyId, mirrorMesh(mesh, mesh.bodyId));
91
+ }
92
+ const removed = [...(result.removed ?? []), ...(result.deleted !== undefined ? [result.deleted] : [])];
93
+ for (const id of removed)
94
+ meshCache.delete(id);
95
+ const nameEntry = result.bodyId !== undefined && result.name !== undefined ? { bodyId: result.bodyId, name: result.name } : null;
96
+ await document.record(op, nameEntry);
97
+ const meshes = [...meshCache.values()];
98
+ const triangles = meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0);
99
+ const sceneUrlBase = deps.ensureSceneRoute();
100
+ if (meshes.length > 0) {
101
+ // Direct worker→three.js transport: packed binary, in-memory, no file
102
+ // write per step (a debounced disk mirror keeps restart replay).
103
+ await deps.store.publish(document.doc.docId, meshes);
104
+ }
105
+ const value = {
106
+ triangles,
107
+ bodies: meshes.length,
108
+ version: document.doc.version,
109
+ };
110
+ if (result.bodyId !== undefined)
111
+ value.bodyId = result.bodyId;
112
+ if (result.name !== undefined)
113
+ value.name = result.name;
114
+ if (removed.length > 0)
115
+ value.removed = removed;
116
+ if (result.volume !== undefined)
117
+ value.volume = result.volume;
118
+ if (filePath !== undefined)
119
+ value.filePath = filePath;
120
+ if (sceneUrlBase !== null) {
121
+ value.sceneUrl = `${sceneUrlBase.replace('/scene', '/bin')}/${document.doc.docId}?v=${document.doc.version}`;
122
+ }
123
+ return value;
124
+ }
125
+ const nextBodyId = () => `b${document.doc.version + 1}`;
126
+ const nextInstanceId = () => `a${document.doc.version + 1}`;
127
+ /**
128
+ * Assembly sync: compose instance meshes from the body cache (pure
129
+ * transform math), publish the packed assembly scene under the stable
130
+ * `asm-<docId>` viewId, and record the op. Runs instead of syncScene —
131
+ * body geometry is untouched by assembly ops.
132
+ */
133
+ async function syncAssembly(op, result, filePath) {
134
+ const instances = result.instances ?? [];
135
+ const meshes = composeAssemblyMeshes(meshCache, instances);
136
+ await document.record(op, null);
137
+ const sceneUrlBase = deps.ensureSceneRoute();
138
+ if (sceneUrlBase !== null) {
139
+ // Publish even when empty — removing the last instance must refresh the tab.
140
+ await deps.store.publish(`asm-${document.doc.docId}`, meshes);
141
+ }
142
+ const triangles = meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0);
143
+ const value = {
144
+ instances: instances.length,
145
+ triangles,
146
+ bodies: meshCache.size,
147
+ version: document.doc.version,
148
+ viewId: `asm-${document.doc.docId}`,
149
+ };
150
+ if (result.instanceId !== undefined)
151
+ value.instanceId = result.instanceId;
152
+ if (filePath !== undefined)
153
+ value.filePath = filePath;
154
+ if (sceneUrlBase !== null) {
155
+ value.sceneUrl = `${sceneUrlBase.replace('/scene', '/bin')}/asm-${document.doc.docId}?v=${document.doc.version}`;
156
+ }
157
+ return value;
158
+ }
159
+ /** Build + publish the drawing sheet for a replayed/executed drawing op. */
160
+ async function publishDrawing(sceneViewId, partName, views, paper) {
161
+ const sheet = buildDrawingSheet({
162
+ partName,
163
+ views: views ?? [],
164
+ paper,
165
+ drawingNo: `DSH-${String(document.doc.version).padStart(3, '0')}`,
166
+ date: new Date().toISOString().slice(0, 10),
167
+ });
168
+ drawingSheets.set(sceneViewId, { sheet, partName });
169
+ lastDrawingId = sceneViewId;
170
+ await deps.sceneStore.putAt(sceneViewId, {
171
+ kind: '2d',
172
+ format: 'drawing',
173
+ entities: sheet.entities,
174
+ bounds: sheet.bounds,
175
+ layers: sheet.layers,
176
+ });
177
+ return sheet;
178
+ }
179
+ /** Most recently created surviving body (default drawing/export target). */
180
+ const lastBodyId = () => {
181
+ const keys = [...meshCache.keys()];
182
+ return keys[keys.length - 1];
183
+ };
184
+ // The model-facing render text: bodyId visibility is the load-bearing part.
185
+ const renderModel = (value) => {
186
+ const lines = [
187
+ value.bodyId !== undefined
188
+ ? `${String(value.name ?? value.bodyId)} → ${String(value.bodyId)} (version ${String(value.version)}, document: ${String(value.bodies)} bodies, ${String(value.triangles)} triangles)`
189
+ : `document: ${String(value.bodies)} bodies, ${String(value.triangles)} triangles (version ${String(value.version)})`,
190
+ ];
191
+ if (Array.isArray(value.removed) && value.removed.length > 0)
192
+ lines.push(`consumed bodies: ${value.removed.join(', ')}`);
193
+ if (value.volume !== undefined)
194
+ lines.push(`volume: ${Number(value.volume).toFixed(2)} mm³`);
195
+ if (value.filePath !== undefined)
196
+ lines.push(`written: ${String(value.filePath)}`);
197
+ return lines.join('\n');
198
+ };
199
+ const metaOf = (value) => ({
200
+ viewId: document.doc.docId,
201
+ kind: '3d',
202
+ format: 'model',
203
+ file: 'modeling document',
204
+ doc: 'part',
205
+ ...(value.sceneUrl === undefined ? {} : { sceneUrl: String(value.sceneUrl) }),
206
+ title: `CAD model · ${String(value.bodies)} ${value.bodies === 1 ? 'body' : 'bodies'}`,
207
+ stats: {
208
+ meshes: Number(value.bodies),
209
+ triangles: Number(value.triangles),
210
+ },
211
+ });
212
+ /** Presentation meta for assembly ops (kind 3d, doc assembly). */
213
+ const assemblyMetaOf = (value) => ({
214
+ viewId: String(value.viewId),
215
+ kind: '3d',
216
+ format: 'assembly',
217
+ file: 'assembly',
218
+ doc: 'assembly',
219
+ ...(value.sceneUrl === undefined ? {} : { sceneUrl: String(value.sceneUrl) }),
220
+ title: `装配体 · ${String(value.instances)} 实例`,
221
+ stats: {
222
+ meshes: Number(value.instances),
223
+ triangles: Number(value.triangles),
224
+ },
225
+ });
226
+ /** Presentation meta for drawing ops (kind 2d, doc drawing). */
227
+ const drawingMetaOf = (value) => ({
228
+ viewId: String(value.drawingId),
229
+ kind: '2d',
230
+ format: 'drawing',
231
+ file: 'drawing',
232
+ doc: 'drawing',
233
+ ...(value.sceneUrl === undefined ? {} : { sceneUrl: String(value.sceneUrl) }),
234
+ title: `工程图 · ${String(value.name ?? value.target)}`,
235
+ stats: {
236
+ entities: Number(value.entities),
237
+ },
238
+ });
239
+ const renderAssembly = (value) => {
240
+ const lines = [
241
+ value.instanceId !== undefined
242
+ ? `instance ${String(value.instanceId)} placed (assembly: ${String(value.instances)} instances, ${String(value.triangles)} triangles)`
243
+ : `assembly: ${String(value.instances)} instances, ${String(value.triangles)} triangles`,
244
+ ];
245
+ if (value.filePath !== undefined)
246
+ lines.push(`written: ${String(value.filePath)}`);
247
+ return lines.join('\n');
248
+ };
249
+ const renderDrawing = (value) => {
250
+ const lines = [
251
+ `工程图 ${String(value.target)}: 主视图/俯视图/左视图/轴测 · 比例 ${String(value.scaleText ?? '')} · ${String(value.entities)} 实体 (version ${String(value.version)})`,
252
+ ];
253
+ if (value.filePath !== undefined)
254
+ lines.push(`written: ${String(value.filePath)}`);
255
+ return lines.join('\n');
256
+ };
257
+ const numberParam = (description) => ({ type: 'number', description });
258
+ const pointParam = (description) => ({ type: 'array', items: { type: 'number' }, description });
259
+ const bodyTarget = { type: 'string', required: true, description: 'BodyId to operate on.' };
260
+ /** Optional fields every tool's schema declares (syncScene emits any subset). */
261
+ const commonOptional = {
262
+ name: { type: 'string', description: 'Body display name.' },
263
+ removed: { type: 'array', items: { type: 'string' }, description: 'Bodies consumed by a boolean.' },
264
+ volume: { type: 'number', description: 'Body volume in mm³.' },
265
+ filePath: { type: 'string', description: 'Written file path (cad_export).' },
266
+ sceneUrl: { type: 'string', description: 'Versioned viewer URL (web compositions).' },
267
+ };
268
+ const requiredCounts = {
269
+ triangles: { type: 'number', required: true, description: 'Document triangle count.' },
270
+ bodies: { type: 'number', required: true, description: 'Bodies in the document.' },
271
+ version: { type: 'number', required: true, description: 'Document version (increments per op).' },
272
+ };
273
+ /** Schema shared by the assembly tool family. */
274
+ const assemblySchema = {
275
+ instances: { type: 'number', required: true, description: 'Instances in the assembly.' },
276
+ instanceId: { type: 'string', description: 'Affected instance id.' },
277
+ viewId: { type: 'string', description: 'Stable assembly scene viewId.' },
278
+ triangles: { type: 'number', required: true, description: 'Assembly triangle count.' },
279
+ bodies: { type: 'number', required: true, description: 'Bodies in the document.' },
280
+ version: { type: 'number', required: true, description: 'Document version.' },
281
+ filePath: { type: 'string', description: 'Written file path (cad_export).' },
282
+ sceneUrl: { type: 'string', description: 'Versioned assembly scene URL (web compositions).' },
283
+ };
284
+ /** Schema for the drawing tool output. */
285
+ const drawingSchema = {
286
+ target: { type: 'string', required: true, description: 'Drawn bodyId.' },
287
+ name: { type: 'string', description: 'Drawing/part display name.' },
288
+ drawingId: { type: 'string', description: 'Stable drawing scene viewId.' },
289
+ entities: { type: 'number', required: true, description: 'Sheet entity count.' },
290
+ scale: { type: 'number', required: true, description: 'Standard-view scale (1 = full size).' },
291
+ scaleText: { type: 'string', description: 'Scale as titled text (1:2 / 2:1).' },
292
+ bodies: { type: 'number', required: true, description: 'Bodies in the document.' },
293
+ triangles: { type: 'number', required: true, description: 'Document triangle count.' },
294
+ version: { type: 'number', required: true, description: 'Document version.' },
295
+ filePath: { type: 'string', description: 'Written file path (cad_export).' },
296
+ sceneUrl: { type: 'string', description: 'Versioned drawing scene URL (web compositions).' },
297
+ };
298
+ const cadCreatePrim = defineTool({
299
+ name: 'cad_create_prim',
300
+ description: 'Create a parametric primitive in the shared modeling document (mm, Z-up). Kinds: box (dx,dy,dz), cylinder (radius,height), sphere (radius), cone (radius1,radius2,height), torus (majorRadius,minorRadius). ' +
301
+ '`at` places the origin; `axis` orients cylinder/cone/torus (default +Z). Returns the bodyId other CAD tools reference. The viewer card updates after every call.',
302
+ parameters: {
303
+ kind: { type: 'string', required: true, enum: ['box', 'cylinder', 'sphere', 'cone', 'torus'], description: 'Primitive kind.' },
304
+ dx: numberParam('box: size X (mm).'),
305
+ dy: numberParam('box: size Y (mm).'),
306
+ dz: numberParam('box: size Z (mm).'),
307
+ radius: numberParam('cylinder/sphere: radius (mm).'),
308
+ radius1: numberParam('cone: base radius (mm).'),
309
+ radius2: numberParam('cone: top radius (mm, 0 = pointed).'),
310
+ height: numberParam('cylinder/cone: height (mm).'),
311
+ majorRadius: numberParam('torus: center radius (mm).'),
312
+ minorRadius: numberParam('torus: tube radius (mm).'),
313
+ at: pointParam('origin [x,y,z] (mm).'),
314
+ axis: pointParam('axis direction [x,y,z] for cylinder/cone/torus.'),
315
+ name: { type: 'string', description: 'Optional display name.' },
316
+ },
317
+ output: {
318
+ schema: {
319
+ type: 'object',
320
+ additionalProperties: false,
321
+ properties: {
322
+ bodyId: { type: 'string', required: true, description: 'Stable body reference.' },
323
+ ...requiredCounts,
324
+ ...commonOptional,
325
+ },
326
+ },
327
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
328
+ presentationMeta: (_args, value) => metaOf(value),
329
+ },
330
+ isConcurrencySafe: () => false,
331
+ async execute(args) {
332
+ await restoreOnce();
333
+ const bodyId = nextBodyId();
334
+ const params = args;
335
+ const op = { kind: 'create_prim', bodyId, prim: args.kind, params, name: args.name };
336
+ const result = await runModelOp(op);
337
+ return syncScene(op, result);
338
+ },
339
+ presentCall: (args) => ({ card: 'generic', title: `CAD create ${String(args.kind)}`, kind: 'other' }),
340
+ presentResult: () => ({ card: 'generic', title: 'CAD create' }),
341
+ });
342
+ const cadExtrude = defineTool({
343
+ 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.',
345
+ parameters: {
346
+ points: { type: 'array', required: true, items: { type: 'number' }, description: 'Flat [x0,y0,x1,y1,…] loop (mm).' },
347
+ height: { type: 'number', description: 'Extrusion height (mm, default 10).' },
348
+ base: { type: 'number', description: 'Z of the profile plane (mm, default 0).' },
349
+ name: { type: 'string', description: 'Optional display name.' },
350
+ },
351
+ output: {
352
+ schema: {
353
+ type: 'object',
354
+ additionalProperties: false,
355
+ properties: {
356
+ bodyId: { type: 'string', required: true },
357
+ ...requiredCounts,
358
+ ...commonOptional,
359
+ },
360
+ },
361
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
362
+ presentationMeta: (_args, value) => metaOf(value),
363
+ },
364
+ 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)');
369
+ }
370
+ const bodyId = nextBodyId();
371
+ const op = { kind: 'extrude_profile', bodyId, points: args.points, height: args.height, base: args.base, name: args.name };
372
+ const result = await runModelOp(op);
373
+ return syncScene(op, result);
374
+ },
375
+ presentCall: () => ({ card: 'generic', title: 'CAD extrude profile', kind: 'other' }),
376
+ presentResult: () => ({ card: 'generic', title: 'CAD extrude' }),
377
+ });
378
+ const cadBoolean = defineTool({
379
+ name: 'cad_boolean',
380
+ 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.',
381
+ parameters: {
382
+ op: { type: 'string', required: true, enum: ['fuse', 'cut', 'common'], description: 'Boolean operation.' },
383
+ target: { type: 'string', required: true, description: 'BodyId kept as the result.' },
384
+ tools: { type: 'array', required: true, items: { type: 'string' }, description: 'BodyIds combined into the target.' },
385
+ },
386
+ output: {
387
+ schema: {
388
+ type: 'object',
389
+ additionalProperties: false,
390
+ properties: {
391
+ bodyId: { type: 'string', required: true },
392
+ ...requiredCounts,
393
+ ...commonOptional,
394
+ },
395
+ },
396
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
397
+ presentationMeta: (_args, value) => metaOf(value),
398
+ },
399
+ isConcurrencySafe: () => false,
400
+ async execute(args) {
401
+ await restoreOnce();
402
+ const op = { kind: 'boolean', op: args.op, target: args.target, tools: args.tools };
403
+ const result = await runModelOp(op);
404
+ return syncScene(op, result);
405
+ },
406
+ presentCall: (args) => ({ card: 'generic', title: `CAD ${String(args.op)} ${String(args.target)}`, kind: 'other' }),
407
+ presentResult: () => ({ card: 'generic', title: 'CAD boolean' }),
408
+ });
409
+ const cadFillet = defineTool({
410
+ name: 'cad_fillet',
411
+ description: 'Round every sharp edge of a body with one radius (mm). Fails when the radius exceeds the adjacent faces.',
412
+ parameters: {
413
+ target: bodyTarget,
414
+ radius: { type: 'number', required: true, description: 'Fillet radius (mm).' },
415
+ },
416
+ output: {
417
+ schema: {
418
+ type: 'object',
419
+ additionalProperties: false,
420
+ properties: {
421
+ bodyId: { type: 'string', required: true },
422
+ ...requiredCounts,
423
+ ...commonOptional,
424
+ },
425
+ },
426
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
427
+ presentationMeta: (_args, value) => metaOf(value),
428
+ },
429
+ isConcurrencySafe: () => false,
430
+ async execute(args) {
431
+ await restoreOnce();
432
+ const op = { kind: 'fillet', target: args.target, radius: args.radius };
433
+ const result = await runModelOp(op);
434
+ return syncScene(op, result);
435
+ },
436
+ presentCall: (args) => ({ card: 'generic', title: `CAD fillet ${String(args.target)}`, kind: 'other' }),
437
+ presentResult: () => ({ card: 'generic', title: 'CAD fillet' }),
438
+ });
439
+ const cadTransform = defineTool({
440
+ name: 'cad_transform',
441
+ description: 'Move/rotate/mirror a body (mm, degrees, Z-up). Applies in order: translate → rotate (XYZ Euler degrees) → mirror (plane through the origin by normal).',
442
+ parameters: {
443
+ target: bodyTarget,
444
+ translate: pointParam('translation [x,y,z] (mm).'),
445
+ rotate: pointParam('rotation [rx,ry,rz] (degrees, XYZ Euler).'),
446
+ mirror: pointParam('mirror plane normal [x,y,z] through the origin.'),
447
+ },
448
+ output: {
449
+ schema: {
450
+ type: 'object',
451
+ additionalProperties: false,
452
+ properties: {
453
+ bodyId: { type: 'string', required: true },
454
+ ...requiredCounts,
455
+ ...commonOptional,
456
+ },
457
+ },
458
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
459
+ presentationMeta: (_args, value) => metaOf(value),
460
+ },
461
+ isConcurrencySafe: () => false,
462
+ async execute(args) {
463
+ await restoreOnce();
464
+ const op = {
465
+ kind: 'transform',
466
+ target: args.target,
467
+ translate: args.translate,
468
+ rotate: args.rotate,
469
+ mirror: args.mirror,
470
+ };
471
+ const result = await runModelOp(op);
472
+ return syncScene(op, result);
473
+ },
474
+ presentCall: (args) => ({ card: 'generic', title: `CAD transform ${String(args.target)}`, kind: 'other' }),
475
+ presentResult: () => ({ card: 'generic', title: 'CAD transform' }),
476
+ });
477
+ const cadExport = defineTool({
478
+ name: 'cad_export',
479
+ description: 'Export to a workspace path (extension selects format): .step / .stl of a body, or .dcprt (the native replayable part document). ' +
480
+ 'target "assembly" exports the whole assembly (STEP assembly of transformed instances). ' +
481
+ 'target "drawing" exports the latest drawing sheet (.svg vector / .dxf exchange).',
482
+ parameters: {
483
+ target: { type: 'string', description: 'BodyId; "assembly" for the whole assembly; "drawing" for the latest sheet (required for .step/.stl; unused for .dcprt).' },
484
+ path: { type: 'string', required: true, description: 'Destination file path (extension selects format).' },
485
+ },
486
+ output: {
487
+ schema: {
488
+ type: 'object',
489
+ additionalProperties: false,
490
+ properties: {
491
+ ...requiredCounts,
492
+ ...commonOptional,
493
+ instances: { type: 'number', description: 'Assembly instance count (assembly export).' },
494
+ viewId: { type: 'string', description: 'Assembly scene viewId (assembly export).' },
495
+ target: { type: 'string', description: 'Exported target: bodyId, "assembly", or "drawing".' },
496
+ drawingId: { type: 'string', description: 'Drawing scene viewId (drawing export).' },
497
+ entities: { type: 'number', description: 'Sheet entity count (drawing export).' },
498
+ scale: { type: 'number', description: 'Standard-view scale (drawing export).' },
499
+ scaleText: { type: 'string', description: 'Scale as titled text (drawing export).' },
500
+ filePath: { type: 'string', required: true, description: 'Written file path.' },
501
+ },
502
+ },
503
+ render: (_args, value) => [{
504
+ type: 'text',
505
+ text: value.viewId !== undefined && String(value.viewId).startsWith('asm-')
506
+ ? renderAssembly(value)
507
+ : renderModel(value),
508
+ }],
509
+ presentationMeta: (_args, value) => {
510
+ const record = value;
511
+ if (typeof record.viewId === 'string' && record.viewId.startsWith('asm-'))
512
+ return assemblyMetaOf(record);
513
+ return metaOf(record);
514
+ },
515
+ },
516
+ isConcurrencySafe: () => true,
517
+ async execute(args) {
518
+ await restoreOnce();
519
+ const resolved = resolveWorkspacePath(args.path, deps.workspaceRoot);
520
+ const lower = args.path.toLowerCase();
521
+ // Assembly export: compound of transformed instances via the worker.
522
+ if (args.target === 'assembly') {
523
+ const format = lower.endsWith('.stl') ? 'stl' : 'step';
524
+ const op = { kind: 'export_assembly', format };
525
+ const result = await runModelOp(op);
526
+ if (result.bytes === undefined)
527
+ throw new Error('assembly export produced no data');
528
+ await writeFile(resolved, Buffer.from(result.bytes));
529
+ return syncAssembly(op, result, resolved);
530
+ }
531
+ // Drawing export: serialize the latest rebuilt sheet — no op recorded
532
+ // (pure export, same convention as .dcprt).
533
+ if (args.target === 'drawing') {
534
+ if (lastDrawingId === null)
535
+ throw new Error('no drawing yet — call cad_drawing first');
536
+ const entry = drawingSheets.get(lastDrawingId);
537
+ if (entry === undefined)
538
+ throw new Error('drawing sheet is unavailable');
539
+ if (lower.endsWith('.dxf'))
540
+ await writeFile(resolved, drawingToDxf(entry.sheet), 'utf8');
541
+ else if (lower.endsWith('.svg'))
542
+ await writeFile(resolved, drawingToSvg(entry.sheet), 'utf8');
543
+ else
544
+ throw new Error('drawing export supports .svg and .dxf paths');
545
+ const meshes = [...meshCache.values()];
546
+ const sceneUrlBase = deps.ensureSceneRoute();
547
+ const value = {
548
+ target: 'drawing',
549
+ name: entry.partName,
550
+ drawingId: lastDrawingId,
551
+ entities: entry.sheet.entityCount,
552
+ scale: entry.sheet.scale,
553
+ scaleText: entry.sheet.scale >= 1 ? `${entry.sheet.scale}:1` : `1:${Math.round(1 / entry.sheet.scale * 100) / 100}`,
554
+ bodies: meshes.length,
555
+ triangles: meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0),
556
+ version: document.doc.version,
557
+ filePath: resolved,
558
+ };
559
+ if (sceneUrlBase !== null)
560
+ value.sceneUrl = `/dsh-cad/scene/${lastDrawingId}?v=${document.doc.version}`;
561
+ return value;
562
+ }
563
+ // .dcprt serializes the whole document on the main thread — no worker
564
+ // op (that path exports one body's geometry), no entry in the log.
565
+ if (lower.endsWith('.dcprt')) {
566
+ if (document.doc.ops.length === 0)
567
+ throw new Error('nothing to export — the modeling document is empty');
568
+ await writeFile(resolved, JSON.stringify(toDcPrtDocument(document.doc)));
569
+ const meshes = [...meshCache.values()];
570
+ const value = {
571
+ triangles: meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0),
572
+ bodies: meshes.length,
573
+ version: document.doc.version,
574
+ filePath: resolved,
575
+ };
576
+ const sceneUrlBase = deps.ensureSceneRoute();
577
+ if (sceneUrlBase !== null) {
578
+ value.sceneUrl = `${sceneUrlBase.replace('/scene', '/bin')}/${document.doc.docId}?v=${document.doc.version}`;
579
+ }
580
+ return value;
581
+ }
582
+ if (args.target === undefined)
583
+ throw new Error('target is required for .step/.stl exports');
584
+ const format = lower.endsWith('.stl') ? 'stl' : 'step';
585
+ const op = { kind: 'export', target: args.target, format };
586
+ const result = await runModelOp(op);
587
+ if (result.bytes === undefined)
588
+ throw new Error('export produced no data');
589
+ await writeFile(resolved, Buffer.from(result.bytes));
590
+ return syncScene(op, result, resolved);
591
+ },
592
+ presentCall: (args) => ({ card: 'generic', title: `CAD export ${String(args.path)}`, kind: 'other' }),
593
+ presentResult: () => ({ card: 'generic', title: 'CAD export' }),
594
+ });
595
+ const cadDelete = defineTool({
596
+ name: 'cad_delete',
597
+ description: 'Delete a body from the modeling document.',
598
+ parameters: {
599
+ target: bodyTarget,
600
+ },
601
+ output: {
602
+ schema: {
603
+ type: 'object',
604
+ additionalProperties: false,
605
+ properties: {
606
+ ...requiredCounts,
607
+ ...commonOptional,
608
+ },
609
+ },
610
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
611
+ presentationMeta: (_args, value) => metaOf(value),
612
+ },
613
+ isConcurrencySafe: () => false,
614
+ async execute(args) {
615
+ await restoreOnce();
616
+ const op = { kind: 'delete', target: args.target };
617
+ const result = await runModelOp(op);
618
+ return syncScene(op, result);
619
+ },
620
+ presentCall: (args) => ({ card: 'generic', title: `CAD delete ${String(args.target)}`, kind: 'delete' }),
621
+ presentResult: () => ({ card: 'generic', title: 'CAD delete' }),
622
+ });
623
+ const cadVolume = defineTool({
624
+ name: 'cad_volume',
625
+ description: "Report a body's exact BRep volume in mm³.",
626
+ parameters: {
627
+ target: bodyTarget,
628
+ },
629
+ output: {
630
+ schema: {
631
+ type: 'object',
632
+ additionalProperties: false,
633
+ properties: {
634
+ ...requiredCounts,
635
+ ...commonOptional,
636
+ volume: { type: 'number', required: true, description: 'Volume (mm³).' },
637
+ },
638
+ },
639
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
640
+ presentationMeta: (_args, value) => metaOf(value),
641
+ },
642
+ isConcurrencySafe: () => true,
643
+ async execute(args) {
644
+ await restoreOnce();
645
+ const op = { kind: 'volume', target: args.target };
646
+ const result = await runModelOp(op);
647
+ return syncScene(op, result);
648
+ },
649
+ presentCall: (args) => ({ card: 'generic', title: `CAD volume ${String(args.target)}`, kind: 'read' }),
650
+ presentResult: () => ({ card: 'generic', title: 'CAD volume' }),
651
+ });
652
+ const cadDrawing = defineTool({
653
+ name: 'cad_drawing',
654
+ description: 'Generate an engineering drawing (工程图) of a body: GB first-angle 主视图/俯视图/左视图 plus an isometric view on an A4/A3 sheet — ' +
655
+ 'hidden lines dashed, frame, title block, overall dimensions, standard scale. Renders in the Drawing tab and exports via cad_export target "drawing" (.svg/.dxf).',
656
+ parameters: {
657
+ target: { type: 'string', description: 'BodyId to draw (default: the most recently created surviving body).' },
658
+ paper: { type: 'string', enum: ['A4', 'A3'], description: 'Sheet size (default A4 landscape).' },
659
+ name: { type: 'string', description: 'Part display name for the title block.' },
660
+ },
661
+ output: {
662
+ schema: {
663
+ type: 'object',
664
+ additionalProperties: false,
665
+ properties: drawingSchema,
666
+ },
667
+ render: (_args, value) => [{ type: 'text', text: renderDrawing(value) }],
668
+ presentationMeta: (_args, value) => drawingMetaOf(value),
669
+ },
670
+ isConcurrencySafe: () => false,
671
+ async execute(args) {
672
+ await restoreOnce();
673
+ const target = args.target ?? lastBodyId();
674
+ if (target === undefined)
675
+ throw new Error('nothing to draw — create a body first');
676
+ if (!meshCache.has(target))
677
+ throw new Error(`unknown body: ${target}`);
678
+ const partName = args.name ?? document.doc.bodyNames[target] ?? target;
679
+ const paper = args.paper === 'A3' ? 'A3' : 'A4';
680
+ const sceneViewId = siblingUuid('d');
681
+ const op = { kind: 'drawing', target, views: DRAWING_VIEWS, sceneViewId, name: partName, paper };
682
+ const result = await runModelOp(op);
683
+ const sheet = await publishDrawing(sceneViewId, partName, result.views, paper);
684
+ const meshes = [...meshCache.values()];
685
+ const sceneUrlBase = deps.ensureSceneRoute();
686
+ const value = {
687
+ target,
688
+ name: partName,
689
+ drawingId: sceneViewId,
690
+ entities: sheet.entityCount,
691
+ scale: sheet.scale,
692
+ scaleText: sheet.scale >= 1 ? `${sheet.scale}:1` : `1:${Math.round(1 / sheet.scale * 100) / 100}`,
693
+ bodies: meshes.length,
694
+ triangles: meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0),
695
+ version: document.doc.version,
696
+ };
697
+ if (sceneUrlBase !== null)
698
+ value.sceneUrl = `/dsh-cad/scene/${sceneViewId}?v=${document.doc.version}`;
699
+ return value;
700
+ },
701
+ presentCall: (args) => ({ card: 'generic', title: `工程图 ${String(args.target ?? '')}`.trim(), kind: 'other' }),
702
+ presentResult: () => ({ card: 'generic', title: '工程图' }),
703
+ });
704
+ const cadAssemblyInsert = defineTool({
705
+ name: 'cad_assembly_insert',
706
+ description: 'Insert a body into the assembly as a placed instance (mm, degrees, Z-up). `at` positions the instance origin; `rotate` orients it (XYZ Euler). ' +
707
+ 'Bodies stay untouched in the Part Studio; the Assembly tab renders every instance. Instances persist in the document op log.',
708
+ parameters: {
709
+ bodyId: { type: 'string', required: true, description: 'BodyId to insert.' },
710
+ at: pointParam('instance position [x,y,z] (mm).'),
711
+ rotate: pointParam('instance rotation [rx,ry,rz] (degrees, XYZ Euler).'),
712
+ name: { type: 'string', description: 'Instance display name (defaults to the body name).' },
713
+ },
714
+ output: {
715
+ schema: { type: 'object', additionalProperties: false, properties: assemblySchema },
716
+ render: (_args, value) => [{ type: 'text', text: renderAssembly(value) }],
717
+ presentationMeta: (_args, value) => assemblyMetaOf(value),
718
+ },
719
+ isConcurrencySafe: () => false,
720
+ async execute(args) {
721
+ await restoreOnce();
722
+ if (!meshCache.has(args.bodyId))
723
+ throw new Error(`unknown body: ${args.bodyId}`);
724
+ const instanceId = nextInstanceId();
725
+ const op = {
726
+ kind: 'assembly_insert',
727
+ instanceId,
728
+ bodyId: args.bodyId,
729
+ name: args.name ?? document.doc.bodyNames[args.bodyId] ?? args.bodyId,
730
+ translate: args.at,
731
+ rotate: args.rotate,
732
+ };
733
+ const result = await runModelOp(op);
734
+ return syncAssembly(op, result);
735
+ },
736
+ presentCall: (args) => ({ card: 'generic', title: `装配 ${String(args.bodyId)}`, kind: 'other' }),
737
+ presentResult: () => ({ card: 'generic', title: '装配插入' }),
738
+ });
739
+ const cadAssemblyMove = defineTool({
740
+ name: 'cad_assembly_move',
741
+ description: 'Set an assembly instance placement (absolute, not incremental): `at` sets the position, `rotate` the orientation (mm, degrees).',
742
+ parameters: {
743
+ instanceId: { type: 'string', required: true, description: 'InstanceId to move.' },
744
+ at: pointParam('new position [x,y,z] (mm).'),
745
+ rotate: pointParam('new rotation [rx,ry,rz] (degrees, XYZ Euler).'),
746
+ },
747
+ output: {
748
+ schema: { type: 'object', additionalProperties: false, properties: assemblySchema },
749
+ render: (_args, value) => [{ type: 'text', text: renderAssembly(value) }],
750
+ presentationMeta: (_args, value) => assemblyMetaOf(value),
751
+ },
752
+ isConcurrencySafe: () => false,
753
+ async execute(args) {
754
+ await restoreOnce();
755
+ const op = {
756
+ kind: 'assembly_transform',
757
+ instanceId: args.instanceId,
758
+ translate: args.at,
759
+ rotate: args.rotate,
760
+ };
761
+ const result = await runModelOp(op);
762
+ return syncAssembly(op, result);
763
+ },
764
+ presentCall: (args) => ({ card: 'generic', title: `移动 ${String(args.instanceId)}`, kind: 'other' }),
765
+ presentResult: () => ({ card: 'generic', title: '装配移动' }),
766
+ });
767
+ const cadAssemblyRemove = defineTool({
768
+ name: 'cad_assembly_remove',
769
+ description: 'Remove an instance from the assembly (the body itself stays in the Part Studio).',
770
+ parameters: {
771
+ instanceId: { type: 'string', required: true, description: 'InstanceId to remove.' },
772
+ },
773
+ output: {
774
+ schema: { type: 'object', additionalProperties: false, properties: assemblySchema },
775
+ render: (_args, value) => [{ type: 'text', text: renderAssembly(value) }],
776
+ presentationMeta: (_args, value) => assemblyMetaOf(value),
777
+ },
778
+ isConcurrencySafe: () => false,
779
+ async execute(args) {
780
+ await restoreOnce();
781
+ const op = { kind: 'assembly_remove', instanceId: args.instanceId };
782
+ const result = await runModelOp(op);
783
+ return syncAssembly(op, result);
784
+ },
785
+ presentCall: (args) => ({ card: 'generic', title: `移除 ${String(args.instanceId)}`, kind: 'delete' }),
786
+ presentResult: () => ({ card: 'generic', title: '装配移除' }),
787
+ });
788
+ return [
789
+ cadCreatePrim,
790
+ cadExtrude,
791
+ cadBoolean,
792
+ cadFillet,
793
+ cadTransform,
794
+ cadExport,
795
+ cadDelete,
796
+ cadVolume,
797
+ cadDrawing,
798
+ cadAssemblyInsert,
799
+ cadAssemblyMove,
800
+ cadAssemblyRemove,
801
+ ];
802
+ }
803
+ export const MODEL_TOOL_NAMES = [
804
+ 'cad_create_prim',
805
+ 'cad_extrude_profile',
806
+ 'cad_boolean',
807
+ 'cad_fillet',
808
+ 'cad_transform',
809
+ 'cad_export',
810
+ 'cad_delete',
811
+ 'cad_volume',
812
+ 'cad_drawing',
813
+ 'cad_assembly_insert',
814
+ 'cad_assembly_move',
815
+ 'cad_assembly_remove',
816
+ ];