dsh-cad 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -41,7 +41,7 @@ Cumulative downloads from the npm registry, refreshed daily by CI:
41
41
 
42
42
  | Capability | Description |
43
43
  | --- | --- |
44
- | ⚙️ Single-kernel architecture | **occt.ts as the primary modeling kernel** (true B-splines, native shell/draft, centroid); opencascade.js stays only as the fallback backend; the old "no editing after shell" seam is gone — fillet/chamfer/boolean keep working after shelling |
44
+ | ⚙️ Kernel chain: cadorange → occt.ts → opencascade.js | Modeling goes through **[cadorange](https://github.com/LAU-MARS/cadorange)** (the agent-native CAD runtime wrapping the same occt.ts/OCCT WASM; public API + the supported `cadorange/occt` escape hatch for meshing/HLR/wire construction); the direct occt.ts adapter and opencascade.js stay as fallback tiers — `DSH_CAD_KERNEL` pins one explicitly. The old "no editing after shell" seam is gone — fillet/chamfer/boolean keep working after shelling |
45
45
  | 🔍 CAD viewing | STL / OBJ / STEP / IGES / BREP / DCPRT (3D), DXF / SVG (2D); interactive viewport in the resident right panel (orbit / zoom / wireframe / pan), with a one-line result row in the conversation |
46
46
  | 🌗 Dark-mode surfaces | Every rendered surface follows the host theme — viewport slab, DXF/drawing strokes, the Three.js grid / XYZ triad / feature edges / default mesh color / sketch overlays, and the ViewCube plate; a theme flip restyles mounted viewers live. Overrides: `--dsh-cad-bg`, `--dsh-cad-stroke`, `--dsh-cad-paper` |
47
47
  | 🧭 CAD editor interactions | Onshape-style ViewCube (26-zone click-to-orient), hover/click face & edge picking with live measurement (area mm² / length mm), Faces+Edges / Faces / Wireframe render modes, switchable BRep demo parts (bracket / flange / shaft) |
@@ -214,12 +214,15 @@ cad_view(path) modeling tools (cad_create_prim, …)
214
214
  exponential-map rotation, radians). Solver capability is staged
215
215
  (point-distance-to-origin today) — the plumbing contract is finished and needs
216
216
  no changes as it grows
217
- - **Single-kernel architecture**: the primary modeling kernel is
218
- **occt.ts** — primitives, curve-segment profiles (TRUE B-spline
219
- interpolation), loft/sweep/revolve, booleans, fillet/chamfer, shell/draft,
220
- mirror (composed as scale(-1) + a π rotation) and centroid all live in one
221
- session; engineering-drawing HLR takes the shape directly (zero STEP hops);
222
- editing after shelling is seamless (the old hosted-body seam is deleted).
217
+ - **Kernel chain**: modeling goes through **cadorange** (agent-native CAD
218
+ runtime; public build123d-style API for prims/curve-segment profiles (TRUE
219
+ B-spline interpolation)/loft/sweep/revolve/booleans (cleaned by default)/
220
+ fillet/chamfer/shell/draft/native mirror/centroid, plus the supported
221
+ `cadorange/occt` escape hatch for viewer tessellation, engineering-drawing
222
+ HLR (zero STEP hops, one wasm session) and wire construction); the direct
223
+ occt.ts adapter and opencascade.js stay as fallback tiers
224
+ (`DSH_CAD_KERNEL` pins one; see docs/cadorange-integration.md for the
225
+ verb-by-verb mapping). Editing after shelling is seamless.
223
226
  opencascade.js remains as the FALLBACK backend (auto-selected when occt.ts
224
227
  cannot load). Assembly STEP export writes a **structured document** (occt.ts
225
228
  `writeStepDocument`): one root product plus one named, placed child
package/README.zh-CN.md CHANGED
@@ -38,7 +38,7 @@ npm 官方源的累计下载量,由 CI 每日刷新:
38
38
 
39
39
  | 能力 | 说明 |
40
40
  | --- | --- |
