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
package/lib/store.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Scene store: viewId → CadScene JSON. Scenes live in memory with an LRU cap
3
+ * and are spilled to <root>/.dsh-cad/scenes/<viewId>.json so replayed cards
4
+ * still fetch their geometry after a process restart.
5
+ */
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { mkdir, readFile, writeFile, unlink } from 'node:fs/promises';
8
+ import path from 'node:path';
9
+ const MAX_MEMORY_SCENES = 16;
10
+ export class SceneStore {
11
+ root;
12
+ memory = new Map();
13
+ constructor(root) {
14
+ this.root = root;
15
+ }
16
+ get directory() {
17
+ return path.join(this.root, '.dsh-cad', 'scenes');
18
+ }
19
+ /** Persist a scene and return its viewId. */
20
+ async put(scene) {
21
+ const viewId = randomUUID();
22
+ await this.putAt(viewId, scene);
23
+ return viewId;
24
+ }
25
+ /** Persist a scene under a stable viewId (overwrites; used by the modeling
26
+ * document so every operation refreshes the same viewer card). */
27
+ async putAt(viewId, scene) {
28
+ this.memory.set(viewId, scene);
29
+ if (this.memory.size > MAX_MEMORY_SCENES) {
30
+ const oldest = this.memory.keys().next().value;
31
+ if (oldest !== undefined)
32
+ this.memory.delete(oldest);
33
+ }
34
+ await mkdir(this.directory, { recursive: true });
35
+ await writeFile(path.join(this.directory, `${viewId}.json`), JSON.stringify(scene));
36
+ }
37
+ /** Fetch a scene by viewId, restoring from disk when the memory copy aged out. */
38
+ async get(viewId) {
39
+ if (!/^[0-9a-f-]{36}$/i.test(viewId))
40
+ return null;
41
+ const memoryHit = this.memory.get(viewId);
42
+ if (memoryHit !== undefined)
43
+ return memoryHit;
44
+ try {
45
+ const text = await readFile(path.join(this.directory, `${viewId}.json`), 'utf8');
46
+ const scene = JSON.parse(text);
47
+ this.memory.set(viewId, scene);
48
+ return scene;
49
+ }
50
+ catch {
51
+ return null;
52
+ }
53
+ }
54
+ /** Best-effort removal (unused for now; kept for completeness of the API). */
55
+ async delete(viewId) {
56
+ this.memory.delete(viewId);
57
+ try {
58
+ await unlink(path.join(this.directory, `${viewId}.json`));
59
+ }
60
+ catch {
61
+ // Already gone.
62
+ }
63
+ }
64
+ /** ETag for a scene payload (cache helper for the route handler). */
65
+ etag(scene) {
66
+ return `"${createHash('sha1').update(JSON.stringify(scene)).digest('hex').slice(0, 16)}"`;
67
+ }
68
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * cad_freecad: run a feature program on an external FreeCAD executor. The
3
+ * program uses the same op shapes as the built-in kernel (create_prim /
4
+ * extrude_profile / boolean / fillet / transform / volume / delete / reset);
5
+ * meshes flow back into the same embedded viewer via the binary scene store.
6
+ * Optionally loads an input CAD file (STEP/BREP/STL) first and/or exports
7
+ * the result (.step/.stl) — the "upload STEP → external-engine pipeline".
8
+ */
9
+ import { randomUUID } from 'node:crypto';
10
+ import { defineTool } from '@deepseek-ai/dsh-tools';
11
+ import { resolveWorkspacePath } from './util.js';
12
+ import { findFreeCad, runFreeCadProgram } from '../cad_connector/freecad-executor.js';
13
+ import { isKnownOpKind, normalizeOps } from '../cad_connector/executor.js';
14
+ // Shared executor-contract normalization; historical alias for callers/tests.
15
+ export const normalizeFreeCadSteps = normalizeOps;
16
+ const INPUT_EXTENSIONS = new Set(['step', 'stp', 'brep', 'stl']);
17
+ const EXPORT_EXTENSIONS = new Set(['step', 'stp', 'stl']);
18
+ export function createFreeCadTool(deps) {
19
+ return defineTool({
20
+ name: 'cad_freecad',
21
+ description: 'Run a feature program on an external FreeCAD executor (requires FreeCAD installed locally or FREECAD_BIN set). ' +
22
+ '`steps` is an array of the same ops the built-in kernel uses: create_prim (box/cylinder/sphere/cone/torus with at/axis), ' +
23
+ 'extrude_profile, boolean (fuse/cut/common), fillet, transform, volume, delete, reset. ' +
24
+ 'Optionally `input` loads a STEP/STP/BREP/STL file as body "input" first, and `exportPath` (.step/.stl) writes the final result. ' +
25
+ 'The rebuilt meshes render in the same viewer card, and by default the program also runs in the FreeCAD GUI — the window ' +
26
+ 'opens with the bodies loaded and stays open, so the user can inspect the model in FreeCAD itself (it doubles as a viewer). ' +
27
+ 'Set `headless: true` only for pure exports/automation where no window is wanted.',
28
+ parameters: {
29
+ steps: {
30
+ type: 'array',
31
+ required: true,
32
+ description: 'Ops executed in order. Canonical shapes: ' +
33
+ '{kind:"create_prim", bodyId, prim:"box|cylinder|sphere|cone|torus", params:{dx,dy,dz | radius,height | radius1,radius2,height | majorRadius,minorRadius, at:[x,y,z], axis:[x,y,z]}}, ' +
34
+ '{kind:"extrude_profile", bodyId, points:[x0,y0,x1,y1,...], height}, ' +
35
+ '{kind:"boolean", op:"cut|fuse|common", target, tools:[bodyIds]}, ' +
36
+ '{kind:"fillet", target, radius}, ' +
37
+ '{kind:"transform", target, translate:[x,y,z], rotate:[rx,ry,rz] deg, mirror:[x,y,z]}, ' +
38
+ '{kind:"volume", target}, {kind:"delete", target}, {kind:"reset"}.',
39
+ },
40
+ input: { type: 'string', description: 'Optional CAD file to load first (STEP .step/.stp, BREP, STL), absolute or workspace-relative.' },
41
+ exportPath: { type: 'string', description: 'Optional export destination; extension selects .step or .stl.' },
42
+ headless: { type: 'boolean', description: 'Run in the console binary without opening the FreeCAD window (pure exports/automation).' },
43
+ },
44
+ output: {
45
+ schema: {
46
+ type: 'object',
47
+ additionalProperties: false,
48
+ properties: {
49
+ viewId: { type: 'string', required: true, description: 'Viewer scene id.' },
50
+ kind: { type: 'string', required: true, description: 'Always "3d".' },
51
+ format: { type: 'string', required: true, description: 'Always "freecad".' },
52
+ file: { type: 'string', required: true, description: 'Source label (input or exported file).' },
53
+ bodies: { type: 'number', required: true, description: 'Bodies produced.' },
54
+ triangles: { type: 'number', required: true, description: 'Total triangles.' },
55
+ sceneUrl: { type: 'string', description: 'Versioned viewer URL (web compositions).' },
56
+ exported: { type: 'string', description: 'Written export path, when requested.' },
57
+ volume: { type: 'number', description: 'Volume in mm³ when exactly one volume was measured.' },
58
+ opened: { type: 'boolean', description: 'FreeCAD GUI opened with the result.' },
59
+ },
60
+ },
61
+ render: (_args, value) => {
62
+ const record = value;
63
+ const lines = [`freecad: ${String(record.bodies)} bodies, ${String(record.triangles)} triangles`];
64
+ if (record.volume !== undefined)
65
+ lines.push(`volume: ${Number(record.volume).toFixed(2)} mm³`);
66
+ if (record.exported !== undefined)
67
+ lines.push(`written: ${String(record.exported)}`);
68
+ if (record.opened === true)
69
+ lines.push('FreeCAD GUI opened — the window stays open for inspection');
70
+ return [{ type: 'text', text: lines.join('\n') }];
71
+ },
72
+ presentationMeta: (_args, value) => {
73
+ const record = value;
74
+ return {
75
+ viewId: String(record.viewId),
76
+ kind: '3d',
77
+ format: 'freecad',
78
+ file: String(record.file),
79
+ ...(record.sceneUrl === undefined ? {} : { sceneUrl: String(record.sceneUrl) }),
80
+ title: `FreeCAD · ${String(record.bodies)} ${record.bodies === 1 ? 'body' : 'bodies'}`,
81
+ stats: { meshes: Number(record.bodies), triangles: Number(record.triangles) },
82
+ };
83
+ },
84
+ },
85
+ timeoutMs: 300_000,
86
+ isConcurrencySafe: () => false,
87
+ async execute(args) {
88
+ const executable = findFreeCad();
89
+ if (executable === null) {
90
+ throw new Error('FreeCAD was not found — install FreeCAD or point FREECAD_BIN at its console binary (freecadcmd)');
91
+ }
92
+ if (!Array.isArray(args.steps) || args.steps.length === 0) {
93
+ throw new Error('steps must be a non-empty array of ops');
94
+ }
95
+ const steps = normalizeOps(args.steps);
96
+ for (const [index, op] of steps.entries()) {
97
+ if (typeof op !== 'object' || op === null || !isKnownOpKind(op.kind)) {
98
+ throw new Error(`steps[${index}] is not an op (missing "kind")`);
99
+ }
100
+ }
101
+ const names = {};
102
+ for (const op of steps) {
103
+ if (typeof op.bodyId === 'string' && typeof op.name === 'string')
104
+ names[op.bodyId] = op.name;
105
+ }
106
+ const program = { ops: steps, names };
107
+ if (args.input !== undefined) {
108
+ const resolved = resolveWorkspacePath(args.input, deps.workspaceRoot);
109
+ const extension = resolved.toLowerCase().split('.').pop() ?? '';
110
+ if (!INPUT_EXTENSIONS.has(extension))
111
+ throw new Error(`input must be one of .step .stp .brep .stl (got .${extension})`);
112
+ program.input = { format: extension, path: resolved, bodyId: 'input' };
113
+ }
114
+ if (args.exportPath !== undefined) {
115
+ const resolved = resolveWorkspacePath(args.exportPath, deps.workspaceRoot);
116
+ const extension = resolved.toLowerCase().split('.').pop() ?? '';
117
+ if (!EXPORT_EXTENSIONS.has(extension))
118
+ throw new Error(`exportPath must be .step or .stl (got .${extension})`);
119
+ program.export = { format: extension, path: resolved };
120
+ }
121
+ const gui = args.headless !== true;
122
+ if (gui)
123
+ program.display = true;
124
+ const result = await runFreeCadProgram(program, gui ? { gui: true } : {});
125
+ if (result.meshes.length === 0) {
126
+ throw new Error('the FreeCAD program produced no bodies');
127
+ }
128
+ const viewId = `freecad-${randomUUID().slice(0, 8)}`;
129
+ const meshes = result.meshes.map((mesh) => ({
130
+ name: mesh.name === '' ? mesh.bodyId : mesh.name,
131
+ positions: mesh.positions,
132
+ normals: mesh.normals,
133
+ indices: mesh.indices,
134
+ }));
135
+ await deps.store.publish(viewId, meshes);
136
+ const triangles = meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0);
137
+ const volumeEntries = Object.entries(result.volumes);
138
+ const value = {
139
+ viewId,
140
+ kind: '3d',
141
+ format: 'freecad',
142
+ file: result.exported ?? (args.input !== undefined ? args.input : 'freecad program'),
143
+ bodies: meshes.length,
144
+ triangles,
145
+ };
146
+ if (volumeEntries.length === 1)
147
+ value.volume = volumeEntries[0][1];
148
+ if (result.exported !== undefined)
149
+ value.exported = result.exported;
150
+ if (args.headless !== true)
151
+ value.opened = true;
152
+ const sceneUrlBase = deps.ensureSceneRoute();
153
+ if (sceneUrlBase !== null)
154
+ value.sceneUrl = `${sceneUrlBase.replace('/scene', '/bin')}/${viewId}`;
155
+ return value;
156
+ },
157
+ presentCall: () => ({ card: 'generic', title: 'CAD FreeCAD run', kind: 'other' }),
158
+ presentResult: () => ({ card: 'generic', title: 'CAD FreeCAD' }),
159
+ });
160
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * cad_fusion: run a feature program on an external Fusion 360 executor
3
+ * (GUI bridge — Fusion has no headless mode, so the window opens with the
4
+ * bodies loaded, doubling as a viewer). Same op shapes as the built-in
5
+ * kernel and cad_freecad; meshes flow back into the same embedded WebGL
6
+ * viewer via the binary scene store.
7
+ */
8
+ import { randomUUID } from 'node:crypto';
9
+ import { defineTool } from '@deepseek-ai/dsh-tools';
10
+ import { resolveWorkspacePath } from './util.js';
11
+ import { isKnownOpKind, normalizeOps } from '../cad_connector/executor.js';
12
+ import { FUSION360_EXECUTOR } from '../cad_connector/fusion360-executor.js';
13
+ export function createFusionTool(deps) {
14
+ return defineTool({
15
+ name: 'cad_fusion',
16
+ description: 'Run a feature program on an external Fusion 360 executor (requires Fusion installed locally; native on Apple Silicon macOS and Windows). ' +
17
+ 'Fusion has no headless mode, so its window opens with the result loaded — it doubles as a viewer. ' +
18
+ '`steps` uses the same ops as the built-in kernel and cad_freecad: create_prim (box/cylinder/sphere/cone/torus), extrude_profile, ' +
19
+ 'boolean (fuse/cut/common), transform, volume, delete, reset. `exportPath` (.step/.stl) writes the result. ' +
20
+ 'First use installs the DshCadBridge add-in into the user Fusion API folder and needs Fusion signed in once.',
21
+ parameters: {
22
+ steps: {
23
+ type: 'array',
24
+ required: true,
25
+ description: 'Ops executed in order. Canonical shapes: ' +
26
+ '{kind:"create_prim", bodyId, prim:"box|cylinder|sphere|cone|torus", params:{dx,dy,dz | radius,height | radius1,radius2,height | majorRadius,minorRadius, at:[x,y,z], axis:[x,y,z]}}, ' +
27
+ '{kind:"extrude_profile", bodyId, points:[x0,y0,x1,y1,...], height}, ' +
28
+ '{kind:"boolean", op:"cut|fuse|common", target, tools:[bodyIds]}, ' +
29
+ '{kind:"transform", target, translate:[x,y,z], rotate:[rx,ry,rz] deg}, ' +
30
+ '{kind:"volume", target}, {kind:"delete", target}, {kind:"reset"}.',
31
+ },
32
+ exportPath: { type: 'string', description: 'Optional export destination; extension selects .step or .stl.' },
33
+ },
34
+ output: {
35
+ schema: {
36
+ type: 'object',
37
+ additionalProperties: false,
38
+ properties: {
39
+ viewId: { type: 'string', required: true, description: 'Viewer scene id.' },
40
+ kind: { type: 'string', required: true, description: 'Always "3d".' },
41
+ format: { type: 'string', required: true, description: 'Always "fusion".' },
42
+ file: { type: 'string', required: true, description: 'Source label (exported file or program).' },
43
+ bodies: { type: 'number', required: true, description: 'Bodies produced.' },
44
+ triangles: { type: 'number', required: true, description: 'Total triangles.' },
45
+ sceneUrl: { type: 'string', description: 'Versioned viewer URL (web compositions).' },
46
+ exported: { type: 'string', description: 'Written export path, when requested.' },
47
+ volume: { type: 'number', description: 'Volume in mm³ when exactly one volume was measured.' },
48
+ opened: { type: 'boolean', description: 'Fusion window opened with the result.' },
49
+ },
50
+ },
51
+ render: (_args, value) => {
52
+ const record = value;
53
+ const lines = [`fusion360: ${String(record.bodies)} bodies, ${String(record.triangles)} triangles`];
54
+ if (record.volume !== undefined)
55
+ lines.push(`volume: ${Number(record.volume).toFixed(2)} mm³`);
56
+ if (record.exported !== undefined)
57
+ lines.push(`written: ${String(record.exported)}`);
58
+ lines.push('Fusion window opened — it stays open for inspection');
59
+ return [{ type: 'text', text: lines.join('\n') }];
60
+ },
61
+ presentationMeta: (_args, value) => {
62
+ const record = value;
63
+ return {
64
+ viewId: String(record.viewId),
65
+ kind: '3d',
66
+ format: 'fusion',
67
+ file: String(record.file),
68
+ ...(record.sceneUrl === undefined ? {} : { sceneUrl: String(record.sceneUrl) }),
69
+ title: `Fusion 360 · ${String(record.bodies)} ${record.bodies === 1 ? 'body' : 'bodies'}`,
70
+ stats: { meshes: Number(record.bodies), triangles: Number(record.triangles) },
71
+ };
72
+ },
73
+ },
74
+ timeoutMs: 300_000,
75
+ isConcurrencySafe: () => false,
76
+ async execute(args) {
77
+ if (!FUSION360_EXECUTOR.available()) {
78
+ throw new Error(FUSION360_EXECUTOR.unavailableReason?.() ?? 'Fusion 360 is not available on this machine');
79
+ }
80
+ if (!Array.isArray(args.steps) || args.steps.length === 0) {
81
+ throw new Error('steps must be a non-empty array of ops');
82
+ }
83
+ const steps = normalizeOps(args.steps);
84
+ for (const [index, op] of steps.entries()) {
85
+ if (typeof op !== 'object' || op === null || !isKnownOpKind(op.kind)) {
86
+ throw new Error(`steps[${index}] is not an op (missing "kind")`);
87
+ }
88
+ }
89
+ const names = {};
90
+ for (const op of steps) {
91
+ if (typeof op.bodyId === 'string' && typeof op.name === 'string')
92
+ names[op.bodyId] = op.name;
93
+ }
94
+ const program = { ops: steps, names, display: true };
95
+ if (args.exportPath !== undefined) {
96
+ const resolved = resolveWorkspacePath(args.exportPath, deps.workspaceRoot);
97
+ const extension = resolved.toLowerCase().split('.').pop() ?? '';
98
+ if (extension !== 'step' && extension !== 'stp' && extension !== 'stl') {
99
+ throw new Error(`exportPath must be .step or .stl (got .${extension})`);
100
+ }
101
+ program.export = { format: extension, path: resolved };
102
+ }
103
+ const result = await FUSION360_EXECUTOR.run(program);
104
+ if (result.meshes.length === 0) {
105
+ throw new Error('the Fusion program produced no bodies');
106
+ }
107
+ const viewId = `fusion-${randomUUID().slice(0, 8)}`;
108
+ const meshes = result.meshes.map((mesh) => ({
109
+ name: mesh.name === '' ? mesh.bodyId : mesh.name,
110
+ positions: mesh.positions,
111
+ normals: mesh.normals,
112
+ indices: mesh.indices,
113
+ }));
114
+ await deps.store.publish(viewId, meshes);
115
+ const triangles = meshes.reduce((sum, mesh) => sum + mesh.indices.length / 3, 0);
116
+ const volumeEntries = Object.entries(result.volumes);
117
+ const value = {
118
+ viewId,
119
+ kind: '3d',
120
+ format: 'fusion',
121
+ file: result.exported ?? 'fusion program',
122
+ bodies: meshes.length,
123
+ triangles,
124
+ opened: true,
125
+ };
126
+ if (volumeEntries.length === 1)
127
+ value.volume = volumeEntries[0][1];
128
+ if (result.exported !== undefined)
129
+ value.exported = result.exported;
130
+ const sceneUrlBase = deps.ensureSceneRoute();
131
+ if (sceneUrlBase !== null)
132
+ value.sceneUrl = `${sceneUrlBase.replace('/scene', '/bin')}/${viewId}`;
133
+ return value;
134
+ },
135
+ presentCall: () => ({ card: 'generic', title: 'CAD Fusion 360 run', kind: 'other' }),
136
+ presentResult: () => ({ card: 'generic', title: 'CAD Fusion 360' }),
137
+ });
138
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * cad_image_profile: PNG image → 2D contours → extrusion-ready polygon.
3
+ * Binarizes (Otsu or manual), traces outer contours, simplifies them, and
4
+ * renders a 2D preview card. The largest contour's points are returned so the
5
+ * agent can continue the pipeline with cad_extrude_profile (+ cad_boolean
6
+ * for holes). Never touches the shared modeling worker.
7
+ */
8
+ import { defineTool } from '@deepseek-ai/dsh-tools';
9
+ import { decodePng } from '../convert/png.js';
10
+ import { extractProfiles } from '../image/profile.js';
11
+ import { loadCadFile, resolveWorkspacePath } from './util.js';
12
+ export function createCadImageTool(deps) {
13
+ return defineTool({
14
+ name: 'cad_image_profile',
15
+ description: 'Extract 2D part contours from a PNG image (sketch, screenshot, or exported drawing): auto-thresholds (Otsu, override with `threshold`; `invert` for light-on-dark), ' +
16
+ 'traces and simplifies outer contours, and renders them in a 2D preview card. Returns the largest contour as flat [x,y,…] points (mm, y-up, `scale` px/mm) ' +
17
+ 'ready for cad_extrude_profile; pass the points on to continue the modeling pipeline.',
18
+ parameters: {
19
+ path: { type: 'string', required: true, description: 'Path to the PNG image, absolute or relative to the workspace root.' },
20
+ threshold: { type: 'number', description: 'Manual binarization threshold 0–255 (default: Otsu auto).' },
21
+ invert: { type: 'boolean', description: 'Trace light shapes on a dark background.' },
22
+ tolerance: { type: 'number', description: 'Contour simplification tolerance in pixels (default 1.5).' },
23
+ scale: { type: 'number', description: 'Pixels per mm (default 1).' },
24
+ },
25
+ output: {
26
+ schema: {
27
+ type: 'object',
28
+ additionalProperties: false,
29
+ properties: {
30
+ viewId: { type: 'string', required: true, description: 'Viewer scene id.' },
31
+ kind: { type: 'string', required: true, description: 'Always "2d".' },
32
+ format: { type: 'string', required: true, description: 'Always "image".' },
33
+ file: { type: 'string', required: true, description: 'Source image path.' },
34
+ entities: { type: 'number', required: true, description: 'Contours extracted.' },
35
+ sceneUrl: { type: 'string', description: 'Viewer URL (web compositions).' },
36
+ points: { type: 'array', items: { type: 'number' }, description: 'Largest contour, flat [x0,y0,x1,y1,…] mm — feed to cad_extrude_profile.' },
37
+ },
38
+ },
39
+ render: (_args, value) => {
40
+ const record = value;
41
+ const count = Array.isArray(record.points) ? record.points.length / 2 : 0;
42
+ return [{ type: 'text', text: `image profile: ${String(record.entities)} contour(s); largest = ${count} points (mm, y-up) — extrude with cad_extrude_profile` }];
43
+ },
44
+ presentationMeta: (_args, value) => {
45
+ const record = value;
46
+ return {
47
+ viewId: String(record.viewId),
48
+ kind: '2d',
49
+ format: 'image',
50
+ file: String(record.file),
51
+ ...(record.sceneUrl === undefined ? {} : { sceneUrl: String(record.sceneUrl) }),
52
+ title: `${String(record.file)} · contours`,
53
+ stats: { entities: Number(record.entities) },
54
+ };
55
+ },
56
+ },
57
+ timeoutMs: 120_000,
58
+ isConcurrencySafe: () => true,
59
+ async execute(args) {
60
+ const resolved = resolveWorkspacePath(args.path, deps.workspaceRoot);
61
+ const buffer = await loadCadFile(resolved);
62
+ const image = decodePng(buffer);
63
+ const profiles = extractProfiles(image, {
64
+ ...(args.threshold === undefined ? {} : { threshold: args.threshold }),
65
+ ...(args.invert === undefined ? {} : { invert: args.invert }),
66
+ ...(args.tolerance === undefined ? {} : { tolerance: args.tolerance }),
67
+ ...(args.scale === undefined ? {} : { scale: args.scale }),
68
+ });
69
+ if (profiles.length === 0) {
70
+ throw new Error('no contours found — try a manual threshold or invert for light-on-dark images');
71
+ }
72
+ const top = profiles.slice(0, 8);
73
+ const scene = {
74
+ kind: '2d',
75
+ format: 'image',
76
+ entities: top.map((profile, index) => ({
77
+ type: 'polyline',
78
+ points: profile.points,
79
+ closed: true,
80
+ layer: index === 0 ? 'OUTER' : 'CONTOUR',
81
+ })),
82
+ bounds: boundsOf(top.map((profile) => profile.points)),
83
+ layers: ['OUTER', 'CONTOUR'],
84
+ };
85
+ const viewId = await deps.store.put(scene);
86
+ const value = {
87
+ viewId,
88
+ kind: '2d',
89
+ format: 'image',
90
+ file: resolved,
91
+ entities: top.length,
92
+ points: top[0].points,
93
+ };
94
+ const sceneUrlBase = deps.ensureSceneRoute();
95
+ if (sceneUrlBase !== null)
96
+ value.sceneUrl = `${sceneUrlBase}/${viewId}`;
97
+ return value;
98
+ },
99
+ presentCall: (args) => ({ card: 'generic', title: `CAD image profile ${String(args.path)}`, kind: 'other' }),
100
+ presentResult: () => ({ card: 'generic', title: 'CAD image profile' }),
101
+ });
102
+ }
103
+ function boundsOf(polygons) {
104
+ let minX = Infinity;
105
+ let minY = Infinity;
106
+ let maxX = -Infinity;
107
+ let maxY = -Infinity;
108
+ for (const points of polygons) {
109
+ for (let i = 0; i < points.length; i += 2) {
110
+ if (points[i] < minX)
111
+ minX = points[i];
112
+ if (points[i + 1] < minY)
113
+ minY = points[i + 1];
114
+ if (points[i] > maxX)
115
+ maxX = points[i];
116
+ if (points[i + 1] > maxY)
117
+ maxY = points[i + 1];
118
+ }
119
+ }
120
+ return { min: { x: minX, y: minY }, max: { x: maxX, y: maxY } };
121
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The `cad_info` tool: geometry metadata without rendering — format, counts,
3
+ * bounds, units, and DXF layer names.
4
+ */
5
+ import { defineTool } from '@deepseek-ai/dsh-tools';
6
+ import { detectFormat } from '../convert/detect.js';
7
+ import { convert } from '../convert/index.js';
8
+ import { sceneStats } from '../types.js';
9
+ import { loadCadFile, resolveWorkspacePath } from './util.js';
10
+ /** Build the cad_info tool definition. */
11
+ export function createCadInfoTool(deps) {
12
+ return defineTool({
13
+ name: 'cad_info',
14
+ description: 'Inspect a CAD file and return geometry metadata only (no viewer): format, part/triangle or entity counts, ' +
15
+ 'axis-aligned bounds, units (STEP/IGES/DCPRT), and layer names (DXF). Cheaper than cad_view when only numbers are needed.',
16
+ parameters: {
17
+ path: {
18
+ type: 'string',
19
+ required: true,
20
+ description: 'Path to the CAD file, absolute or relative to the workspace root.',
21
+ },
22
+ },
23
+ output: {
24
+ schema: {
25
+ type: 'object',
26
+ additionalProperties: false,
27
+ properties: {
28
+ format: { type: 'string', required: true, description: 'Detected source format.' },
29
+ file: { type: 'string', required: true, description: 'Resolved file path.' },
30
+ kind: { type: 'string', enum: ['3d', '2d'], required: true },
31
+ meshes: { type: 'number', description: 'Part count (3D).' },
32
+ triangles: { type: 'number', description: 'Total triangle count (3D).' },
33
+ entities: { type: 'number', description: 'Drawing entity count (2D).' },
34
+ layers: { type: 'array', items: { type: 'string' }, description: 'Layer names (DXF).' },
35
+ units: { type: 'string', description: 'Source units (3D).' },
36
+ boundsMin: { type: 'array', items: { type: 'number' }, description: 'Bounds minimum (xyz for 3D, xy for 2D).' },
37
+ boundsMax: { type: 'array', items: { type: 'number' }, description: 'Bounds maximum (xyz for 3D, xy for 2D).' },
38
+ },
39
+ },
40
+ render: (_args, value) => {
41
+ const lines = [`${value.file} — ${value.format.toUpperCase()} (${value.kind.toUpperCase()})`];
42
+ if (value.kind === '3d') {
43
+ lines.push(`parts: ${value.meshes ?? 0}`);
44
+ lines.push(`triangles: ${value.triangles ?? 0}`);
45
+ if (value.units !== undefined)
46
+ lines.push(`units: ${value.units}`);
47
+ }
48
+ else {
49
+ lines.push(`entities: ${value.entities ?? 0}`);
50
+ if ((value.layers ?? []).length > 0)
51
+ lines.push(`layers: ${(value.layers ?? []).join(', ')}`);
52
+ }
53
+ if (value.boundsMin !== undefined && value.boundsMax !== undefined) {
54
+ lines.push(`bounds: [${value.boundsMin.map((v) => v.toFixed(3)).join(', ')}] → [${value.boundsMax.map((v) => v.toFixed(3)).join(', ')}]`);
55
+ }
56
+ return [{ type: 'text', text: lines.join('\n') }];
57
+ },
58
+ },
59
+ timeoutMs: 180_000,
60
+ isConcurrencySafe: () => true,
61
+ async execute(args) {
62
+ const resolved = resolveWorkspacePath(args.path, deps.workspaceRoot);
63
+ const name = resolved.split('/').pop() ?? resolved;
64
+ const format = detectFormat(name);
65
+ if (format === null) {
66
+ throw new Error(`unsupported CAD format: ${name} — supported extensions are .stl .obj .step .stp .iges .igs .brep .dcprt .dxf .svg`);
67
+ }
68
+ const buffer = await loadCadFile(resolved);
69
+ const scene = await convert(buffer, format, name);
70
+ const stats = sceneStats(scene);
71
+ // Optional fields spread conditionally: an explicit undefined value
72
+ // would fail the registry's lossless-JSON output validation.
73
+ return {
74
+ format: stats.format,
75
+ file: resolved,
76
+ kind: scene.kind,
77
+ ...(stats.meshes === undefined ? {} : { meshes: stats.meshes }),
78
+ ...(stats.triangles === undefined ? {} : { triangles: stats.triangles }),
79
+ ...(stats.entities === undefined ? {} : { entities: stats.entities }),
80
+ ...(stats.layers === undefined ? {} : { layers: stats.layers }),
81
+ ...(stats.units === undefined ? {} : { units: stats.units }),
82
+ ...(stats.boundsMin === undefined ? {} : { boundsMin: stats.boundsMin }),
83
+ ...(stats.boundsMax === undefined ? {} : { boundsMax: stats.boundsMax }),
84
+ };
85
+ },
86
+ presentCall: (args) => ({
87
+ card: 'generic',
88
+ title: `CAD info ${args.path}`,
89
+ kind: 'read',
90
+ }),
91
+ });
92
+ }