dsh-cad 0.7.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
@@ -37,7 +42,10 @@ function assertUsable(shape, what, hint) {
37
42
  }
38
43
  }
39
44
 
40
- /** bodyId → { shape, name } — the live document. */
45
+ /** bodyId → { shape, name, step? } — the live document. A body whose solid
46
+ * was produced by an occt.ts-only op (shell/draft) is HOSTED: `step` carries
47
+ * the STEP bytes as the source of truth and `shape` is null, because
48
+ * opencascade.js in this build cannot read geometry back. */
41
49
  const bodies = new Map()
42
50
  let nextBodyNumber = 1
43
51
 
@@ -98,8 +106,7 @@ async function applyOp(op) {
98
106
  return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
99
107
  }
100
108
  case 'chamfer': {
101
- const body = bodies.get(op.target)
102
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
109
+ const body = requireShape(op.target, 'chamfer')
103
110
  const shape = adapter.chamferAll(body.shape, op.distance)
104
111
  assertUsable(shape, 'chamfer', '距离可能超出相邻面')
105
112
  body.shape = shape
@@ -153,14 +160,26 @@ async function applyOp(op) {
153
160
  bodies.set(bodyId, { shape, name: op.name ?? bodyId })
154
161
  return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
155
162
  }
163
+ case 'shell': {
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) }
171
+ }
172
+ case 'draft': {
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
+ }
156
180
  case 'boolean': {
157
- const target = bodies.get(op.target)
158
- if (target === undefined) throw new Error(`unknown body: ${op.target}`)
159
- const tools = op.tools.map((id) => {
160
- const tool = bodies.get(id)
161
- if (tool === undefined) throw new Error(`unknown body: ${id}`)
162
- return tool.shape
163
- })
181
+ const target = requireShape(op.target, 'boolean')
182
+ const tools = op.tools.map((id) => requireShape(id, 'boolean').shape)
164
183
  const shape = adapter.boolean(op.op, target.shape, tools)
165
184
  target.shape = shape
166
185
  // Consumed tool bodies are removed from the document (CAD convention).
@@ -171,15 +190,14 @@ async function applyOp(op) {
171
190
  return { bodyId: op.target, name: target.name, removed, mesh: meshOf(shape, target.name) }
172
191
  }
173
192
  case 'fillet': {
174
- const body = bodies.get(op.target)
175
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
176
- const { shape, edges } = adapter.filletAll(body.shape, op.radius ?? 1)
193
+ const body = requireShape(op.target, 'fillet')
194
+ const shape = adapter.filletAll(body.shape, op.radius ?? 1)
195
+ assertUsable(shape, 'fillet', '半径可能超出相邻面')
177
196
  body.shape = shape
178
- 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) }
179
198
  }
