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 +10 -7
- package/README.zh-CN.md +7 -5
- package/lib/modeling/cadorange-adapter.cjs +735 -0
- package/lib/modeling/cadorange-bridge.cjs +104 -0
- package/lib/modeling/modeling-worker.cjs +85 -8
- package/lib/modeling/occt-bridge.cjs +1 -1
- package/lib/modeling/occtts-adapter.cjs +4 -1
- package/package.json +4 -3
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
|
-
| ⚙️
|
|
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
|
-
- **
|
|
218
|
-
|
|
219
|
-
interpolation)
|
|
220
|
-
mirror
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
| ⚙️
|
|
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
|
-
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
476
|
-
parentPort.on('message', runMessage)
|
|
553
|
+
ready()
|
|
477
554
|
}
|
|
478
555
|
|
|
479
556
|
async function runMessage(message) {
|
|
@@ -402,7 +402,10 @@ function createOcctTsAdapter(mod) {
|
|
|
402
402
|
}
|
|
403
403
|
|
|
404
404
|
function chamferAll(shape, distance) {
|
|
405
|
-
|
|
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.
|
|
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": "^
|
|
76
|
+
"occt.ts": "^1.1.0",
|
|
76
77
|
"opencascade.js": "^1.1.1",
|
|
77
78
|
"three": "^0.185.1"
|
|
78
79
|
},
|