dsh-cad 0.13.0 → 0.13.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -1
- package/README.zh-CN.md +8 -1
- package/lib/client.js +165 -165
- package/lib/client.js.map +3 -3
- package/lib/modeling/assembly.js +35 -4
- package/lib/modeling/document.js +5 -3
- package/lib/modeling/modeling-worker.cjs +18 -6
- package/lib/modeling/occt-bridge.cjs +3 -0
- package/lib/modeling/occtts-adapter.cjs +10 -0
- package/lib/tools/cad-freecad.js +4 -4
- package/lib/tools/cad-fusion.js +3 -3
- package/lib/tools/cad-image.js +3 -3
- package/lib/tools/cad-info.js +3 -3
- package/lib/tools/cad-model.js +35 -5
- package/lib/tools/cad-onshape.js +3 -3
- package/lib/tools/cad-view.js +3 -3
- package/lib/tools/util.js +20 -0
- package/lib/types/modeling/assembly.d.ts +4 -0
- package/lib/types/modeling/document.d.ts +6 -2
- package/lib/types/tools/util.d.ts +13 -0
- package/lib/types.js +6 -4
- package/package.json +94 -93
package/lib/modeling/assembly.js
CHANGED
|
@@ -106,8 +106,36 @@ export function foldAssemblyState(ops, bodyNames) {
|
|
|
106
106
|
const instances = [];
|
|
107
107
|
let entities = [];
|
|
108
108
|
let constraints = [];
|
|
109
|
+
const liveBodyIds = new Set();
|
|
109
110
|
for (const op of ops) {
|
|
110
111
|
switch (op.kind) {
|
|
112
|
+
case 'create_prim':
|
|
113
|
+
case 'extrude_profile':
|
|
114
|
+
case 'loft':
|
|
115
|
+
case 'revolve':
|
|
116
|
+
case 'sweep':
|
|
117
|
+
liveBodyIds.add(op.bodyId);
|
|
118
|
+
break;
|
|
119
|
+
case 'pattern': {
|
|
120
|
+
// Mirror the worker's unique-copy rule so replay-derived ids match
|
|
121
|
+
// (repeat patterns of the same target uniquify against live ids).
|
|
122
|
+
const count = Math.max(1, Math.trunc(op.count));
|
|
123
|
+
for (let i = 1; i < count; i++) {
|
|
124
|
+
let seq = i;
|
|
125
|
+
let id = `${op.target}p${seq}`;
|
|
126
|
+
while (liveBodyIds.has(id))
|
|
127
|
+
id = `${op.target}p${++seq}`;
|
|
128
|
+
liveBodyIds.add(id);
|
|
129
|
+
}
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case 'delete':
|
|
133
|
+
liveBodyIds.delete(op.target);
|
|
134
|
+
break;
|
|
135
|
+
case 'boolean':
|
|
136
|
+
for (const tool of op.tools)
|
|
137
|
+
liveBodyIds.delete(tool);
|
|
138
|
+
break;
|
|
111
139
|
case 'assembly_insert':
|
|
112
140
|
instances.push({
|
|
113
141
|
instanceId: op.instanceId,
|
|
@@ -142,11 +170,11 @@ export function foldAssemblyState(ops, bodyNames) {
|
|
|
142
170
|
break;
|
|
143
171
|
}
|
|
144
172
|
}
|
|
145
|
-
return { instances, entities, constraints };
|
|
173
|
+
return { instances, entities, constraints, liveBodyIds };
|
|
146
174
|
}
|
|
147
175
|
/** Build the assembly-tree payload from a restored document. */
|
|
148
176
|
export function assemblyTreePayload(doc) {
|
|
149
|
-
const { instances, entities, constraints } = foldAssemblyState(doc.ops, doc.bodyNames);
|
|
177
|
+
const { instances, entities, constraints, liveBodyIds } = foldAssemblyState(doc.ops, doc.bodyNames);
|
|
150
178
|
const names = assemblyDisplayNames(instances);
|
|
151
179
|
const nameByInstanceId = new Map(instances.map((instance, index) => [instance.instanceId, names[index]]));
|
|
152
180
|
const entityName = (id) => {
|
|
@@ -165,8 +193,11 @@ export function assemblyTreePayload(doc) {
|
|
|
165
193
|
bodyId: instance.bodyId,
|
|
166
194
|
name: names[index],
|
|
167
195
|
color: instanceColor(index),
|
|
168
|
-
//
|
|
169
|
-
|
|
196
|
+
// Missing only when BOTH witnesses lack the body: liveBodyIds (folded
|
|
197
|
+
// from the op log — covers pattern copies in documents written before
|
|
198
|
+
// copy names were recorded, issue #6) and bodyNames (the recorded name
|
|
199
|
+
// manifest).
|
|
200
|
+
missing: !liveBodyIds.has(instance.bodyId) && doc.bodyNames[instance.bodyId] === undefined,
|
|
170
201
|
})),
|
|
171
202
|
constraints: constraints.map((constraint) => {
|
|
172
203
|
const entry = { id: constraint.id, type: String(constraint.kind.type ?? 'constraint') };
|
package/lib/modeling/document.js
CHANGED
|
@@ -44,12 +44,14 @@ export class ModelDocument {
|
|
|
44
44
|
// No document yet — a fresh one stays in memory until the first op.
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
-
/** Append an applied operation and persist.
|
|
47
|
+
/** Append an applied operation and persist. Multi-create ops (pattern)
|
|
48
|
+
* pass an array so every copy's display name lands in bodyNames. */
|
|
48
49
|
async record(op, bodyName) {
|
|
49
50
|
this.doc.ops.push(op);
|
|
50
51
|
this.doc.version += 1;
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
const entries = Array.isArray(bodyName) ? bodyName : bodyName === null ? [] : [bodyName];
|
|
53
|
+
for (const entry of entries)
|
|
54
|
+
this.doc.bodyNames[entry.bodyId] = entry.name;
|
|
53
55
|
if (op.kind === 'delete' || op.kind === 'boolean') {
|
|
54
56
|
const removed = op.kind === 'delete' ? [op.target] : op.tools;
|
|
55
57
|
for (const id of removed)
|
|
@@ -12,6 +12,7 @@ const { parentPort } = require('node:worker_threads')
|
|
|
12
12
|
const path = require('node:path')
|
|
13
13
|
const fs = require('node:fs')
|
|
14
14
|
const { createRequire } = require('node:module')
|
|
15
|
+
const { pathToFileURL } = require('node:url')
|
|
15
16
|
const { createAdapter } = require('./occt-adapter.cjs')
|
|
16
17
|
const { createOcctTsAdapter } = require('./occtts-adapter.cjs')
|
|
17
18
|
const { createOcctBridge, loadSharedOcctModule } = require('./occt-bridge.cjs')
|
|
@@ -20,10 +21,6 @@ const require3 = createRequire(__filename)
|
|
|
20
21
|
// The ES6 emscripten build reads a global __dirname when its factory runs.
|
|
21
22
|
if (typeof globalThis.__dirname === 'undefined') globalThis.__dirname = __dirname
|
|
22
23
|
|
|
23
|
-
const loaderPath = require3.resolve('opencascade.js/dist/opencascade.wasm.js')
|
|
24
|
-
const loaderModule = require3(loaderPath)
|
|
25
|
-
const wasmBinary = fs.readFileSync(path.join(path.dirname(loaderPath), 'opencascade.wasm.wasm'))
|
|
26
|
-
|
|
27
24
|
// Kernel selection: occt.ts is the PRIMARY backend (single-kernel modeling,
|
|
28
25
|
// true B-splines, shell/draft native, direct HLR); opencascade.js stays as
|
|
29
26
|
// the fallback when occt.ts cannot load. `adapter` carries whichever.
|
|
@@ -133,7 +130,13 @@ async function applyOp(op) {
|
|
|
133
130
|
const count = Math.max(1, Math.trunc(op.count))
|
|
134
131
|
const created = []
|
|
135
132
|
for (let i = 1; i < count; i++) {
|
|
136
|
-
|
|
133
|
+
// Unique copy ids even when the same target is patterned repeatedly:
|
|
134
|
+
// the naive `<target>p<i>` collides with the previous pattern's
|
|
135
|
+
// copies and silently overwrites them (issue #6). The rule is
|
|
136
|
+
// deterministic, so replaying the op log reproduces the same ids.
|
|
137
|
+
let seq = i
|
|
138
|
+
let id = `${op.target}p${seq}`
|
|
139
|
+
while (bodies.has(id)) id = `${op.target}p${++seq}`
|
|
137
140
|
let shape
|
|
138
141
|
if (op.mode === 'circular') {
|
|
139
142
|
// Rotation about a principal axis through `at`: translate in, rotate,
|
|
@@ -155,7 +158,7 @@ async function applyOp(op) {
|
|
|
155
158
|
translate: [op.delta[0] * i, op.delta[1] * i, op.delta[2] * i],
|
|
156
159
|
})
|
|
157
160
|
}
|
|
158
|
-
const name = `${body.name}·${
|
|
161
|
+
const name = `${body.name}·${seq}`
|
|
159
162
|
bodies.set(id, { shape, name })
|
|
160
163
|
created.push({ bodyId: id, name, mesh: meshOf(shape, name) })
|
|
161
164
|
}
|
|
@@ -455,6 +458,15 @@ async function boot() {
|
|
|
455
458
|
kernelName = 'occt.ts'
|
|
456
459
|
} else {
|
|
457
460
|
console.warn('[dsh-cad] occt.ts unavailable — falling back to the opencascade.js backend')
|
|
461
|
+
// Load the fallback kernel lazily: a top-level require() of the
|
|
462
|
+
// emscripten loader crashes the whole worker at module scope when the
|
|
463
|
+
// resolved opencascade.js build is ESM (ERR_REQUIRE_ESM) — boot() never
|
|
464
|
+
// runs and the main thread hangs waiting for ready. Dynamic import()
|
|
465
|
+
// accepts both module formats and confines a broken fallback build to
|
|
466
|
+
// this branch (surfaced as a clean init error via boot().catch).
|
|
467
|
+
const loaderPath = require3.resolve('opencascade.js/dist/opencascade.wasm.js')
|
|
468
|
+
const loaderModule = await import(pathToFileURL(loaderPath).href)
|
|
469
|
+
const wasmBinary = fs.readFileSync(path.join(path.dirname(loaderPath), 'opencascade.wasm.wasm'))
|
|
458
470
|
const initOpenCascade = loaderModule.default ?? loaderModule
|
|
459
471
|
occt = await initOpenCascade({ wasmBinary })
|
|
460
472
|
adapter = createAdapter(occt)
|
|
@@ -78,6 +78,9 @@ let sharedModule = null
|
|
|
78
78
|
/** Load (once) and return the shared occt.ts raw module, or null when absent. */
|
|
79
79
|
async function loadSharedOcctModule() {
|
|
80
80
|
if (sharedModule !== null && sharedModule !== undefined) return sharedModule
|
|
81
|
+
// DSH_CAD_KERNEL=opencascade.js forces the fallback backend (diagnostics
|
|
82
|
+
// and regression tests — compare kernel behavior on a real model).
|
|
83
|
+
if (process.env.DSH_CAD_KERNEL === 'opencascade.js') return null
|
|
81
84
|
const distDir = resolveDistDir()
|
|
82
85
|
if (distDir === null) return null
|
|
83
86
|
try {
|
|
@@ -349,6 +349,12 @@ function createOcctTsAdapter(mod) {
|
|
|
349
349
|
}
|
|
350
350
|
const next = mod.shell(shape, Math.abs(thickness), ptr, faces.length)
|
|
351
351
|
if (next.isNull()) throw wrapError('shell', new Error('null shape'))
|
|
352
|
+
// The kernel can return the input unchanged on chamfered/tangent faces
|
|
353
|
+
// (issue #6) — an unchanged volume means the shell silently no-op'd;
|
|
354
|
+
// fail loudly with an actionable hint instead of reporting success.
|
|
355
|
+
if (Math.abs(next.volume() - shape.volume()) < 1e-6) {
|
|
356
|
+
throw wrapError('shell', new Error('kernel returned an unchanged solid — shell 未生效(倒角/相切面是已知诱因),请减小壁厚或先抽壳后倒角'))
|
|
357
|
+
}
|
|
352
358
|
return next
|
|
353
359
|
} finally {
|
|
354
360
|
if (ptr !== 0) mod._free(ptr)
|
|
@@ -360,6 +366,10 @@ function createOcctTsAdapter(mod) {
|
|
|
360
366
|
const d = direction ?? [0, 0, 1]
|
|
361
367
|
const next = mod.draft(shape, (angleDegrees * Math.PI) / 180, d[0], d[1], d[2], 0, 0, 1, 0, 0, true)
|
|
362
368
|
if (next.isNull()) throw wrapError('draft', new Error('null shape'))
|
|
369
|
+
// Same silent no-op guard as shell: unchanged volume = nothing happened.
|
|
370
|
+
if (Math.abs(next.volume() - shape.volume()) < 1e-6) {
|
|
371
|
+
throw wrapError('draft', new Error('kernel returned an unchanged solid — draft 未生效(没有可倾斜的壁面?),请检查拔模方向或改用显式面选择'))
|
|
372
|
+
}
|
|
363
373
|
return next
|
|
364
374
|
}
|
|
365
375
|
|
package/lib/tools/cad-freecad.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { randomUUID } from 'node:crypto';
|
|
10
10
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
11
|
-
import {
|
|
11
|
+
import { resolveSessionPath } from './util.js';
|
|
12
12
|
import { findFreeCad, runFreeCadProgram } from '../cad_connector/freecad-executor.js';
|
|
13
13
|
import { isKnownOpKind, normalizeOps } from '../cad_connector/executor.js';
|
|
14
14
|
// Shared executor-contract normalization; historical alias for callers/tests.
|
|
@@ -84,7 +84,7 @@ export function createFreeCadTool(deps) {
|
|
|
84
84
|
},
|
|
85
85
|
timeoutMs: 300_000,
|
|
86
86
|
isConcurrencySafe: () => false,
|
|
87
|
-
async execute(args) {
|
|
87
|
+
async execute(args, exec) {
|
|
88
88
|
const executable = findFreeCad();
|
|
89
89
|
if (executable === null) {
|
|
90
90
|
throw new Error('FreeCAD was not found — install FreeCAD or point FREECAD_BIN at its console binary (freecadcmd)');
|
|
@@ -105,14 +105,14 @@ export function createFreeCadTool(deps) {
|
|
|
105
105
|
}
|
|
106
106
|
const program = { ops: steps, names };
|
|
107
107
|
if (args.input !== undefined) {
|
|
108
|
-
const resolved =
|
|
108
|
+
const resolved = resolveSessionPath(args.input, exec, deps.workspaceRoot);
|
|
109
109
|
const extension = resolved.toLowerCase().split('.').pop() ?? '';
|
|
110
110
|
if (!INPUT_EXTENSIONS.has(extension))
|
|
111
111
|
throw new Error(`input must be one of .step .stp .brep .stl (got .${extension})`);
|
|
112
112
|
program.input = { format: extension, path: resolved, bodyId: 'input' };
|
|
113
113
|
}
|
|
114
114
|
if (args.exportPath !== undefined) {
|
|
115
|
-
const resolved =
|
|
115
|
+
const resolved = resolveSessionPath(args.exportPath, exec, deps.workspaceRoot);
|
|
116
116
|
const extension = resolved.toLowerCase().split('.').pop() ?? '';
|
|
117
117
|
if (!EXPORT_EXTENSIONS.has(extension))
|
|
118
118
|
throw new Error(`exportPath must be .step or .stl (got .${extension})`);
|
package/lib/tools/cad-fusion.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { randomUUID } from 'node:crypto';
|
|
9
9
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
10
|
-
import {
|
|
10
|
+
import { resolveSessionPath } from './util.js';
|
|
11
11
|
import { isKnownOpKind, normalizeOps } from '../cad_connector/executor.js';
|
|
12
12
|
import { FUSION360_EXECUTOR } from '../cad_connector/fusion360-executor.js';
|
|
13
13
|
export function createFusionTool(deps) {
|
|
@@ -73,7 +73,7 @@ export function createFusionTool(deps) {
|
|
|
73
73
|
},
|
|
74
74
|
timeoutMs: 300_000,
|
|
75
75
|
isConcurrencySafe: () => false,
|
|
76
|
-
async execute(args) {
|
|
76
|
+
async execute(args, exec) {
|
|
77
77
|
if (!FUSION360_EXECUTOR.available()) {
|
|
78
78
|
throw new Error(FUSION360_EXECUTOR.unavailableReason?.() ?? 'Fusion 360 is not available on this machine');
|
|
79
79
|
}
|
|
@@ -93,7 +93,7 @@ export function createFusionTool(deps) {
|
|
|
93
93
|
}
|
|
94
94
|
const program = { ops: steps, names, display: true };
|
|
95
95
|
if (args.exportPath !== undefined) {
|
|
96
|
-
const resolved =
|
|
96
|
+
const resolved = resolveSessionPath(args.exportPath, exec, deps.workspaceRoot);
|
|
97
97
|
const extension = resolved.toLowerCase().split('.').pop() ?? '';
|
|
98
98
|
if (extension !== 'step' && extension !== 'stp' && extension !== 'stl') {
|
|
99
99
|
throw new Error(`exportPath must be .step or .stl (got .${extension})`);
|
package/lib/tools/cad-image.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
9
9
|
import { decodePng } from '../convert/png.js';
|
|
10
10
|
import { extractProfiles } from '../image/profile.js';
|
|
11
|
-
import { loadCadFile,
|
|
11
|
+
import { loadCadFile, resolveSessionPath } from './util.js';
|
|
12
12
|
export function createCadImageTool(deps) {
|
|
13
13
|
return defineTool({
|
|
14
14
|
name: 'cad_image_profile',
|
|
@@ -56,8 +56,8 @@ export function createCadImageTool(deps) {
|
|
|
56
56
|
},
|
|
57
57
|
timeoutMs: 120_000,
|
|
58
58
|
isConcurrencySafe: () => true,
|
|
59
|
-
async execute(args) {
|
|
60
|
-
const resolved =
|
|
59
|
+
async execute(args, exec) {
|
|
60
|
+
const resolved = resolveSessionPath(args.path, exec, deps.workspaceRoot);
|
|
61
61
|
const buffer = await loadCadFile(resolved);
|
|
62
62
|
const image = decodePng(buffer);
|
|
63
63
|
const profiles = extractProfiles(image, {
|
package/lib/tools/cad-info.js
CHANGED
|
@@ -6,7 +6,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
6
6
|
import { detectFormat } from '../convert/detect.js';
|
|
7
7
|
import { convert } from '../convert/index.js';
|
|
8
8
|
import { sceneStats } from '../types.js';
|
|
9
|
-
import { loadCadFile,
|
|
9
|
+
import { loadCadFile, resolveSessionPath } from './util.js';
|
|
10
10
|
/** Build the cad_info tool definition. */
|
|
11
11
|
export function createCadInfoTool(deps) {
|
|
12
12
|
return defineTool({
|
|
@@ -58,8 +58,8 @@ export function createCadInfoTool(deps) {
|
|
|
58
58
|
},
|
|
59
59
|
timeoutMs: 180_000,
|
|
60
60
|
isConcurrencySafe: () => true,
|
|
61
|
-
async execute(args) {
|
|
62
|
-
const resolved =
|
|
61
|
+
async execute(args, exec) {
|
|
62
|
+
const resolved = resolveSessionPath(args.path, exec, deps.workspaceRoot);
|
|
63
63
|
const name = resolved.split('/').pop() ?? resolved;
|
|
64
64
|
const format = detectFormat(name);
|
|
65
65
|
if (format === null) {
|
package/lib/tools/cad-model.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
1
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
3
4
|
import { runModelOp, workerResetEpoch } from '../modeling/client.js';
|
|
4
5
|
import { ModelDocument } from '../modeling/document.js';
|
|
5
6
|
import { composeAssemblyMeshes } from '../modeling/assembly.js';
|
|
6
7
|
import { buildDrawingSheet, drawingToDxf, drawingToSvg } from '../modeling/drawing.js';
|
|
7
|
-
import {
|
|
8
|
+
import { resolveSessionPath } from './util.js';
|
|
8
9
|
import { toDcPrtDocument } from '../feature_script/dc_prt.js';
|
|
9
10
|
import { createConstraintTools } from './cad-constraint.js';
|
|
10
11
|
/** Mirror a worker mesh with its raw typed arrays (binary-transport ready). */
|
|
@@ -359,6 +360,24 @@ export function createModelTools(deps) {
|
|
|
359
360
|
];
|
|
360
361
|
if (Array.isArray(value.removed) && value.removed.length > 0)
|
|
361
362
|
lines.push(`consumed bodies: ${value.removed.join(', ')}`);
|
|
363
|
+
// cad_sketch_list: the sketches are the payload — render them, or the
|
|
364
|
+
// result reads as just a body count (issue #6).
|
|
365
|
+
if (Array.isArray(value.sketches)) {
|
|
366
|
+
const sketches = value.sketches;
|
|
367
|
+
if (sketches.length === 0) {
|
|
368
|
+
lines.push('sketches: (none — create one with cad_sketch_new)');
|
|
369
|
+
}
|
|
370
|
+
else {
|
|
371
|
+
for (const sketch of sketches) {
|
|
372
|
+
const size = sketch.type === 'circle'
|
|
373
|
+
? `R${String(sketch.radius)}`
|
|
374
|
+
: sketch.type === 'segments'
|
|
375
|
+
? `${String(sketch.segments)} segments`
|
|
376
|
+
: `${String(sketch.points)} points`;
|
|
377
|
+
lines.push(`sketch ${String(sketch.name)}: ${String(sketch.type)} (${size})`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
362
381
|
if (value.volume !== undefined)
|
|
363
382
|
lines.push(`volume: ${Number(value.volume).toFixed(2)} mm³`);
|
|
364
383
|
if (value.filePath !== undefined)
|
|
@@ -381,6 +400,8 @@ export function createModelTools(deps) {
|
|
|
381
400
|
/** Presentation meta for assembly ops (kind 3d, doc assembly). */
|
|
382
401
|
const assemblyMetaOf = (value) => ({
|
|
383
402
|
viewId: String(value.viewId),
|
|
403
|
+
// viewId 是装配场景 id(asm-<docId>);docId 供客户端查文档级数据(特征树等)。
|
|
404
|
+
docId: document.doc.docId,
|
|
384
405
|
kind: '3d',
|
|
385
406
|
format: 'assembly',
|
|
386
407
|
file: 'assembly',
|
|
@@ -395,6 +416,8 @@ export function createModelTools(deps) {
|
|
|
395
416
|
/** Presentation meta for drawing ops (kind 2d, doc drawing). */
|
|
396
417
|
const drawingMetaOf = (value) => ({
|
|
397
418
|
viewId: String(value.drawingId),
|
|
419
|
+
// viewId 是图纸场景 id;docId 供客户端查文档级数据(特征树等)。
|
|
420
|
+
docId: document.doc.docId,
|
|
398
421
|
kind: '2d',
|
|
399
422
|
format: 'drawing',
|
|
400
423
|
file: 'drawing',
|
|
@@ -1023,12 +1046,16 @@ export function createModelTools(deps) {
|
|
|
1023
1046
|
...(args.angle !== undefined ? { angle: args.angle } : {}),
|
|
1024
1047
|
};
|
|
1025
1048
|
const result = await runModelOp(op);
|
|
1026
|
-
const
|
|
1049
|
+
const createdEntries = result.created ?? [];
|
|
1050
|
+
const created = createdEntries.map((entry) => {
|
|
1027
1051
|
if (entry.mesh !== undefined)
|
|
1028
1052
|
meshCache.set(entry.bodyId, mirrorMesh(entry.mesh, entry.bodyId));
|
|
1029
1053
|
return entry.bodyId;
|
|
1030
1054
|
});
|
|
1031
|
-
|
|
1055
|
+
// Record every copy's name: bodyNames is the manifest the assembly
|
|
1056
|
+
// tree reads, and skipping copies marked pattern-derived instances as
|
|
1057
|
+
// missing (issue #6).
|
|
1058
|
+
await document.record(op, createdEntries.map((entry) => ({ bodyId: entry.bodyId, name: entry.name ?? entry.bodyId })));
|
|
1032
1059
|
const meshes = [...meshCache.values()];
|
|
1033
1060
|
const sceneUrlBase = deps.ensureSceneRoute();
|
|
1034
1061
|
if (sceneUrlBase !== null && meshes.length > 0) {
|
|
@@ -1188,7 +1215,10 @@ export function createModelTools(deps) {
|
|
|
1188
1215
|
isConcurrencySafe: () => true,
|
|
1189
1216
|
async execute(args, exec) {
|
|
1190
1217
|
await resolveDoc(exec);
|
|
1191
|
-
const resolved =
|
|
1218
|
+
const resolved = resolveSessionPath(args.path, exec, deps.workspaceRoot);
|
|
1219
|
+
// Auto-create the parent directory — the session workspace is the base,
|
|
1220
|
+
// and nested export paths should just work (issue #6).
|
|
1221
|
+
await mkdir(path.dirname(resolved), { recursive: true });
|
|
1192
1222
|
const lower = args.path.toLowerCase();
|
|
1193
1223
|
// Assembly export: compound of transformed instances via the worker.
|
|
1194
1224
|
if (args.target === 'assembly') {
|
package/lib/tools/cad-onshape.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { randomUUID } from 'node:crypto';
|
|
11
11
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
12
|
-
import {
|
|
12
|
+
import { resolveSessionPath } from './util.js';
|
|
13
13
|
import { onshapeAvailable, onshapeUnavailableReason, runOnshapeProgram } from '../cad_connector/onshape-executor.js';
|
|
14
14
|
import { isKnownOpKind, normalizeOps } from '../cad_connector/executor.js';
|
|
15
15
|
const EXPORT_EXTENSIONS = new Set(['stl', 'step', 'stp', 'x_t', 'x_b']);
|
|
@@ -90,7 +90,7 @@ export function createOnshapeTool(deps) {
|
|
|
90
90
|
},
|
|
91
91
|
timeoutMs: 300_000,
|
|
92
92
|
isConcurrencySafe: () => false,
|
|
93
|
-
async execute(args) {
|
|
93
|
+
async execute(args, exec) {
|
|
94
94
|
if (!onshapeAvailable())
|
|
95
95
|
throw new Error(onshapeUnavailableReason());
|
|
96
96
|
if (!Array.isArray(args.steps) || args.steps.length === 0) {
|
|
@@ -121,7 +121,7 @@ export function createOnshapeTool(deps) {
|
|
|
121
121
|
: {}),
|
|
122
122
|
};
|
|
123
123
|
if (args.exportPath !== undefined) {
|
|
124
|
-
const resolved =
|
|
124
|
+
const resolved = resolveSessionPath(args.exportPath, exec, deps.workspaceRoot);
|
|
125
125
|
const extension = resolved.toLowerCase().split('.').pop() ?? '';
|
|
126
126
|
if (!EXPORT_EXTENSIONS.has(extension)) {
|
|
127
127
|
throw new Error(`exportPath must be one of ${[...EXPORT_EXTENSIONS].map((ext) => `.${ext}`).join(' ')} (got .${extension})`);
|
package/lib/tools/cad-view.js
CHANGED
|
@@ -7,7 +7,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
|
7
7
|
import { detectFormat, is3DFormat } from '../convert/detect.js';
|
|
8
8
|
import { convert } from '../convert/index.js';
|
|
9
9
|
import { sceneStats } from '../types.js';
|
|
10
|
-
import { loadCadFile,
|
|
10
|
+
import { loadCadFile, resolveSessionPath } from './util.js';
|
|
11
11
|
function formatBounds(stats) {
|
|
12
12
|
if (stats.boundsMin === undefined || stats.boundsMax === undefined)
|
|
13
13
|
return '';
|
|
@@ -71,8 +71,8 @@ export function createCadViewTool(deps) {
|
|
|
71
71
|
},
|
|
72
72
|
timeoutMs: 180_000,
|
|
73
73
|
isConcurrencySafe: () => true,
|
|
74
|
-
async execute(args) {
|
|
75
|
-
const resolved =
|
|
74
|
+
async execute(args, exec) {
|
|
75
|
+
const resolved = resolveSessionPath(args.path, exec, deps.workspaceRoot);
|
|
76
76
|
const name = basename(resolved);
|
|
77
77
|
const format = detectFormat(name);
|
|
78
78
|
if (format === null) {
|
package/lib/tools/util.js
CHANGED
|
@@ -10,6 +10,26 @@ import path from 'node:path';
|
|
|
10
10
|
export function resolveWorkspacePath(input, workspaceRoot) {
|
|
11
11
|
return path.isAbsolute(input) ? input : path.join(workspaceRoot, input);
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The calling session's workspace cwd, when the host's agent runtime exposes
|
|
15
|
+
* it (`exec.agent.session.header.cwd`). dsh web/desktop launch the host with
|
|
16
|
+
* cwd = workspace, but a host started from another directory (e.g. a dsh
|
|
17
|
+
* source checkout) breaks that assumption — issue #6: exports landed in the
|
|
18
|
+
* launch directory instead of the session's workspace.
|
|
19
|
+
*/
|
|
20
|
+
export function sessionCwdOf(exec) {
|
|
21
|
+
const cwd = exec?.agent?.session?.header?.cwd;
|
|
22
|
+
return typeof cwd === 'string' && path.isAbsolute(cwd) ? cwd : null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Resolve a model-supplied path against the SESSION workspace first, falling
|
|
26
|
+
* back to the process-cwd workspace root on hosts without session metadata.
|
|
27
|
+
*/
|
|
28
|
+
export function resolveSessionPath(input, exec, fallbackRoot) {
|
|
29
|
+
if (path.isAbsolute(input))
|
|
30
|
+
return input;
|
|
31
|
+
return path.join(sessionCwdOf(exec) ?? fallbackRoot, input);
|
|
32
|
+
}
|
|
13
33
|
/** Load a CAD file buffer, failing with model-friendly errors. */
|
|
14
34
|
export async function loadCadFile(resolvedPath) {
|
|
15
35
|
let info;
|
|
@@ -38,6 +38,10 @@ export interface FoldedAssembly {
|
|
|
38
38
|
instances: AssemblyInstance[];
|
|
39
39
|
entities: ConstraintEntity[];
|
|
40
40
|
constraints: ConstraintEntry[];
|
|
41
|
+
/** Body ids alive at the end of the log — the ground truth for "missing".
|
|
42
|
+
* Folded from ops rather than read off bodyNames because documents written
|
|
43
|
+
* before pattern copies were recorded have incomplete name maps. */
|
|
44
|
+
liveBodyIds: Set<string>;
|
|
41
45
|
}
|
|
42
46
|
/**
|
|
43
47
|
* Rebuild the assembly state purely from the persisted op log — the worker
|
|
@@ -24,11 +24,15 @@ export declare class ModelDocument {
|
|
|
24
24
|
private get file();
|
|
25
25
|
/** Load the persisted document if one exists. */
|
|
26
26
|
restore(): Promise<void>;
|
|
27
|
-
/** Append an applied operation and persist.
|
|
27
|
+
/** Append an applied operation and persist. Multi-create ops (pattern)
|
|
28
|
+
* pass an array so every copy's display name lands in bodyNames. */
|
|
28
29
|
record(op: ModelOp, bodyName: {
|
|
29
30
|
bodyId: string;
|
|
30
31
|
name: string;
|
|
31
|
-
} |
|
|
32
|
+
} | Array<{
|
|
33
|
+
bodyId: string;
|
|
34
|
+
name: string;
|
|
35
|
+
}> | null): Promise<void>;
|
|
32
36
|
/** Persist the current state without appending an op (registry creation). */
|
|
33
37
|
save(): Promise<void>;
|
|
34
38
|
/**
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
/** Resolve a model-supplied path against the workspace root. */
|
|
2
2
|
export declare function resolveWorkspacePath(input: string, workspaceRoot: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The calling session's workspace cwd, when the host's agent runtime exposes
|
|
5
|
+
* it (`exec.agent.session.header.cwd`). dsh web/desktop launch the host with
|
|
6
|
+
* cwd = workspace, but a host started from another directory (e.g. a dsh
|
|
7
|
+
* source checkout) breaks that assumption — issue #6: exports landed in the
|
|
8
|
+
* launch directory instead of the session's workspace.
|
|
9
|
+
*/
|
|
10
|
+
export declare function sessionCwdOf(exec: unknown): string | null;
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a model-supplied path against the SESSION workspace first, falling
|
|
13
|
+
* back to the process-cwd workspace root on hosts without session metadata.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveSessionPath(input: string, exec: unknown, fallbackRoot: string): string;
|
|
3
16
|
/** Load a CAD file buffer, failing with model-friendly errors. */
|
|
4
17
|
export declare function loadCadFile(resolvedPath: string): Promise<Buffer>;
|
package/lib/types.js
CHANGED
|
@@ -5,13 +5,16 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** Flatten a converted scene into the model/card-facing summary. */
|
|
7
7
|
export function sceneStats(scene) {
|
|
8
|
+
// Tool output must round-trip lossless JSON: NaN/Infinity in source geometry
|
|
9
|
+
// (or a converter edge case) would make the registry reject the whole result
|
|
10
|
+
// — drop bounds containing non-finite numbers instead (issue #6).
|
|
11
|
+
const bounds = (min, max) => [...min, ...max].every((value) => Number.isFinite(value)) ? { boundsMin: min, boundsMax: max } : {};
|
|
8
12
|
if (scene.kind === '3d') {
|
|
9
13
|
return {
|
|
10
14
|
format: scene.format,
|
|
11
15
|
meshes: scene.meshes.length,
|
|
12
16
|
triangles: scene.meshes.reduce((sum, mesh) => sum + mesh.triangleCount, 0),
|
|
13
|
-
|
|
14
|
-
boundsMax: [scene.bounds.max.x, scene.bounds.max.y, scene.bounds.max.z],
|
|
17
|
+
...bounds([scene.bounds.min.x, scene.bounds.min.y, scene.bounds.min.z], [scene.bounds.max.x, scene.bounds.max.y, scene.bounds.max.z]),
|
|
15
18
|
units: scene.units,
|
|
16
19
|
};
|
|
17
20
|
}
|
|
@@ -19,7 +22,6 @@ export function sceneStats(scene) {
|
|
|
19
22
|
format: scene.format,
|
|
20
23
|
entities: scene.entities.length,
|
|
21
24
|
layers: scene.layers,
|
|
22
|
-
|
|
23
|
-
boundsMax: [scene.bounds.max.x, scene.bounds.max.y],
|
|
25
|
+
...bounds([scene.bounds.min.x, scene.bounds.min.y], [scene.bounds.max.x, scene.bounds.max.y]),
|
|
24
26
|
};
|
|
25
27
|
}
|