180
199
  case 'transform': {
181
- const body = bodies.get(op.target)
182
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
200
+ const body = requireShape(op.target, 'transform')
183
201
  const shape = adapter.transform(body.shape, {
184
202
  translate: op.translate,
185
203
  rotate: op.rotate,
@@ -191,13 +209,13 @@ async function applyOp(op) {
191
209
  case 'tessellate_all': {
192
210
  const meshes = []
193
211
  for (const [bodyId, body] of bodies) {
212
+ if (body.shape === null || body.shape === undefined) continue // hosted bodies re-mesh via their ops
194
213
  meshes.push({ bodyId, name: body.name, ...meshOf(body.shape, body.name) })
195
214
  }
196
215
  return { meshes }
197
216
  }
198
217
  case 'export': {
199
- const body = bodies.get(op.target)
200
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
218
+ const body = requireShape(op.target, 'export')
201
219
  const bytes = adapter.exportFile(body.shape, op.format)
202
220
  return { bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }
203
221
  }
@@ -238,17 +256,20 @@ async function applyOp(op) {
238
256
  }
239
257
  case 'export_assembly': {
240
258
  if (instances.size === 0) throw new Error('the assembly is empty')
241
- const builder = new occt.BRep_Builder()
242
- const compound = new occt.TopoDS_Compound()
243
- 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
244
264
  let added = 0
245
265
  for (const instance of instances.values()) {
246
266
  const body = bodies.get(instance.bodyId)
247
267
  if (body === undefined) continue // stale instance of a consumed body
248
- builder.Add(compound, adapter.transform(body.shape, {
268
+ const placed = adapter.transform(body.shape, {
249
269
  translate: instance.translate,
250
270
  rotate: instance.rotate,
251
- }))
271
+ })
272
+ compound = compound === null ? placed : adapter.boolean('fuse', compound, [placed])
252
273
  added++
253
274
  }
254
275
  if (added === 0) throw new Error('no instance references a live body')
@@ -256,9 +277,12 @@ async function applyOp(op) {
256
277
  return { bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), instances: instancesList() }
257
278
  }
258
279
  case 'volume': {
259
- const body = bodies.get(op.target)
260
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
261
- return { volume: adapter.volume(body.shape) }
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 */ }
284
+ }
285
+ return result
262
286
  }
263
287
  case 'delete': {
264
288
  if (!bodies.delete(op.target)) throw new Error(`unknown body: ${op.target}`)
@@ -306,6 +330,43 @@ async function getOcctBridge() {
306
330
  return occtBridge
307
331
  }
308
332
 
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). */
361
+ function requireShape(bodyId, what) {
362
+ const body = bodies.get(bodyId)
363
+ if (body === undefined) throw new Error(`unknown body: ${bodyId}`)
364
+ if (body.shape === null || body.shape === undefined) {
365
+ throw new Error(`${bodyId} has no live shape (${what})`)
366
+ }
367
+ return body
368
+ }
369
+
309
370
  async function drawingViews(op) {
310
371
  const body = bodies.get(op.target)
311
372
  if (body === undefined) throw new Error(`unknown body: ${op.target}`)
@@ -319,21 +380,34 @@ async function drawingViews(op) {
319
380
  return { views: projected.views }
320
381
  }
321
382
 
322
- const initOpenCascade = loaderModule.default ?? loaderModule
323
- initOpenCascade({ wasmBinary }).then((instance) => {
324
- occt = instance
325
- adapter = createAdapter(occt)
326
- 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
+ {
327
401
  const transfers = []
328
402
  try {
329
403
  const result = await applyOp(message.op)
330
- // Collect transferable mesh buffers.
331
404
  const collect = (mesh) => {
332
405
  if (mesh === undefined) return
333
406
  transfers.push(mesh.positions.buffer, mesh.indices.buffer, mesh.normals.buffer)
334
407
  }
335
408
  if (result.mesh !== undefined) collect(result.mesh)
336
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)
337
411
  if (result.bytes !== undefined) transfers.push(result.bytes)
338
412
  parentPort.postMessage({ jobId: message.jobId, ok: true, result }, transfers)
339
413
  } catch (error) {
@@ -343,8 +417,10 @@ initOpenCascade({ wasmBinary }).then((instance) => {
343
417
  error: error instanceof Error ? error.message : String(error),
344
418
  })
345
419
  }
346
- })
347
- parentPort.postMessage({ jobId: 0, ok: true, result: { ready: true } })
348
- }).catch((error) => {
349
- 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)}` })
350
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 {
@@ -143,22 +161,227 @@ async function hiddenLineViews(mod, stepBytes, views) {
143
161
  }
144
162
  }
145
163
 
164
+
165
+ // ── occt.ts-hosted solid operations (shell / draft / boolean / transform) ────
166
+ //
167
+ // opencascade.js cannot read geometry BACK (STEPControl_Reader and BRepTools
168
+ // readers are opaque no-method shells in that build), so a solid produced by
169
+ // an occt.ts-only op cannot return to the opencascade.js modeling session.
170
+ // Instead the result stays hosted as STEP bytes on the occt.ts side: every
171
+ // hosted op re-reads the STEP, operates, and writes it back, while meshes for
172
+ // display/volume come from the occt.ts tessellator. opencascade.js-exclusive
173
+ // ops (fillet/chamfer/loft/sweep/revolve) refuse hosted bodies with a clear
174
+ // error instead of silently operating on stale geometry.
175
+
176
+ /** Read STEP bytes into an occt.ts shape (caller deletes). */
177
+ function readShape(mod, stepBytes) {
178
+ const ptr = mod._malloc(stepBytes.length)
179
+ try {
180
+ mod.HEAPU8.set(stepBytes, ptr)
181
+ const shape = mod.readStep(ptr, stepBytes.length, 'mm')
182
+ if (shape.isNull()) throw new Error(`occt.ts readStep: ${mod.lastError()}`)
183
+ return shape
184
+ } finally {
185
+ mod._free(ptr)
186
+ }
187
+ }
188
+
189
+ /** Write an occt.ts shape to STEP bytes. */
190
+ function toStepBytes(mod, shape) {
191
+ const text = mod.writeStep(shape, 'mm')
192
+ return Buffer.from(text, 'utf8')
193
+ }
194
+
195
+ /** Tessellate an occt.ts shape into the worker mesh format. */
196
+ function tessellateMesh(mod, shape, name, linearDeflection = 0.3) {
197
+ const data = mod.tessellate(shape, linearDeflection, 20, false)
198
+ try {
199
+ const positions = new Float32Array(mod.HEAPU8.buffer, data.positionsPtr(), data.positionCount() * 3).slice()
200
+ const indices = new Uint32Array(mod.HEAPU8.buffer, data.indicesPtr(), data.indexCount()).slice()
201
+ const triangles = indices.length / 3
202
+ // Flat per-triangle normals (the mechanical-CAD look the viewer expects).
203
+ const normals = new Float32Array(positions.length)
204
+ for (let i = 0; i + 2 < indices.length; i += 3) {
205
+ const a = indices[i] * 3, b = indices[i + 1] * 3, c = indices[i + 2] * 3
206
+ const ux = positions[c] - positions[a], uy = positions[c + 1] - positions[a + 1], uz = positions[c + 2] - positions[a + 2]
207
+ const vx = positions[b] - positions[a], vy = positions[b + 1] - positions[a + 1], vz = positions[b + 2] - positions[a + 2]
208
+ let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
209
+ const len = Math.hypot(nx, ny, nz) || 1
210
+ nx /= len; ny /= len; nz /= len
211
+ for (const corner of [a, b, c]) {
212
+ normals[corner] = nx; normals[corner + 1] = ny; normals[corner + 2] = nz
213
+ }
214
+ }
215
+ return { name, positions, normals, indices, vertexCount: positions.length / 3, triangleCount: triangles }
216
+ } finally {
217
+ data.delete()
218
+ }
219
+ }
220
+
221
+ /** Faces (1-based indices) whose outward plane-normal matches a target list. */
222
+ function matchFaces(mod, shape, openNormals) {
223
+ if (!Array.isArray(openNormals) || openNormals.length === 0) return []
224
+ const desc = JSON.parse(mod.describe(shape))
225
+ const faces = Array.isArray(desc.faces) ? desc.faces : []
226
+ const picked = []
227
+ for (const target of openNormals) {
228
+ const tlen = Math.hypot(target[0], target[1], target[2])
229
+ if (tlen < 1e-12) continue
230
+ const tn = [target[0] / tlen, target[1] / tlen, target[2] / tlen]
231
+ let best = null
232
+ let bestScore = 0.9
233
+ for (const face of faces) {
234
+ if (face.surface !== 'plane' || !Array.isArray(face.normal)) continue
235
+ const dot = face.normal[0] * tn[0] + face.normal[1] * tn[1] + face.normal[2] * tn[2]
236
+ // |dot| ≈ 1 means parallel; the OUTER face along ±target has the larger
237
+ // origin·target projection (a box carries both ±Z faces).
238
+ if (Math.abs(dot) < 0.9) continue
239
+ const outward = Math.sign(dot) >= 0 ? 1 : -1
240
+ const proj = (face.origin[0] * tn[0] + face.origin[1] * tn[1] + face.origin[2] * tn[2]) * outward
241
+ const score = Math.abs(dot) + proj * 1e-3
242
+ if (score > bestScore) { bestScore = score; best = face.index }
243
+ }
244
+ if (best !== null) picked.push(best)
245
+ }
246
+ return picked
247
+ }
248
+
249
+ /** int32 face-index array into wasm memory; returns {ptr, count} or null. */
250
+ function faceListPtr(mod, faces) {
251
+ if (!Array.isArray(faces) || faces.length === 0) return { ptr: 0, count: 0 }
252
+ const ptr = mod._malloc(faces.length * 4)
253
+ for (let i = 0; i < faces.length; i++) {
254
+ mod.HEAPU32[ptr / 4 + i] = faces[i]
255
+ }
256
+ return { ptr, count: faces.length }
257
+ }
258
+
259
+ /**
260
+ * Run one occt.ts-hosted solid operation on STEP bytes:
261
+ * shell(stepBytes, thickness, openNormals) → { step, mesh, volume, faces }
262
+ * draft(stepBytes, angleDegrees, direction, faces?) → { step, mesh, volume }
263
+ * The STEP bytes of the RESULT are the hosted body's new source of truth.
264
+ */
265
+ function hostedSolidOp(mod, kind, stepBytes, params) {
266
+ const shape = readShape(mod, stepBytes)
267
+ try {
268
+ let result
269
+ if (kind === 'shell') {
270
+ const faces = Array.isArray(params.faces) && params.faces.length > 0
271
+ ? params.faces
272
+ : matchFaces(mod, shape, params.openNormals)
273
+ const list = faceListPtr(mod, faces)
274
+ try {
275
+ result = mod.shell(shape, Math.abs(params.thickness), list.ptr, list.count)
276
+ } finally {
277
+ if (list.ptr !== 0) mod._free(list.ptr)
278
+ }
279
+ if (result.isNull()) throw new Error(`shell failed: ${mod.lastError()}`)
280
+ } else if (kind === 'draft') {
281
+ const faces = Array.isArray(params.faces) && params.faces.length > 0 ? params.faces : []
282
+ const list = faceListPtr(mod, faces)
283
+ const d = params.direction ?? [0, 0, 1]
284
+ try {
285
+ result = mod.draft(shape, (params.angle * Math.PI) / 180, d[0], d[1], d[2], 0, 0, 1, list.ptr, list.count, faces.length === 0)
286
+ } finally {
287
+ if (list.ptr !== 0) mod._free(list.ptr)
288
+ }
289
+ if (result.isNull()) throw new Error(`draft failed: ${mod.lastError()}`)
290
+ } else {
291
+ throw new Error(`unknown hosted op: ${kind}`)
292
+ }
293
+ try {
294
+ return {
295
+ step: toStepBytes(mod, result),
296
+ mesh: tessellateMesh(mod, result, params.name ?? 'hosted'),
297
+ volume: Math.abs(result.volume()),
298
+ }
299
+ } finally {
300
+ result.delete()
301
+ }
302
+ } finally {
303
+ shape.delete()
304
+ }
305
+ }
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
+
146
354
  /**
147
355
  * Create the bridge, or null when the dist is absent/unloadable (the caller
148
356
  * falls back to the mesh HLR). The heavy wasm load is lazy and cached.
149
357
  */
150
358
  async function createOcctBridge(options = {}) {
359
+ const mod = options.mod ?? await loadSharedOcctModule()
360
+ if (mod === null) return null
151
361
  const distDir = resolveDistDir(options.distDir)
152
- if (distDir === null) return null
153
- const mod = await loadModule(distDir)
154
362
  if (typeof mod.hiddenLines !== 'function' || typeof mod.readStep !== 'function') {
155
363
  throw new Error(`occt.ts dist at ${distDir} lacks hiddenLines/readStep`)
156
364
  }
365
+ for (const required of ['shell', 'draft', 'describe', 'writeStep', 'tessellate']) {
366
+ if (typeof mod[required] !== 'function') {
367
+ throw new Error(`occt.ts dist at ${distDir} lacks ${required} (need occt.ts >= 0.3.0)`)
368
+ }
369
+ }
157
370
  return {
158
371
  kernel: 'occt.ts',
159
372
  distDir,
160
373
  occtVersion: mod.occtVersion(),
161
374
  hiddenLineViews: (stepBytes, views) => hiddenLineViews(mod, stepBytes, views),
375
+ hiddenLineViewsOf: (shape, views) => hiddenLineViewsOfShape(mod, shape, views),
376
+ hostedSolidOp: (kind, stepBytes, params) => hostedSolidOp(mod, kind, stepBytes, params),
377
+ volumeOf: (stepBytes) => {
378
+ const shape = readShape(mod, stepBytes)
379
+ try {
380
+ return Math.abs(shape.volume())
381
+ } finally {
382
+ shape.delete()
383
+ }
384
+ },
162
385
  }
163
386
  }
164
387
 
@@ -247,4 +470,4 @@ function simplify(points, epsilon) {
247
470
  return out
248
471
  }
249
472
 
250
- 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
  },
@@ -597,6 +598,70 @@ export function createModelTools(deps) {
597
598
  presentCall: (args) => ({ card: 'generic', title: `CAD chamfer ${String(args.target)}`, kind: 'other' }),
598
599
  presentResult: () => ({ card: 'generic', title: 'CAD chamfer' }),
599
600
  });
601
+ const cadShell = defineTool({
602
+ name: 'cad_shell',
603
+ description: 'Shell (抽壳): hollow a solid into a wall of `thickness` mm (the wall grows inward, the outer skin is preserved). `open` lists outward NORMALS [x,y,z] whose faces become openings (e.g. [[0,0,1]] opens the top of a box; empty = sealed hollow). ' +
604
+ 'Runs on the occt.ts kernel (>= 0.3.0): the result becomes a HOSTED body — volume/export(.step)/display keep working, but opencascade.js-only edits (fillet/chamfer/loft/sweep/revolve/boolean) on it are refused, so do those before shelling.',
605
+ parameters: {
606
+ target: bodyTarget,
607
+ thickness: { type: 'number', required: true, description: 'Wall thickness (mm, positive).' },
608
+ open: { type: 'array', items: { type: 'array', items: { type: 'number' } }, description: 'Outward normals of the faces to open, e.g. [[0,0,1]].' },
609
+ },
610
+ output: {
611
+ schema: {
612
+ type: 'object',
613
+ additionalProperties: false,
614
+ properties: {
615
+ bodyId: { type: 'string', required: true },
616
+ ...requiredCounts,
617
+ ...commonOptional,
618
+ },
619
+ },
620
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
621
+ presentationMeta: (_args, value) => metaOf(value),
622
+ },
623
+ isConcurrencySafe: () => false,
624
+ async execute(args, exec) {
625
+ await resolveDoc(exec);
626
+ const op = { kind: 'shell', target: args.target, thickness: args.thickness, ...(Array.isArray(args.open) ? { openNormals: args.open } : {}) };
627
+ const result = await runModelOp(op);
628
+ return syncScene(op, result);
629
+ },
630
+ presentCall: (args) => ({ card: 'generic', title: `CAD shell ${String(args.target)}`, kind: 'other' }),
631
+ presentResult: () => ({ card: 'generic', title: 'CAD shell' }),
632
+ });
633
+ const cadDraft = defineTool({
634
+ name: 'cad_draft',
635
+ description: 'Draft (拔模): tilt the walls of a solid by `angle` degrees for mold release — faces pivot about a neutral plane, dimensions there unchanged. `direction` is the pull direction (default +Z); by default the faces parallel to it (the walls) are drafted. ' +
636
+ 'Runs on the occt.ts kernel (>= 0.3.0): the result becomes a HOSTED body (same caveats as cad_shell).',
637
+ parameters: {
638
+ target: bodyTarget,
639
+ angle: { type: 'number', required: true, description: 'Draft angle in degrees (positive opens the walls toward the pull direction).' },
640
+ direction: { type: 'array', items: { type: 'number' }, description: 'Pull direction [x,y,z] (default +Z).' },
641
+ },
642
+ output: {
643
+ schema: {
644
+ type: 'object',
645
+ additionalProperties: false,
646
+ properties: {
647
+ bodyId: { type: 'string', required: true },
648
+ ...requiredCounts,
649
+ ...commonOptional,
650
+ },
651
+ },
652
+ render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
653
+ presentationMeta: (_args, value) => metaOf(value),
654
+ },
655
+ isConcurrencySafe: () => false,
656
+ async execute(args, exec) {
657
+ await resolveDoc(exec);
658
+ const op = { kind: 'draft', target: args.target, angle: args.angle, ...(Array.isArray(args.direction) ? { direction: args.direction } : {}) };
659
+ const result = await runModelOp(op);
660
+ return syncScene(op, result);
661
+ },
662
+ presentCall: (args) => ({ card: 'generic', title: `CAD draft ${String(args.target)} ${String(args.angle ?? '')}°`, kind: 'other' }),
663
+ presentResult: () => ({ card: 'generic', title: 'CAD draft' }),
664
+ });
600
665
  const cadPattern = defineTool({
601
666
  name: 'cad_pattern',
602
667
  description: 'Pattern (阵列): replicate a body into `count` total placements. Linear: `delta` [dx,dy,dz] spacing per copy. Circular: rotate copies around a principal axis (+X/+Y/+Z) through `at` over `angle` degrees (default 360). Creates new bodies (target·1 … target·(count−1)); fuse afterwards for a single body.',
@@ -1303,6 +1368,8 @@ export function createModelTools(deps) {
1303
1368
  cadExtrude,
1304
1369
  cadRevolve,
1305
1370
  cadChamfer,
1371
+ cadShell,
1372
+ cadDraft,
1306
1373
  cadPattern,
1307
1374
  cadLoft,
1308
1375
  cadSweep,
@@ -1329,6 +1396,8 @@ export const MODEL_TOOL_NAMES = [
1329
1396
  'cad_extrude_profile',
1330
1397
  'cad_revolve',
1331
1398
  'cad_chamfer',
1399
+ 'cad_shell',
1400
+ 'cad_draft',
1332
1401
  'cad_pattern',
1333
1402
  'cad_loft',
1334
1403
  'cad_sweep',
@@ -81,6 +81,18 @@ export type ModelOp = {
81
81
  kind: 'chamfer';
82
82
  target: string;
83
83
  distance: number;
84
+ } | {
85
+ kind: 'shell';
86
+ target: string;
87
+ thickness: number;
88
+ openNormals?: Array<[number, number, number]>;
89
+ faces?: number[];
90
+ } | {
91
+ kind: 'draft';
92
+ target: string;
93
+ angle: number;
94
+ direction?: [number, number, number];
95
+ faces?: number[];
84
96
  } | {
85
97
  kind: 'pattern';
86
98
  target: string;
@@ -13,4 +13,4 @@ export interface ModelToolDeps {
13
13
  }
14
14
  /** Build the whole tool family over the document registry + worker + stores. */
15
15
  export declare function createModelTools(deps: ModelToolDeps): ToolDefinition[];
16
- export declare const MODEL_TOOL_NAMES: readonly ["cad_create_prim", "cad_extrude_profile", "cad_revolve", "cad_chamfer", "cad_pattern", "cad_loft", "cad_sweep", "cad_boolean", "cad_fillet", "cad_transform", "cad_export", "cad_delete", "cad_volume", "cad_drawing", "cad_assembly_insert", "cad_assembly_move", "cad_assembly_remove", "cad_docs", "cad_doc_new", "cad_doc_open", "cad_doc_rename", "cad_doc_delete", "cad_constraint", "cad_solve", "cad_motion"];
16
+ export declare const MODEL_TOOL_NAMES: readonly ["cad_create_prim", "cad_extrude_profile", "cad_revolve", "cad_chamfer", "cad_shell", "cad_draft", "cad_pattern", "cad_loft", "cad_sweep", "cad_boolean", "cad_fillet", "cad_transform", "cad_export", "cad_delete", "cad_volume", "cad_drawing", "cad_assembly_insert", "cad_assembly_move", "cad_assembly_remove", "cad_docs", "cad_doc_new", "cad_doc_open", "cad_doc_rename", "cad_doc_delete", "cad_constraint", "cad_solve", "cad_motion"];