dsh-cad 0.2.0 → 0.8.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.
@@ -1,235 +1,389 @@
1
- /**
2
- * Modeling worker entry (plain CJS — the emscripten loader must not be pulled
3
- * through ESM; see the M0 notes on ERR_AMBIGUOUS_MODULE_SYNTAX).
4
- *
5
- * Protocol (main → worker): {jobId, op} where op replays one document
6
- * operation. (worker → main): {jobId, ok, result} with transferable buffers
7
- * for tessellation data.
8
- */
9
- 'use strict'
10
-
11
- const { parentPort } = require('node:worker_threads')
12
- const path = require('node:path')
13
- const fs = require('node:fs')
14
- const { createRequire } = require('node:module')
15
- const { createAdapter } = require('./occt-adapter.cjs')
16
- const { projectViews } = require('./hlr.cjs')
17
-
18
- const require3 = createRequire(__filename)
19
- // The ES6 emscripten build reads a global __dirname when its factory runs.
20
- if (typeof globalThis.__dirname === 'undefined') globalThis.__dirname = __dirname
21
-
22
- const loaderPath = require3.resolve('opencascade.js/dist/opencascade.wasm.js')
23
- const loaderModule = require3(loaderPath)
24
- const wasmBinary = fs.readFileSync(path.join(path.dirname(loaderPath), 'opencascade.wasm.wasm'))
25
-
26
- let adapter = null
27
- let occt = null
28
-
29
- /** bodyId → { shape, name } — the live document. */
30
- const bodies = new Map()
31
- let nextBodyNumber = 1
32
-
33
- /** instanceId → { instanceId, bodyId, name, translate, rotate } — the assembly. */
34
- const instances = new Map()
35
-
36
- const triplet = (value) => (Array.isArray(value) && value.length === 3 ? value : [0, 0, 0])
37
-
38
- function instancesList() {
39
- const list = []
40
- for (const instance of instances.values()) {
41
- list.push({
42
- instanceId: instance.instanceId,
43
- bodyId: instance.bodyId,
44
- name: instance.name,
45
- translate: triplet(instance.translate),
46
- rotate: triplet(instance.rotate),
47
- })
48
- }
49
- return list
50
- }
51
-
52
- function meshOf(shape, name) {
53
- const { positions, indices } = adapter.tessellate(shape)
54
- const normals = adapter.faceNormals(positions, indices)
55
- return {
56
- name,
57
- positions,
58
- normals,
59
- indices,
60
- vertexCount: positions.length / 3,
61
- triangleCount: indices.length / 3,
62
- }
63
- }
64
-
65
- function applyOp(op) {
66
- switch (op.kind) {
67
- case 'create_prim': {
68
- const bodyId = op.bodyId
69
- const shape = adapter.makePrim(op.prim, op.params ?? {})
70
- bodies.set(bodyId, { shape, name: op.name ?? bodyId })
71
- return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
72
- }
73
- case 'extrude_profile': {
74
- const bodyId = op.bodyId
75
- const shape = adapter.makeExtrudedProfile(op.points, op.height ?? 10, op.base ?? 0)
76
- bodies.set(bodyId, { shape, name: op.name ?? bodyId })
77
- return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
78
- }
79
- case 'boolean': {
80
- const target = bodies.get(op.target)
81
- if (target === undefined) throw new Error(`unknown body: ${op.target}`)
82
- const tools = op.tools.map((id) => {
83
- const tool = bodies.get(id)
84
- if (tool === undefined) throw new Error(`unknown body: ${id}`)
85
- return tool.shape
86
- })
87
- const shape = adapter.boolean(op.op, target.shape, tools)
88
- target.shape = shape
89
- // Consumed tool bodies are removed from the document (CAD convention).
90
- const removed = []
91
- for (const id of op.tools) {
92
- if (id !== op.target && bodies.delete(id)) removed.push(id)
93
- }
94
- return { bodyId: op.target, name: target.name, removed, mesh: meshOf(shape, target.name) }
95
- }
96
- case 'fillet': {
97
- const body = bodies.get(op.target)
98
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
99
- const { shape, edges } = adapter.filletAll(body.shape, op.radius ?? 1)
100
- body.shape = shape
101
- return { bodyId: op.target, name: body.name, edges, mesh: meshOf(shape, body.name) }
102
- }
103
- case 'transform': {
104
- const body = bodies.get(op.target)
105
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
106
- const shape = adapter.transform(body.shape, {
107
- translate: op.translate,
108
- rotate: op.rotate,
109
- mirror: op.mirror,
110
- })
111
- body.shape = shape
112
- return { bodyId: op.target, name: body.name, mesh: meshOf(shape, body.name) }
113
- }
114
- case 'tessellate_all': {
115
- const meshes = []
116
- for (const [bodyId, body] of bodies) {
117
- meshes.push({ bodyId, name: body.name, ...meshOf(body.shape, body.name) })
118
- }
119
- return { meshes }
120
- }
121
- case 'export': {
122
- const body = bodies.get(op.target)
123
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
124
- const bytes = adapter.exportFile(body.shape, op.format)
125
- return { bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }
126
- }
127
- case 'drawing': {
128
- const body = bodies.get(op.target)
129
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
130
- // Finer tessellation for drawings: hidden-line views are judged at
131
- // silhouette accuracy, not screen-approximation accuracy.
132
- const { positions, indices } = adapter.tessellate(body.shape, 0.1)
133
- const views = Array.isArray(op.views) && op.views.length > 0 ? op.views : DEFAULT_DRAWING_VIEWS
134
- return { views: projectViews({ positions, indices }, views).views }
135
- }
136
- case 'assembly_insert': {
137
- const body = bodies.get(op.bodyId)
138
- if (body === undefined) throw new Error(`unknown body: ${op.bodyId}`)
139
- if (instances.has(op.instanceId)) throw new Error(`instance already exists: ${op.instanceId}`)
140
- instances.set(op.instanceId, {
141
- instanceId: op.instanceId,
142
- bodyId: op.bodyId,
143
- name: op.name ?? body.name,
144
- translate: triplet(op.translate),
145
- rotate: triplet(op.rotate),
146
- })
147
- return { instanceId: op.instanceId, bodyId: op.bodyId, instances: instancesList() }
148
- }
149
- case 'assembly_transform': {
150
- const instance = instances.get(op.instanceId)
151
- if (instance === undefined) throw new Error(`unknown instance: ${op.instanceId}`)
152
- if (op.translate !== undefined) instance.translate = triplet(op.translate)
153
- if (op.rotate !== undefined) instance.rotate = triplet(op.rotate)
154
- return { instanceId: op.instanceId, instances: instancesList() }
155
- }
156
- case 'assembly_remove': {
157
- if (!instances.delete(op.instanceId)) throw new Error(`unknown instance: ${op.instanceId}`)
158
- return { instanceId: op.instanceId, removed: op.instanceId, instances: instancesList() }
159
- }
160
- case 'export_assembly': {
161
- if (instances.size === 0) throw new Error('the assembly is empty')
162
- const builder = new occt.BRep_Builder()
163
- const compound = new occt.TopoDS_Compound()
164
- builder.MakeCompound(compound)
165
- let added = 0
166
- for (const instance of instances.values()) {
167
- const body = bodies.get(instance.bodyId)
168
- if (body === undefined) continue // stale instance of a consumed body
169
- builder.Add(compound, adapter.transform(body.shape, {
170
- translate: instance.translate,
171
- rotate: instance.rotate,
172
- }))
173
- added++
174
- }
175
- if (added === 0) throw new Error('no instance references a live body')
176
- const bytes = adapter.exportFile(compound, op.format)
177
- return { bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), instances: instancesList() }
178
- }
179
- case 'volume': {
180
- const body = bodies.get(op.target)
181
- if (body === undefined) throw new Error(`unknown body: ${op.target}`)
182
- return { volume: adapter.volume(body.shape) }
183
- }
184
- case 'delete': {
185
- if (!bodies.delete(op.target)) throw new Error(`unknown body: ${op.target}`)
186
- return { deleted: op.target }
187
- }
188
- case 'reset': {
189
- bodies.clear()
190
- instances.clear()
191
- nextBodyNumber = 1
192
- return { cleared: true }
193
- }
194
- default:
195
- throw new Error(`unknown op: ${op.kind}`)
196
- }
197
- }
198
-
199
- /** Standard third/first-angle drawing views (GB first-angle arrangement). */
200
- const DEFAULT_DRAWING_VIEWS = [
201
- { name: 'front', dir: [0, -1, 0], xDir: [1, 0, 0] },
202
- { name: 'top', dir: [0, 0, 1], xDir: [1, 0, 0] },
203
- { name: 'left', dir: [-1, 0, 0], xDir: [0, 1, 0] },
204
- { name: 'iso', dir: [1, -1, 1], xDir: [1, 1, 0] },
205
- ]
206
-
207
- const initOpenCascade = loaderModule.default ?? loaderModule
208
- initOpenCascade({ wasmBinary }).then((instance) => {
209
- occt = instance
210
- adapter = createAdapter(occt)
211
- parentPort.on('message', (message) => {
212
- const transfers = []
213
- try {
214
- const result = applyOp(message.op)
215
- // Collect transferable mesh buffers.
216
- const collect = (mesh) => {
217
- if (mesh === undefined) return
218
- transfers.push(mesh.positions.buffer, mesh.indices.buffer, mesh.normals.buffer)
219
- }
220
- if (result.mesh !== undefined) collect(result.mesh)
221
- for (const mesh of result.meshes ?? []) collect(mesh)
222
- if (result.bytes !== undefined) transfers.push(result.bytes)
223
- parentPort.postMessage({ jobId: message.jobId, ok: true, result }, transfers)
224
- } catch (error) {
225
- parentPort.postMessage({
226
- jobId: message.jobId,
227
- ok: false,
228
- error: error instanceof Error ? error.message : String(error),
229
- })
230
- }
231
- })
232
- parentPort.postMessage({ jobId: 0, ok: true, result: { ready: true } })
233
- }).catch((error) => {
234
- parentPort.postMessage({ jobId: 0, ok: false, error: `modeling kernel init failed: ${error.message}` })
235
- })
1
+ /**
2
+ * Modeling worker entry (plain CJS — the emscripten loader must not be pulled
3
+ * through ESM; see the M0 notes on ERR_AMBIGUOUS_MODULE_SYNTAX).
4
+ *
5
+ * Protocol (main → worker): {jobId, op} where op replays one document
6
+ * operation. (worker → main): {jobId, ok, result} with transferable buffers
7
+ * for tessellation data.
8
+ */
9
+ 'use strict'
10
+
11
+ const { parentPort } = require('node:worker_threads')
12
+ const path = require('node:path')
13
+ const fs = require('node:fs')
14
+ const { createRequire } = require('node:module')
15
+ const { createAdapter } = require('./occt-adapter.cjs')
16
+ const { createOcctBridge } = require('./occt-bridge.cjs')
17
+
18
+ const require3 = createRequire(__filename)
19
+ // The ES6 emscripten build reads a global __dirname when its factory runs.
20
+ if (typeof globalThis.__dirname === 'undefined') globalThis.__dirname = __dirname
21
+
22
+ const loaderPath = require3.resolve('opencascade.js/dist/opencascade.wasm.js')
23
+ const loaderModule = require3(loaderPath)
24
+ const wasmBinary = fs.readFileSync(path.join(path.dirname(loaderPath), 'opencascade.wasm.wasm'))
25
+
26
+ let adapter = null
27
+ let occt = null
28
+
29
+ /**
30
+ * Reject a degenerate result BEFORE registering it: the kernel cannot
31
+ * tessellate a self-intersecting BRep (it fails deep inside the mesher with
32
+ * a cryptic error), and an unrenderable body in the document helps no one.
33
+ */
34
+ function assertUsable(shape, what, hint) {
35
+ if (adapter.isValid(shape) === false) {
36
+ throw new Error(`the ${what} produced an invalid shape (self-intersecting or degenerate)${hint === undefined ? '' : `: ${hint}`}`)
37
+ }
38
+ }
39
+
40
+ /** bodyId → { shape, name, step? } — the live document. A body whose solid
41
+ * was produced by an occt.ts-only op (shell/draft) is HOSTED: `step` carries
42
+ * the STEP bytes as the source of truth and `shape` is null, because
43
+ * opencascade.js in this build cannot read geometry back. */
44
+ const bodies = new Map()
45
+ let nextBodyNumber = 1
46
+
47
+ /** instanceId → { instanceId, bodyId, name, translate, rotate } — the assembly. */
48
+ const instances = new Map()
49
+
50
+ const triplet = (value) => (Array.isArray(value) && value.length === 3 ? value : [0, 0, 0])
51
+
52
+ function instancesList() {
53
+ const list = []
54
+ for (const instance of instances.values()) {
55
+ list.push({
56
+ instanceId: instance.instanceId,
57
+ bodyId: instance.bodyId,
58
+ name: instance.name,
59
+ translate: triplet(instance.translate),
60
+ rotate: triplet(instance.rotate),
61
+ })
62
+ }
63
+ return list
64
+ }
65
+
66
+ function meshOf(shape, name) {
67
+ const { positions, indices } = adapter.tessellate(shape)
68
+ const normals = adapter.faceNormals(positions, indices)
69
+ return {
70
+ name,
71
+ positions,
72
+ normals,
73
+ indices,
74
+ vertexCount: positions.length / 3,
75
+ triangleCount: indices.length / 3,
76
+ }
77
+ }
78
+
79
+ async function applyOp(op) {
80
+ switch (op.kind) {
81
+ case 'create_prim': {
82
+ const bodyId = op.bodyId
83
+ const shape = adapter.makePrim(op.prim, op.params ?? {})
84
+ bodies.set(bodyId, { shape, name: op.name ?? bodyId })
85
+ return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
86
+ }
87
+ case 'extrude_profile': {
88
+ const bodyId = op.bodyId
89
+ // `profile` (segments/circle object) supersedes the legacy flat `points`;
90
+ // both forms flow through the same curve-aware builder.
91
+ const profile = op.profile ?? op.points
92
+ const shape = adapter.extrudeProfile2D(profile, op.height ?? 10, op.base ?? 0)
93
+ bodies.set(bodyId, { shape, name: op.name ?? bodyId })
94
+ return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
95
+ }
96
+ case 'revolve': {
97
+ const bodyId = op.bodyId
98
+ const shape = adapter.makeRevolve(op.profile, { axis: op.axis, at: op.at, angle: op.angle })
99
+ assertUsable(shape, 'revolve', '检查轮廓是否全部位于轴的一侧且共面')
100
+ bodies.set(bodyId, { shape, name: op.name ?? bodyId })
101
+ return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
102
+ }
103
+ case 'chamfer': {
104
+ const body = requireShape(op.target, 'chamfer')
105
+ const shape = adapter.chamferAll(body.shape, op.distance)
106
+ assertUsable(shape, 'chamfer', '距离可能超出相邻面')
107
+ body.shape = shape
108
+ return { bodyId: op.target, name: body.name, mesh: meshOf(shape, body.name) }
109
+ }
110
+ case 'pattern': {
111
+ const body = bodies.get(op.target)
112
+ if (body === undefined) throw new Error(`unknown body: ${op.target}`)
113
+ const count = Math.max(1, Math.trunc(op.count))
114
+ const created = []
115
+ for (let i = 1; i < count; i++) {
116
+ const id = `${op.target}p${i}`
117
+ let shape
118
+ if (op.mode === 'circular') {
119
+ // Rotation about a principal axis through `at`: translate in, rotate,
120
+ // translate back (transform applies Euler rotations about the origin).
121
+ const axis = op.axis ?? [0, 0, 1]
122
+ const at = op.at ?? [0, 0, 0]
123
+ const step = ((op.angle ?? 360) / count) * i
124
+ const principal = Math.abs(axis[0]) > 0.9 ? [step, 0, 0]
125
+ : Math.abs(axis[1]) > 0.9 ? [0, step, 0]
126
+ : Math.abs(axis[2]) > 0.9 ? [0, 0, step]
127
+ : null
128
+ if (principal === null) throw new Error('circular pattern axes must be principal (+X/+Y/+Z)')
129
+ shape = adapter.transform(adapter.transform(
130
+ adapter.transform(body.shape, { translate: [-at[0], -at[1], -at[2]] }),
131
+ { rotate: principal },
132
+ ), { translate: [at[0], at[1], at[2]] })
133
+ } else {
134
+ shape = adapter.transform(body.shape, {
135
+ translate: [op.delta[0] * i, op.delta[1] * i, op.delta[2] * i],
136
+ })
137
+ }
138
+ const name = `${body.name}·${i}`
139
+ bodies.set(id, { shape, name })
140
+ created.push({ bodyId: id, name, mesh: meshOf(shape, name) })
141
+ }
142
+ return { bodyId: op.target, created }
143
+ }
144
+ case 'loft': {
145
+ const bodyId = op.bodyId
146
+ const shape = adapter.makeLoft(op.sections, { solid: op.solid, ruled: op.ruled })
147
+ assertUsable(shape, 'loft', '检查各截面是否闭合、点数是否合理')
148
+ bodies.set(bodyId, { shape, name: op.name ?? bodyId })
149
+ return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
150
+ }
151
+ case 'sweep': {
152
+ const bodyId = op.bodyId
153
+ const shape = adapter.makeSweep(op.profile, op.path)
154
+ assertUsable(shape, 'sweep', '路径上的尖角配合较大截面会自交——把拐角改为圆滑/倒角路径')
155
+ bodies.set(bodyId, { shape, name: op.name ?? bodyId })
156
+ return { bodyId, name: bodies.get(bodyId).name, mesh: meshOf(shape, bodies.get(bodyId).name) }
157
+ }
158
+ 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 }
168
+ }
169
+ 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 }
179
+ }
180
+ case 'boolean': {
181
+ const target = requireShape(op.target, 'boolean')
182
+ const tools = op.tools.map((id) => requireShape(id, 'boolean').shape)
183
+ const shape = adapter.boolean(op.op, target.shape, tools)
184
+ target.shape = shape
185
+ // Consumed tool bodies are removed from the document (CAD convention).
186
+ const removed = []
187
+ for (const id of op.tools) {
188
+ if (id !== op.target && bodies.delete(id)) removed.push(id)
189
+ }
190
+ return { bodyId: op.target, name: target.name, removed, mesh: meshOf(shape, target.name) }
191
+ }
192
+ case 'fillet': {
193
+ const body = requireShape(op.target, 'fillet')
194
+ const { shape, edges } = adapter.filletAll(body.shape, op.radius ?? 1)
195
+ body.shape = shape
196
+ return { bodyId: op.target, name: body.name, edges, mesh: meshOf(shape, body.name) }
197
+ }
198
+ case 'transform': {
199
+ const body = requireShape(op.target, 'transform')
200
+ const shape = adapter.transform(body.shape, {
201
+ translate: op.translate,
202
+ rotate: op.rotate,
203
+ mirror: op.mirror,
204
+ })
205
+ body.shape = shape
206
+ return { bodyId: op.target, name: body.name, mesh: meshOf(shape, body.name) }
207
+ }
208
+ case 'tessellate_all': {
209
+ const meshes = []
210
+ for (const [bodyId, body] of bodies) {
211
+ if (body.shape === null || body.shape === undefined) continue // hosted bodies re-mesh via their ops
212
+ meshes.push({ bodyId, name: body.name, ...meshOf(body.shape, body.name) })
213
+ }
214
+ return { meshes }
215
+ }
216
+ 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
+ }
224
+ const bytes = adapter.exportFile(body.shape, op.format)
225
+ return { bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) }
226
+ }
227
+ case 'drawing': {
228
+ return drawingViews(op)
229
+ }
230
+ case 'assembly_insert': {
231
+ const body = bodies.get(op.bodyId)
232
+ if (body === undefined) throw new Error(`unknown body: ${op.bodyId}`)
233
+ if (instances.has(op.instanceId)) throw new Error(`instance already exists: ${op.instanceId}`)
234
+ instances.set(op.instanceId, {
235
+ instanceId: op.instanceId,
236
+ bodyId: op.bodyId,
237
+ name: op.name ?? body.name,
238
+ translate: triplet(op.translate),
239
+ rotate: triplet(op.rotate),
240
+ })
241
+ return { instanceId: op.instanceId, bodyId: op.bodyId, instances: instancesList() }
242
+ }
243
+ case 'assembly_transform': {
244
+ const instance = instances.get(op.instanceId)
245
+ if (instance === undefined) throw new Error(`unknown instance: ${op.instanceId}`)
246
+ if (op.translate !== undefined) instance.translate = triplet(op.translate)
247
+ if (op.rotate !== undefined) instance.rotate = triplet(op.rotate)
248
+ return { instanceId: op.instanceId, instances: instancesList() }
249
+ }
250
+ case 'assembly_remove': {
251
+ if (!instances.delete(op.instanceId)) throw new Error(`unknown instance: ${op.instanceId}`)
252
+ return { instanceId: op.instanceId, removed: op.instanceId, instances: instancesList() }
253
+ }
254
+ case 'assembly_list': {
255
+ return { instances: instancesList() }
256
+ }
257
+ case 'constraints': {
258
+ // Persist-only op: the constraint model lives in the document log and
259
+ // is solved on the main thread (Ansatz wasm); the worker stores nothing.
260
+ return { stored: true, entities: op.model.entities.length, constraints: op.model.constraints.length }
261
+ }
262
+ case 'export_assembly': {
263
+ 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)
267
+ let added = 0
268
+ for (const instance of instances.values()) {
269
+ const body = bodies.get(instance.bodyId)
270
+ if (body === undefined) continue // stale instance of a consumed body
271
+ builder.Add(compound, adapter.transform(body.shape, {
272
+ translate: instance.translate,
273
+ rotate: instance.rotate,
274
+ }))
275
+ added++
276
+ }
277
+ if (added === 0) throw new Error('no instance references a live body')
278
+ const bytes = adapter.exportFile(compound, op.format)
279
+ return { bytes: bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), instances: instancesList() }
280
+ }
281
+ 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) }
289
+ }
290
+ return { volume: Math.abs(adapter.volume(body.shape)) }
291
+ }
292
+ case 'delete': {
293
+ if (!bodies.delete(op.target)) throw new Error(`unknown body: ${op.target}`)
294
+ return { deleted: op.target }
295
+ }
296
+ case 'reset': {
297
+ bodies.clear()
298
+ instances.clear()
299
+ nextBodyNumber = 1
300
+ return { cleared: true }
301
+ }
302
+ default:
303
+ throw new Error(`unknown op: ${op.kind}`)
304
+ }
305
+ }
306
+
307
+ /** Standard third/first-angle drawing views (GB first-angle arrangement). */
308
+ const DEFAULT_DRAWING_VIEWS = [
309
+ { name: 'front', dir: [0, -1, 0], xDir: [1, 0, 0] },
310
+ { name: 'top', dir: [0, 0, 1], xDir: [1, 0, 0] },
311
+ { name: 'left', dir: [-1, 0, 0], xDir: [0, 1, 0] },
312
+ { name: 'iso', dir: [1, -1, 1], xDir: [1, 1, 0] },
313
+ ]
314
+
315
+ /**
316
+ * The occt.ts kernel (true OCCT hidden-line removal) — the one and only
317
+ * drawing engine. Lazily loaded on the first drawing op and cached,
318
+ * including failures, so a missing dist costs one filesystem probe per
319
+ * worker, not per drawing.
320
+ */
321
+ let occtBridge = null
322
+ let occtBridgeTried = false
323
+ async function getOcctBridge() {
324
+ if (occtBridgeTried) return occtBridge
325
+ occtBridgeTried = true
326
+ try {
327
+ occtBridge = await createOcctBridge()
328
+ if (occtBridge !== null) {
329
+ console.log(`[dsh-cad] occt.ts kernel loaded (OCCT ${occtBridge.occtVersion}) from ${occtBridge.distDir}`)
330
+ }
331
+ } catch (error) {
332
+ console.warn(`[dsh-cad] occt.ts kernel failed to load: ${error instanceof Error ? error.message : error}`)
333
+ occtBridge = null
334
+ }
335
+ return occtBridge
336
+ }
337
+
338
+ /** Reject ops that need an opencascade.js BRep on a hosted body. */
339
+ function requireShape(bodyId, what) {
340
+ const body = bodies.get(bodyId)
341
+ 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`)
344
+ }
345
+ return body
346
+ }
347
+
348
+ async function drawingViews(op) {
349
+ const body = bodies.get(op.target)
350
+ if (body === undefined) throw new Error(`unknown body: ${op.target}`)
351
+ const views = Array.isArray(op.views) && op.views.length > 0 ? op.views : DEFAULT_DRAWING_VIEWS
352
+ const bridge = await getOcctBridge()
353
+ if (bridge === null) {
354
+ throw new Error('the occt.ts kernel is required for engineering drawings but was not found — install it (npm i occt.ts) or point DSH_OCCTJS_DIST at a dist directory')
355
+ }
356
+ const stepBytes = adapter.exportFile(body.shape, 'step')
357
+ const projected = await bridge.hiddenLineViews(stepBytes, views)
358
+ return { views: projected.views }
359
+ }
360
+
361
+ const initOpenCascade = loaderModule.default ?? loaderModule
362
+ initOpenCascade({ wasmBinary }).then((instance) => {
363
+ occt = instance
364
+ adapter = createAdapter(occt)
365
+ parentPort.on('message', async (message) => {
366
+ const transfers = []
367
+ try {
368
+ const result = await applyOp(message.op)
369
+ // Collect transferable mesh buffers.
370
+ const collect = (mesh) => {
371
+ if (mesh === undefined) return
372
+ transfers.push(mesh.positions.buffer, mesh.indices.buffer, mesh.normals.buffer)
373
+ }
374
+ if (result.mesh !== undefined) collect(result.mesh)
375
+ for (const mesh of result.meshes ?? []) collect(mesh)
376
+ if (result.bytes !== undefined) transfers.push(result.bytes)
377
+ parentPort.postMessage({ jobId: message.jobId, ok: true, result }, transfers)
378
+ } catch (error) {
379
+ parentPort.postMessage({
380
+ jobId: message.jobId,
381
+ ok: false,
382
+ error: error instanceof Error ? error.message : String(error),
383
+ })
384
+ }
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}` })
389
+ })