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.
- package/README.md +15 -2
- package/README.zh-CN.md +12 -2
- package/lib/client.js +21 -21
- package/lib/client.js.map +3 -3
- package/lib/modeling/modeling-worker.cjs +113 -37
- package/lib/modeling/occt-adapter.cjs +1 -1
- package/lib/modeling/occt-bridge.cjs +227 -4
- package/lib/tools/cad-model.js +71 -2
- package/lib/types/modeling/client.d.ts +12 -0
- package/lib/types/tools/cad-model.d.ts +1 -1
- package/package.json +2 -2
|
@@ -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 {
|
|
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 =
|
|
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 =
|
|
158
|
-
|
|
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 =
|
|
175
|
-
|
|
176
|
-
|
|
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,
|
|
197
|
+
return { bodyId: op.target, name: body.name, mesh: meshOf(shape, body.name) }
|
|
179
198
|
}
|
|
180
199
|
case 'transform': {
|
|
181
|
-
const body =
|
|
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 =
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
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 =
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
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
|
|
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 }
|
package/lib/tools/cad-model.js
CHANGED
|
@@ -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`
|
|
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('
|
|
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"];
|