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.
@@ -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
- // bodyNames tracks every live body (unnamed bodies record their id).
169
- missing: doc.bodyNames[instance.bodyId] === undefined,
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') };
@@ -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
- if (bodyName !== null)
52
- this.doc.bodyNames[bodyName.bodyId] = bodyName.name;
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
- const id = `${op.target}p${i}`
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}·${i}`
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
 
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { randomUUID } from 'node:crypto';
10
10
  import { defineTool } from '@deepseek-ai/dsh-tools';
11
- import { resolveWorkspacePath } from './util.js';
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 = resolveWorkspacePath(args.input, deps.workspaceRoot);
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 = resolveWorkspacePath(args.exportPath, deps.workspaceRoot);
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})`);
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { randomUUID } from 'node:crypto';
9
9
  import { defineTool } from '@deepseek-ai/dsh-tools';
10
- import { resolveWorkspacePath } from './util.js';
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 = resolveWorkspacePath(args.exportPath, deps.workspaceRoot);
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})`);
@@ -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, resolveWorkspacePath } from './util.js';
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 = resolveWorkspacePath(args.path, deps.workspaceRoot);
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, {
@@ -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, resolveWorkspacePath } from './util.js';
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 = resolveWorkspacePath(args.path, deps.workspaceRoot);
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) {
@@ -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 { resolveWorkspacePath } from './util.js';
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 created = (result.created ?? []).map((entry) => {
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
- await document.record(op, null);
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 = resolveWorkspacePath(args.path, deps.workspaceRoot);
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') {
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { randomUUID } from 'node:crypto';
11
11
  import { defineTool } from '@deepseek-ai/dsh-tools';
12
- import { resolveWorkspacePath } from './util.js';
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 = resolveWorkspacePath(args.exportPath, deps.workspaceRoot);
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})`);
@@ -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, resolveWorkspacePath } from './util.js';
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 = resolveWorkspacePath(args.path, deps.workspaceRoot);
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
- } | null): Promise<void>;
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
- boundsMin: [scene.bounds.min.x, scene.bounds.min.y, scene.bounds.min.z],
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
- boundsMin: [scene.bounds.min.x, scene.bounds.min.y],
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
  }