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 }
@@ -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
- return syncScene(op, result);
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' }),
@@ -31,6 +31,8 @@ export interface OpResult {
31
31
  }>;
32
32
  bytes?: ArrayBuffer;
33
33
  volume?: number;
34
+ /** Center of mass [x,y,z] (occt.ts backend). */
35
+ centroid?: number[];
34
36
  deleted?: string;
35
37
  cleared?: boolean;
36
38
  views?: DrawingView[];
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.0",
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
+ }