dsh-cad 0.8.0 → 0.9.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.
@@ -13,7 +13,8 @@ const path = require('node:path')
13
13
  const fs = require('node:fs')
14
14
  const { createRequire } = require('node:module')
15
15
  const { createAdapter } = require('./occt-adapter.cjs')
16
- const { createOcctBridge } = require('./occt-bridge.cjs')
16
+ const { createOcctTsAdapter } = require('./occtts-adapter.cjs')
17
+ const { createOcctBridge, loadSharedOcctModule } = require('./occt-bridge.cjs')
17
18
 
18
19
  const require3 = createRequire(__filename)
19
20
  // The ES6 emscripten build reads a global __dirname when its factory runs.
@@ -23,8 +24,12 @@ const loaderPath = require3.resolve('opencascade.js/dist/opencascade.wasm.js')
23
24
  const loaderModule = require3(loaderPath)
24
25
  const wasmBinary = fs.readFileSync(path.join(path.dirname(loaderPath), 'opencascade.wasm.wasm'))
25
26
 
27
+ // Kernel selection: occt.ts is the PRIMARY backend (single-kernel modeling,
28
+ // true B-splines, shell/draft native, direct HLR); opencascade.js stays as
29
+ // the fallback when occt.ts cannot load. `adapter` carries whichever.
26
30
  let adapter = null
27
31
  let occt = null
32
+ let kernelName = 'occt.ts'
28
33
 
