dsh-cad 0.9.0 → 0.9.1
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.
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* occt.ts modeling adapter — the PRIMARY kernel backend (plain CJS, runs in
|
|
3
|
+
* the modeling worker). Mirrors the occt-adapter.cjs interface so the worker
|
|
4
|
+
* is kernel-agnostic; opencascade.js stays installed only as the fallback
|
|
5
|
+
* backend for environments where occt.ts cannot load.
|
|
6
|
+
*
|
|
7
|
+
* Shape representation: opaque occt.ts WasmShape handles. Geometry lives in
|
|
8
|
+
* the occt.ts wasm heap; STEP/BRep exchange and true B-splines
|
|
9
|
+
* (makeBsplineThrough) are native here, and every op the worker needs is
|
|
10
|
+
* bound — including mirror, composed as scale(-1) point reflection followed
|
|
11
|
+
* by a π rotation about the mirror normal (verified against bounds).
|
|
12
|
+
*
|
|
13
|
+
* Profiles are built in the world XY plane (where occt.ts circle parameter
|
|
14
|
+
* 0 = +X, CCW toward +Y — matching the profileWire (u,v) convention) and then
|
|
15
|
+
* RIGIDLY MOVED into the caller's frame (origin + basis u/v). The frame move
|
|
16
|
+
* is a two-rotation decomposition: first align Z onto n = u×v (about Z×n),
|
|
17
|
+
* then roll about n until X lands on u — so local profile coordinates map
|
|
18
|
+
* exactly onto the requested plane for extrude/sweep/revolve alike.
|
|
19
|
+
*/
|
|
20
|
+
'use strict'
|
|
21
|
+
|
|
22
|
+
const vec3 = {
|
|
23
|
+
cross: (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]],
|
|
24
|
+
dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
|
|
25
|
+
norm: (a) => {
|
|
26
|
+
const len = Math.hypot(a[0], a[1], a[2]) || 1
|
|
27
|
+
return [a[0] / len, a[1] / len, a[2] / len]
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createOcctTsAdapter(mod) {
|
|
32
|
+
const wrapError = (what, e) => new Error(`${what}: ${mod.hasError() ? mod.lastError() : e instanceof Error ? e.message : String(e)}`)
|
|
33
|
+
|
|
34
|
+
/** std::vector<double>-like return (bounds/centroid) → plain JS array. */
|
|
35
|
+
const vecOf = (v) => Array.from({ length: v.size() }, (_, i) => v.get(i))
|
|
36
|
+
|
|
37
|
+
/** Heap helpers for the ptr/count style bindings. */
|
|
38
|
+
const mallocF64 = (values) => {
|
|
39
|
+
const ptr = mod._malloc(values.length * 8)
|
|
40
|
+
const view = new Float64Array(mod.HEAPU8.buffer, ptr, values.length)
|
|
41
|
+
for (let i = 0; i < values.length; i++) view[i] = values[i]
|
|
42
|
+
return { ptr, free: () => mod._free(ptr) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── primitives ─────────────────────────────────────────────────────────────
|
|
46
|
+
function makePrim(kind, params) {
|
|
47
|
+
const p = params ?? {}
|
|
48
|
+
const at = p.at ?? [0, 0, 0]
|
|
49
|
+
const axis = p.axis ?? [0, 0, 1]
|
|
50
|
+
switch (kind) {
|
|
51
|
+
case 'box': {
|
|
52
|
+
// makeBox builds from the origin; translate for `at`.
|
|
53
|
+
const s = mod.makeBox(p.dx ?? 10, p.dy ?? 10, p.dz ?? 10)
|
|
54
|
+
if (at.some((v) => v !== 0)) return mod.translate(s, at[0], at[1], at[2])
|
|
55
|
+
return s
|
|
56
|
+
}
|
|
57
|
+
case 'cylinder':
|
|
58
|
+
return mod.makeCylinder(p.radius ?? 5, p.height ?? 10, axis[0], axis[1], axis[2], at[0], at[1], at[2])
|
|
59
|
+
case 'sphere':
|
|
60
|
+
return mod.makeSphere(p.radius ?? 5, at[0], at[1], at[2])
|
|
61
|
+
case 'cone':
|
|
62
|
+
return mod.makeCone(p.radius1 ?? 5, p.radius2 ?? 0, p.height ?? 10, axis[0], axis[1], axis[2], at[0], at[1], at[2])
|
|
63
|
+
case 'torus':
|
|
64
|
+
return mod.makeTorus(p.majorRadius ?? 10, p.minorRadius ?? 2, axis[0], axis[1], axis[2], at[0], at[1], at[2])
|
|
65
|
+
default:
|
|
66
|
+
throw new Error(`unknown primitive kind: ${kind}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── frame move (XY-built profile → caller's frame) ─────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* Rigidly move a shape built in world XY so that local X→u, Y→v,
|
|
73
|
+
* Z→u×v, origin→o3. Two rotations about the ORIGIN, then one translation.
|
|
74
|
+
*/
|
|
75
|
+
function moveToFrame(shape, o3, u3, v3) {
|
|
76
|
+
const n = vec3.norm(vec3.cross(u3, v3))
|
|
77
|
+
const un = vec3.norm(u3)
|
|
78
|
+
let moved = shape
|
|
79
|
+
// Step 1: rotate Z onto n (about axis = Z×n, angle = acos(Z·n)).
|
|
80
|
+
const zAxis = [0, 0, 1]
|
|
81
|
+
if (Math.abs(n[2] - 1) > 1e-9) {
|
|
82
|
+
if (Math.abs(n[2] + 1) < 1e-9) {
|
|
83
|
+
// n = −Z: rotate π about X.
|
|
84
|
+
moved = mod.rotate(moved, 1, 0, 0, Math.PI)
|
|
85
|
+
} else {
|
|
86
|
+
const axis = vec3.norm(vec3.cross(zAxis, n))
|
|
87
|
+
const angle = Math.acos(Math.min(1, Math.max(-1, n[2])))
|
|
88
|
+
moved = mod.rotate(moved, axis[0], axis[1], axis[2], angle)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Step 2: roll about n until X lands on u (u ⊥ n). After step 1 X sits at
|
|
92
|
+
// x' = R1·X = cosθ·X + sinθ·(a×X) + (a·X)(1−cosθ)·a (a = norm(Z×n), θ = ∠(Z,n));
|
|
93
|
+
// the roll angle is signed by (x'×u)·n.
|
|
94
|
+
const cosT = n[2]
|
|
95
|
+
const sinT = Math.sin(Math.acos(Math.min(1, Math.max(-1, n[2]))))
|
|
96
|
+
let xp
|
|
97
|
+
if (Math.abs(n[2] - 1) < 1e-9 || Math.abs(n[2] + 1) < 1e-9) {
|
|
98
|
+
// θ = 0 or π about X: X stays X (π about X keeps X fixed).
|
|
99
|
+
xp = [1, 0, 0]
|
|
100
|
+
} else {
|
|
101
|
+
const a = vec3.norm(vec3.cross(zAxis, n))
|
|
102
|
+
const axX = vec3.cross(a, [1, 0, 0])
|
|
103
|
+
const aDotX = a[0]
|
|
104
|
+
xp = [
|
|
105
|
+
cosT * 1 + sinT * axX[0] + aDotX * (1 - cosT) * a[0],
|
|
106
|
+
cosT * 0 + sinT * axX[1] + aDotX * (1 - cosT) * a[1],
|
|
107
|
+
cosT * 0 + sinT * axX[2] + aDotX * (1 - cosT) * a[2],
|
|
108
|
+
]
|
|
109
|
+
}
|
|
110
|
+
const cosPhi = Math.min(1, Math.max(-1, vec3.dot(xp, un)))
|
|
111
|
+
let phi = Math.acos(cosPhi)
|
|
112
|
+
if (vec3.dot(vec3.cross(xp, un), n) < 0) phi = -phi
|
|
113
|
+
if (Math.abs(phi) > 1e-9) {
|
|
114
|
+
moved = mod.rotate(moved, n[0], n[1], n[2], phi)
|
|
115
|
+
}
|
|
116
|
+
// Step 3: translate to o3.
|
|
117
|
+
if (o3[0] !== 0 || o3[1] !== 0 || o3[2] !== 0) {
|
|
118
|
+
moved = mod.translate(moved, o3[0], o3[1], o3[2])
|
|
119
|
+
}
|
|
120
|
+
return moved
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── profile wires (line / arc / bspline / circle chains) ───────────────────
|
|
124
|
+
/**
|
|
125
|
+
* Build a CLOSED wire for a profile in the caller's (o3, u3, v3) frame.
|
|
126
|
+
* Local 2D coordinates are interpreted in the XY plane, then the wire is
|
|
127
|
+
* moved to the frame — arc angles (atan2 in (u,v)) match the XY circle
|
|
128
|
+
* parameterization by construction.
|
|
129
|
+
*/
|
|
130
|
+
function profileWire(profile, o3, u3, v3) {
|
|
131
|
+
const parts = new mod.VectorShape()
|
|
132
|
+
try {
|
|
133
|
+
let built
|
|
134
|
+
if (Array.isArray(profile)) {
|
|
135
|
+
if (profile.length < 6 || profile.length % 2 !== 0) throw new Error('a flat profile needs ≥3 [x,y] pairs')
|
|
136
|
+
const xyz = []
|
|
137
|
+
for (let i = 0; i + 1 < profile.length; i += 2) xyz.push(profile[i], profile[i + 1], 0)
|
|
138
|
+
built = polygonWireXY(xyz)
|
|
139
|
+
} else if (profile !== null && typeof profile === 'object') {
|
|
140
|
+
if (profile.circle !== undefined) {
|
|
141
|
+
const c = profile.circle
|
|
142
|
+
if (!Array.isArray(c.center) || typeof c.radius !== 'number') throw new Error("circle profile needs 'center: [x,y]' and 'radius'")
|
|
143
|
+
built = mod.makeCircle(c.radius, 0, 0, 1, c.center[0], c.center[1], 0)
|
|
144
|
+
} else {
|
|
145
|
+
built = segmentWireXY(profile)
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
throw new Error('a profile must be a flat points array or a {start, segments}/{circle} object')
|
|
149
|
+
}
|
|
150
|
+
if (built === undefined || built.isNull()) throw wrapError('profile wire construction', new Error('null wire'))
|
|
151
|
+
const moved = moveToFrame(built, o3, vec3.norm(u3), vec3.norm(v3))
|
|
152
|
+
if (moved.isNull()) throw wrapError('profile frame move', new Error('null shape'))
|
|
153
|
+
return moved
|
|
154
|
+
} finally {
|
|
155
|
+
parts.delete()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Closed polygon wire from flat world-XY triplets. */
|
|
160
|
+
function polygonWireXY(xyz) {
|
|
161
|
+
const { ptr, free } = mallocF64(xyz)
|
|
162
|
+
try {
|
|
163
|
+
return mod.makePolygon(ptr, xyz.length / 3, true)
|
|
164
|
+
} finally {
|
|
165
|
+
free()
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Segment-chain wire (line/arc/bspline) built in world XY. */
|
|
170
|
+
function segmentWireXY(profile) {
|
|
171
|
+
const segs = Array.isArray(profile.segments) ? profile.segments : []
|
|
172
|
+
if (segs.length === 0) throw new Error('a segment profile needs at least one segment')
|
|
173
|
+
if (!Array.isArray(profile.start) || profile.start.length !== 2) throw new Error('profile.start must be [x,y]')
|
|
174
|
+
const parts = new mod.VectorShape()
|
|
175
|
+
try {
|
|
176
|
+
let cur = [profile.start[0], profile.start[1]]
|
|
177
|
+
const push = (shape) => {
|
|
178
|
+
if (shape.isNull()) throw wrapError('segment edge', new Error('null edge'))
|
|
179
|
+
parts.push_back(shape)
|
|
180
|
+
}
|
|
181
|
+
for (const seg of segs) {
|
|
182
|
+
if (seg === null || typeof seg !== 'object') throw new Error('each profile segment must be an object')
|
|
183
|
+
if (seg.type === 'line') {
|
|
184
|
+
if (!Array.isArray(seg.to) || seg.to.length !== 2) throw new Error("line segment needs 'to: [x,y]'")
|
|
185
|
+
push(mod.makeLine(cur[0], cur[1], 0, seg.to[0], seg.to[1], 0))
|
|
186
|
+
cur = seg.to
|
|
187
|
+
} else if (seg.type === 'arc') {
|
|
188
|
+
if (!Array.isArray(seg.to) || seg.to.length !== 2 || !Array.isArray(seg.center) || seg.center.length !== 2) {
|
|
189
|
+
throw new Error("arc segment needs 'to: [x,y]' and 'center: [cx,cy]'")
|
|
190
|
+
}
|
|
191
|
+
const c = seg.center
|
|
192
|
+
const r0 = Math.hypot(cur[0] - c[0], cur[1] - c[1])
|
|
193
|
+
const r1 = Math.hypot(seg.to[0] - c[0], seg.to[1] - c[1])
|
|
194
|
+
if (Math.abs(r0 - r1) > 1e-4 * Math.max(r0, r1) + 1e-6) {
|
|
195
|
+
throw new Error(`arc endpoints are not equidistant from the center (r=${r0.toFixed(4)} vs ${r1.toFixed(4)})`)
|
|
196
|
+
}
|
|
197
|
+
const a0 = Math.atan2(cur[1] - c[1], cur[0] - c[0])
|
|
198
|
+
let a1 = Math.atan2(seg.to[1] - c[1], seg.to[0] - c[0])
|
|
199
|
+
// occt.ts makeArc wants u1 < u2 on the circle parameterization
|
|
200
|
+
// (0 = +X, CCW); resolve ccw/cw into an increasing span.
|
|
201
|
+
const span = seg.ccw !== false ? (a1 > a0 ? a1 - a0 : a1 + 2 * Math.PI - a0) : (a1 < a0 ? a0 - a1 : a0 + 2 * Math.PI - a1)
|
|
202
|
+
const start = seg.ccw !== false ? a0 : a1
|
|
203
|
+
push(mod.makeArc(r0, 0, 0, 1, c[0], c[1], 0, start, start + span))
|
|
204
|
+
cur = seg.to
|
|
205
|
+
} else if (seg.type === 'bspline') {
|
|
206
|
+
const through = Array.isArray(seg.through) ? seg.through : []
|
|
207
|
+
if (through.length < 2) throw new Error("bspline segment needs 'through: [[x,y],…]' (≥2 points)")
|
|
208
|
+
// True interpolation through the points (kernel-side B-spline).
|
|
209
|
+
const pts = [cur[0], cur[1], 0]
|
|
210
|
+
for (const p of through) pts.push(p[0], p[1], 0)
|
|
211
|
+
const { ptr, free } = mallocF64(pts)
|
|
212
|
+
try {
|
|
213
|
+
push(mod.makeBsplineThrough(ptr, pts.length / 3, false, 1e-6))
|
|
214
|
+
} finally {
|
|
215
|
+
free()
|
|
216
|
+
}
|
|
217
|
+
cur = through[through.length - 1]
|
|
218
|
+
} else {
|
|
219
|
+
throw new Error(`unknown profile segment type: ${String(seg.type)}`)
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (Math.hypot(cur[0] - profile.start[0], cur[1] - profile.start[1]) > 1e-9) {
|
|
223
|
+
push(mod.makeLine(cur[0], cur[1], 0, profile.start[0], profile.start[1], 0))
|
|
224
|
+
}
|
|
225
|
+
const wire = mod.makeWire(parts)
|
|
226
|
+
if (wire.isNull()) throw wrapError('makeWire', new Error('null wire'))
|
|
227
|
+
return wire
|
|
228
|
+
} finally {
|
|
229
|
+
parts.delete()
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── solid ops ───────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/** Extrude any 2D profile form from z = base along +Z. */
|
|
236
|
+
function extrudeProfile2D(profile, height, base = 0) {
|
|
237
|
+
if (!(height > 0)) throw new Error('the extrusion height must be positive')
|
|
238
|
+
const wire = profileWire(profile, [0, 0, base], [1, 0, 0], [0, 1, 0])
|
|
239
|
+
const solid = mod.extrude(wire, 0, 0, height, true)
|
|
240
|
+
if (solid.isNull()) throw wrapError('extrude', new Error('null shape'))
|
|
241
|
+
return solid
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Loft through closed 3D section wires. */
|
|
245
|
+
function makeLoft(sections, options = {}) {
|
|
246
|
+
if (!Array.isArray(sections) || sections.length < 2) throw new Error('a loft needs at least 2 sections')
|
|
247
|
+
const v = new mod.VectorShape()
|
|
248
|
+
try {
|
|
249
|
+
for (const section of sections) {
|
|
250
|
+
if (!Array.isArray(section) || section.length < 9 || section.length % 3 !== 0) {
|
|
251
|
+
throw new Error('each section must be a flat [x,y,z,…] loop (≥9 numbers)')
|
|
252
|
+
}
|
|
253
|
+
v.push_back(polygonWireXY(section))
|
|
254
|
+
}
|
|
255
|
+
const solid = mod.loft(v, options.ruled ?? false, options.solid ?? true)
|
|
256
|
+
if (solid.isNull()) throw wrapError('loft', new Error('null shape'))
|
|
257
|
+
if (Math.abs(volume(solid)) < 1e-9) {
|
|
258
|
+
throw new Error('loft produced an empty solid (sections must be closed, planar, non-degenerate loops)')
|
|
259
|
+
}
|
|
260
|
+
return solid
|
|
261
|
+
} finally {
|
|
262
|
+
v.delete()
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Sweep a 2D profile along a 3D polyline path (auto-oriented on the start tangent). */
|
|
267
|
+
function makeSweep(profile, pathPoints) {
|
|
268
|
+
if (!Array.isArray(pathPoints) || pathPoints.length < 6 || pathPoints.length % 3 !== 0) {
|
|
269
|
+
throw new Error('the path needs at least 2 [x,y,z] triplets (≥6 numbers)')
|
|
270
|
+
}
|
|
271
|
+
const tx = pathPoints[3] - pathPoints[0]
|
|
272
|
+
const ty = pathPoints[4] - pathPoints[1]
|
|
273
|
+
const tz = pathPoints[5] - pathPoints[2]
|
|
274
|
+
const tLen = Math.hypot(tx, ty, tz)
|
|
275
|
+
if (tLen < 1e-12) throw new Error('the path starts with a zero-length segment')
|
|
276
|
+
const n = [tx / tLen, ty / tLen, tz / tLen]
|
|
277
|
+
const axis = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
|
|
278
|
+
const d = vec3.dot(axis, n)
|
|
279
|
+
const vx = vec3.norm([axis[0] - n[0] * d, axis[1] - n[1] * d, axis[2] - n[2] * d])
|
|
280
|
+
const vy = vec3.cross(n, vx)
|
|
281
|
+
const wire = profileWire(profile, [pathPoints[0], pathPoints[1], pathPoints[2]], vx, vy)
|
|
282
|
+
const spinePts = []
|
|
283
|
+
for (let i = 0; i + 2 < pathPoints.length; i += 3) spinePts.push(pathPoints[i], pathPoints[i + 1], pathPoints[i + 2])
|
|
284
|
+
const spine = (() => {
|
|
285
|
+
const { ptr, free } = mallocF64(spinePts)
|
|
286
|
+
try {
|
|
287
|
+
return mod.makePolygon(ptr, spinePts.length / 3, false)
|
|
288
|
+
} finally {
|
|
289
|
+
free()
|
|
290
|
+
}
|
|
291
|
+
})()
|
|
292
|
+
const solid = mod.sweep(wire, spine, true)
|
|
293
|
+
if (solid.isNull()) throw wrapError('sweep', new Error('null shape'))
|
|
294
|
+
return solid
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Revolve a 2D profile (local x = radial, y = along the axis) about the axis. */
|
|
298
|
+
function makeRevolve(profile, options = {}) {
|
|
299
|
+
const axis = options.axis ?? [0, 0, 1]
|
|
300
|
+
const at = options.at ?? [0, 0, 0]
|
|
301
|
+
const angle = options.angle ?? Math.PI * 2
|
|
302
|
+
const alen = Math.hypot(axis[0], axis[1], axis[2])
|
|
303
|
+
if (alen < 1e-12) throw new Error('the revolve axis must be a non-zero direction')
|
|
304
|
+
const n = [axis[0] / alen, axis[1] / alen, axis[2] / alen]
|
|
305
|
+
const helper = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
|
|
306
|
+
const d = vec3.dot(helper, n)
|
|
307
|
+
const radial = vec3.norm([helper[0] - n[0] * d, helper[1] - n[1] * d, helper[2] - n[2] * d])
|
|
308
|
+
const wire = profileWire(profile, at, radial, n)
|
|
309
|
+
const solid = mod.revolve(wire, n[0], n[1], n[2], at[0], at[1], at[2], angle, true)
|
|
310
|
+
if (solid.isNull()) throw wrapError('revolve', new Error('null shape'))
|
|
311
|
+
return solid
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function boolean(op, target, tools) {
|
|
315
|
+
let result = target
|
|
316
|
+
for (const tool of tools) {
|
|
317
|
+
const fn = op === 'fuse' ? mod.fuse : op === 'cut' ? mod.cut : mod.common
|
|
318
|
+
const next = fn(result, tool)
|
|
319
|
+
if (next.isNull()) throw wrapError(`boolean ${op}`, new Error('null shape'))
|
|
320
|
+
result = next
|
|
321
|
+
}
|
|
322
|
+
return result
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function filletAll(shape, radius) {
|
|
326
|
+
const next = mod.fillet(shape, radius, 0, 0)
|
|
327
|
+
if (next.isNull()) throw wrapError('fillet', new Error('null shape (radius may exceed the adjacent faces)'))
|
|
328
|
+
return next
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function chamferAll(shape, distance) {
|
|
332
|
+
const next = mod.chamfer(shape, distance, 0, 0)
|
|
333
|
+
if (next.isNull()) throw wrapError('chamfer', new Error('null shape (distance may exceed the adjacent faces)'))
|
|
334
|
+
return next
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Shell: hollow to a wall thickness; `openFaces` are 1-based indices as
|
|
339
|
+
* listed by describe() (empty = sealed). Face matching by normal lives in
|
|
340
|
+
* the worker (it has describe output) — this takes indices directly.
|
|
341
|
+
*/
|
|
342
|
+
function shell(shape, thickness, openFaces) {
|
|
343
|
+
const faces = Array.isArray(openFaces) && openFaces.length > 0 ? openFaces : []
|
|
344
|
+
let ptr = 0
|
|
345
|
+
try {
|
|
346
|
+
if (faces.length > 0) {
|
|
347
|
+
ptr = mod._malloc(faces.length * 4)
|
|
348
|
+
for (let i = 0; i < faces.length; i++) mod.HEAPU32[ptr / 4 + i] = faces[i]
|
|
349
|
+
}
|
|
350
|
+
const next = mod.shell(shape, Math.abs(thickness), ptr, faces.length)
|
|
351
|
+
if (next.isNull()) throw wrapError('shell', new Error('null shape'))
|
|
352
|
+
return next
|
|
353
|
+
} finally {
|
|
354
|
+
if (ptr !== 0) mod._free(ptr)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Draft: tilt walls by angle degrees toward `direction` (auto wall select). */
|
|
359
|
+
function draft(shape, angleDegrees, direction) {
|
|
360
|
+
const d = direction ?? [0, 0, 1]
|
|
361
|
+
const next = mod.draft(shape, (angleDegrees * Math.PI) / 180, d[0], d[1], d[2], 0, 0, 1, 0, 0, true)
|
|
362
|
+
if (next.isNull()) throw wrapError('draft', new Error('null shape'))
|
|
363
|
+
return next
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function transform(shape, { translate, rotate, mirror }) {
|
|
367
|
+
let result = shape
|
|
368
|
+
if (translate !== undefined) result = mod.translate(result, translate[0], translate[1], translate[2])
|
|
369
|
+
if (rotate !== undefined) {
|
|
370
|
+
const [rx, ry, rz] = rotate
|
|
371
|
+
if (rx) result = mod.rotate(result, 1, 0, 0, (rx * Math.PI) / 180)
|
|
372
|
+
if (ry) result = mod.rotate(result, 0, 1, 0, (ry * Math.PI) / 180)
|
|
373
|
+
if (rz) result = mod.rotate(result, 0, 0, 1, (rz * Math.PI) / 180)
|
|
374
|
+
}
|
|
375
|
+
if (mirror !== undefined) {
|
|
376
|
+
const n = vec3.norm(mirror)
|
|
377
|
+
// Plane mirror = point reflection (scale −1) + π rotation about the
|
|
378
|
+
// mirror normal — the only improper transform composable from the
|
|
379
|
+
// bound primitives (verified against bounds).
|
|
380
|
+
result = mod.scale(result, -1)
|
|
381
|
+
result = mod.rotate(result, n[0], n[1], n[2], Math.PI)
|
|
382
|
+
}
|
|
383
|
+
if (result.isNull()) throw wrapError('transform', new Error('null shape'))
|
|
384
|
+
return result
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function isValid(shape) {
|
|
388
|
+
try { return shape.isValid() === true } catch { return null }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function volume(shape) {
|
|
392
|
+
return shape.volume()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function centroid(shape) {
|
|
396
|
+
return vecOf(shape.centroid())
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── tessellation ───────────────────────────────────────────────────────────
|
|
400
|
+
function tessellate(shape, linearDeflection = 0.3) {
|
|
401
|
+
const data = mod.tessellate(shape, linearDeflection, 20, false)
|
|
402
|
+
try {
|
|
403
|
+
const positions = new Float32Array(mod.HEAPU8.buffer, data.positionsPtr(), data.positionCount() * 3).slice()
|
|
404
|
+
const indices = new Uint32Array(mod.HEAPU8.buffer, data.indicesPtr(), data.indexCount()).slice()
|
|
405
|
+
return { positions, indices }
|
|
406
|
+
} finally {
|
|
407
|
+
data.delete()
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Flat per-triangle normals (the mechanical-CAD look the viewer expects). */
|
|
412
|
+
function faceNormals(positions, indices) {
|
|
413
|
+
const normals = new Float32Array(positions.length)
|
|
414
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
415
|
+
const a = indices[i] * 3
|
|
416
|
+
const b = indices[i + 1] * 3
|
|
417
|
+
const c = indices[i + 2] * 3
|
|
418
|
+
const ux = positions[c] - positions[a], uy = positions[c + 1] - positions[a + 1], uz = positions[c + 2] - positions[a + 2]
|
|
419
|
+
const vx = positions[b] - positions[a], vy = positions[b + 1] - positions[a + 1], vz = positions[b + 2] - positions[a + 2]
|
|
420
|
+
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
421
|
+
const len = Math.hypot(nx, ny, nz) || 1
|
|
422
|
+
nx /= len; ny /= len; nz /= len
|
|
423
|
+
for (const corner of [a, b, c]) {
|
|
424
|
+
normals[corner] = nx; normals[corner + 1] = ny; normals[corner + 2] = nz
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return normals
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// ── export ─────────────────────────────────────────────────────────────────
|
|
431
|
+
function exportFile(shape, format) {
|
|
432
|
+
if (format === 'step') {
|
|
433
|
+
return Buffer.from(mod.writeStep(shape, 'mm'), 'utf8')
|
|
434
|
+
}
|
|
435
|
+
if (format === 'stl') {
|
|
436
|
+
const { positions, indices } = tessellate(shape, 0.1)
|
|
437
|
+
const normals = faceNormals(positions, indices)
|
|
438
|
+
const triangleCount = indices.length / 3
|
|
439
|
+
const buffer = Buffer.alloc(84 + triangleCount * 50)
|
|
440
|
+
buffer.write('dsh-cad binary STL', 0, 22, 'latin1')
|
|
441
|
+
buffer.writeUInt32LE(triangleCount, 80)
|
|
442
|
+
let offset = 84
|
|
443
|
+
for (let triangle = 0; triangle < triangleCount; triangle++) {
|
|
444
|
+
const a = indices[triangle * 3] * 3
|
|
445
|
+
const b = indices[triangle * 3 + 1] * 3
|
|
446
|
+
const c = indices[triangle * 3 + 2] * 3
|
|
447
|
+
for (let component = 0; component < 3; component++) {
|
|
448
|
+
buffer.writeFloatLE(normals[a + component], offset)
|
|
449
|
+
offset += 4
|
|
450
|
+
}
|
|
451
|
+
for (const vertex of [a, b, c]) {
|
|
452
|
+
buffer.writeFloatLE(positions[vertex], offset)
|
|
453
|
+
buffer.writeFloatLE(positions[vertex + 1], offset + 4)
|
|
454
|
+
buffer.writeFloatLE(positions[vertex + 2], offset + 8)
|
|
455
|
+
offset += 12
|
|
456
|
+
}
|
|
457
|
+
offset += 2
|
|
458
|
+
}
|
|
459
|
+
return buffer
|
|
460
|
+
}
|
|
461
|
+
throw new Error(`unsupported export format: ${format}`)
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ── describe (agent eyes / face matching) ──────────────────────────────────
|
|
465
|
+
function describe(shape) {
|
|
466
|
+
return JSON.parse(mod.describe(shape))
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
kernel: 'occt.ts',
|
|
471
|
+
pnt: null, dir: null, ENUM: null, // legacy adapter surface (unused on this backend)
|
|
472
|
+
makePrim, extrudeProfile2D, makeLoft, makeSweep, makeRevolve,
|
|
473
|
+
filletAll, chamferAll, shell, draft, boolean, transform,
|
|
474
|
+
isValid, volume, centroid,
|
|
475
|
+
tessellate, faceNormals, exportFile, describe,
|
|
476
|
+
profileWire,
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
module.exports = { createOcctTsAdapter }
|
package/lib/tools/cad-model.js
CHANGED
|
@@ -984,6 +984,7 @@ export function createModelTools(deps) {
|
|
|
984
984
|
...requiredCounts,
|
|
985
985
|
...commonOptional,
|
|
986
986
|
volume: { type: 'number', required: true, description: 'Volume (mm³).' },
|
|
987
|
+
centroid: { type: 'array', items: { type: 'number' }, description: 'Center of mass [x,y,z] (occt.ts backend).' },
|
|
987
988
|
},
|
|
988
989
|
},
|
|
989
990
|
render: (_args, value) => [{ type: 'text', text: renderModel(value) }],
|
|
@@ -994,7 +995,10 @@ export function createModelTools(deps) {
|
|
|
994
995
|
await resolveDoc(exec);
|
|
995
996
|
const op = { kind: 'volume', target: args.target };
|
|
996
997
|
const result = await runModelOp(op);
|
|
997
|
-
|
|
998
|
+
const value = await syncScene(op, result);
|
|
999
|
+
if (Array.isArray(result.centroid))
|
|
1000
|
+
value.centroid = result.centroid;
|
|
1001
|
+
return value;
|
|
998
1002
|
},
|
|
999
1003
|
presentCall: (args) => ({ card: 'generic', title: `CAD volume ${String(args.target)}`, kind: 'read' }),
|
|
1000
1004
|
presentResult: () => ({ card: 'generic', title: 'CAD volume' }),
|
package/package.json
CHANGED
|
@@ -1,92 +1,92 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "dsh-cad",
|
|
3
|
-
"description": "CAD visualization plugin for DeepSeek Harness: cad_view / cad_info tools with an embedded 3D (STL/OBJ/STEP/IGES) and 2D (DXF/SVG) viewer card in the Web UI",
|
|
4
|
-
"version": "0.9.
|
|
5
|
-
"type": "module",
|
|
6
|
-
"license": "MIT",
|
|
7
|
-
"author": "LAU-MARS",
|
|
8
|
-
"homepage": "https://lau-mars.github.io/dsh-cad/",
|
|
9
|
-
"repository": {
|
|
10
|
-
"type": "git",
|
|
11
|
-
"url": "git+https://github.com/LAU-MARS/dsh-cad.git"
|
|
12
|
-
},
|
|
13
|
-
"bugs": {
|
|
14
|
-
"url": "https://github.com/LAU-MARS/dsh-cad/issues"
|
|
15
|
-
},
|
|
16
|
-
"keywords": [
|
|
17
|
-
"deepseek-harness",
|
|
18
|
-
"dsh",
|
|
19
|
-
"dsh-plugin",
|
|
20
|
-
"cad",
|
|
21
|
-
"3d",
|
|
22
|
-
"2d",
|
|
23
|
-
"occt",
|
|
24
|
-
"opencascade",
|
|
25
|
-
"threejs",
|
|
26
|
-
"step",
|
|
27
|
-
"iges",
|
|
28
|
-
"dxf",
|
|
29
|
-
"svg"
|
|
30
|
-
],
|
|
31
|
-
"engines": {
|
|
32
|
-
"node": ">=22",
|
|
33
|
-
"dsh": ">=0.1.0-rc.7"
|
|
34
|
-
},
|
|
35
|
-
"main": "lib/index.js",
|
|
36
|
-
"types": "lib/types/index.d.ts",
|
|
37
|
-
"exports": {
|
|
38
|
-
".": {
|
|
39
|
-
"types": "./lib/types/index.d.ts",
|
|
40
|
-
"default": "./lib/index.js"
|
|
41
|
-
},
|
|
42
|
-
"./client": {
|
|
43
|
-
"default": "./lib/client.js"
|
|
44
|
-
},
|
|
45
|
-
"./package.json": "./package.json"
|
|
46
|
-
},
|
|
47
|
-
"dsh": {
|
|
48
|
-
"bundle": {
|
|
49
|
-
"patch": "./cordis.patch.yml"
|
|
50
|
-
},
|
|
51
|
-
"client": {
|
|
52
|
-
"inject": [
|
|
53
|
-
"@deepseek-ai/dsh-client-runtime",
|
|
54
|
-
"@deepseek-ai/dsh-client-ui-conversation"
|
|
55
|
-
],
|
|
56
|
-
"platform": "web"
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
"files": [
|
|
60
|
-
"lib",
|
|
61
|
-
"README.md"
|
|
62
|
-
],
|
|
63
|
-
"scripts": {
|
|
64
|
-
"build": "tsc -p tsconfig.json && node esbuild.client.mjs && node -e \"const fs=require('fs'); fs.copyFileSync('src/convert/step-worker.mjs','lib/convert/step-worker.mjs'); fs.mkdirSync('lib/modeling',{recursive:true}); fs.copyFileSync('src/modeling/occt-adapter.cjs','lib/modeling/occt-adapter.cjs'); fs.copyFileSync('src/modeling/modeling-worker.cjs','lib/modeling/modeling-worker.cjs'); fs.copyFileSync('src/modeling/occt-bridge.cjs','lib/modeling/occt-bridge.cjs'); for (const part of ['bracket','flange','shaft']) fs.copyFileSync('assets/demo-'+part+'.brep','lib/demo-'+part+'.brep')\"",
|
|
65
|
-
"watch": "tsc -p tsconfig.json --watch & node esbuild.client.mjs --watch",
|
|
66
|
-
"test": "vitest run --no-file-parallelism",
|
|
67
|
-
"test:watch": "vitest"
|
|
68
|
-
},
|
|
69
|
-
"dependencies": {
|
|
70
|
-
"ansatz-wasm": "^0.2.0",
|
|
71
|
-
"dxf-parser": "^1.1.2",
|
|
72
|
-
"occt-import-js": "^0.0.23",
|
|
73
|
-
"occt.ts": "^0.5.0",
|
|
74
|
-
"opencascade.js": "^1.1.1",
|
|
75
|
-
"three": "^0.185.1"
|
|
76
|
-
},
|
|
77
|
-
"peerDependencies": {
|
|
78
|
-
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
79
|
-
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
|
80
|
-
"react": "^18.2.0"
|
|
81
|
-
},
|
|
82
|
-
"devDependencies": {
|
|
83
|
-
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
|
84
|
-
"@types/node": "^22.10.0",
|
|
85
|
-
"@types/react": "^18.3.1",
|
|
86
|
-
"esbuild": "^0.28.2",
|
|
87
|
-
"react": "^18.2.0",
|
|
88
|
-
"react-dom": "^18.3.1",
|
|
89
|
-
"typescript": "^5.7.0",
|
|
90
|
-
"vitest": "^4.1.10"
|
|
91
|
-
}
|
|
92
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-cad",
|
|
3
|
+
"description": "CAD visualization plugin for DeepSeek Harness: cad_view / cad_info tools with an embedded 3D (STL/OBJ/STEP/IGES) and 2D (DXF/SVG) viewer card in the Web UI",
|
|
4
|
+
"version": "0.9.1",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "LAU-MARS",
|
|
8
|
+
"homepage": "https://lau-mars.github.io/dsh-cad/",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/LAU-MARS/dsh-cad.git"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/LAU-MARS/dsh-cad/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"deepseek-harness",
|
|
18
|
+
"dsh",
|
|
19
|
+
"dsh-plugin",
|
|
20
|
+
"cad",
|
|
21
|
+
"3d",
|
|
22
|
+
"2d",
|
|
23
|
+
"occt",
|
|
24
|
+
"opencascade",
|
|
25
|
+
"threejs",
|
|
26
|
+
"step",
|
|
27
|
+
"iges",
|
|
28
|
+
"dxf",
|
|
29
|
+
"svg"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=22",
|
|
33
|
+
"dsh": ">=0.1.0-rc.7"
|
|
34
|
+
},
|
|
35
|
+
"main": "lib/index.js",
|
|
36
|
+
"types": "lib/types/index.d.ts",
|
|
37
|
+
"exports": {
|
|
38
|
+
".": {
|
|
39
|
+
"types": "./lib/types/index.d.ts",
|
|
40
|
+
"default": "./lib/index.js"
|
|
41
|
+
},
|
|
42
|
+
"./client": {
|
|
43
|
+
"default": "./lib/client.js"
|
|
44
|
+
},
|
|
45
|
+
"./package.json": "./package.json"
|
|
46
|
+
},
|
|
47
|
+
"dsh": {
|
|
48
|
+
"bundle": {
|
|
49
|
+
"patch": "./cordis.patch.yml"
|
|
50
|
+
},
|
|
51
|
+
"client": {
|
|
52
|
+
"inject": [
|
|
53
|
+
"@deepseek-ai/dsh-client-runtime",
|
|
54
|
+
"@deepseek-ai/dsh-client-ui-conversation"
|
|
55
|
+
],
|
|
56
|
+
"platform": "web"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"files": [
|
|
60
|
+
"lib",
|
|
61
|
+
"README.md"
|
|
62
|
+
],
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsc -p tsconfig.json && node esbuild.client.mjs && node -e \"const fs=require('fs'); fs.copyFileSync('src/convert/step-worker.mjs','lib/convert/step-worker.mjs'); fs.mkdirSync('lib/modeling',{recursive:true}); fs.copyFileSync('src/modeling/occt-adapter.cjs','lib/modeling/occt-adapter.cjs'); fs.copyFileSync('src/modeling/modeling-worker.cjs','lib/modeling/modeling-worker.cjs'); fs.copyFileSync('src/modeling/occt-bridge.cjs','lib/modeling/occt-bridge.cjs'); fs.copyFileSync('src/modeling/occtts-adapter.cjs','lib/modeling/occtts-adapter.cjs'); for (const part of ['bracket','flange','shaft']) fs.copyFileSync('assets/demo-'+part+'.brep','lib/demo-'+part+'.brep')\"",
|
|
65
|
+
"watch": "tsc -p tsconfig.json --watch & node esbuild.client.mjs --watch",
|
|
66
|
+
"test": "vitest run --no-file-parallelism",
|
|
67
|
+
"test:watch": "vitest"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"ansatz-wasm": "^0.2.0",
|
|
71
|
+
"dxf-parser": "^1.1.2",
|
|
72
|
+
"occt-import-js": "^0.0.23",
|
|
73
|
+
"occt.ts": "^0.5.0",
|
|
74
|
+
"opencascade.js": "^1.1.1",
|
|
75
|
+
"three": "^0.185.1"
|
|
76
|
+
},
|
|
77
|
+
"peerDependencies": {
|
|
78
|
+
"@deepseek-ai/cordis": "^4.0.1-rc.1",
|
|
79
|
+
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
|
80
|
+
"react": "^18.2.0"
|
|
81
|
+
},
|
|
82
|
+
"devDependencies": {
|
|
83
|
+
"@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
|
|
84
|
+
"@types/node": "^22.10.0",
|
|
85
|
+
"@types/react": "^18.3.1",
|
|
86
|
+
"esbuild": "^0.28.2",
|
|
87
|
+
"react": "^18.2.0",
|
|
88
|
+
"react-dom": "^18.3.1",
|
|
89
|
+
"typescript": "^5.7.0",
|
|
90
|
+
"vitest": "^4.1.10"
|
|
91
|
+
}
|
|
92
|
+
}
|