41
- | ⚙️ 单内核架构 | **occt.ts 为主建模内核**(真 BSpline、抽壳/拔模原生、质心),opencascade.js 仅作为加载失败时的降级后端;旧版的「抽壳后不可编辑」接缝消失——抽壳/拔模后可继续圆角/倒角/布尔 |
41
+ | ⚙️ 内核链:cadorange → occt.ts → opencascade.js | 建模经 **[cadorange](https://github.com/LAU-MARS/cadorange)**(agent 原生 CAD 运行时,底层同一 occt.ts/OCCT WASM;公共 API + 官方 `cadorange/occt` 逃生口承担网格/HLR/线构造),occt.ts 直连与 opencascade.js 保留为回退层级——`DSH_CAD_KERNEL` 可显式指定。旧版的「抽壳后不可编辑」接缝消失——抽壳/拔模后可继续圆角/倒角/布尔 |
42
42
  | 🔍 CAD 查看 | STL / OBJ / STEP / IGES / BREP / DCPRT(3D),DXF / SVG(2D),右侧常驻面板交互视口(轨道旋转 / 缩放 / 线框 / 平移),对话中只留一行结果摘要 |
43
43
  | 🌗 深色模式绘制面 | 所有绘制面跟随宿主主题——视口底板、DXF/工程图描边、Three.js 网格 / XYZ 轴 / 特征边 / 默认网格色 / 草图覆盖层,以及 ViewCube 面板;主题切换实时重绘已挂载的查看器。覆盖钩子:`--dsh-cad-bg`、`--dsh-cad-stroke`、`--dsh-cad-paper` |
44
44
  | 🧭 CAD 编辑器交互 | Onshape 风格 ViewCube(26 区域点击定向)、悬停/点选面与边实时测量(面积 mm² / 长度 mm)、面+边 / 面 / 线框三种渲染模式、支架 / 法兰 / 轴 BRep 示例件一键切换 |
@@ -197,10 +197,12 @@ cad_view(path) 建模工具(cad_create_prim 等)
197
197
  原样透出给 LLM。装配实例位姿(平移 + XYZ 欧拉度)与求解器 rigid3(平移 + 指数映射
198
198
  旋转,弧度)双向映射。求解器能力按阶段推进(当前支持点到原点距离),管道契约
199
199
  已就绪、无需随求解器成长改动
200
- - **单内核架构**:建模主内核为 **occt.ts**——prims/曲线段轮廓
201
- (真 BSpline 插值)/放样/扫掠/旋转/布尔/圆角/倒角/抽壳/拔模/镜像(scale(-1)+
202
- 旋转 π 组合)/质心全在一个会话内,工程图 HLR 直接吃 shape(零 STEP 中转),
203
- 抽壳后继续编辑无缝(旧托管接缝已删除)。opencascade.js 保留为**降级后端**
200
+ - **内核链**:建模经 **cadorange**(agent 原生 CAD 运行时;build123d 风格
201
+ 公共 API 承担 prims/曲线段轮廓(真 BSpline 插值)/放样/扫掠/旋转/布尔
202
+ (默认同域清理)/圆角/倒角/抽壳/拔模/原生镜像/质心,官方 `cadorange/occt`
203
+ 逃生口承担查看器网格、工程图 HLR(零 STEP 中转、单一 wasm 会话)与线构造);
204
+ occt.ts 直连与 opencascade.js 保留为回退层级(`DSH_CAD_KERNEL` 可显式指定,
205
+ 动词级映射见 docs/cadorange-integration.md)。抽壳后继续编辑无缝。opencascade.js 保留为**降级后端**
204
206
  (occt.ts 加载失败时自动启用)。装配体 STEP 导出为**结构化文档**(occt.ts
205
207
  `writeStepDocument`):一个根产品 + 每实例一个命名、带位姿的子产品——
206
208
  实例分离在文件中保留(STL 仍为单一熔合网格;降级后端同样走 fuse)。fuse
@@ -0,0 +1,735 @@
1
+ /**
2
+ * cadorange-backed kernel adapter: maps dsh-cad's adapter surface onto
3
+ * cadorange's public API (build123d algebra mode), with the supported
4
+ * `cadorange/occt` escape hatch (ADR-0001) covering what the public API does
5
+ * not expose yet:
6
+ *
7
+ * - viewer tessellation `getOcc().tessellate(shape.wrapped)`
8
+ * - drawing HLR `getOcc().hiddenLines(shape.wrapped, …)`
9
+ * - wire/face construction `makeLine/makeArc/makeBsplineThrough/…`
10
+ * for dsh-cad's raw inputs (3D point loops, arbitrary-plane profiles,
11
+ * periodic smooth sections, 3D sweep spines) — built as occt.ts shapes,
12
+ * then adopted into cadorange via `fromRaw` (a session-owned copy).
13
+ *
14
+ * Everything expressible in the public API goes through it: primitives,
15
+ * booleans (cleaned by default), fillet/chamfer, extrude/revolve/loft/sweep,
16
+ * shell/draft, transforms (native mirror — no scale(-1) composition),
17
+ * describe/volume/centroid and STEP/STL/STEP-assembly export.
18
+ *
19
+ * Opt-in backend: `DSH_CAD_KERNEL=cadorange`. The default chain stays
20
+ * occt.ts → opencascade.js until this backend holds the parity gate.
21
+ */
22
+ 'use strict'
23
+
24
+ const { chainSegments, simplify } = require('./occt-bridge.cjs')
25
+
26
+ const vec3 = {
27
+ 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]],
28
+ dot: (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2],
29
+ norm: (a) => {
30
+ const len = Math.hypot(a[0], a[1], a[2]) || 1
31
+ return [a[0] / len, a[1] / len, a[2] / len]
32
+ },
33
+ }
34
+
35
+ const RAD2DEG = 180 / Math.PI
36
+
37
+ /** Row-major 3×3 rotation from XYZ Euler degrees — the assembly export
38
+ * convention p' = T + Rx·(Ry·(Rz·p)). */
39
+ function eulerMatrix(rotate) {
40
+ const [rx, ry, rz] = rotate
41
+ const cx = Math.cos(rx / RAD2DEG), sx = Math.sin(rx / RAD2DEG)
42
+ const cy = Math.cos(ry / RAD2DEG), sy = Math.sin(ry / RAD2DEG)
43
+ const cz = Math.cos(rz / RAD2DEG), sz = Math.sin(rz / RAD2DEG)
44
+ return [
45
+ cy * cz, -cy * sz, sy,
46
+ cx * sz + sx * sy * cz, cx * cz - sx * sy * sz, -sx * cy,
47
+ sx * sz - cx * sy * cz, sx * cz + cx * sy * sz, cx * cy,
48
+ ]
49
+ }
50
+
51
+ /** Stable rounding key for one flattened [x,y,…] polyline. */
52
+ function polylineKey(points) {
53
+ const keys = []
54
+ for (let i = 0; i + 1 < points.length; i += 2) {
55
+ keys.push(`${Math.round(points[i] * 100)}|${Math.round(points[i + 1] * 100)}`)
56
+ }
57
+ keys.sort()
58
+ return keys.join(';')
59
+ }
60
+
61
+ function createCadorangeAdapter(mods) {
62
+ const C = mods.core
63
+ const CO = mods.occt
64
+ const occ = CO.getOcc()
65
+
66
+ const wrapError = (what, e) => new Error(`${what}: ${e instanceof Error ? e.message : String(e)}`)
67
+
68
+ // ── escape-hatch plumbing ───────────────────────────────────────────────────
69
+
70
+ /** Adopt an occt.ts shape into cadorange (session-owned copy), freeing the
71
+ * original — the JS-owned raw wrapper from toRaw is ours to delete. */
72
+ function adopt(occShape) {
73
+ const raw = occ.toRaw(occShape)
74
+ try {
75
+ const shape = CO.fromRaw(raw)
76
+ occShape.dispose()
77
+ return shape
78
+ } finally {
79
+ if (typeof raw.delete === 'function') raw.delete()
80
+ }
81
+ }
82
+
83
+ const asOcc = (shape) => CO.asOccShape(shape.wrapped)
84
+
85
+ // ── primitives ─────────────────────────────────────────────────────────────
86
+
87
+ const alongZ = (axis) => {
88
+ const n = vec3.norm(Array.isArray(axis) ? axis : [0, 0, 1])
89
+ return Math.abs(n[0]) < 1e-9 && Math.abs(n[1]) < 1e-9 && n[2] > 1 - 1e-9
90
+ }
91
+
92
+ function makePrim(kind, params) {
93
+ const p = params ?? {}
94
+ const at = Array.isArray(p.at) ? p.at : [0, 0, 0]
95
+ const axis = vec3.norm(Array.isArray(p.axis) ? p.axis : [0, 0, 1])
96
+ switch (kind) {
97
+ case 'box':
98
+ return C.Box(p.dx ?? 10, p.dy ?? 10, p.dz ?? 10, { origin: at })
99
+ case 'cylinder':
100
+ // The public Cylinder takes loc {origin, zDir} — arbitrary axes included.
101
+ return C.Cylinder({ radius: p.radius ?? 5, height: p.height ?? 10, loc: { origin: at, zDir: axis } })
102
+ case 'sphere':
103
+ return C.Sphere({ radius: p.radius ?? 5, loc: { origin: at } })
104
+ case 'cone':
105
+ // Public Cone has no direction yet — non-default axes go via the hatch.
106
+ if (alongZ(p.axis)) return C.Cone({ radius1: p.radius1 ?? 5, radius2: p.radius2 ?? 0, height: p.height ?? 10, loc: { origin: at } })
107
+ return adopt(occ.makeCone(p.radius1 ?? 5, p.radius2 ?? 0, p.height ?? 10, { origin: at, direction: axis }))
108
+ case 'torus':
109
+ if (alongZ(p.axis)) return C.Torus({ radius1: p.majorRadius ?? 10, radius2: p.minorRadius ?? 2, loc: { origin: at } })
110
+ return adopt(occ.makeTorus(p.majorRadius ?? 10, p.minorRadius ?? 2, { origin: at, direction: axis }))
111
+ default:
112
+ throw new Error(`unknown primitive kind: ${String(kind)}`)
113
+ }
114
+ }
115
+
116
+ // ── profile wires (line / arc / bspline / circle chains, any plane) ────────
117
+ //
118
+ // Segments are built in world XY (local (x,y), z=0) — where occt.ts circle
119
+ // parameter 0 = +X, CCW toward +Y, matching the profile's local coordinates
120
+ // by construction — then the finished wire is RIGIDLY MOVED into the
121
+ // caller's frame via `located()` (rotate Z onto n = u×v with X onto u,
122
+ // translate to o). Angles therefore never depend on occt.ts's internal
123
+ // in-plane reference for arbitrary normals.
124
+
125
+ const profileToXYZ = (points) => {
126
+ const xyz = []
127
+ for (let i = 0; i + 1 < points.length; i += 2) xyz.push([points[i], points[i + 1], 0])
128
+ return xyz
129
+ }
130
+
131
+ /** Segment-chain wire (line/arc/bspline) built in world XY. */
132
+ function segmentWireXY(profile) {
133
+ const segs = Array.isArray(profile.segments) ? profile.segments : []
134
+ if (segs.length === 0) throw new Error('a segment profile needs at least one segment')
135
+ if (!Array.isArray(profile.start) || profile.start.length !== 2) throw new Error('profile.start must be [x,y]')
136
+ const parts = []
137
+ let keep = null
138
+ let cur = [profile.start[0], profile.start[1]]
139
+ const push = (shape) => {
140
+ if (shape.isNull()) throw wrapError('segment edge', new Error('null edge'))
141
+ parts.push(shape)
142
+ }
143
+ const lineTo = (to) => occ.makeLine([cur[0], cur[1], 0], [to[0], to[1], 0])
144
+ try {
145
+ for (const seg of segs) {
146
+ if (seg === null || typeof seg !== 'object') throw new Error('each profile segment must be an object')
147
+ if (seg.type === 'line') {
148
+ if (!Array.isArray(seg.to) || seg.to.length !== 2) throw new Error("line segment needs 'to: [x,y]'")
149
+ push(lineTo(seg.to))
150
+ cur = seg.to
151
+ } else if (seg.type === 'arc') {
152
+ if (!Array.isArray(seg.to) || seg.to.length !== 2 || !Array.isArray(seg.center) || seg.center.length !== 2) {
153
+ throw new Error("arc segment needs 'to: [x,y]' and 'center: [cx,cy]'")
154
+ }
155
+ const c = seg.center
156
+ const r0 = Math.hypot(cur[0] - c[0], cur[1] - c[1])
157
+ const r1 = Math.hypot(seg.to[0] - c[0], seg.to[1] - c[1])
158
+ if (Math.abs(r0 - r1) > 1e-4 * Math.max(r0, r1) + 1e-6) {
159
+ throw new Error(`arc endpoints are not equidistant from the center (r=${r0.toFixed(4)} vs ${r1.toFixed(4)})`)
160
+ }
161
+ // CCW around +Z over an increasing span — ccw/cw is resolved by
162
+ // which endpoint leads (occt.ts wants start < end < start + 360).
163
+ const a0 = Math.atan2(cur[1] - c[1], cur[0] - c[0])
164
+ const a1 = Math.atan2(seg.to[1] - c[1], seg.to[0] - c[0])
165
+ const span = seg.ccw !== false
166
+ ? (a1 > a0 ? a1 - a0 : a1 + 2 * Math.PI - a0)
167
+ : (a1 < a0 ? a0 - a1 : a0 + 2 * Math.PI - a1)
168
+ const start = seg.ccw !== false ? a0 : a1
169
+ push(occ.makeArc(r0, {
170
+ origin: [c[0], c[1], 0],
171
+ normal: [0, 0, 1],
172
+ startDegrees: start * RAD2DEG,
173
+ endDegrees: (start + span) * RAD2DEG,
174
+ }))
175
+ cur = seg.to
176
+ } else if (seg.type === 'bspline') {
177
+ const through = Array.isArray(seg.through) ? seg.through : []
178
+ if (through.length < 2) throw new Error("bspline segment needs 'through: [[x,y],…]' (≥2 points)")
179
+ // True interpolation through the points, current point included.
180
+ const pts = [[cur[0], cur[1], 0], ...through.map((pt) => [pt[0], pt[1], 0])]
181
+ push(occ.makeBsplineThrough(pts, { periodic: false, tolerance: 1e-6 }))
182
+ cur = through[through.length - 1]
183
+ } else {
184
+ throw new Error(`unknown profile segment type: ${String(seg.type)}`)
185
+ }
186
+ }
187
+ if (Math.hypot(cur[0] - profile.start[0], cur[1] - profile.start[1]) > 1e-9) push(lineTo(profile.start))
188
+ if (parts.length === 1) {
189
+ // A single closed B-spline already IS a wire; makeWire needs ≥2 parts.
190
+ if (parts[0].shapeType() === 'Wire') {
191
+ keep = parts[0]
192
+ return keep
193
+ }
194
+ throw new Error('a segment profile needs at least 2 segments (a single open edge cannot close a loop)')
195
+ }
196
+ const wire = occ.makeWire(parts)
197
+ if (wire.isNull()) throw wrapError('makeWire', new Error('null wire'))
198
+ return wire
199
+ } finally {
200
+ for (const part of parts) if (part !== keep) part.dispose()
201
+ }
202
+ }
203
+
204
+ /** One profile form → a closed wire in world XY (an occt.ts shape). */
205
+ function occWireXY(profile) {
206
+ if (Array.isArray(profile)) {
207
+ if (profile.length < 6 || profile.length % 2 !== 0) throw new Error('a flat profile needs ≥3 [x,y] pairs')
208
+ return occ.makePolygon(profileToXYZ(profile), { close: true })
209
+ }
210
+ if (profile === null || typeof profile !== 'object') throw new Error('a profile must be a flat [x,y,…] loop, {circle} or {start, segments}')
211
+ if (profile.circle !== undefined) {
212
+ const c = profile.circle
213
+ if (!Array.isArray(c.center) || typeof c.radius !== 'number' || !(c.radius > 0)) throw new Error('profile.circle needs center [x,y] and a positive radius')
214
+ return occ.makeCircle(c.radius, { origin: [c.center[0], c.center[1], 0], normal: [0, 0, 1] })
215
+ }
216
+ return segmentWireXY(profile)
217
+ }
218
+
219
+ const identityFrame = (o, u, v) =>
220
+ Math.abs(o[0]) < 1e-9 && Math.abs(o[1]) < 1e-9 && Math.abs(o[2]) < 1e-9 &&
221
+ Math.abs(u[0] - 1) < 1e-9 && Math.abs(u[1]) < 1e-9 && Math.abs(u[2]) < 1e-9 &&
222
+ Math.abs(v[0]) < 1e-9 && Math.abs(v[1] - 1) < 1e-9 && Math.abs(v[2]) < 1e-9
223
+
224
+ /**
225
+ * Build the profile's closed wire, rigidly moved so local X→u, Y→v,
226
+ * origin→o (u×v is the plane normal). The move is `located()`: rotate Z
227
+ * onto n with X onto u, then translate — local profile coordinates map
228
+ * exactly onto the requested plane.
229
+ */
230
+ function wireAtFrame(profile, o, u, v) {
231
+ const built = adopt(occWireXY(profile))
232
+ if (identityFrame(o, u, v)) return built
233
+ return built.located(new C.Location(o, vec3.cross(u, v), u))
234
+ }
235
+
236
+ // ── solid ops ───────────────────────────────────────────────────────────────
237
+
238
+ /** Extrude any 2D profile form from z = base along +Z. */
239
+ function extrudeProfile2D(profile, height, base = 0) {
240
+ if (!(height > 0)) throw new Error('the extrusion height must be positive')
241
+ const wire = wireAtFrame(profile, [0, 0, base], [1, 0, 0], [0, 1, 0])
242
+ return C.extrude(wire, height, { dir: [0, 0, 1] })
243
+ }
244
+
245
+ /** Plane frame (origin, in-plane X, in-plane Y) for a planar curve section. */
246
+ function sectionFrame(section) {
247
+ const at = section.at ?? [0, 0, 0]
248
+ if (!Array.isArray(at) || at.length !== 3) throw new Error('section.at must be [x,y,z]')
249
+ const n = vec3.norm(Array.isArray(section.normal) ? section.normal : [0, 0, 1])
250
+ const seed = Array.isArray(section.xAxis) ? section.xAxis : [1, 0, 0]
251
+ const d = vec3.dot(seed, n)
252
+ let u = [seed[0] - n[0] * d, seed[1] - n[1] * d, seed[2] - n[2] * d]
253
+ if (Math.hypot(u[0], u[1], u[2]) < 1e-9) {
254
+ const alt = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
255
+ const d2 = vec3.dot(alt, n)
256
+ u = [alt[0] - n[0] * d2, alt[1] - n[1] * d2, alt[2] - n[2] * d2]
257
+ }
258
+ u = vec3.norm(u)
259
+ // v = n × u so that u × v = n — profile +Y follows the plane normal cross X.
260
+ return [at, u, vec3.cross(n, u)]
261
+ }
262
+
263
+ /**
264
+ * Closed wire through a flat [x,y,z, …] loop: a polygon (the historical
265
+ * section) or — with `smooth` — a PERIODIC B-spline interpolating every
266
+ * point, so a loft skinned over it carries no crease lines.
267
+ */
268
+ function loopWire(xyz, smooth) {
269
+ if (!Array.isArray(xyz) || xyz.length < 9 || xyz.length % 3 !== 0) {
270
+ throw new Error('a section needs at least 3 [x,y,z] triplets (≥9 numbers)')
271
+ }
272
+ const pts = []
273
+ for (let i = 0; i + 2 < xyz.length; i += 3) pts.push([xyz[i], xyz[i + 1], xyz[i + 2]])
274
+ if (!smooth) return adopt(occ.makePolygon(pts, { close: true }))
275
+ return adopt(occ.makeBsplineThrough(pts, { periodic: true, tolerance: 1e-6 }))
276
+ }
277
+
278
+ /**
279
+ * One loft section → a closed wire.
280
+ * [x,y,z, …] flat point loop (polygon, or smooth B-spline)
281
+ * { points: […], smooth?: bool } same, with a per-section override
282
+ * { through: [x,y,z, …] } periodic B-spline through the points
283
+ * { profile, at?, normal?, xAxis? } 2D curve profile placed in a plane
284
+ */
285
+ function sectionWire(section, smoothDefault) {
286
+ if (Array.isArray(section)) return loopWire(section, smoothDefault)
287
+ if (section === null || typeof section !== 'object') {
288
+ throw new Error('a loft section must be a flat [x,y,z,…] loop or a {points|through|profile, …} object')
289
+ }
290
+ if (section.profile !== undefined) {
291
+ const [o, u, v] = sectionFrame(section)
292
+ return wireAtFrame(section.profile, o, u, v)
293
+ }
294
+ if (section.through !== undefined) return loopWire(section.through, true)
295
+ if (section.points !== undefined) return loopWire(section.points, section.smooth ?? smoothDefault)
296
+ throw new Error('a section object needs `points`, `through` or `profile`')
297
+ }
298
+
299
+ function makeLoft(sections, options = {}) {
300
+ if (!Array.isArray(sections) || sections.length < 2) throw new Error('a loft needs at least 2 sections')
301
+ const smooth = options.smooth === true
302
+ const wires = sections.map((section) => sectionWire(section, smooth))
303
+ // solid:false (an open shell) has no public cadorange option yet — the
304
+ // hatch reaches occt.ts's LoftOptions.solid directly.
305
+ const solid = options.solid === false
306
+ ? adopt(occ.loft(wires.map(asOcc), { ruled: options.ruled === true, solid: false }))
307
+ : C.loft(wires, { ruled: options.ruled === true })
308
+ if (Math.abs(solid.volume()) < 1e-9) {
309
+ throw new Error('loft produced an empty solid (sections must be closed, planar, non-degenerate loops)')
310
+ }
311
+ return solid
312
+ }
313
+
314
+ /** Sweep a 2D profile along a 3D polyline path (auto-oriented on the start tangent). */
315
+ function makeSweep(profile, pathPoints) {
316
+ if (!Array.isArray(pathPoints) || pathPoints.length < 6 || pathPoints.length % 3 !== 0) {
317
+ throw new Error('the path needs at least 2 [x,y,z] triplets (≥6 numbers)')
318
+ }
319
+ const at = (i) => [pathPoints[i], pathPoints[i + 1], pathPoints[i + 2]]
320
+ const tLen = Math.hypot(pathPoints[3] - pathPoints[0], pathPoints[4] - pathPoints[1], pathPoints[5] - pathPoints[2])
321
+ if (tLen < 1e-12) throw new Error('the path starts with a zero-length segment')
322
+ const t = [(pathPoints[3] - pathPoints[0]) / tLen, (pathPoints[4] - pathPoints[1]) / tLen, (pathPoints[5] - pathPoints[2]) / tLen]
323
+ const seed = Math.abs(t[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
324
+ const d = vec3.dot(seed, t)
325
+ const vx = vec3.norm([seed[0] - t[0] * d, seed[1] - t[1] * d, seed[2] - t[2] * d])
326
+ const vy = vec3.cross(t, vx)
327
+ const wire = wireAtFrame(profile, at(0), vx, vy)
328
+ const spine = adopt(occ.makePolygon(
329
+ Array.from({ length: pathPoints.length / 3 }, (_, i) => at(i * 3)),
330
+ { close: false },
331
+ ))
332
+ return C.sweep(wire, spine)
333
+ }
334
+
335
+ /** Revolve a 2D profile (local x = radial, y = along the axis) about the axis. */
336
+ function makeRevolve(profile, options = {}) {
337
+ const axis = options.axis ?? [0, 0, 1]
338
+ const at = options.at ?? [0, 0, 0]
339
+ const angle = options.angle ?? Math.PI * 2
340
+ const alen = Math.hypot(axis[0], axis[1], axis[2])
341
+ if (alen < 1e-12) throw new Error('the revolve axis must be a non-zero direction')
342
+ const n = [axis[0] / alen, axis[1] / alen, axis[2] / alen]
343
+ const helper = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
344
+ const d = vec3.dot(helper, n)
345
+ const radial = vec3.norm([helper[0] - n[0] * d, helper[1] - n[1] * d, helper[2] - n[2] * d])
346
+ const wire = wireAtFrame(profile, at, radial, n)
347
+ return C.revolve(wire, { origin: at, direction: n }, angle * RAD2DEG)
348
+ }
349
+
350
+ function boolean(op, target, tools) {
351
+ let result = target
352
+ for (const tool of tools) {
353
+ if (op === 'fuse') result = result.fuse(tool)
354
+ else if (op === 'cut') result = result.cut(tool)
355
+ else if (op === 'common') result = result.common(tool)
356
+ else throw new Error(`unknown boolean op: ${String(op)}`)
357
+ }
358
+ return result
359
+ }
360
+
361
+ function filletAll(shape, radius) {
362
+ try {
363
+ return C.fillet(shape, shape.edges(), radius)
364
+ } catch (error) {
365
+ throw wrapError('fillet', error instanceof Error && /radius|exceed/i.test(error.message) ? error : new Error('radius may exceed the adjacent faces'))
366
+ }
367
+ }
368
+
369
+ function chamferAll(shape, distance) {
370
+ try {
371
+ return C.chamfer(shape, shape.edges(), distance)
372
+ } catch (error) {
373
+ throw wrapError('chamfer', error instanceof Error && /distance|exceed/i.test(error.message) ? error : new Error('distance may exceed the adjacent faces'))
374
+ }
375
+ }
376
+
377
+ /**
378
+ * Shell: hollow to a wall thickness; `openFaces` are 1-based indices as
379
+ * listed by describe() (empty = sealed). Face matching by normal lives in
380
+ * the worker (it has describe output) — this takes indices directly.
381
+ */
382
+ function shell(shape, thickness, openFaces) {
383
+ const indices = Array.isArray(openFaces) ? openFaces : []
384
+ const faces = shape.faces().items
385
+ const open = indices.map((i) => faces[i - 1]).filter((f) => f !== undefined)
386
+ const next = C.shell(shape, Math.abs(thickness), open.length > 0 ? { open } : {})
387
+ // The kernel can return the input unchanged on chamfered/tangent faces
388
+ // (issue #6) — fail loudly with an actionable hint instead of reporting
389
+ // success.
390
+ if (Math.abs(next.volume() - shape.volume()) < 1e-6) {
391
+ throw wrapError('shell', new Error('kernel returned an unchanged solid — shell 未生效(倒角/相切面是已知诱因),请减小壁厚或先抽壳后倒角'))
392
+ }
393
+ return next
394
+ }
395
+
396
+ /** Draft: tilt walls by angle degrees toward `direction` (auto wall select). */
397
+ function draft(shape, angleDegrees, direction) {
398
+ const next = C.draft(shape, angleDegrees, { direction: direction ?? [0, 0, 1] })
399
+ if (Math.abs(next.volume() - shape.volume()) < 1e-6) {
400
+ throw wrapError('draft', new Error('kernel returned an unchanged solid — draft 未生效(没有可倾斜的壁面?),请检查拔模方向或改用显式面选择'))
401
+ }
402
+ return next
403
+ }
404
+
405
+ /** Mirror here is the native plane mirror — no scale(-1)+rotation composition. */
406
+ function transform(shape, { translate, rotate, mirror } = {}) {
407
+ let result = shape
408
+ if (translate !== undefined) result = result.translate(translate)
409
+ if (rotate !== undefined) {
410
+ const [rx, ry, rz] = rotate
411
+ if (rx) result = result.rotate(new C.Axis([0, 0, 0], [1, 0, 0]), rx)
412
+ if (ry) result = result.rotate(new C.Axis([0, 0, 0], [0, 1, 0]), ry)
413
+ if (rz) result = result.rotate(new C.Axis([0, 0, 0], [0, 0, 1]), rz)
414
+ }
415
+ if (mirror !== undefined) {
416
+ result = result.mirror({ plane: { origin: [0, 0, 0], normal: vec3.norm(mirror) } })
417
+ }
418
+ return result
419
+ }
420
+
421
+ function isValid(shape) {
422
+ try { return shape.isValid() === true } catch { return null }
423
+ }
424
+
425
+ // ── tessellation (viewer pipeline, via the escape hatch) ───────────────────
426
+
427
+ /** Mesh {positions, indices} — occt.ts copies the wasm buffers for us. */
428
+ function tessellate(shape, linearDeflection = 0.3) {
429
+ const mesh = occ.tessellate(asOcc(shape), { linearDeflection: linearDeflection })
430
+ return { positions: mesh.positions, indices: mesh.indices }
431
+ }
432
+
433
+ /** Flat per-triangle normals (the mechanical-CAD look the viewer expects). */
434
+ function faceNormals(positions, indices) {
435
+ const normals = new Float32Array(positions.length)
436
+ for (let i = 0; i + 2 < indices.length; i += 3) {
437
+ const a = indices[i] * 3
438
+ const b = indices[i + 1] * 3
439
+ const c = indices[i + 2] * 3
440
+ const ux = positions[c] - positions[a], uy = positions[c + 1] - positions[a + 1], uz = positions[c + 2] - positions[a + 2]
441
+ const vx = positions[b] - positions[a], vy = positions[b + 1] - positions[a + 1], vz = positions[b + 2] - positions[a + 2]
442
+ let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
443
+ const len = Math.hypot(nx, ny, nz) || 1
444
+ nx /= len; ny /= len; nz /= len
445
+ for (const corner of [a, b, c]) {
446
+ normals[corner] = nx; normals[corner + 1] = ny; normals[corner + 2] = nz
447
+ }
448
+ }
449
+ return normals
450
+ }
451
+
452
+ // ── describe (agent eyes / face matching) ──────────────────────────────────
453
+
454
+ /** The dsh-cad describe shape, normalized from cadorange's cached meta().
455
+ * `origin` mirrors `center` — the worker's normal-matching scores faces by
456
+ * their position along the target normal through it. */
457
+ function describe(shape) {
458
+ const m = shape.meta()
459
+ return {
460
+ shapeType: m.shapeType,
461
+ volume: m.volume,
462
+ area: m.area,
463
+ bbox: m.bbox,
464
+ center: m.center,
465
+ valid: m.valid,
466
+ faces: m.faces.map((f) => ({
467
+ index: f.index,
468
+ surface: f.surface,
469
+ area: f.area,
470
+ center: f.center,
471
+ origin: f.center,
472
+ ...(f.normal !== undefined ? { normal: [...f.normal] } : {}),
473
+ ...(f.axis !== undefined ? { axis: [...f.axis] } : {}),
474
+ ...(f.radius !== undefined ? { radius: f.radius } : {}),
475
+ })),
476
+ edges: m.edges.map((e) => ({
477
+ index: e.index,
478
+ curve: e.curve,
479
+ length: e.length,
480
+ center: e.center,
481
+ origin: e.center,
482
+ ...(e.axis !== undefined ? { axis: [...e.axis] } : {}),
483
+ ...(e.radius !== undefined ? { radius: e.radius } : {}),
484
+ })),
485
+ vertices: m.vertices.map((v) => ({ index: v.index, position: [...v.position] })),
486
+ }
487
+ }
488
+
489
+ // ── sketch display wires ───────────────────────────────────────────────────
490
+
491
+ /**
492
+ * Discretize a sketch profile for viewport display: ONE flat polyline
493
+ * [x,y,z,…] on z=0 plus the TRUE vertex points (polygon corners / segment
494
+ * endpoints — never arc or B-spline sampling points; circles carry no
495
+ * vertices). Polygons/circles/arcs are analytic in JS; B-spline segments
496
+ * round-trip through the kernel's edge tessellator. Returns null when the
497
+ * profile is not discretizable (never throws — display-only).
498
+ */
499
+ function sketchWirePoints(profile) {
500
+ const polyline = []
501
+ const vertices = []
502
+ try {
503
+ if (Array.isArray(profile)) {
504
+ if (profile.length < 6 || profile.length % 2 !== 0) return null
505
+ for (let i = 0; i + 1 < profile.length; i += 2) {
506
+ polyline.push(profile[i], profile[i + 1], 0)
507
+ vertices.push(profile[i], profile[i + 1], 0)
508
+ }
509
+ polyline.push(profile[0], profile[1], 0)
510
+ return { polyline, vertices }
511
+ }
512
+ if (profile === null || typeof profile !== 'object') return null
513
+ if (profile.circle !== undefined) {
514
+ const c = profile.circle
515
+ if (!Array.isArray(c.center) || typeof c.radius !== 'number' || !(c.radius > 0)) return null
516
+ const steps = 96
517
+ for (let i = 0; i <= steps; i++) {
518
+ const a = (i / steps) * 2 * Math.PI
519
+ polyline.push(c.center[0] + c.radius * Math.cos(a), c.center[1] + c.radius * Math.sin(a), 0)
520
+ }
521
+ return { polyline, vertices }
522
+ }
523
+ const segs = Array.isArray(profile.segments) ? profile.segments : []
524
+ if (segs.length === 0 || !Array.isArray(profile.start) || profile.start.length !== 2) return null
525
+ let cur = [profile.start[0], profile.start[1]]
526
+ polyline.push(cur[0], cur[1], 0)
527
+ vertices.push(cur[0], cur[1], 0)
528
+ for (const seg of segs) {
529
+ if (seg === null || typeof seg !== 'object') continue
530
+ if (seg.type === 'line') {
531
+ if (!Array.isArray(seg.to)) continue
532
+ cur = [seg.to[0], seg.to[1]]
533
+ polyline.push(cur[0], cur[1], 0)
534
+ vertices.push(cur[0], cur[1], 0)
535
+ } else if (seg.type === 'arc') {
536
+ if (!Array.isArray(seg.to) || !Array.isArray(seg.center)) continue
537
+ const c = seg.center
538
+ const r = Math.hypot(cur[0] - c[0], cur[1] - c[1])
539
+ const a0 = Math.atan2(cur[1] - c[1], cur[0] - c[0])
540
+ const a1 = Math.atan2(seg.to[1] - c[1], seg.to[0] - c[0])
541
+ const span = seg.ccw !== false ? (a1 > a0 ? a1 - a0 : a1 + 2 * Math.PI - a0) : (a1 < a0 ? a0 - a1 : a0 + 2 * Math.PI - a1)
542
+ const start = seg.ccw !== false ? a0 : a1
543
+ const steps = Math.max(2, Math.ceil((Math.abs(span) / (Math.PI / 2)) * 12))
544
+ for (let i = 1; i <= steps; i++) {
545
+ const a = start + (span * i) / steps
546
+ polyline.push(c[0] + r * Math.cos(a), c[1] + r * Math.sin(a), 0)
547
+ }
548
+ cur = [seg.to[0], seg.to[1]]
549
+ vertices.push(cur[0], cur[1], 0)
550
+ } else if (seg.type === 'bspline' && Array.isArray(seg.through) && seg.through.length >= 2) {
551
+ const pts = [[cur[0], cur[1], 0], ...seg.through.map((p) => [p[0], p[1], 0])]
552
+ const spline = occ.makeBsplineThrough(pts, { periodic: false, tolerance: 1e-6 })
553
+ try {
554
+ const mesh = occ.tessellate(spline, { linearDeflection: 0.1 })
555
+ for (let i = 0; i + 2 < mesh.edges.length; i += 3) polyline.push(mesh.edges[i] ?? 0, mesh.edges[i + 1] ?? 0, 0)
556
+ } finally {
557
+ spline.dispose()
558
+ }
559
+ const last = seg.through[seg.through.length - 1]
560
+ cur = [last[0], last[1]]
561
+ vertices.push(cur[0], cur[1], 0)
562
+ }
563
+ }
564
+ if (Math.hypot(cur[0] - profile.start[0], cur[1] - profile.start[1]) > 1e-9) {
565
+ polyline.push(profile.start[0], profile.start[1], 0)
566
+ }
567
+ return polyline.length >= 9 ? { polyline, vertices } : null
568
+ } catch {
569
+ return null
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Triangulate the sketch's enclosed region (the translucent Onshape-style
575
+ * fill): face the exact profile wire through the kernel and tessellate.
576
+ * Returns { positions, indices } as plain arrays, or null when the profile
577
+ * does not bound a face (display-only, never throws).
578
+ */
579
+ function sketchFaceMesh(profile) {
580
+ let wire = null
581
+ let face = null
582
+ try {
583
+ wire = occWireXY(profile)
584
+ face = occ.makeFace(wire, [])
585
+ if (face.isNull()) return null
586
+ const mesh = occ.tessellate(face, { linearDeflection: 0.1 })
587
+ if (mesh.indices.length < 3) return null
588
+ return { positions: Array.from(mesh.positions), indices: Array.from(mesh.indices) }
589
+ } catch {
590
+ return null
591
+ } finally {
592
+ if (face !== null && typeof face.dispose === 'function') face.dispose()
593
+ if (wire !== null && typeof wire.dispose === 'function') wire.dispose()
594
+ }
595
+ }
596
+
597
+ // ── export ─────────────────────────────────────────────────────────────────
598
+
599
+ function exportFile(shape, format) {
600
+ if (format === 'step' || format === 'stp') return C.exportStep(shape)
601
+ if (format === 'stl') {
602
+ // cadorange's exportStl is ASCII-first; dsh-cad's export contract is the
603
+ // BINARY STL the direct backend emits (byte-identical format, smaller
604
+ // files, and the STL parser stays kernel-agnostic) — so pack it here
605
+ // from the tessellation, exactly like the occt.ts backend does.
606
+ const { positions, indices } = tessellate(shape, 0.1)
607
+ const normals = faceNormals(positions, indices)
608
+ const triangleCount = indices.length / 3
609
+ const buffer = Buffer.alloc(84 + triangleCount * 50)
610
+ buffer.write('dsh-cad binary STL', 0, 22, 'latin1')
611
+ buffer.writeUInt32LE(triangleCount, 80)
612
+ let offset = 84
613
+ for (let triangle = 0; triangle < triangleCount; triangle++) {
614
+ const a = indices[triangle * 3] * 3
615
+ const b = indices[triangle * 3 + 1] * 3
616
+ const c = indices[triangle * 3 + 2] * 3
617
+ for (let component = 0; component < 3; component++) {
618
+ buffer.writeFloatLE(normals[a + component], offset)
619
+ offset += 4
620
+ }
621
+ for (const vertex of [a, b, c]) {
622
+ buffer.writeFloatLE(positions[vertex], offset)
623
+ buffer.writeFloatLE(positions[vertex + 1], offset + 4)
624
+ buffer.writeFloatLE(positions[vertex + 2], offset + 8)
625
+ offset += 12
626
+ }
627
+ offset += 2
628
+ }
629
+ return buffer
630
+ }
631
+ throw new Error(`unsupported export format: ${String(format)}`)
632
+ }
633
+
634
+ /**
635
+ * Structured assembly STEP: one root product plus one named, placed child
636
+ * per instance — instance separation survives the file (cadorange's
637
+ * exportStepAssembly writes an XCAF document). `nodes`:
638
+ * [{ name, shape, translate?, rotate? }] with unplaced body shapes; the
639
+ * node transform carries the instance placement (identity → null).
640
+ */
641
+ function exportStepDocument(nodes, rootName = 'assembly') {
642
+ const children = nodes.map((node) => {
643
+ let transform = null
644
+ if (node.translate !== undefined || node.rotate !== undefined) {
645
+ const r = eulerMatrix(node.rotate ?? [0, 0, 0])
646
+ const t = node.translate ?? [0, 0, 0]
647
+ transform = [r[0], r[1], r[2], t[0], r[3], r[4], r[5], t[1], r[6], r[7], r[8], t[2]]
648
+ }
649
+ return { name: String(node.name ?? 'part'), color: null, transform, solid: node.shape }
650
+ })
651
+ return C.exportStepAssembly([{ name: rootName, color: null, transform: null, children }])
652
+ }
653
+
654
+ // ── engineering-drawing HLR (same kernel session, no STEP round-trip) ──────
655
+
656
+ /**
657
+ * True hidden-line views through the escape hatch — the exact pipeline the
658
+ * occt.ts bridge runs, minus the STEP export/re-import: the projection
659
+ * frame comes back with the result and is remapped onto the requested
660
+ * (xDir, up) frame before segment chaining. views: [{ name, dir, xDir }].
661
+ */
662
+ function hiddenLineViewsOf(shape, views) {
663
+ const occShape = asOcc(shape)
664
+ return {
665
+ hlr: 'cadorange',
666
+ views: views.map((view) => {
667
+ const w = vec3.norm(view.dir)
668
+ const u = vec3.norm(view.xDir)
669
+ const v = vec3.norm(vec3.cross(w, u)) // screen-up in model space
670
+ const result = occ.hiddenLines(occShape, { direction: w, up: v, deflection: 0.1 })
671
+ // Remap the engine's (xAxis, yAxis) frame → drawing (u, v).
672
+ const a = vec3.dot(result.xAxis, u)
673
+ const b = vec3.dot(result.yAxis, u)
674
+ const c = vec3.dot(result.xAxis, v)
675
+ const d = vec3.dot(result.yAxis, v)
676
+ const remap = (arr) => {
677
+ const xyz = []
678
+ for (let i = 0; i + 2 < arr.length; i += 3) {
679
+ xyz.push(arr[i] * a + arr[i + 1] * b, arr[i] * c + arr[i + 1] * d, 0)
680
+ }
681
+ return xyz
682
+ }
683
+ const toPolylines = (xyz) =>
684
+ chainSegments(
685
+ Array.from({ length: xyz.length / 6 }, (_, s) => [
686
+ xyz[s * 6], xyz[s * 6 + 1], xyz[s * 6 + 3], xyz[s * 6 + 4],
687
+ ]),
688
+ ).map((chain) => simplify(chain, 0.02)).filter((chain) => chain.length >= 4)
689
+ const visiblePolylines = toPolylines(remap(result.visible))
690
+ const visibleKeys = new Set(visiblePolylines.map(polylineKey))
691
+ // An outline edge hidden behind a face whose projection coincides with
692
+ // its visible twin is already drawn — drop the dashed duplicate.
693
+ const hiddenPolylines = toPolylines(remap(result.hidden)).filter(
694
+ (chain) => !visibleKeys.has(polylineKey(chain)),
695
+ )
696
+ return { name: view.name, visible: visiblePolylines, hidden: hiddenPolylines }
697
+ }),
698
+ }
699
+ }
700
+
701
+ return {
702
+ kernel: 'cadorange',
703
+
704
+ makePrim,
705
+ extrudeProfile2D,
706
+ makeLoft,
707
+ makeSweep,
708
+ makeRevolve,
709
+ filletAll,
710
+ chamferAll,
711
+ shell,
712
+ draft,
713
+ boolean,
714
+ transform,
715
+ isValid,
716
+ volume: (shape) => shape.volume(),
717
+ centroid: (shape) => [...shape.center()],
718
+ describe,
719
+ tessellate,
720
+ faceNormals,
721
+ exportFile,
722
+ exportStepDocument,
723
+ hiddenLineViewsOf,
724
+ sketchWirePoints,
725
+ sketchFaceMesh,
726
+ /** Free a superseded shape (cadorange owns the wasm lifetime). */
727
+ dispose(shape) {
728
+ if (shape !== null && shape !== undefined && typeof shape.dispose === 'function') {
729
+ try { shape.dispose() } catch { /* already freed */ }
730
+ }
731
+ },
732
+ }
733
+ }
734
+
735
+ module.exports = { createCadorangeAdapter }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * cadorange resolution + loading. cadorange is the agent-native CAD runtime
3
+ * wrapping occt.ts; with `DSH_CAD_KERNEL=cadorange` it IS dsh-cad's modeling
4
+ * backend, so both subpaths are loaded from ONE package instance:
5
+ *
6
+ * - `cadorange` — the public modeling API (prims/ops/sketch/io)
7
+ * - `cadorange/occt` — the supported escape hatch (getOcc/asOccShape/
8
+ * fromRaw) backing tessellation, HLR and wire
9
+ * construction; it shares the kernel singleton with
10
+ * the core entry through the common dist chunks.
11
+ *
12
+ * Resolution order (per entry):
13
+ * 1. `$DSH_CADORANGE` — a package root (its dist/index.js) or a direct .js file
14
+ * 2. Node's own dependency resolution (npm-installed `cadorange`)
15
+ * 3. `<repo>/../cadorange/packages/cadorange/dist/index.js` — sibling checkout
16
+ *
17
+ * Loading is always a dynamic import() of a file URL: a top-level require() of
18
+ * an ESM build crashes the worker at module scope (issue #5).
19
+ */
20
+ 'use strict'
21
+
22
+ const fs = require('node:fs')
23
+ const path = require('node:path')
24
+ const { pathToFileURL } = require('node:url')
25
+
26
+ /**
27
+ * The dist directory holding index.js + occt/index.js, from any entry or
28
+ * package-root candidate. null when the layout is not recognizable.
29
+ */
30
+ function distDirOf(candidate) {
31
+ let dir = fs.statSync(candidate).isDirectory() ? candidate : path.dirname(candidate)
32
+ // Each level: its dist/ first (package roots point at it without carrying a
33
+ // package.json — e.g. a DSH_CADORANGE override), then a bare index.js (the
34
+ // dist dir itself), then keep walking up (early builds resolved to src/*.ts).
35
+ for (;;) {
36
+ const dist = path.join(dir, 'dist')
37
+ if (fs.existsSync(path.join(dist, 'index.js'))) return dist
38
+ if (fs.existsSync(path.join(dir, 'index.js'))) return dir
39
+ if (fs.existsSync(path.join(dir, 'package.json'))) return null // a root without a dist
40
+ const parent = path.dirname(dir)
41
+ if (parent === dir) return null
42
+ dir = parent
43
+ }
44
+ }
45
+
46
+ /** All candidate dist directories, in priority order (missing ones dropped). */
47
+ function candidateDistDirs() {
48
+ const dirs = []
49
+ const push = (candidate) => {
50
+ try {
51
+ const dist = distDirOf(candidate)
52
+ if (dist !== null && !dirs.includes(dist)) dirs.push(dist)
53
+ } catch { /* unreadable candidate — skip */ }
54
+ }
55
+ const env = process.env.DSH_CADORANGE
56
+ if (env !== undefined && env !== '') push(env)
57
+ try {
58
+ // require.resolve follows the exports map; early cadorange builds
59
+ // resolved to TypeScript sources, so distDirOf also accepts those roots.
60
+ push(require.resolve('cadorange'))
61
+ } catch { /* cadorange not installed as a dependency */ }
62
+ push(path.resolve(__dirname, '..', '..', '..', 'cadorange', 'packages', 'cadorange', 'dist'))
63
+ return dirs
64
+ }
65
+
66
+ /**
67
+ * The dist directory containing BOTH entry points, or null. The escape-hatch
68
+ * entry is mandatory for the adapter (meshing/HLR), so a core-only dist is
69
+ * rejected here rather than exploding on the first modeling op.
70
+ */
71
+ function resolveCadorangeDist() {
72
+ for (const dir of candidateDistDirs()) {
73
+ if (fs.existsSync(path.join(dir, 'occt', 'index.js'))) return dir
74
+ }
75
+ return null
76
+ }
77
+
78
+ let shared = null
79
+ let sharedPromise = null
80
+
81
+ /**
82
+ * Load (once) both cadorange namespaces as `{ core, occt }`, or null when
83
+ * unresolvable. Concurrent callers share one import (a failed import clears
84
+ * the promise so a later call can retry after an install).
85
+ */
86
+ async function loadCadorange() {
87
+ if (shared !== null) return shared
88
+ if (sharedPromise === null) {
89
+ const dir = resolveCadorangeDist()
90
+ if (dir === null) return null
91
+ sharedPromise = (async () => {
92
+ const toUrl = (file) => pathToFileURL(path.join(dir, file)).href
93
+ const [core, occt] = await Promise.all([import(toUrl('index.js')), import(toUrl('occt/index.js'))])
94
+ return { core, occt }
95
+ })().catch((error) => {
96
+ sharedPromise = null
97
+ throw error
98
+ })
99
+ }
100
+ shared = await sharedPromise
101
+ return shared
102
+ }
103
+
104
+ module.exports = { loadCadorange, resolveCadorangeDist }
@@ -16,6 +16,8 @@ const { pathToFileURL } = require('node:url')
16
16
  const { createAdapter } = require('./occt-adapter.cjs')
17
17
  const { createOcctTsAdapter } = require('./occtts-adapter.cjs')
18
18
  const { createOcctBridge, loadSharedOcctModule } = require('./occt-bridge.cjs')
19
+ const { loadCadorange } = require('./cadorange-bridge.cjs')
20
+ const { createCadorangeAdapter } = require('./cadorange-adapter.cjs')
19
21
 
20
22
  const require3 = createRequire(__filename)
21
23
  // The ES6 emscripten build reads a global __dirname when its factory runs.
@@ -92,6 +94,24 @@ function meshOf(shape, name) {
92
94
  }
93
95
  }
94
96
 
97
+ /**
98
+ * Swap a body's live shape, freeing the superseded one. Kernel-owned memory
99
+ * needs explicit disposal on the cadorange backend; the direct backends have
100
+ * no adapter.dispose and are skipped (unchanged behaviour).
101
+ */
102
+ function replaceShape(body, shape) {
103
+ const previous = body.shape
104
+ body.shape = shape
105
+ if (previous !== undefined && previous !== null && previous !== shape && typeof adapter.dispose === 'function') {
106
+ adapter.dispose(previous)
107
+ }
108
+ }
109
+
110
+ /** Drop one body, freeing its shape when the backend owns the lifetime. */
111
+ function disposeBody(entry) {
112
+ if (entry !== undefined && typeof adapter.dispose === 'function') adapter.dispose(entry.shape)
113
+ }
114
+
95
115
  async function applyOp(op) {
96
116
  switch (op.kind) {
97
117
  case 'create_prim': {
@@ -121,7 +141,7 @@ async function applyOp(op) {
121
141
  const body = requireShape(op.target, 'chamfer')
122
142
  const shape = adapter.chamferAll(body.shape, op.distance)
123
143
  assertUsable(shape, 'chamfer', '距离可能超出相邻面')
124
- body.shape = shape
144
+ replaceShape(body, shape)
125
145
  return { bodyId: op.target, name: body.name, mesh: meshOf(shape, body.name) }
126
146
  }
127
147
  case 'pattern': {
@@ -184,7 +204,7 @@ async function applyOp(op) {
184
204
  const faces = Array.isArray(op.faces) && op.faces.length > 0 ? op.faces : matchFacesByNormal(body.shape, op.openNormals)
185
205
  const shape = adapter.shell(body.shape, op.thickness, faces)
186
206
  assertUsable(shape, 'shell', '检查壁厚是否超出几何允许')
187
- body.shape = shape
207
+ replaceShape(body, shape)
188
208
  return { bodyId: op.target, name: body.name, volume: Math.abs(adapter.volume(shape)), mesh: meshOf(shape, body.name) }
189
209
  }
190
210
  case 'draft': {
@@ -192,7 +212,7 @@ async function applyOp(op) {
192
212
  if (typeof adapter.draft !== 'function') throw new Error('draft requires the occt.ts kernel backend — it is unavailable, reinstall dsh-cad')
193
213
  const shape = adapter.draft(body.shape, op.angle, op.direction)
194
214
  assertUsable(shape, 'draft', '检查拔模角度是否超出几何允许')
195
- body.shape = shape
215
+ replaceShape(body, shape)
196
216
  return { bodyId: op.target, name: body.name, volume: Math.abs(adapter.volume(shape)), mesh: meshOf(shape, body.name) }
197
217
  }
198
218
  case 'boolean': {
@@ -203,7 +223,12 @@ async function applyOp(op) {
203
223
  // Consumed tool bodies are removed from the document (CAD convention).
204
224
  const removed = []
205
225
  for (const id of op.tools) {
206
- if (id !== op.target && bodies.delete(id)) removed.push(id)
226
+ if (id === op.target) continue
227
+ const entry = bodies.get(id)
228
+ if (bodies.delete(id)) {
229
+ removed.push(id)
230
+ disposeBody(entry)
231
+ }
207
232
  }
208
233
  return { bodyId: op.target, name: target.name, removed, mesh: meshOf(shape, target.name) }
209
234
  }
@@ -211,7 +236,7 @@ async function applyOp(op) {
211
236
  const body = requireShape(op.target, 'fillet')
212
237
  const shape = adapter.filletAll(body.shape, op.radius ?? 1)
213
238
  assertUsable(shape, 'fillet', '半径可能超出相邻面')
214
- body.shape = shape
239
+ replaceShape(body, shape)
215
240
  return { bodyId: op.target, name: body.name, mesh: meshOf(shape, body.name) }
216
241
  }
217
242
  case 'transform': {
@@ -221,7 +246,7 @@ async function applyOp(op) {
221
246
  rotate: op.rotate,
222
247
  mirror: op.mirror,
223
248
  })
224
- body.shape = shape
249
+ replaceShape(body, shape)
225
250
  return { bodyId: op.target, name: body.name, mesh: meshOf(shape, body.name) }
226
251
  }
227
252
  case 'tessellate_all': {
@@ -355,10 +380,13 @@ async function applyOp(op) {
355
380
  return result
356
381
  }
357
382
  case 'delete': {
383
+ const entry = bodies.get(op.target)
358
384
  if (!bodies.delete(op.target)) throw new Error(`unknown body: ${op.target}`)
385
+ disposeBody(entry)
359
386
  return { deleted: op.target }
360
387
  }
361
388
  case 'reset': {
389
+ for (const body of bodies.values()) disposeBody(body)
362
390
  bodies.clear()
363
391
  instances.clear()
364
392
  sketches.clear()
@@ -442,6 +470,11 @@ async function drawingViews(op) {
442
470
  const body = bodies.get(op.target)
443
471
  if (body === undefined) throw new Error(`unknown body: ${op.target}`)
444
472
  const views = Array.isArray(op.views) && op.views.length > 0 ? op.views : DEFAULT_DRAWING_VIEWS
473
+ // The cadorange backend HLRs through its own kernel session (the escape
474
+ // hatch) — no STEP export/re-import and no second wasm instance.
475
+ if (typeof adapter.hiddenLineViewsOf === 'function') {
476
+ return { views: adapter.hiddenLineViewsOf(body.shape, views).views }
477
+ }
445
478
  const bridge = await getOcctBridge()
446
479
  if (bridge === null) {
447
480
  throw new Error('the occt.ts kernel is required for engineering drawings but was not found — install it (npm i occt.ts) or point DSH_OCCTJS_DIST at a dist directory')
@@ -451,7 +484,52 @@ async function drawingViews(op) {
451
484
  return { views: projected.views }
452
485
  }
453
486
 
487
+ /** Announce readiness and start serving ops. */
488
+ function ready() {
489
+ parentPort.postMessage({ jobId: 0, ok: true, result: { ready: true, kernel: kernelName } })
490
+ parentPort.on('message', runMessage)
491
+ }
492
+
493
+ async function bootCadorange() {
494
+ const mods = await loadCadorange()
495
+ if (mods === null) return null
496
+ await mods.core.init()
497
+ return createCadorangeAdapter(mods)
498
+ }
499
+
454
500
  async function boot() {
501
+ const requested = process.env.DSH_CAD_KERNEL
502
+ if (requested === 'cadorange') {
503
+ // Explicit opt-in: cadorange or bust — a clear error beats a silent
504
+ // fallback when a specific backend was requested.
505
+ try {
506
+ adapter = await bootCadorange()
507
+ } catch (error) {
508
+ // Name the backend in the failure — the package's own message doesn't.
509
+ throw new Error(`cadorange init failed: ${error instanceof Error ? error.message : String(error)}`)
510
+ }
511
+ if (adapter === null) {
512
+ throw new Error('cadorange is not resolvable — set DSH_CADORANGE to its package root, npm i cadorange, or check out cadorange next to this repo')
513
+ }
514
+ kernelName = 'cadorange'
515
+ ready()
516
+ return
517
+ }
518
+ if (requested === undefined || requested === '') {
519
+ // Default chain: cadorange (the agent-native runtime over the same
520
+ // occt.ts wasm) -> direct occt.ts -> opencascade.js. A cadorange boot
521
+ // failure degrades LOUDLY to the next tier — the warning names both.
522
+ try {
523
+ adapter = await bootCadorange()
524
+ } catch (error) {
525
+ console.warn(`[dsh-cad] cadorange backend failed to boot (${error instanceof Error ? error.message : String(error)}) — falling back to the direct occt.ts backend`)
526
+ }
527
+ if (adapter !== null) {
528
+ kernelName = 'cadorange'
529
+ ready()
530
+ return
531
+ }
532
+ }
455
533
  const mod = await loadSharedOcctModule()
456
534
  if (mod !== null) {
457
535
  adapter = createOcctTsAdapter(mod)
@@ -472,8 +550,7 @@ async function boot() {
472
550
  adapter = createAdapter(occt)
473
551
  kernelName = 'opencascade.js'
474
552
  }
475
- parentPort.postMessage({ jobId: 0, ok: true, result: { ready: true, kernel: kernelName } })
476
- parentPort.on('message', runMessage)
553
+ ready()
477
554
  }
478
555
 
479
556
  async function runMessage(message) {
@@ -490,4 +490,4 @@ function simplify(points, epsilon) {
490
490
  return out
491
491
  }
492
492
 
493
- module.exports = { createOcctBridge, resolveDistDir, loadSharedOcctModule }
493
+ module.exports = { createOcctBridge, resolveDistDir, loadSharedOcctModule, chainSegments, simplify }
@@ -402,7 +402,10 @@ function createOcctTsAdapter(mod) {
402
402
  }
403
403
 
404
404
  function chamferAll(shape, distance) {
405
- const next = mod.chamfer(shape, distance, 0, 0)
405
+ // occt.ts >= 1.1.0: chamfer(shape, distance, distance2, edgesPtr, edgeCount)
406
+ // — distance2 = NaN selects the symmetric bevel (a finite value is an
407
+ // asymmetric leg; 0 builds nothing).
408
+ const next = mod.chamfer(shape, distance, Number.NaN, 0, 0)
406
409
  if (next.isNull()) throw wrapError('chamfer', new Error('null shape (distance may exceed the adjacent faces)'))
407
410
  return next
408
411
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-cad",
3
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.14.1",
4
+ "version": "0.15.0",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "LAU-MARS",
@@ -62,7 +62,7 @@
62
62
  "cordis.patch.yml"
63
63
  ],
64
64
  "scripts": {
65
- "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
+ "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'); fs.copyFileSync('src/modeling/cadorange-bridge.cjs','lib/modeling/cadorange-bridge.cjs'); fs.copyFileSync('src/modeling/cadorange-adapter.cjs','lib/modeling/cadorange-adapter.cjs'); for (const part of ['bracket','flange','shaft']) fs.copyFileSync('assets/demo-'+part+'.brep','lib/demo-'+part+'.brep')\"",
66
66
  "watch": "tsc -p tsconfig.json --watch & node esbuild.client.mjs --watch",
67
67
  "test": "vitest run --no-file-parallelism",
68
68
  "test:watch": "vitest",
@@ -70,9 +70,10 @@
70
70
  },
71
71
  "dependencies": {
72
72
  "ansatz-wasm": "^0.2.0",
73
+ "cadorange": "^0.9.0",
73
74
  "dxf-parser": "^1.1.2",
74
75
  "occt-import-js": "^0.0.23",
75
- "occt.ts": "^0.7.0",
76
+ "occt.ts": "^1.1.0",
76
77
  "opencascade.js": "^1.1.1",
77
78
  "three": "^0.185.1"
78
79
  },