dsh-cad 0.2.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/LICENSE +201 -0
- package/README.md +190 -0
- package/README.zh-CN.md +173 -0
- package/cordis.patch.yml +3 -0
- package/lib/b64.js +12 -0
- package/lib/cad_connector/builtin-executor.js +55 -0
- package/lib/cad_connector/builtin.js +8 -0
- package/lib/cad_connector/executor.js +76 -0
- package/lib/cad_connector/freecad-executor.js +398 -0
- package/lib/cad_connector/freecad.js +8 -0
- package/lib/cad_connector/fusion360-executor.js +398 -0
- package/lib/cad_connector/fusion360.js +8 -0
- package/lib/cad_connector/gstarcad3d.js +8 -0
- package/lib/cad_connector/index.js +20 -0
- package/lib/cad_connector/onshape.js +8 -0
- package/lib/cad_connector/solidworks.js +8 -0
- package/lib/cad_connector/types.js +6 -0
- package/lib/cad_connector/zw3d.js +8 -0
- package/lib/client.js +4117 -0
- package/lib/client.js.map +7 -0
- package/lib/convert/dcprt.js +38 -0
- package/lib/convert/detect.js +22 -0
- package/lib/convert/dxf.js +124 -0
- package/lib/convert/index.js +70 -0
- package/lib/convert/obj.js +106 -0
- package/lib/convert/png.js +114 -0
- package/lib/convert/step-worker.mjs +61 -0
- package/lib/convert/step.js +66 -0
- package/lib/convert/stl.js +110 -0
- package/lib/convert/svg.js +101 -0
- package/lib/demo-bracket.brep +668 -0
- package/lib/demo-flange.brep +709 -0
- package/lib/demo-shaft.brep +516 -0
- package/lib/feature_script/dc_asm.js +17 -0
- package/lib/feature_script/dc_engineering.js +19 -0
- package/lib/feature_script/dc_prt.js +21 -0
- package/lib/feature_script/index.js +7 -0
- package/lib/image/profile.js +218 -0
- package/lib/index.js +77 -0
- package/lib/modeling/assembly.js +63 -0
- package/lib/modeling/bin-format.js +80 -0
- package/lib/modeling/bin-store.js +67 -0
- package/lib/modeling/client.js +117 -0
- package/lib/modeling/document.js +55 -0
- package/lib/modeling/drawing.js +324 -0
- package/lib/modeling/hlr.cjs +328 -0
- package/lib/modeling/modeling-worker.cjs +235 -0
- package/lib/modeling/occt-adapter.cjs +271 -0
- package/lib/routes.js +142 -0
- package/lib/store.js +68 -0
- package/lib/tools/cad-freecad.js +160 -0
- package/lib/tools/cad-fusion.js +138 -0
- package/lib/tools/cad-image.js +121 -0
- package/lib/tools/cad-info.js +92 -0
- package/lib/tools/cad-model.js +816 -0
- package/lib/tools/cad-view.js +112 -0
- package/lib/tools/util.js +25 -0
- package/lib/types/b64.d.ts +8 -0
- package/lib/types/cad/builtin.d.ts +3 -0
- package/lib/types/cad/freecad.d.ts +3 -0
- package/lib/types/cad/fusion360.d.ts +3 -0
- package/lib/types/cad/gstarcad3d.d.ts +3 -0
- package/lib/types/cad/index.d.ts +8 -0
- package/lib/types/cad/onshape.d.ts +3 -0
- package/lib/types/cad/solidworks.d.ts +3 -0
- package/lib/types/cad/types.d.ts +21 -0
- package/lib/types/cad/zw3d.d.ts +3 -0
- package/lib/types/cad_connector/builtin-executor.d.ts +9 -0
- package/lib/types/cad_connector/builtin.d.ts +3 -0
- package/lib/types/cad_connector/executor.d.ts +69 -0
- package/lib/types/cad_connector/freecad-executor.d.ts +55 -0
- package/lib/types/cad_connector/freecad.d.ts +3 -0
- package/lib/types/cad_connector/fusion360-executor.d.ts +11 -0
- package/lib/types/cad_connector/fusion360.d.ts +3 -0
- package/lib/types/cad_connector/gstarcad3d.d.ts +3 -0
- package/lib/types/cad_connector/index.d.ts +8 -0
- package/lib/types/cad_connector/onshape.d.ts +3 -0
- package/lib/types/cad_connector/solidworks.d.ts +3 -0
- package/lib/types/cad_connector/types.d.ts +21 -0
- package/lib/types/cad_connector/zw3d.d.ts +3 -0
- package/lib/types/convert/dcprt.d.ts +14 -0
- package/lib/types/convert/detect.d.ts +7 -0
- package/lib/types/convert/dxf.d.ts +3 -0
- package/lib/types/convert/index.d.ts +9 -0
- package/lib/types/convert/obj.d.ts +7 -0
- package/lib/types/convert/png.d.ts +8 -0
- package/lib/types/convert/step.d.ts +4 -0
- package/lib/types/convert/stl.d.ts +7 -0
- package/lib/types/convert/svg.d.ts +8 -0
- package/lib/types/feature_script/dc_asm.d.ts +40 -0
- package/lib/types/feature_script/dc_engineering.d.ts +55 -0
- package/lib/types/feature_script/dc_prt.d.ts +37 -0
- package/lib/types/feature_script/index.d.ts +7 -0
- package/lib/types/image/profile.d.ts +28 -0
- package/lib/types/index.d.ts +18 -0
- package/lib/types/modeling/assembly.d.ts +17 -0
- package/lib/types/modeling/bin-format.d.ts +42 -0
- package/lib/types/modeling/bin-store.d.ts +20 -0
- package/lib/types/modeling/client.d.ts +125 -0
- package/lib/types/modeling/document.d.ts +26 -0
- package/lib/types/modeling/drawing.d.ts +38 -0
- package/lib/types/routes.d.ts +27 -0
- package/lib/types/store.d.ts +18 -0
- package/lib/types/tools/cad-freecad.d.ts +10 -0
- package/lib/types/tools/cad-fusion.d.ts +8 -0
- package/lib/types/tools/cad-image.d.ts +8 -0
- package/lib/types/tools/cad-info.d.ts +6 -0
- package/lib/types/tools/cad-model.d.ts +13 -0
- package/lib/types/tools/cad-view.d.ts +10 -0
- package/lib/types/tools/util.d.ts +4 -0
- package/lib/types/types.d.ts +142 -0
- package/lib/types.js +25 -0
- package/package.json +91 -0
|
@@ -0,0 +1,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 { 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
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OCCT modeling adapter (plain CJS JavaScript — runs inside the modeling
|
|
3
|
+
* worker). Encapsulates the opencascade.js binding surface verified in M0:
|
|
4
|
+
* overloaded constructors use the `_N` suffix convention and this file is the
|
|
5
|
+
* single place that knows those spellings.
|
|
6
|
+
*
|
|
7
|
+
* Document model: bodyId → TopoDS_Shape, in-memory in the worker. The main
|
|
8
|
+
* thread persists an operation log and replays it for restart recovery.
|
|
9
|
+
*/
|
|
10
|
+
'use strict'
|
|
11
|
+
|
|
12
|
+
function createAdapter(occt) {
|
|
13
|
+
// ── verified constructor spellings (see test/m0-kernel-check.cjs) ──────────
|
|
14
|
+
const pnt = (x, y, z) => new occt.gp_Pnt_3(x, y, z)
|
|
15
|
+
const dir = (x, y, z) => new occt.gp_Dir_4(x, y, z)
|
|
16
|
+
const vec = (x, y, z) => new occt.gp_Vec_4(x, y, z)
|
|
17
|
+
const identityTrsf = () => new occt.gp_Trsf_1()
|
|
18
|
+
const ENUM = occt.TopAbs_ShapeEnum
|
|
19
|
+
|
|
20
|
+
const shapeOf = (make) => make.Shape()
|
|
21
|
+
const volume = (shape) => {
|
|
22
|
+
const props = new occt.GProp_GProps_2(pnt(0, 0, 0))
|
|
23
|
+
occt.BRepGProp.VolumeProperties_1(shape, props, false, true, false)
|
|
24
|
+
return props.Mass()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── primitives ─────────────────────────────────────────────────────────────
|
|
28
|
+
function makePrim(kind, params) {
|
|
29
|
+
const at = params.at ?? [0, 0, 0]
|
|
30
|
+
const p = params
|
|
31
|
+
switch (kind) {
|
|
32
|
+
case 'box': {
|
|
33
|
+
const dx = p.dx ?? 10, dy = p.dy ?? 10, dz = p.dz ?? 10
|
|
34
|
+
return shapeOf(new occt.BRepPrimAPI_MakeBox_3(pnt(at[0], at[1], at[2]), pnt(at[0] + dx, at[1] + dy, at[2] + dz)))
|
|
35
|
+
}
|
|
36
|
+
case 'cylinder': {
|
|
37
|
+
const axis = p.axis ?? [0, 0, 1]
|
|
38
|
+
const ax2 = new occt.gp_Ax2_3(pnt(at[0], at[1], at[2]), dir(axis[0], axis[1], axis[2]))
|
|
39
|
+
return shapeOf(new occt.BRepPrimAPI_MakeCylinder_3(ax2, p.radius ?? 5, p.height ?? 10))
|
|
40
|
+
}
|
|
41
|
+
case 'sphere': {
|
|
42
|
+
// Verified variants: only the pure-double ctors work in this build;
|
|
43
|
+
// placement (at/axis) is applied by an exact transform below.
|
|
44
|
+
return place(new occt.BRepPrimAPI_MakeSphere_1(p.radius ?? 5).Shape(), at, p.axis)
|
|
45
|
+
}
|
|
46
|
+
case 'cone': {
|
|
47
|
+
return place(new occt.BRepPrimAPI_MakeCone_1(p.radius1 ?? 5, p.radius2 ?? 0, p.height ?? 10).Shape(), at, p.axis)
|
|
48
|
+
}
|
|
49
|
+
case 'torus': {
|
|
50
|
+
return place(new occt.BRepPrimAPI_MakeTorus_1(p.majorRadius ?? 10, p.minorRadius ?? 2).Shape(), at, p.axis)
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
throw new Error(`unknown primitive kind: ${kind}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Place an origin-built primitive: rotate +Z onto `axis` (exact axis-angle
|
|
59
|
+
* rotation about the origin), then translate to `at`. Both default to
|
|
60
|
+
* identity when omitted/default.
|
|
61
|
+
*/
|
|
62
|
+
function place(shape, at, axis) {
|
|
63
|
+
const ax = axis ?? [0, 0, 1]
|
|
64
|
+
const len = Math.hypot(ax[0], ax[1], ax[2])
|
|
65
|
+
const u = len === 0 ? [0, 0, 1] : [ax[0] / len, ax[1] / len, ax[2] / len]
|
|
66
|
+
// Identity only when the axis IS +Z (e.g. [0,0,-1] must rotate by π).
|
|
67
|
+
const isDefaultAxis = Math.abs(u[0]) + Math.abs(u[1]) < 1e-12 && u[2] > 1 - 1e-9
|
|
68
|
+
let result = shape
|
|
69
|
+
if (!isDefaultAxis) {
|
|
70
|
+
const trsf = identityTrsf()
|
|
71
|
+
trsf.SetRotation_1(new occt.gp_Ax1_2(pnt(0, 0, 0), dir(u[0], u[1], u[2])), Math.acos(Math.min(1, Math.max(-1, u[2]))))
|
|
72
|
+
result = new occt.BRepBuilderAPI_Transform_2(result, trsf, true).Shape()
|
|
73
|
+
}
|
|
74
|
+
if (at !== undefined && (at[0] !== 0 || at[1] !== 0 || at[2] !== 0)) {
|
|
75
|
+
const move = identityTrsf()
|
|
76
|
+
move.SetTranslation_1(vec(at[0], at[1], at[2]))
|
|
77
|
+
result = new occt.BRepBuilderAPI_Transform_2(result, move, true).Shape()
|
|
78
|
+
}
|
|
79
|
+
return result
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── profile extrusion ─────────────────────────────────────────────────────
|
|
83
|
+
/** points: flat [x0,y0, x1,y1, ...] closed loop in the XY plane at z = base. */
|
|
84
|
+
function makeExtrudedProfile(points, height, base = 0) {
|
|
85
|
+
if (points.length < 6 || points.length % 2 !== 0) {
|
|
86
|
+
throw new Error('profile needs at least 3 points (6 flat numbers)')
|
|
87
|
+
}
|
|
88
|
+
// Verified path (the 1.1.1 build exposes no direct wire→face ctor):
|
|
89
|
+
// polygon wire → bound an infinite planar face with Add(wire) → extrude.
|
|
90
|
+
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
91
|
+
for (let i = 0; i + 1 < points.length; i += 2) {
|
|
92
|
+
poly.Add_1(pnt(points[i], points[i + 1], base))
|
|
93
|
+
}
|
|
94
|
+
poly.Close()
|
|
95
|
+
if (!poly.IsDone()) throw new Error('profile polygon is invalid (duplicate or collinear-only points)')
|
|
96
|
+
const planeAx3 = new occt.gp_Ax3_3(pnt(0, 0, base), dir(0, 0, 1), dir(1, 0, 0))
|
|
97
|
+
const faceBuilder = new occt.BRepBuilderAPI_MakeFace_3(new occt.gp_Pln_2(planeAx3))
|
|
98
|
+
faceBuilder.Add(poly.Wire())
|
|
99
|
+
const face = faceBuilder.Face()
|
|
100
|
+
if (!faceBuilder.IsDone() || face.IsNull()) throw new Error('profile face construction failed')
|
|
101
|
+
return shapeOf(new occt.BRepPrimAPI_MakePrism_1(face, vec(0, 0, height), true, false))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── booleans / fillet / transform ──────────────────────────────────────────
|
|
105
|
+
function boolean(op, target, tools) {
|
|
106
|
+
let result = target
|
|
107
|
+
for (const tool of tools) {
|
|
108
|
+
// BRepAlgoAPI_*_3 is the verified (shape, shape) constructor.
|
|
109
|
+
const Ctor = op === 'fuse' ? occt.BRepAlgoAPI_Fuse_3 : op === 'cut' ? occt.BRepAlgoAPI_Cut_3 : occt.BRepAlgoAPI_Common_3
|
|
110
|
+
const algo = new Ctor(result, tool)
|
|
111
|
+
algo.Build()
|
|
112
|
+
if (!algo.IsDone()) throw new Error(`boolean ${op} failed`)
|
|
113
|
+
result = algo.Shape()
|
|
114
|
+
}
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function filletAll(shape, radius) {
|
|
119
|
+
const algo = new occt.BRepFilletAPI_MakeFillet(shape, 1e-4)
|
|
120
|
+
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_EDGE, ENUM.TopAbs_SHAPE)
|
|
121
|
+
let edges = 0
|
|
122
|
+
while (explorer.More()) {
|
|
123
|
+
algo.Add_2(radius, castEdge(explorer.Current()))
|
|
124
|
+
edges++
|
|
125
|
+
explorer.Next()
|
|
126
|
+
}
|
|
127
|
+
if (edges === 0) throw new Error('no edges to fillet')
|
|
128
|
+
algo.Build()
|
|
129
|
+
if (!algo.IsDone()) throw new Error('fillet failed (radius may exceed the adjacent faces)')
|
|
130
|
+
return { shape: algo.Shape(), edges }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function castEdge(shape) {
|
|
134
|
+
if (occt.TopoDS.Edge_s) return occt.TopoDS.Edge_s(shape)
|
|
135
|
+
return occt.TopoDS.Edge_2(shape)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function castFace(shape) {
|
|
139
|
+
if (occt.TopoDS.Face_s) return occt.TopoDS.Face_s(shape)
|
|
140
|
+
return occt.TopoDS.Face_2(shape)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function transform(shape, { translate, rotate, mirror }) {
|
|
144
|
+
const trsf = identityTrsf()
|
|
145
|
+
if (translate !== undefined) trsf.SetTranslation_1(vec(translate[0], translate[1], translate[2]))
|
|
146
|
+
if (rotate !== undefined) {
|
|
147
|
+
const [rx, ry, rz] = rotate
|
|
148
|
+
if (rx) trsf.Multiply(rotationTrsf([1, 0, 0], rx * Math.PI / 180))
|
|
149
|
+
if (ry) trsf.Multiply(rotationTrsf([0, 1, 0], ry * Math.PI / 180))
|
|
150
|
+
if (rz) trsf.Multiply(rotationTrsf([0, 0, 1], rz * Math.PI / 180))
|
|
151
|
+
}
|
|
152
|
+
if (mirror !== undefined) {
|
|
153
|
+
const [mx, my, mz] = mirror
|
|
154
|
+
const mirrorTrsf = identityTrsf()
|
|
155
|
+
mirrorTrsf.SetMirror_1(new occt.gp_Ax2_3(pnt(0, 0, 0), dir(mx ?? 0, my ?? 0, mz ?? 1)))
|
|
156
|
+
trsf.Multiply(mirrorTrsf)
|
|
157
|
+
}
|
|
158
|
+
const algo = new occt.BRepBuilderAPI_Transform_2(shape, trsf, true)
|
|
159
|
+
return algo.Shape()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function rotationTrsf(axis, radians) {
|
|
163
|
+
const t = identityTrsf()
|
|
164
|
+
t.SetRotation_1(new occt.gp_Ax1_2(pnt(0, 0, 0), dir(axis[0], axis[1], axis[2])), radians)
|
|
165
|
+
return t
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── tessellation ───────────────────────────────────────────────────────────
|
|
169
|
+
function tessellate(shape, linearDeflection = 0.3) {
|
|
170
|
+
const mesher = new occt.BRepMesh_IncrementalMesh_2(shape, linearDeflection, false, 0.5, true)
|
|
171
|
+
mesher.Perform_1()
|
|
172
|
+
const positions = []
|
|
173
|
+
const indices = []
|
|
174
|
+
const loc = new occt.TopLoc_Location_2(identityTrsf())
|
|
175
|
+
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_FACE, ENUM.TopAbs_SHAPE)
|
|
176
|
+
let vertexBase = 0
|
|
177
|
+
while (explorer.More()) {
|
|
178
|
+
const faceShape = explorer.Current()
|
|
179
|
+
const handle = occt.BRep_Tool.Triangulation(castFace(faceShape), loc)
|
|
180
|
+
const tri = handle && handle.IsNull && !handle.IsNull() ? handle.get() : handle
|
|
181
|
+
if (tri) {
|
|
182
|
+
// A REVERSED face renders its surface natural orientation backwards —
|
|
183
|
+
// flip the triangle winding so normals point out of the material.
|
|
184
|
+
const reversed = faceShape.Orientation_1 !== undefined && faceShape.Orientation_1().value === 1
|
|
185
|
+
const nodeCount = tri.NbNodes()
|
|
186
|
+
for (let i = 1; i <= nodeCount; i++) {
|
|
187
|
+
const node = tri.Node(i)
|
|
188
|
+
// Optional location transform on the triangulation.
|
|
189
|
+
const transformed = loc.IsIdentity() ? node : node.Transformed(loc.Transformation())
|
|
190
|
+
positions.push(transformed.X(), transformed.Y(), transformed.Z())
|
|
191
|
+
}
|
|
192
|
+
for (let i = 1; i <= tri.NbTriangles(); i++) {
|
|
193
|
+
const t = tri.Triangle(i)
|
|
194
|
+
// Poly_Triangle.Get() uses out-params embind cannot express; Value(1..3) returns the indices.
|
|
195
|
+
const n1 = vertexBase + t.Value(1) - 1
|
|
196
|
+
const n2 = vertexBase + t.Value(2) - 1
|
|
197
|
+
const n3 = vertexBase + t.Value(3) - 1
|
|
198
|
+
if (reversed) indices.push(n1, n3, n2)
|
|
199
|
+
else indices.push(n1, n2, n3)
|
|
200
|
+
}
|
|
201
|
+
vertexBase += nodeCount
|
|
202
|
+
}
|
|
203
|
+
explorer.Next()
|
|
204
|
+
}
|
|
205
|
+
return { positions: Float32Array.from(positions), indices: Uint32Array.from(indices) }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Flat per-triangle normals (mechanical-CAD look, no smooth-vertex table). */
|
|
209
|
+
function faceNormals(positions, indices) {
|
|
210
|
+
const normals = new Float32Array(positions.length)
|
|
211
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
212
|
+
const a = indices[i] * 3, b = indices[i + 1] * 3, c = indices[i + 2] * 3
|
|
213
|
+
const ux = positions[c] - positions[a], uy = positions[c + 1] - positions[a + 1], uz = positions[c + 2] - positions[a + 2]
|
|
214
|
+
const vx = positions[b] - positions[a], vy = positions[b + 1] - positions[a + 1], vz = positions[b + 2] - positions[a + 2]
|
|
215
|
+
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
216
|
+
const len = Math.hypot(nx, ny, nz) || 1
|
|
217
|
+
nx /= len; ny /= len; nz /= len
|
|
218
|
+
for (const corner of [a, b, c]) {
|
|
219
|
+
normals[corner] = nx; normals[corner + 1] = ny; normals[corner + 2] = nz
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return normals
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── export: STEP via MEMFS, STL as direct binary bytes ───────────────────
|
|
226
|
+
function exportFile(shape, format) {
|
|
227
|
+
if (format === 'step') {
|
|
228
|
+
const writer = new occt.STEPControl_Writer_1()
|
|
229
|
+
writer.Transfer(shape, 0, true)
|
|
230
|
+
writer.Write('model.step')
|
|
231
|
+
return Buffer.from(occt.FS.readFile('model.step'))
|
|
232
|
+
}
|
|
233
|
+
if (format === 'stl') {
|
|
234
|
+
// StlAPI_Writer intermittently fails inside the WASM filesystem; the
|
|
235
|
+
// tessellated mesh is exact, so emit binary STL bytes directly.
|
|
236
|
+
const { positions, indices } = tessellate(shape, 0.1)
|
|
237
|
+
const normals = faceNormals(positions, indices)
|
|
238
|
+
const triangleCount = indices.length / 3
|
|
239
|
+
const buffer = Buffer.alloc(84 + triangleCount * 50)
|
|
240
|
+
buffer.write('dsh-cad binary STL', 0, 22, 'latin1')
|
|
241
|
+
buffer.writeUInt32LE(triangleCount, 80)
|
|
242
|
+
let offset = 84
|
|
243
|
+
for (let triangle = 0; triangle < triangleCount; triangle++) {
|
|
244
|
+
const a = indices[triangle * 3] * 3
|
|
245
|
+
const b = indices[triangle * 3 + 1] * 3
|
|
246
|
+
const c = indices[triangle * 3 + 2] * 3
|
|
247
|
+
for (let component = 0; component < 3; component++) {
|
|
248
|
+
buffer.writeFloatLE(normals[a + component], offset)
|
|
249
|
+
offset += 4
|
|
250
|
+
}
|
|
251
|
+
for (const vertex of [a, b, c]) {
|
|
252
|
+
buffer.writeFloatLE(positions[vertex], offset)
|
|
253
|
+
buffer.writeFloatLE(positions[vertex + 1], offset + 4)
|
|
254
|
+
buffer.writeFloatLE(positions[vertex + 2], offset + 8)
|
|
255
|
+
offset += 12
|
|
256
|
+
}
|
|
257
|
+
offset += 2 // attribute byte count
|
|
258
|
+
}
|
|
259
|
+
return buffer
|
|
260
|
+
}
|
|
261
|
+
throw new Error(`unsupported export format: ${format}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
pnt, dir, ENUM,
|
|
266
|
+
makePrim, makeExtrudedProfile, boolean, filletAll, transform,
|
|
267
|
+
tessellate, faceNormals, exportFile, volume,
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
module.exports = { createAdapter }
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Same-origin scene routes:
|
|
3
|
+
* - GET /dsh-cad/scene/<viewId> — JSON scenes (cad_view files)
|
|
4
|
+
* - GET /dsh-cad/bin/<viewId> — packed binary scenes (modeling document)
|
|
5
|
+
* - GET /dsh-cad/demo-scene — the built-in demo example, parsed from the
|
|
6
|
+
* packaged demo-bracket.brep by OCCT (local
|
|
7
|
+
* file ↔ editor display correspondence)
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { convert } from './convert/index.js';
|
|
12
|
+
export const SCENE_ROUTE_PATH = '/dsh-cad/scene';
|
|
13
|
+
export const BIN_ROUTE_PATH = '/dsh-cad/bin';
|
|
14
|
+
export const DEMO_SCENE_ROUTE_PATH = '/dsh-cad/demo-scene';
|
|
15
|
+
/** The built-in demo examples (packaged as lib/demo-<part>.brep). */
|
|
16
|
+
export const DEMO_PARTS = ['bracket', 'flange', 'shaft'];
|
|
17
|
+
/** Register the scene route on the shared HTTP server. Returns a disposer. */
|
|
18
|
+
export function registerSceneRoute(server, store) {
|
|
19
|
+
return server.register({
|
|
20
|
+
kind: 'prefix',
|
|
21
|
+
path: SCENE_ROUTE_PATH,
|
|
22
|
+
handler: async (req, res) => {
|
|
23
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
24
|
+
const segments = url.pathname.split('/').filter((segment) => segment !== '');
|
|
25
|
+
// ['/dsh-cad', 'scene', '<viewId>'] → viewId is the 3rd segment.
|
|
26
|
+
const viewId = segments[2];
|
|
27
|
+
if (req.method !== 'GET' || viewId === undefined) {
|
|
28
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
29
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const scene = await store.get(viewId);
|
|
33
|
+
if (scene === null) {
|
|
34
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
35
|
+
res.end(JSON.stringify({ error: 'unknown scene' }));
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const etag = store.etag(scene);
|
|
39
|
+
if (req.headers['if-none-match'] === etag) {
|
|
40
|
+
res.writeHead(304);
|
|
41
|
+
res.end();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const body = Buffer.from(JSON.stringify(scene));
|
|
45
|
+
res.writeHead(200, {
|
|
46
|
+
'content-type': 'application/json',
|
|
47
|
+
'content-length': body.length,
|
|
48
|
+
'cache-control': 'private, max-age=31536000, immutable',
|
|
49
|
+
etag,
|
|
50
|
+
});
|
|
51
|
+
res.end(body);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Register the binary scene route. Returns a disposer. */
|
|
56
|
+
export function registerBinRoute(server, store) {
|
|
57
|
+
return server.register({
|
|
58
|
+
kind: 'prefix',
|
|
59
|
+
path: BIN_ROUTE_PATH,
|
|
60
|
+
handler: async (req, res) => {
|
|
61
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
62
|
+
const segments = url.pathname.split('/').filter((segment) => segment !== '');
|
|
63
|
+
// ['/dsh-cad', 'bin', '<viewId>'] → viewId is the 3rd segment.
|
|
64
|
+
const viewId = segments[2];
|
|
65
|
+
if (req.method !== 'GET' || viewId === undefined) {
|
|
66
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
67
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const entry = await store.get(viewId);
|
|
71
|
+
if (entry === null) {
|
|
72
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
73
|
+
res.end(JSON.stringify({ error: 'unknown scene' }));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (req.headers['if-none-match'] === entry.etag) {
|
|
77
|
+
res.writeHead(304);
|
|
78
|
+
res.end();
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
res.writeHead(200, {
|
|
82
|
+
'content-type': 'application/octet-stream',
|
|
83
|
+
'content-length': entry.buffer.length,
|
|
84
|
+
'cache-control': 'no-store',
|
|
85
|
+
etag: entry.etag,
|
|
86
|
+
});
|
|
87
|
+
res.end(entry.buffer);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Parse a packaged demo BRep once per part (OCCT import worker), cached. */
|
|
92
|
+
const demoCache = new Map();
|
|
93
|
+
function loadDemoScene(part) {
|
|
94
|
+
let entry = demoCache.get(part);
|
|
95
|
+
if (entry === undefined) {
|
|
96
|
+
entry = (async () => {
|
|
97
|
+
const brepPath = new URL(`./demo-${part}.brep`, import.meta.url);
|
|
98
|
+
const buffer = await readFile(brepPath);
|
|
99
|
+
const scene = await convert(buffer, 'brep', `demo-${part}`);
|
|
100
|
+
const body = Buffer.from(JSON.stringify(scene));
|
|
101
|
+
return { body, etag: `"demo-${part}-${createHash('sha1').update(body).digest('hex')}"` };
|
|
102
|
+
})();
|
|
103
|
+
demoCache.set(part, entry);
|
|
104
|
+
entry.catch(() => demoCache.delete(part));
|
|
105
|
+
}
|
|
106
|
+
return entry;
|
|
107
|
+
}
|
|
108
|
+
/** Register the demo-scene route: GET /dsh-cad/demo-scene?part=<bracket|flange|shaft>. */
|
|
109
|
+
export function registerDemoRoute(server) {
|
|
110
|
+
return server.register({
|
|
111
|
+
kind: 'exact',
|
|
112
|
+
path: DEMO_SCENE_ROUTE_PATH,
|
|
113
|
+
handler: async (req, res) => {
|
|
114
|
+
const url = new URL(req.url ?? '/', 'http://localhost');
|
|
115
|
+
const part = url.searchParams.get('part') ?? 'bracket';
|
|
116
|
+
if (req.method !== 'GET' || !DEMO_PARTS.includes(part)) {
|
|
117
|
+
res.writeHead(404, { 'content-type': 'application/json' });
|
|
118
|
+
res.end(JSON.stringify({ error: part === undefined ? 'not found' : `unknown demo part: ${part}` }));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const cached = await loadDemoScene(part);
|
|
123
|
+
if (req.headers['if-none-match'] === cached.etag) {
|
|
124
|
+
res.writeHead(304);
|
|
125
|
+
res.end();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
res.writeHead(200, {
|
|
129
|
+
'content-type': 'application/json',
|
|
130
|
+
'content-length': cached.body.length,
|
|
131
|
+
'cache-control': 'private, max-age=31536000, immutable',
|
|
132
|
+
etag: cached.etag,
|
|
133
|
+
});
|
|
134
|
+
res.end(cached.body);
|
|
135
|
+
}
|
|
136
|
+
catch (cause) {
|
|
137
|
+
res.writeHead(500, { 'content-type': 'application/json' });
|
|
138
|
+
res.end(JSON.stringify({ error: cause instanceof Error ? cause.message : String(cause) }));
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|