29
34
  /**
30
35
  * Reject a degenerate result BEFORE registering it: the kernel cannot
@@ -156,26 +161,21 @@ async function applyOp(op) {
156
161
  return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
157
162
  }
158
163
  case 'shell': {
159
- const body = bodies.get(op.target)
160
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
161
- const stepIn = body.step ?? adapter.exportFile(body.shape, 'step')
162
- const bridge = await getOcctBridge()
163
- if (bridge === null) throw new Error('shell requires the occt.ts kernel (>= 0.3.0) — reinstall dsh-cad so the dependency pulls it')
164
- const out = bridge.hostedSolidOp('shell', stepIn, { thickness: op.thickness, openNormals: op.openNormals, faces: op.faces, name: body.name })
165
- body.step = out.step
166
- body.shape = null
167
- return { bodyId: op.target, name: body.name, volume: out.volume, mesh: out.mesh }
164
+ const body = requireShape(op.target, 'shell')
165
+ if (typeof adapter.shell !== 'function') throw new Error('shell requires the occt.ts kernel backend — it is unavailable, reinstall dsh-cad')
166
+ const faces = Array.isArray(op.faces) && op.faces.length > 0 ? op.faces : matchFacesByNormal(body.shape, op.openNormals)
167
+ const shape = adapter.shell(body.shape, op.thickness, faces)
168
+ assertUsable(shape, 'shell', '检查壁厚是否超出几何允许')
169
+ body.shape = shape
170
+ return { bodyId: op.target, name: body.name, volume: Math.abs(adapter.volume(shape)), mesh: meshOf(shape, body.name) }
168
171
  }
169
172
  case 'draft': {
170
- const body = bodies.get(op.target)
171
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
172
- const stepIn = body.step ?? adapter.exportFile(body.shape, 'step')
173
- const bridge = await getOcctBridge()
174
- if (bridge === null) throw new Error('draft requires the occt.ts kernel (>= 0.3.0) — reinstall dsh-cad so the dependency pulls it')
175
- const out = bridge.hostedSolidOp('draft', stepIn, { angle: op.angle, direction: op.direction, faces: op.faces, name: body.name })
176
- body.step = out.step
177
- body.shape = null
178
- return { bodyId: op.target, name: body.name, volume: out.volume, mesh: out.mesh }
173
+ const body = requireShape(op.target, 'draft')
174
+ if (typeof adapter.draft !== 'function') throw new Error('draft requires the occt.ts kernel backend — it is unavailable, reinstall dsh-cad')
175
+ const shape = adapter.draft(body.shape, op.angle, op.direction)
176
+ assertUsable(shape, 'draft', '检查拔模角度是否超出几何允许')
177
+ body.shape = shape
178
+ return { bodyId: op.target, name: body.name, volume: Math.abs(adapter.volume(shape)), mesh: meshOf(shape, body.name) }
179
179
  }
180
180
  case 'boolean': {
181
181
  const target = requireShape(op.target, 'boolean')
@@ -191,9 +191,10 @@ async function applyOp(op) {
191
191
  }
192
192
  case 'fillet': {
193
193
  const body = requireShape(op.target, 'fillet')
194
- const { shape, edges } = adapter.filletAll(body.shape, op.radius ?? 1)
194
+ const shape = adapter.filletAll(body.shape, op.radius ?? 1)
195
+ assertUsable(shape, 'fillet', '半径可能超出相邻面')
195
196
  body.shape = shape
196
- return { bodyId: op.target, name: body.name, edges, mesh: meshOf(shape, body.name) }
197
+ return { bodyId: op.target, name: body.name, mesh: meshOf(shape, body.name) }
197
198
  }
198
199
  case 'transform': {
199
200
  const body = requireShape(op.target, 'transform')
@@ -214,13 +215,7 @@ async function applyOp(op) {
214
215
  return { meshes }
215
216
  }
216
217
  case 'export': {
217
- const body = bodies.get(op.target)
218
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
219
- if (body.step !== undefined) {
220
- // Hosted body: the STEP bytes ARE the parametric exchange form.
221
- if (op.format !== 'step') throw new Error('stl export of a hosted body requires tessellation — export .step, or re-export before shelling')
222
- return { bytes: body.step.buffer.slice(body.step.byteOffset, body.step.byteOffset + body.step.byteLength) }
223
- }
218
+ const body = requireShape(op.target, 'export')
224
219
  const bytes = adapter.exportFile(body.shape, op.format)
225
220
  return { bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }
226
221
  }
@@ -261,17 +256,20 @@ async function applyOp(op) {
261
256
  }
262
257
  case 'export_assembly': {
263
258
  if (instances.size === 0) throw new Error('the assembly is empty')
264
- const builder = new occt.BRep_Builder()
265
- const compound = new occt.TopoDS_Compound()
266
- builder.MakeCompound(compound)
259
+ // occt.ts has no compound binding — fuse the placed instances into one
260
+ // solid (the STEP carries the union; instance separation stays in the
261
+ // assembly scene/document). The legacy backend kept separate solids via
262
+ // a TopoDS_Compound; both exports open identically in CAD tools.
263
+ let compound = null
267
264
  let added = 0
268
265
  for (const instance of instances.values()) {
269
266
  const body = bodies.get(instance.bodyId)
270
267
  if (body === undefined) continue // stale instance of a consumed body
271
- builder.Add(compound, adapter.transform(body.shape, {
268
+ const placed = adapter.transform(body.shape, {
272
269
  translate: instance.translate,
273
270
  rotate: instance.rotate,
274
- }))
271
+ })
272
+ compound = compound === null ? placed : adapter.boolean('fuse', compound, [placed])
275
273
  added++
276
274
  }
277
275
  if (added === 0) throw new Error('no instance references a live body')
@@ -279,15 +277,12 @@ async function applyOp(op) {
279
277
  return { bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), instances: instancesList() }
280
278
  }
281
279
  case 'volume': {
282
- const body = bodies.get(op.target)
283
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
284
- if (body.step !== undefined) {
285
- // Hosted body: ask the occt.ts kernel directly.
286
- const bridge = await getOcctBridge()
287
- if (bridge === null) throw new Error('volume on a hosted body requires the occt.ts kernel')
288
- return { volume: bridge.volumeOf(body.step) }
280
+ const body = requireShape(op.target, 'volume')
281
+ const result = { volume: Math.abs(adapter.volume(body.shape)) }
282
+ if (typeof adapter.centroid === 'function') {
283
+ try { result.centroid = adapter.centroid(body.shape) } catch { /* centroid optional */ }
289
284
  }
290
- return { volume: Math.abs(adapter.volume(body.shape)) }
285
+ return result
291
286
  }
292
287
  case 'delete': {
293
288
  if (!bodies.delete(op.target)) throw new Error(`unknown body: ${op.target}`)
@@ -335,12 +330,39 @@ async function getOcctBridge() {
335
330
  return occtBridge
336
331
  }
337
332
 
338
- /** Reject ops that need an opencascade.js BRep on a hosted body. */
333
+ /** Faces (1-based) whose outward plane-normal matches the target normals. */
334
+ function matchFacesByNormal(shape, openNormals) {
335
+ if (!Array.isArray(openNormals) || openNormals.length === 0) return []
336
+ if (typeof adapter.describe !== 'function') throw new Error('face matching by normal requires the occt.ts kernel backend')
337
+ const desc = adapter.describe(shape)
338
+ const faces = Array.isArray(desc.faces) ? desc.faces : []
339
+ const picked = []
340
+ for (const target of openNormals) {
341
+ const tlen = Math.hypot(target[0], target[1], target[2])
342
+ if (tlen < 1e-12) continue
343
+ const tn = [target[0] / tlen, target[1] / tlen, target[2] / tlen]
344
+ let best = null
345
+ let bestScore = -Infinity
346
+ for (const face of faces) {
347
+ if (face.surface !== 'plane' || !Array.isArray(face.normal)) continue
348
+ const dot = face.normal[0] * tn[0] + face.normal[1] * tn[1] + face.normal[2] * tn[2]
349
+ if (Math.abs(dot) < 0.9) continue
350
+ const outward = dot >= 0 ? 1 : -1
351
+ const proj = (face.origin[0] * tn[0] + face.origin[1] * tn[1] + face.origin[2] * tn[2]) * outward
352
+ const score = Math.abs(dot) + proj * 1e-3
353
+ if (score > bestScore) { bestScore = score; best = face.index }
354
+ }
355
+ if (best !== null) picked.push(best)
356
+ }
357
+ return picked
358
+ }
359
+
360
+ /** Resolve a body's live shape (single-kernel: always present). */
339
361
  function requireShape(bodyId, what) {
340
362
  const body = bodies.get(bodyId)
341
363
  if (body === undefined) throw new Error(`unknown body: ${bodyId}`)
342
- if (body.shape === undefined || body.shape === null) {
343
- throw new Error(`${bodyId} is hosted on the occt.ts kernel (created by shell/draft); ${what} needs the opencascade.js BRep and cannot read hosted geometry back — model the feature before shelling, or reconstruct the body`)
364
+ if (body.shape === null || body.shape === undefined) {
365
+ throw new Error(`${bodyId} has no live shape (${what})`)
344
366
  }
345
367
  return body
346
368
  }
@@ -358,21 +380,34 @@ async function drawingViews(op) {
358
380
  return { views: projected.views }
359
381
  }
360
382
 
361
- const initOpenCascade = loaderModule.default ?? loaderModule
362
- initOpenCascade({ wasmBinary }).then((instance) => {
363
- occt = instance
364
- adapter = createAdapter(occt)
365
- parentPort.on('message', async (message) => {
383
+ async function boot() {
384
+ const mod = await loadSharedOcctModule()
385
+ if (mod !== null) {
386
+ adapter = createOcctTsAdapter(mod)
387
+ kernelName = 'occt.ts'
388
+ } else {
389
+ console.warn('[dsh-cad] occt.ts unavailable — falling back to the opencascade.js backend')
390
+ const initOpenCascade = loaderModule.default ?? loaderModule
391
+ occt = await initOpenCascade({ wasmBinary })
392
+ adapter = createAdapter(occt)
393
+ kernelName = 'opencascade.js'
394
+ }
395
+ parentPort.postMessage({ jobId: 0, ok: true, result: { ready: true, kernel: kernelName } })
396
+ parentPort.on('message', runMessage)
397
+ }
398
+
399
+ async function runMessage(message) {
400
+ {
366
401
  const transfers = []
367
402
  try {
368
403
  const result = await applyOp(message.op)
369
- // Collect transferable mesh buffers.
370
404
  const collect = (mesh) => {
371
405
  if (mesh === undefined) return
372
406
  transfers.push(mesh.positions.buffer, mesh.indices.buffer, mesh.normals.buffer)
373
407
  }
374
408
  if (result.mesh !== undefined) collect(result.mesh)
375
409
  for (const mesh of result.meshes ?? []) collect(mesh)
410
+ if (result.created !== undefined) for (const entry of result.created) if (entry.mesh !== undefined) collect(entry.mesh)
376
411
  if (result.bytes !== undefined) transfers.push(result.bytes)
377
412
  parentPort.postMessage({ jobId: message.jobId, ok: true, result }, transfers)
378
413
  } catch (error) {
@@ -382,8 +417,10 @@ initOpenCascade({ wasmBinary }).then((instance) => {
382
417
  error: error instanceof Error ? error.message : String(error),
383
418
  })
384
419
  }
385
- })
386
- parentPort.postMessage({ jobId: 0, ok: true, result: { ready: true } })
387
- }).catch((error) => {
388
- parentPort.postMessage({ jobId: 0, ok: false, error: `modeling kernel init failed: ${error.message}` })
420
+ }
421
+ }
422
+
423
+ boot().catch((error) => {
424
+ parentPort.postMessage({ jobId: 0, ok: false, error: `modeling kernel init failed: ${error instanceof Error ? error.message : String(error)}` })
389
425
  })
426
+
@@ -127,7 +127,7 @@ function createAdapter(occt) {
127
127
  if (edges === 0) throw new Error('no edges to fillet')
128
128
  algo.Build()
129
129
  if (!algo.IsDone()) throw new Error('fillet failed (radius may exceed the adjacent faces)')
130
- return { shape: algo.Shape(), edges }
130
+ return algo.Shape()
131
131
  }
132
132
 
133
133
  function castEdge(shape) {
@@ -54,6 +54,24 @@ function resolveDistDir(explicit) {
54
54
  return null
55
55
  }
56
56
 
57
+ /** Shared wasm-module singleton: the worker's modeling adapter, the HLR path
58
+ * and the hosted ops all reuse ONE occt.ts instance per process. */
59
+ let sharedModule = null
60
+
61
+ /** Load (once) and return the shared occt.ts raw module, or null when absent. */
62
+ async function loadSharedOcctModule() {
63
+ if (sharedModule !== null && sharedModule !== undefined) return sharedModule
64
+ const distDir = resolveDistDir()
65
+ if (distDir === null) return null
66
+ try {
67
+ sharedModule = await loadModule(distDir)
68
+ } catch (error) {
69
+ console.warn(`[dsh-cad] occt.ts kernel failed to load: ${error instanceof Error ? error.message : error}`)
70
+ sharedModule = null
71
+ }
72
+ return sharedModule
73
+ }
74
+
57
75
  async function loadModule(distDir) {
58
76
  const imported = await import(pathToFileURL(path.join(distDir, 'wasm', 'occtjs.js')).href)
59
77
  const factory = imported.default
@@ -81,7 +99,7 @@ async function hiddenLineViews(mod, stepBytes, views) {
81
99
  const ptr = mod._malloc(stepBytes.length)
82
100
  try {
83
101
  mod.HEAPU8.set(stepBytes, ptr)
84
- const shape = mod.readStep(ptr, stepBytes.length)
102
+ const shape = mod.readStep(ptr, stepBytes.length, 'mm')
85
103
  if (shape.isNull()) throw new Error(`occt.ts readStep: ${mod.lastError()}`)
86
104
  try {
87
105
  return {
@@ -286,14 +304,61 @@ function hostedSolidOp(mod, kind, stepBytes, params) {
286
304
  }
287
305
  }
288
306
 
307
+ /**
308
+ * Hidden-line views of a shape that ALREADY lives in this occt.ts instance
309
+ * (the single-kernel path — no STEP hop). Same frame/remap contract as
310
+ * hiddenLineViews.
311
+ */
312
+ function hiddenLineViewsOfShape(mod, shape, views) {
313
+ return {
314
+ views: views.map((view) => {
315
+ const w = norm(view.dir)
316
+ const u = norm(view.xDir)
317
+ const v = norm(cross(w, u))
318
+ const dv = dot(w, v)
319
+ let x = [v[0] - w[0] * dv, v[1] - w[1] * dv, v[2] - w[2] * dv]
320
+ if (Math.hypot(x[0], x[1], x[2]) < 1e-9) x = Math.abs(w[2]) < 0.9 ? [0, 0, 1] : [1, 0, 0]
321
+ x = norm(x)
322
+ const y = norm(cross(w, x))
323
+ const raw = mod.hiddenLines(shape, w[0], w[1], w[2], x[0], x[1], x[2], 0.1)
324
+ const read = (ptr2, count) => new Float32Array(mod.HEAPU8.buffer, ptr2, count * 3).slice()
325
+ let visible
326
+ let hidden
327
+ try {
328
+ visible = read(raw.visiblePtr(), raw.visiblePointCount())
329
+ hidden = read(raw.hiddenPtr(), raw.hiddenPointCount())
330
+ } finally {
331
+ raw.delete()
332
+ }
333
+ const a = dot(x, u)
334
+ const b = dot(y, u)
335
+ const c = dot(x, v)
336
+ const d = dot(y, v)
337
+ const remap = (arr) => {
338
+ const xyz = []
339
+ for (let i = 0; i + 2 < arr.length; i += 3) xyz.push(arr[i] * a + arr[i + 1] * b, arr[i] * c + arr[i + 1] * d, 0)
340
+ return xyz
341
+ }
342
+ const toPolylines = (xyz) =>
343
+ chainSegments(
344
+ Array.from({ length: xyz.length / 6 }, (_, si) => [xyz[si * 6], xyz[si * 6 + 1], xyz[si * 6 + 3], xyz[si * 6 + 4]]),
345
+ ).map((chain) => simplify(chain, 0.02)).filter((chain) => chain.length >= 4)
346
+ const visiblePolylines = toPolylines(remap(visible))
347
+ const visibleKeys = new Set(visiblePolylines.map(polylineKey))
348
+ const hiddenPolylines = toPolylines(remap(hidden)).filter((chain) => !visibleKeys.has(polylineKey(chain)))
349
+ return { name: view.name, visible: visiblePolylines, hidden: hiddenPolylines }
350
+ }),
351
+ }
352
+ }
353
+
289
354
  /**
290
355
  * Create the bridge, or null when the dist is absent/unloadable (the caller
291
356
  * falls back to the mesh HLR). The heavy wasm load is lazy and cached.
292
357
  */
293
358
  async function createOcctBridge(options = {}) {
359
+ const mod = options.mod ?? await loadSharedOcctModule()
360
+ if (mod === null) return null
294
361
  const distDir = resolveDistDir(options.distDir)
295
- if (distDir === null) return null
296
- const mod = await loadModule(distDir)
297
362
  if (typeof mod.hiddenLines !== 'function' || typeof mod.readStep !== 'function') {
298
363
  throw new Error(`occt.ts dist at ${distDir} lacks hiddenLines/readStep`)
299
364
  }
@@ -307,6 +372,7 @@ async function createOcctBridge(options = {}) {
307
372
  distDir,
308
373
  occtVersion: mod.occtVersion(),
309
374
  hiddenLineViews: (stepBytes, views) => hiddenLineViews(mod, stepBytes, views),
375
+ hiddenLineViewsOf: (shape, views) => hiddenLineViewsOfShape(mod, shape, views),
310
376
  hostedSolidOp: (kind, stepBytes, params) => hostedSolidOp(mod, kind, stepBytes, params),
311
377
  volumeOf: (stepBytes) => {
312
378
  const shape = readShape(mod, stepBytes)
@@ -404,4 +470,4 @@ function simplify(points, epsilon) {
404
470
  return out
405
471
  }
406
472
 
407
- module.exports = { createOcctBridge, resolveDistDir }
473
+ module.exports = { createOcctBridge, resolveDistDir, loadSharedOcctModule }
@@ -372,7 +372,8 @@ export function createModelTools(deps) {
372
372
  const cadCreatePrim = defineTool({
373
373
  name: 'cad_create_prim',
374
374
  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). ' +
375
- '`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.',
375
+ '`at` is the anchor point with per-kind semantics: box → min corner (the solid spans at .. at+[dx,dy,dz] along +X/+Y/+Z); cylinder/cone → base-circle center (the solid extends `height` from it along `axis`); sphere/torus → geometric center. ' +
376
+ '`axis` orients cylinder/cone/torus (default +Z). Returns the bodyId other CAD tools reference. The viewer card updates after every call.',
376
377
  parameters: {
377
378
  kind: { type: 'string', required: true, enum: ['box', 'cylinder', 'sphere', 'cone', 'torus'], description: 'Primitive kind.' },
378
379
  dx: numberParam('box: size X (mm).'),
@@ -384,7 +385,7 @@ export function createModelTools(deps) {
384
385
  height: numberParam('cylinder/cone: height (mm).'),
385
386
  majorRadius: numberParam('torus: center radius (mm).'),
386
387
  minorRadius: numberParam('torus: tube radius (mm).'),
387
- at: pointParam('origin [x,y,z] (mm).'),
388
+ at: pointParam('anchor [x,y,z] (mm): box = min corner, cylinder/cone = base-circle center, sphere/torus = geometric center.'),
388
389
  axis: pointParam('axis direction [x,y,z] for cylinder/cone/torus.'),
389
390
  name: { type: 'string', description: 'Optional display name.' },
390
391
  },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-cad",
3
3
  "description": "CAD visualization plugin for DeepSeek Harness: cad_view / cad_info tools with an embedded 3D (STL/OBJ/STEP/IGES) and 2D (DXF/SVG) viewer card in the Web UI",
4
- "version": "0.8.0",
4
+ "version": "0.9.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "LAU-MARS",
@@ -70,7 +70,7 @@
70
70
  "ansatz-wasm": "^0.2.0",
71
71
  "dxf-parser": "^1.1.2",
72
72
  "occt-import-js": "^0.0.23",
73
- "occt.ts": "^0.3.0",
73
+ "occt.ts": "^0.5.0",
74
74
  "opencascade.js": "^1.1.1",
75
75
  "three": "^0.185.1"
76
76
  },