dsh-cad 0.15.0 → 0.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -15
- package/README.zh-CN.md +9 -11
- package/lib/cad_connector/builtin.js +1 -1
- package/lib/modeling/cadorange-adapter.cjs +1 -1
- package/lib/modeling/modeling-worker.cjs +13 -29
- package/lib/modeling/occt-bridge.cjs +7 -162
- package/lib/modeling/occtts-adapter.cjs +2 -2
- package/lib/tools/cad-model.js +1 -1
- package/lib/types/cad_connector/builtin.d.ts +1 -1
- package/lib/types/convert/dcprt.d.ts +1 -1
- package/package.json +2 -3
- package/lib/modeling/occt-adapter.cjs +0 -704
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
|
-
| ⚙️ Kernel chain: cadorange → occt.ts
|
|
44
|
+
| ⚙️ Kernel chain: cadorange → occt.ts | Modeling goes through **[cadorange](https://github.com/LAU-MARS/cadorange)** (the agent-native CAD runtime wrapping the occt.ts/OCCT WASM; public API + the supported `cadorange/occt` escape hatch for meshing/HLR/wire construction); the direct occt.ts adapter stays as the fallback tier — `DSH_CAD_KERNEL` pins one explicitly (the opencascade.js tier was removed in v0.15.1, −64MB install). 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) |
|
|
@@ -152,7 +152,7 @@ versioned URL), and the resident panel's "Part" tab tracks the latest model in r
|
|
|
152
152
|
|
|
153
153
|
## Connectors (roadmap)
|
|
154
154
|
|
|
155
|
-
Modeling
|
|
155
|
+
Modeling runs on the **occt.ts kernel** via cadorange (OCCT in the browser —
|
|
156
156
|
zero install). The connectors below refer to **external CAD engines** acting as
|
|
157
157
|
executors for the same tool family, planned for future support:
|
|
158
158
|
|
|
@@ -174,7 +174,7 @@ backend changes only the quality of the produced geometry.
|
|
|
174
174
|
|
|
175
175
|
```
|
|
176
176
|
cad_view(path) modeling tools (cad_create_prim, …)
|
|
177
|
-
→ import worker (occt-import-js) → modeling worker (
|
|
177
|
+
→ import worker (occt-import-js) → modeling worker (occt.ts WASM via cadorange)
|
|
178
178
|
→ CadScene JSON (base64-f32) → exact BRep geometry + meshing
|
|
179
179
|
→ GET /dsh-cad/scene/<id> → in-memory binary scene (f32/u32 packed)
|
|
180
180
|
→ GET /dsh-cad/bin/<docId>
|
|
@@ -183,9 +183,7 @@ cad_view(path) modeling tools (cad_create_prim, …)
|
|
|
183
183
|
```
|
|
184
184
|
|
|
185
185
|
- **Two workers**: import (occt-import-js, read-only STEP/IGES/BREP) and modeling
|
|
186
|
-
(
|
|
187
|
-
suffix convention of embind overloaded constructors is wrapped in
|
|
188
|
-
`src/modeling/occt-adapter.cjs` (all verified at runtime)
|
|
186
|
+
(cadorange over the occt.ts wasm) are separate, both lazily started
|
|
189
187
|
- **Zero-copy pipeline**: modeling scenes use zero base64 / zero large JSON arrays /
|
|
190
188
|
zero per-step disk writes (disk mirror debounced 1.5s, replayed only on service
|
|
191
189
|
restart); `cad_export` is the only explicit file export
|
|
@@ -193,7 +191,7 @@ cad_view(path) modeling tools (cad_create_prim, …)
|
|
|
193
191
|
are restored by replay after a restart
|
|
194
192
|
- **Drawing HLR kernel**: hidden lines run on **occt.ts** (npm dependency,
|
|
195
193
|
`npm i occt.ts`) — true OCCT removal, no fallback engine, a missing kernel is
|
|
196
|
-
a hard error.
|
|
194
|
+
a hard error. Kernel APIs: `hiddenLines()` true hidden-line
|
|
197
195
|
removal, byte-level STEP/BRep `readStep`/`readBrep`/`writeStep`/`writeBrep`
|
|
198
196
|
without MEMFS, tessellation with built-in feature-edge extraction, and a
|
|
199
197
|
`hasError()`/`lastError()` error contract. Geometry crosses kernels as STEP
|
|
@@ -220,14 +218,13 @@ cad_view(path) modeling tools (cad_create_prim, …)
|
|
|
220
218
|
fillet/chamfer/shell/draft/native mirror/centroid, plus the supported
|
|
221
219
|
`cadorange/occt` escape hatch for viewer tessellation, engineering-drawing
|
|
222
220
|
HLR (zero STEP hops, one wasm session) and wire construction); the direct
|
|
223
|
-
occt.ts adapter
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
`writeStepDocument`): one root product plus one named, placed child
|
|
221
|
+
occt.ts adapter stays as the fallback tier (`DSH_CAD_KERNEL` pins one; see
|
|
222
|
+
docs/cadorange-integration.md for the verb-by-verb mapping). Editing after
|
|
223
|
+
shelling is seamless. Assembly STEP export writes a **structured document**
|
|
224
|
+
(cadorange
|
|
225
|
+
`exportStepAssembly`): one root product plus one named, placed child
|
|
229
226
|
per instance — instance separation survives the file (STL stays a single
|
|
230
|
-
fused mesh
|
|
227
|
+
fused mesh). Instance separation stays in
|
|
231
228
|
the assembly scene/document for the fuse paths.
|
|
232
229
|
Face selection matches `describe()` plane normals (outermost along ±normal)
|
|
233
230
|
- **Client**: esbuild single-file CJS factory (three.js inlined ~560KB, react provided
|
|
@@ -268,7 +265,6 @@ either theme:
|
|
|
268
265
|
|
|
269
266
|
```sh
|
|
270
267
|
npm test # full suite: converters / modeling worker (exact volume assertions) / DCPRT round-trip / FreeCAD + Onshape executors / image profiles / binary pipeline / document persistence
|
|
271
|
-
node test/m0-kernel-check.cjs # OCCT kernel API smoke test
|
|
272
268
|
node test/route-check.mjs # JSON scene routing layer
|
|
273
269
|
node test/visual/serve.mjs # browser card/tab visual verification page (http://127.0.0.1:3987)
|
|
274
270
|
```
|
package/README.zh-CN.md
CHANGED
|
@@ -38,7 +38,7 @@ npm 官方源的累计下载量,由 CI 每日刷新:
|
|
|
38
38
|
|
|
39
39
|
| 能力 | 说明 |
|
|
40
40
|
| --- | --- |
|
|
41
|
-
| ⚙️ 内核链:cadorange → occt.ts
|
|
41
|
+
| ⚙️ 内核链:cadorange → occt.ts | 建模经 **[cadorange](https://github.com/LAU-MARS/cadorange)**(agent 原生 CAD 运行时,底层 occt.ts/OCCT WASM;公共 API + 官方 `cadorange/occt` 逃生口承担网格/HLR/线构造),occt.ts 直连保留为回退层级——`DSH_CAD_KERNEL` 可显式指定(opencascade.js 档已于 v0.15.1 移除,安装体积 −64MB)。旧版的「抽壳后不可编辑」接缝消失——抽壳/拔模后可继续圆角/倒角/布尔 |
|
|
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 示例件一键切换 |
|
|
@@ -146,7 +146,7 @@ patch 内容以包根目录的 `cordis.patch.yml` 随包分发,安装器经同
|
|
|
146
146
|
|
|
147
147
|
## 连接器(规划中)
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
建模经 **cadorange** 跑在 **occt.ts** 内核上(浏览器内的 OCCT 内核,零安装);
|
|
150
150
|
下表连接器指未来以**外部 CAD 引擎作为执行器**驱动同一工具族:
|
|
151
151
|
|
|
152
152
|
| 连接器 | 套件 | 平台 | 状态 |
|
|
@@ -166,7 +166,7 @@ WebGL 显示层因此永不改变——更换后端只影响生成几何的质
|
|
|
166
166
|
|
|
167
167
|
```
|
|
168
168
|
cad_view(path) 建模工具(cad_create_prim 等)
|
|
169
|
-
→ 导入 worker(occt-import-js) → 建模 worker(
|
|
169
|
+
→ 导入 worker(occt-import-js) → 建模 worker(cadorange / occt.ts WASM)
|
|
170
170
|
→ CadScene JSON(base64-f32) → BRep 精确几何 + 网格化
|
|
171
171
|
→ GET /dsh-cad/scene/<id> → 内存二进制场景(f32/u32 打包)
|
|
172
172
|
→ GET /dsh-cad/bin/<docId>
|
|
@@ -174,14 +174,13 @@ cad_view(path) 建模工具(cad_create_prim 等)
|
|
|
174
174
|
浏览器卡片 + 常驻 "3D" 页签(three.js / SVG,Z-up,XYZ 轴)
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
- **两个 worker**:导入(occt-import-js,只读 STEP/IGES/BREP)与建模(
|
|
178
|
-
|
|
179
|
-
`src/modeling/occt-adapter.cjs`(全部经运行时实证)
|
|
177
|
+
- **两个 worker**:导入(occt-import-js,只读 STEP/IGES/BREP)与建模(cadorange
|
|
178
|
+
over occt.ts wasm)分离,均惰性启动
|
|
180
179
|
- **直通管道**:建模场景零 base64 / 零 JSON 大数组 / 零每步落盘(磁盘镜像 1.5s 防抖,
|
|
181
180
|
仅服务重启回放);`cad_export` 是唯一的显式文件导出
|
|
182
181
|
- **建模文档**:`<workspace>/.dsh-cad/model.json` 操作日志,重启后重放恢复全部 body
|
|
183
182
|
- **工程图消隐内核**:隐藏线由 **occt.ts**(npm 依赖,`npm i occt.ts`)执行
|
|
184
|
-
真实 OCCT
|
|
183
|
+
真实 OCCT 消隐,无替代引擎、缺失即报错。内核 API:
|
|
185
184
|
`hiddenLines()` 真实消隐线、STEP/BRep 字节级 `readStep`/`readBrep`/`writeStep`/
|
|
186
185
|
`writeBrep`(无需 MEMFS)、自带特征边提取的网格化、`hasError()`/`lastError()`
|
|
187
186
|
错误契约。几何以 STEP 字节跨内核交换,投影线段重映射进图纸坐标系。内核 dist
|
|
@@ -201,9 +200,9 @@ cad_view(path) 建模工具(cad_create_prim 等)
|
|
|
201
200
|
公共 API 承担 prims/曲线段轮廓(真 BSpline 插值)/放样/扫掠/旋转/布尔
|
|
202
201
|
(默认同域清理)/圆角/倒角/抽壳/拔模/原生镜像/质心,官方 `cadorange/occt`
|
|
203
202
|
逃生口承担查看器网格、工程图 HLR(零 STEP 中转、单一 wasm 会话)与线构造);
|
|
204
|
-
occt.ts
|
|
205
|
-
动词级映射见 docs/cadorange-integration.md)。抽壳后继续编辑无缝。
|
|
206
|
-
|
|
203
|
+
occt.ts 直连保留为回退层级(`DSH_CAD_KERNEL` 可显式指定,
|
|
204
|
+
动词级映射见 docs/cadorange-integration.md)。抽壳后继续编辑无缝。
|
|
205
|
+
装配体 STEP 导出为**结构化文档**(cadorange
|
|
207
206
|
`writeStepDocument`):一个根产品 + 每实例一个命名、带位姿的子产品——
|
|
208
207
|
实例分离在文件中保留(STL 仍为单一熔合网格;降级后端同样走 fuse)。fuse
|
|
209
208
|
路径下实例分离仅保留在装配场景/文档中。面选择按 `describe()` 平面法向
|
|
@@ -242,7 +241,6 @@ cad_view(path) 建模工具(cad_create_prim 等)
|
|
|
242
241
|
|
|
243
242
|
```sh
|
|
244
243
|
npm test # 全套:转换器 / 建模 worker(体积精确断言)/ DCPRT 往返 / FreeCAD + Onshape 执行器 / 图片轮廓 / 二进制管道 / 文档持久化
|
|
245
|
-
node test/m0-kernel-check.cjs # OCCT 内核 API 冒烟
|
|
246
244
|
node test/route-check.mjs # JSON 场景路由层
|
|
247
245
|
node test/visual/serve.mjs # 浏览器卡片/页签视觉验证页(http://127.0.0.1:3987)
|
|
248
246
|
```
|
|
@@ -4,5 +4,5 @@ export const BUILTIN_CONNECTOR = {
|
|
|
4
4
|
vendor: 'dsh-cad',
|
|
5
5
|
language: 'webgl',
|
|
6
6
|
status: 'built-in',
|
|
7
|
-
binding: '
|
|
7
|
+
binding: 'cadorange/occt.ts WASM worker; three.js typed-array rendering, zero install',
|
|
8
8
|
};
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* describe/volume/centroid and STEP/STL/STEP-assembly export.
|
|
18
18
|
*
|
|
19
19
|
* Opt-in backend: `DSH_CAD_KERNEL=cadorange`. The default chain stays
|
|
20
|
-
* occt.ts
|
|
20
|
+
* direct-occt.ts tier as the fallback (the opencascade.js tier was removed in v0.15.1).
|
|
21
21
|
*/
|
|
22
22
|
'use strict'
|
|
23
23
|
|
|
@@ -13,7 +13,6 @@ const path = require('node:path')
|
|
|
13
13
|
const fs = require('node:fs')
|
|
14
14
|
const { createRequire } = require('node:module')
|
|
15
15
|
const { pathToFileURL } = require('node:url')
|
|
16
|
-
const { createAdapter } = require('./occt-adapter.cjs')
|
|
17
16
|
const { createOcctTsAdapter } = require('./occtts-adapter.cjs')
|
|
18
17
|
const { createOcctBridge, loadSharedOcctModule } = require('./occt-bridge.cjs')
|
|
19
18
|
const { loadCadorange } = require('./cadorange-bridge.cjs')
|
|
@@ -23,11 +22,10 @@ const require3 = createRequire(__filename)
|
|
|
23
22
|
// The ES6 emscripten build reads a global __dirname when its factory runs.
|
|
24
23
|
if (typeof globalThis.__dirname === 'undefined') globalThis.__dirname = __dirname
|
|
25
24
|
|
|
26
|
-
// Kernel selection:
|
|
27
|
-
//
|
|
28
|
-
//
|
|
25
|
+
// Kernel selection: cadorange is the primary backend (agent-native runtime
|
|
26
|
+
// over the occt.ts wasm), the direct occt.ts adapter is the fallback; the
|
|
27
|
+
// opencascade.js tier was removed in v0.15.1. `adapter` carries whichever.
|
|
29
28
|
let adapter = null
|
|
30
|
-
let occt = null
|
|
31
29
|
let kernelName = 'occt.ts'
|
|
32
30
|
|
|
33
31
|
/**
|
|
@@ -41,10 +39,7 @@ function assertUsable(shape, what, hint) {
|
|
|
41
39
|
}
|
|
42
40
|
}
|
|
43
41
|
|
|
44
|
-
/** bodyId → { shape, name
|
|
45
|
-
* was produced by an occt.ts-only op (shell/draft) is HOSTED: `step` carries
|
|
46
|
-
* the STEP bytes as the source of truth and `shape` is null, because
|
|
47
|
-
* opencascade.js in this build cannot read geometry back. */
|
|
42
|
+
/** bodyId → { shape, name } — the live document (kernel-owned shapes). */
|
|
48
43
|
const bodies = new Map()
|
|
49
44
|
let nextBodyNumber = 1
|
|
50
45
|
|
|
@@ -499,6 +494,9 @@ async function bootCadorange() {
|
|
|
499
494
|
|
|
500
495
|
async function boot() {
|
|
501
496
|
const requested = process.env.DSH_CAD_KERNEL
|
|
497
|
+
if (requested === 'opencascade.js') {
|
|
498
|
+
throw new Error('the opencascade.js fallback backend was removed in v0.15.1 — the kernel chain is cadorange → occt.ts; unset DSH_CAD_KERNEL or use cadorange / occt.ts')
|
|
499
|
+
}
|
|
502
500
|
if (requested === 'cadorange') {
|
|
503
501
|
// Explicit opt-in: cadorange or bust — a clear error beats a silent
|
|
504
502
|
// fallback when a specific backend was requested.
|
|
@@ -516,8 +514,8 @@ async function boot() {
|
|
|
516
514
|
return
|
|
517
515
|
}
|
|
518
516
|
if (requested === undefined || requested === '') {
|
|
519
|
-
// Default chain: cadorange (the agent-native runtime over the
|
|
520
|
-
//
|
|
517
|
+
// Default chain: cadorange (the agent-native runtime over the occt.ts
|
|
518
|
+
// wasm) -> direct occt.ts. A cadorange boot
|
|
521
519
|
// failure degrades LOUDLY to the next tier — the warning names both.
|
|
522
520
|
try {
|
|
523
521
|
adapter = await bootCadorange()
|
|
@@ -531,25 +529,11 @@ async function boot() {
|
|
|
531
529
|
}
|
|
532
530
|
}
|
|
533
531
|
const mod = await loadSharedOcctModule()
|
|
534
|
-
if (mod
|
|
535
|
-
|
|
536
|
-
kernelName = 'occt.ts'
|
|
537
|
-
} else {
|
|
538
|
-
console.warn('[dsh-cad] occt.ts unavailable — falling back to the opencascade.js backend')
|
|
539
|
-
// Load the fallback kernel lazily: a top-level require() of the
|
|
540
|
-
// emscripten loader crashes the whole worker at module scope when the
|
|
541
|
-
// resolved opencascade.js build is ESM (ERR_REQUIRE_ESM) — boot() never
|
|
542
|
-
// runs and the main thread hangs waiting for ready. Dynamic import()
|
|
543
|
-
// accepts both module formats and confines a broken fallback build to
|
|
544
|
-
// this branch (surfaced as a clean init error via boot().catch).
|
|
545
|
-
const loaderPath = require3.resolve('opencascade.js/dist/opencascade.wasm.js')
|
|
546
|
-
const loaderModule = await import(pathToFileURL(loaderPath).href)
|
|
547
|
-
const wasmBinary = fs.readFileSync(path.join(path.dirname(loaderPath), 'opencascade.wasm.wasm'))
|
|
548
|
-
const initOpenCascade = loaderModule.default ?? loaderModule
|
|
549
|
-
occt = await initOpenCascade({ wasmBinary })
|
|
550
|
-
adapter = createAdapter(occt)
|
|
551
|
-
kernelName = 'opencascade.js'
|
|
532
|
+
if (mod === null) {
|
|
533
|
+
throw new Error('no modeling kernel available: cadorange failed to boot and the direct occt.ts backend is unavailable — reinstall dsh-cad (npm i occt.ts) or point DSH_OCCTJS_DIST at an occt.ts dist directory')
|
|
552
534
|
}
|
|
535
|
+
adapter = createOcctTsAdapter(mod)
|
|
536
|
+
kernelName = 'occt.ts'
|
|
553
537
|
ready()
|
|
554
538
|
}
|
|
555
539
|
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
* expose hidden-line removal, plus STEP/BRep byte import and built-in edge
|
|
5
5
|
* extraction on tessellation).
|
|
6
6
|
*
|
|
7
|
-
* The
|
|
8
|
-
* serves one purpose: true-HLR drawing views
|
|
9
|
-
* STEP bytes (adapter's proven
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* exact same view data the
|
|
7
|
+
* The kernel chain is cadorange → direct occt.ts (the opencascade.js tier was
|
|
8
|
+
* removed in v0.15.1); this bridge serves one purpose: true-HLR drawing views
|
|
9
|
+
* for the direct backend. Geometry crosses as STEP bytes (adapter's proven
|
|
10
|
+
* MEMFS export → occt.ts readStep), and the projected segments are remapped
|
|
11
|
+
* into the caller's screen frame (u = right, v = up) before chaining into
|
|
12
|
+
* polylines, so the sheet layout consumes the exact same view data the
|
|
13
|
+
* mesh-projected fallback produces.
|
|
13
14
|
*
|
|
14
15
|
* Resolution order for the dist directory (first hit wins):
|
|
15
16
|
* 1. explicit `distDir` argument
|
|
@@ -78,9 +79,6 @@ let sharedModule = null
|
|
|
78
79
|
/** Load (once) and return the shared occt.ts raw module, or null when absent. */
|
|
79
80
|
async function loadSharedOcctModule() {
|
|
80
81
|
if (sharedModule !== null && sharedModule !== undefined) return sharedModule
|
|
81
|
-
// DSH_CAD_KERNEL=opencascade.js forces the fallback backend (diagnostics
|
|
82
|
-
// and regression tests — compare kernel behavior on a real model).
|
|
83
|
-
if (process.env.DSH_CAD_KERNEL === 'opencascade.js') return null
|
|
84
82
|
const distDir = resolveDistDir()
|
|
85
83
|
if (distDir === null) return null
|
|
86
84
|
try {
|
|
@@ -180,150 +178,6 @@ async function hiddenLineViews(mod, stepBytes, views) {
|
|
|
180
178
|
mod._free(ptr)
|
|
181
179
|
}
|
|
182
180
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
// ── occt.ts-hosted solid operations (shell / draft / boolean / transform) ────
|
|
186
|
-
//
|
|
187
|
-
// opencascade.js cannot read geometry BACK (STEPControl_Reader and BRepTools
|
|
188
|
-
// readers are opaque no-method shells in that build), so a solid produced by
|
|
189
|
-
// an occt.ts-only op cannot return to the opencascade.js modeling session.
|
|
190
|
-
// Instead the result stays hosted as STEP bytes on the occt.ts side: every
|
|
191
|
-
// hosted op re-reads the STEP, operates, and writes it back, while meshes for
|
|
192
|
-
// display/volume come from the occt.ts tessellator. opencascade.js-exclusive
|
|
193
|
-
// ops (fillet/chamfer/loft/sweep/revolve) refuse hosted bodies with a clear
|
|
194
|
-
// error instead of silently operating on stale geometry.
|
|
195
|
-
|
|
196
|
-
/** Read STEP bytes into an occt.ts shape (caller deletes). */
|
|
197
|
-
function readShape(mod, stepBytes) {
|
|
198
|
-
const ptr = mod._malloc(stepBytes.length)
|
|
199
|
-
try {
|
|
200
|
-
mod.HEAPU8.set(stepBytes, ptr)
|
|
201
|
-
const shape = mod.readStep(ptr, stepBytes.length, 'mm')
|
|
202
|
-
if (shape.isNull()) throw new Error(`occt.ts readStep: ${mod.lastError()}`)
|
|
203
|
-
return shape
|
|
204
|
-
} finally {
|
|
205
|
-
mod._free(ptr)
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/** Write an occt.ts shape to STEP bytes. */
|
|
210
|
-
function toStepBytes(mod, shape) {
|
|
211
|
-
const text = mod.writeStep(shape, 'mm')
|
|
212
|
-
return Buffer.from(text, 'utf8')
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
/** Tessellate an occt.ts shape into the worker mesh format. */
|
|
216
|
-
function tessellateMesh(mod, shape, name, linearDeflection = 0.3) {
|
|
217
|
-
const data = mod.tessellate(shape, linearDeflection, 20, false)
|
|
218
|
-
try {
|
|
219
|
-
const positions = new Float32Array(mod.HEAPU8.buffer, data.positionsPtr(), data.positionCount() * 3).slice()
|
|
220
|
-
const indices = new Uint32Array(mod.HEAPU8.buffer, data.indicesPtr(), data.indexCount()).slice()
|
|
221
|
-
const triangles = indices.length / 3
|
|
222
|
-
// Flat per-triangle normals (the mechanical-CAD look the viewer expects).
|
|
223
|
-
const normals = new Float32Array(positions.length)
|
|
224
|
-
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
225
|
-
const a = indices[i] * 3, b = indices[i + 1] * 3, c = indices[i + 2] * 3
|
|
226
|
-
const ux = positions[c] - positions[a], uy = positions[c + 1] - positions[a + 1], uz = positions[c + 2] - positions[a + 2]
|
|
227
|
-
const vx = positions[b] - positions[a], vy = positions[b + 1] - positions[a + 1], vz = positions[b + 2] - positions[a + 2]
|
|
228
|
-
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
229
|
-
const len = Math.hypot(nx, ny, nz) || 1
|
|
230
|
-
nx /= len; ny /= len; nz /= len
|
|
231
|
-
for (const corner of [a, b, c]) {
|
|
232
|
-
normals[corner] = nx; normals[corner + 1] = ny; normals[corner + 2] = nz
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
return { name, positions, normals, indices, vertexCount: positions.length / 3, triangleCount: triangles }
|
|
236
|
-
} finally {
|
|
237
|
-
data.delete()
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
/** Faces (1-based indices) whose outward plane-normal matches a target list. */
|
|
242
|
-
function matchFaces(mod, shape, openNormals) {
|
|
243
|
-
if (!Array.isArray(openNormals) || openNormals.length === 0) return []
|
|
244
|
-
const desc = JSON.parse(mod.describe(shape))
|
|
245
|
-
const faces = Array.isArray(desc.faces) ? desc.faces : []
|
|
246
|
-
const picked = []
|
|
247
|
-
for (const target of openNormals) {
|
|
248
|
-
const tlen = Math.hypot(target[0], target[1], target[2])
|
|
249
|
-
if (tlen < 1e-12) continue
|
|
250
|
-
const tn = [target[0] / tlen, target[1] / tlen, target[2] / tlen]
|
|
251
|
-
let best = null
|
|
252
|
-
let bestScore = 0.9
|
|
253
|
-
for (const face of faces) {
|
|
254
|
-
if (face.surface !== 'plane' || !Array.isArray(face.normal)) continue
|
|
255
|
-
const dot = face.normal[0] * tn[0] + face.normal[1] * tn[1] + face.normal[2] * tn[2]
|
|
256
|
-
// |dot| ≈ 1 means parallel; the OUTER face along ±target has the larger
|
|
257
|
-
// origin·target projection (a box carries both ±Z faces).
|
|
258
|
-
if (Math.abs(dot) < 0.9) continue
|
|
259
|
-
const outward = Math.sign(dot) >= 0 ? 1 : -1
|
|
260
|
-
const proj = (face.origin[0] * tn[0] + face.origin[1] * tn[1] + face.origin[2] * tn[2]) * outward
|
|
261
|
-
const score = Math.abs(dot) + proj * 1e-3
|
|
262
|
-
if (score > bestScore) { bestScore = score; best = face.index }
|
|
263
|
-
}
|
|
264
|
-
if (best !== null) picked.push(best)
|
|
265
|
-
}
|
|
266
|
-
return picked
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
/** int32 face-index array into wasm memory; returns {ptr, count} or null. */
|
|
270
|
-
function faceListPtr(mod, faces) {
|
|
271
|
-
if (!Array.isArray(faces) || faces.length === 0) return { ptr: 0, count: 0 }
|
|
272
|
-
const ptr = mod._malloc(faces.length * 4)
|
|
273
|
-
for (let i = 0; i < faces.length; i++) {
|
|
274
|
-
mod.HEAPU32[ptr / 4 + i] = faces[i]
|
|
275
|
-
}
|
|
276
|
-
return { ptr, count: faces.length }
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Run one occt.ts-hosted solid operation on STEP bytes:
|
|
281
|
-
* shell(stepBytes, thickness, openNormals) → { step, mesh, volume, faces }
|
|
282
|
-
* draft(stepBytes, angleDegrees, direction, faces?) → { step, mesh, volume }
|
|
283
|
-
* The STEP bytes of the RESULT are the hosted body's new source of truth.
|
|
284
|
-
*/
|
|
285
|
-
function hostedSolidOp(mod, kind, stepBytes, params) {
|
|
286
|
-
const shape = readShape(mod, stepBytes)
|
|
287
|
-
try {
|
|
288
|
-
let result
|
|
289
|
-
if (kind === 'shell') {
|
|
290
|
-
const faces = Array.isArray(params.faces) && params.faces.length > 0
|
|
291
|
-
? params.faces
|
|
292
|
-
: matchFaces(mod, shape, params.openNormals)
|
|
293
|
-
const list = faceListPtr(mod, faces)
|
|
294
|
-
try {
|
|
295
|
-
result = mod.shell(shape, Math.abs(params.thickness), list.ptr, list.count)
|
|
296
|
-
} finally {
|
|
297
|
-
if (list.ptr !== 0) mod._free(list.ptr)
|
|
298
|
-
}
|
|
299
|
-
if (result.isNull()) throw new Error(`shell failed: ${mod.lastError()}`)
|
|
300
|
-
} else if (kind === 'draft') {
|
|
301
|
-
const faces = Array.isArray(params.faces) && params.faces.length > 0 ? params.faces : []
|
|
302
|
-
const list = faceListPtr(mod, faces)
|
|
303
|
-
const d = params.direction ?? [0, 0, 1]
|
|
304
|
-
try {
|
|
305
|
-
result = mod.draft(shape, (params.angle * Math.PI) / 180, d[0], d[1], d[2], 0, 0, 1, list.ptr, list.count, faces.length === 0)
|
|
306
|
-
} finally {
|
|
307
|
-
if (list.ptr !== 0) mod._free(list.ptr)
|
|
308
|
-
}
|
|
309
|
-
if (result.isNull()) throw new Error(`draft failed: ${mod.lastError()}`)
|
|
310
|
-
} else {
|
|
311
|
-
throw new Error(`unknown hosted op: ${kind}`)
|
|
312
|
-
}
|
|
313
|
-
try {
|
|
314
|
-
return {
|
|
315
|
-
step: toStepBytes(mod, result),
|
|
316
|
-
mesh: tessellateMesh(mod, result, params.name ?? 'hosted'),
|
|
317
|
-
volume: Math.abs(result.volume()),
|
|
318
|
-
}
|
|
319
|
-
} finally {
|
|
320
|
-
result.delete()
|
|
321
|
-
}
|
|
322
|
-
} finally {
|
|
323
|
-
shape.delete()
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
181
|
/**
|
|
328
182
|
* Hidden-line views of a shape that ALREADY lives in this occt.ts instance
|
|
329
183
|
* (the single-kernel path — no STEP hop). Same frame/remap contract as
|
|
@@ -393,15 +247,6 @@ async function createOcctBridge(options = {}) {
|
|
|
393
247
|
occtVersion: mod.occtVersion(),
|
|
394
248
|
hiddenLineViews: (stepBytes, views) => hiddenLineViews(mod, stepBytes, views),
|
|
395
249
|
hiddenLineViewsOf: (shape, views) => hiddenLineViewsOfShape(mod, shape, views),
|
|
396
|
-
hostedSolidOp: (kind, stepBytes, params) => hostedSolidOp(mod, kind, stepBytes, params),
|
|
397
|
-
volumeOf: (stepBytes) => {
|
|
398
|
-
const shape = readShape(mod, stepBytes)
|
|
399
|
-
try {
|
|
400
|
-
return Math.abs(shape.volume())
|
|
401
|
-
} finally {
|
|
402
|
-
shape.delete()
|
|
403
|
-
}
|
|
404
|
-
},
|
|
405
250
|
}
|
|
406
251
|
}
|
|
407
252
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* occt.ts modeling adapter — the PRIMARY kernel backend (plain CJS, runs in
|
|
3
3
|
* the modeling worker). Mirrors the occt-adapter.cjs interface so the worker
|
|
4
|
-
* is kernel-agnostic
|
|
5
|
-
*
|
|
4
|
+
* is kernel-agnostic and serves as the fallback tier behind cadorange
|
|
5
|
+
* (the opencascade.js tier was removed in v0.15.1).
|
|
6
6
|
*
|
|
7
7
|
* Shape representation: opaque occt.ts WasmShape handles. Geometry lives in
|
|
8
8
|
* the occt.ts wasm heap; STEP/BRep exchange and true B-splines
|
package/lib/tools/cad-model.js
CHANGED
|
@@ -957,7 +957,7 @@ export function createModelTools(deps) {
|
|
|
957
957
|
const cadShell = defineTool({
|
|
958
958
|
name: 'cad_shell',
|
|
959
959
|
description: 'Shell (抽壳): hollow a solid into a wall of `thickness` mm (the wall grows inward, the outer skin is preserved). `open` lists outward NORMALS [x,y,z] whose faces become openings (e.g. [[0,0,1]] opens the top of a box; empty = sealed hollow). ' +
|
|
960
|
-
'
|
|
960
|
+
'Keeps the model editable after shelling: fillet/chamfer/loft/sweep/revolve/boolean all keep working on the shelled solid.',
|
|
961
961
|
parameters: {
|
|
962
962
|
target: bodyTarget,
|
|
963
963
|
thickness: { type: 'number', required: true, description: 'Wall thickness (mm, positive).' },
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
/** The built-in modeling kernel:
|
|
1
|
+
/** The built-in modeling kernel: cadorange over the occt.ts wasm, in a worker, rendered with WebGL/three.js. */
|
|
2
2
|
import type { CadConnector } from './types.js';
|
|
3
3
|
export declare const BUILTIN_CONNECTOR: CadConnector;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* history on the OCCT worker, and tessellate the rebuilt bodies into
|
|
4
4
|
* indexed CadMeshes.
|
|
5
5
|
*
|
|
6
|
-
* Replay runs on the SHARED worker (via runModelOp): the
|
|
6
|
+
* Replay runs on the SHARED worker (via runModelOp): the modeling kernel
|
|
7
7
|
* heap is too large for a second concurrent instance. Every replay posts a
|
|
8
8
|
* `reset`, which bumps the client's reset epoch — the live modeling session
|
|
9
9
|
* detects the stale epoch and re-replays its own document before the next
|
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.15.
|
|
4
|
+
"version": "0.15.1",
|
|
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/
|
|
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/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",
|
|
@@ -74,7 +74,6 @@
|
|
|
74
74
|
"dxf-parser": "^1.1.2",
|
|
75
75
|
"occt-import-js": "^0.0.23",
|
|
76
76
|
"occt.ts": "^1.1.0",
|
|
77
|
-
"opencascade.js": "^1.1.1",
|
|
78
77
|
"three": "^0.185.1"
|
|
79
78
|
},
|
|
80
79
|
"peerDependencies": {
|
|
@@ -1,704 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OCCT modeling adapter (plain CJS JavaScript — runs inside the modeling
|
|
3
|
-
* worker). Encapsulates the opencascade.js binding surface verified in M0:
|
|
4
|
-
* overloaded constructors use the `_N` suffix convention and this file is the
|
|
5
|
-
* single place that knows those spellings.
|
|
6
|
-
*
|
|
7
|
-
* Document model: bodyId → TopoDS_Shape, in-memory in the worker. The main
|
|
8
|
-
* thread persists an operation log and replays it for restart recovery.
|
|
9
|
-
*/
|
|
10
|
-
'use strict'
|
|
11
|
-
|
|
12
|
-
function createAdapter(occt) {
|
|
13
|
-
// ── verified constructor spellings (see test/m0-kernel-check.cjs) ──────────
|
|
14
|
-
const pnt = (x, y, z) => new occt.gp_Pnt_3(x, y, z)
|
|
15
|
-
const dir = (x, y, z) => new occt.gp_Dir_4(x, y, z)
|
|
16
|
-
const vec = (x, y, z) => new occt.gp_Vec_4(x, y, z)
|
|
17
|
-
const identityTrsf = () => new occt.gp_Trsf_1()
|
|
18
|
-
const ENUM = occt.TopAbs_ShapeEnum
|
|
19
|
-
|
|
20
|
-
const shapeOf = (make) => make.Shape()
|
|
21
|
-
const volume = (shape) => {
|
|
22
|
-
const props = new occt.GProp_GProps_2(pnt(0, 0, 0))
|
|
23
|
-
occt.BRepGProp.VolumeProperties_1(shape, props, false, true, false)
|
|
24
|
-
return props.Mass()
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// ── primitives ─────────────────────────────────────────────────────────────
|
|
28
|
-
function makePrim(kind, params) {
|
|
29
|
-
const at = params.at ?? [0, 0, 0]
|
|
30
|
-
const p = params
|
|
31
|
-
switch (kind) {
|
|
32
|
-
case 'box': {
|
|
33
|
-
const dx = p.dx ?? 10, dy = p.dy ?? 10, dz = p.dz ?? 10
|
|
34
|
-
return shapeOf(new occt.BRepPrimAPI_MakeBox_3(pnt(at[0], at[1], at[2]), pnt(at[0] + dx, at[1] + dy, at[2] + dz)))
|
|
35
|
-
}
|
|
36
|
-
case 'cylinder': {
|
|
37
|
-
const axis = p.axis ?? [0, 0, 1]
|
|
38
|
-
const ax2 = new occt.gp_Ax2_3(pnt(at[0], at[1], at[2]), dir(axis[0], axis[1], axis[2]))
|
|
39
|
-
return shapeOf(new occt.BRepPrimAPI_MakeCylinder_3(ax2, p.radius ?? 5, p.height ?? 10))
|
|
40
|
-
}
|
|
41
|
-
case 'sphere': {
|
|
42
|
-
// Verified variants: only the pure-double ctors work in this build;
|
|
43
|
-
// placement (at/axis) is applied by an exact transform below.
|
|
44
|
-
return place(new occt.BRepPrimAPI_MakeSphere_1(p.radius ?? 5).Shape(), at, p.axis)
|
|
45
|
-
}
|
|
46
|
-
case 'cone': {
|
|
47
|
-
return place(new occt.BRepPrimAPI_MakeCone_1(p.radius1 ?? 5, p.radius2 ?? 0, p.height ?? 10).Shape(), at, p.axis)
|
|
48
|
-
}
|
|
49
|
-
case 'torus': {
|
|
50
|
-
return place(new occt.BRepPrimAPI_MakeTorus_1(p.majorRadius ?? 10, p.minorRadius ?? 2).Shape(), at, p.axis)
|
|
51
|
-
}
|
|
52
|
-
default:
|
|
53
|
-
throw new Error(`unknown primitive kind: ${kind}`)
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Place an origin-built primitive: rotate +Z onto `axis` (exact axis-angle
|
|
59
|
-
* rotation about the origin), then translate to `at`. Both default to
|
|
60
|
-
* identity when omitted/default.
|
|
61
|
-
*/
|
|
62
|
-
function place(shape, at, axis) {
|
|
63
|
-
const ax = axis ?? [0, 0, 1]
|
|
64
|
-
const len = Math.hypot(ax[0], ax[1], ax[2])
|
|
65
|
-
const u = len === 0 ? [0, 0, 1] : [ax[0] / len, ax[1] / len, ax[2] / len]
|
|
66
|
-
// Identity only when the axis IS +Z (e.g. [0,0,-1] must rotate by π).
|
|
67
|
-
const isDefaultAxis = Math.abs(u[0]) + Math.abs(u[1]) < 1e-12 && u[2] > 1 - 1e-9
|
|
68
|
-
let result = shape
|
|
69
|
-
if (!isDefaultAxis) {
|
|
70
|
-
const trsf = identityTrsf()
|
|
71
|
-
trsf.SetRotation_1(new occt.gp_Ax1_2(pnt(0, 0, 0), dir(u[0], u[1], u[2])), Math.acos(Math.min(1, Math.max(-1, u[2]))))
|
|
72
|
-
result = new occt.BRepBuilderAPI_Transform_2(result, trsf, true).Shape()
|
|
73
|
-
}
|
|
74
|
-
if (at !== undefined && (at[0] !== 0 || at[1] !== 0 || at[2] !== 0)) {
|
|
75
|
-
const move = identityTrsf()
|
|
76
|
-
move.SetTranslation_1(vec(at[0], at[1], at[2]))
|
|
77
|
-
result = new occt.BRepBuilderAPI_Transform_2(result, move, true).Shape()
|
|
78
|
-
}
|
|
79
|
-
return result
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ── profile extrusion ─────────────────────────────────────────────────────
|
|
83
|
-
/** points: flat [x0,y0, x1,y1, ...] closed loop in the XY plane at z = base. */
|
|
84
|
-
function makeExtrudedProfile(points, height, base = 0) {
|
|
85
|
-
if (points.length < 6 || points.length % 2 !== 0) {
|
|
86
|
-
throw new Error('profile needs at least 3 points (6 flat numbers)')
|
|
87
|
-
}
|
|
88
|
-
// Verified path (the 1.1.1 build exposes no direct wire→face ctor):
|
|
89
|
-
// polygon wire → bound an infinite planar face with Add(wire) → extrude.
|
|
90
|
-
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
91
|
-
for (let i = 0; i + 1 < points.length; i += 2) {
|
|
92
|
-
poly.Add_1(pnt(points[i], points[i + 1], base))
|
|
93
|
-
}
|
|
94
|
-
poly.Close()
|
|
95
|
-
if (!poly.IsDone()) throw new Error('profile polygon is invalid (duplicate or collinear-only points)')
|
|
96
|
-
const planeAx3 = new occt.gp_Ax3_3(pnt(0, 0, base), dir(0, 0, 1), dir(1, 0, 0))
|
|
97
|
-
const faceBuilder = new occt.BRepBuilderAPI_MakeFace_3(new occt.gp_Pln_2(planeAx3))
|
|
98
|
-
faceBuilder.Add(poly.Wire())
|
|
99
|
-
const face = faceBuilder.Face()
|
|
100
|
-
if (!faceBuilder.IsDone() || face.IsNull()) throw new Error('profile face construction failed')
|
|
101
|
-
return shapeOf(new occt.BRepPrimAPI_MakePrism_1(face, vec(0, 0, height), true, false))
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ── booleans / fillet / transform ──────────────────────────────────────────
|
|
105
|
-
function boolean(op, target, tools) {
|
|
106
|
-
let result = target
|
|
107
|
-
for (const tool of tools) {
|
|
108
|
-
// BRepAlgoAPI_*_3 is the verified (shape, shape) constructor.
|
|
109
|
-
const Ctor = op === 'fuse' ? occt.BRepAlgoAPI_Fuse_3 : op === 'cut' ? occt.BRepAlgoAPI_Cut_3 : occt.BRepAlgoAPI_Common_3
|
|
110
|
-
const algo = new Ctor(result, tool)
|
|
111
|
-
algo.Build()
|
|
112
|
-
if (!algo.IsDone()) throw new Error(`boolean ${op} failed`)
|
|
113
|
-
result = algo.Shape()
|
|
114
|
-
}
|
|
115
|
-
return result
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function filletAll(shape, radius) {
|
|
119
|
-
const algo = new occt.BRepFilletAPI_MakeFillet(shape, 1e-4)
|
|
120
|
-
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_EDGE, ENUM.TopAbs_SHAPE)
|
|
121
|
-
let edges = 0
|
|
122
|
-
while (explorer.More()) {
|
|
123
|
-
algo.Add_2(radius, castEdge(explorer.Current()))
|
|
124
|
-
edges++
|
|
125
|
-
explorer.Next()
|
|
126
|
-
}
|
|
127
|
-
if (edges === 0) throw new Error('no edges to fillet')
|
|
128
|
-
algo.Build()
|
|
129
|
-
if (!algo.IsDone()) throw new Error('fillet failed (radius may exceed the adjacent faces)')
|
|
130
|
-
return algo.Shape()
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function castEdge(shape) {
|
|
134
|
-
if (occt.TopoDS.Edge_s) return occt.TopoDS.Edge_s(shape)
|
|
135
|
-
return occt.TopoDS.Edge_2(shape)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function castFace(shape) {
|
|
139
|
-
if (occt.TopoDS.Face_s) return occt.TopoDS.Face_s(shape)
|
|
140
|
-
return occt.TopoDS.Face_2(shape)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function transform(shape, { translate, rotate, mirror }) {
|
|
144
|
-
const trsf = identityTrsf()
|
|
145
|
-
if (translate !== undefined) trsf.SetTranslation_1(vec(translate[0], translate[1], translate[2]))
|
|
146
|
-
if (rotate !== undefined) {
|
|
147
|
-
const [rx, ry, rz] = rotate
|
|
148
|
-
if (rx) trsf.Multiply(rotationTrsf([1, 0, 0], rx * Math.PI / 180))
|
|
149
|
-
if (ry) trsf.Multiply(rotationTrsf([0, 1, 0], ry * Math.PI / 180))
|
|
150
|
-
if (rz) trsf.Multiply(rotationTrsf([0, 0, 1], rz * Math.PI / 180))
|
|
151
|
-
}
|
|
152
|
-
if (mirror !== undefined) {
|
|
153
|
-
const [mx, my, mz] = mirror
|
|
154
|
-
const mirrorTrsf = identityTrsf()
|
|
155
|
-
mirrorTrsf.SetMirror_1(new occt.gp_Ax2_3(pnt(0, 0, 0), dir(mx ?? 0, my ?? 0, mz ?? 1)))
|
|
156
|
-
trsf.Multiply(mirrorTrsf)
|
|
157
|
-
}
|
|
158
|
-
const algo = new occt.BRepBuilderAPI_Transform_2(shape, trsf, true)
|
|
159
|
-
return algo.Shape()
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function rotationTrsf(axis, radians) {
|
|
163
|
-
const t = identityTrsf()
|
|
164
|
-
t.SetRotation_1(new occt.gp_Ax1_2(pnt(0, 0, 0), dir(axis[0], axis[1], axis[2])), radians)
|
|
165
|
-
return t
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ── tessellation ───────────────────────────────────────────────────────────
|
|
169
|
-
function tessellate(shape, linearDeflection = 0.3) {
|
|
170
|
-
const mesher = new occt.BRepMesh_IncrementalMesh_2(shape, linearDeflection, false, 0.5, true)
|
|
171
|
-
mesher.Perform_1()
|
|
172
|
-
const positions = []
|
|
173
|
-
const indices = []
|
|
174
|
-
const loc = new occt.TopLoc_Location_2(identityTrsf())
|
|
175
|
-
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_FACE, ENUM.TopAbs_SHAPE)
|
|
176
|
-
let vertexBase = 0
|
|
177
|
-
while (explorer.More()) {
|
|
178
|
-
const faceShape = explorer.Current()
|
|
179
|
-
const handle = occt.BRep_Tool.Triangulation(castFace(faceShape), loc)
|
|
180
|
-
const tri = handle && handle.IsNull && !handle.IsNull() ? handle.get() : handle
|
|
181
|
-
if (tri) {
|
|
182
|
-
// A REVERSED face renders its surface natural orientation backwards —
|
|
183
|
-
// flip the triangle winding so normals point out of the material.
|
|
184
|
-
const reversed = faceShape.Orientation_1 !== undefined && faceShape.Orientation_1().value === 1
|
|
185
|
-
const nodeCount = tri.NbNodes()
|
|
186
|
-
for (let i = 1; i <= nodeCount; i++) {
|
|
187
|
-
const node = tri.Node(i)
|
|
188
|
-
// Optional location transform on the triangulation.
|
|
189
|
-
const transformed = loc.IsIdentity() ? node : node.Transformed(loc.Transformation())
|
|
190
|
-
positions.push(transformed.X(), transformed.Y(), transformed.Z())
|
|
191
|
-
}
|
|
192
|
-
for (let i = 1; i <= tri.NbTriangles(); i++) {
|
|
193
|
-
const t = tri.Triangle(i)
|
|
194
|
-
// Poly_Triangle.Get() uses out-params embind cannot express; Value(1..3) returns the indices.
|
|
195
|
-
const n1 = vertexBase + t.Value(1) - 1
|
|
196
|
-
const n2 = vertexBase + t.Value(2) - 1
|
|
197
|
-
const n3 = vertexBase + t.Value(3) - 1
|
|
198
|
-
if (reversed) indices.push(n1, n3, n2)
|
|
199
|
-
else indices.push(n1, n2, n3)
|
|
200
|
-
}
|
|
201
|
-
vertexBase += nodeCount
|
|
202
|
-
}
|
|
203
|
-
explorer.Next()
|
|
204
|
-
}
|
|
205
|
-
return { positions: Float32Array.from(positions), indices: Uint32Array.from(indices) }
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Flat per-triangle normals (mechanical-CAD look, no smooth-vertex table). */
|
|
209
|
-
function faceNormals(positions, indices) {
|
|
210
|
-
const normals = new Float32Array(positions.length)
|
|
211
|
-
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
212
|
-
const a = indices[i] * 3, b = indices[i + 1] * 3, c = indices[i + 2] * 3
|
|
213
|
-
const ux = positions[c] - positions[a], uy = positions[c + 1] - positions[a + 1], uz = positions[c + 2] - positions[a + 2]
|
|
214
|
-
const vx = positions[b] - positions[a], vy = positions[b + 1] - positions[a + 1], vz = positions[b + 2] - positions[a + 2]
|
|
215
|
-
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
216
|
-
const len = Math.hypot(nx, ny, nz) || 1
|
|
217
|
-
nx /= len; ny /= len; nz /= len
|
|
218
|
-
for (const corner of [a, b, c]) {
|
|
219
|
-
normals[corner] = nx; normals[corner + 1] = ny; normals[corner + 2] = nz
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return normals
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── export: STEP via MEMFS, STL as direct binary bytes ───────────────────
|
|
226
|
-
function exportFile(shape, format) {
|
|
227
|
-
if (format === 'step') {
|
|
228
|
-
const writer = new occt.STEPControl_Writer_1()
|
|
229
|
-
writer.Transfer(shape, 0, true)
|
|
230
|
-
writer.Write('model.step')
|
|
231
|
-
return Buffer.from(occt.FS.readFile('model.step'))
|
|
232
|
-
}
|
|
233
|
-
if (format === 'stl') {
|
|
234
|
-
// StlAPI_Writer intermittently fails inside the WASM filesystem; the
|
|
235
|
-
// tessellated mesh is exact, so emit binary STL bytes directly.
|
|
236
|
-
const { positions, indices } = tessellate(shape, 0.1)
|
|
237
|
-
const normals = faceNormals(positions, indices)
|
|
238
|
-
const triangleCount = indices.length / 3
|
|
239
|
-
const buffer = Buffer.alloc(84 + triangleCount * 50)
|
|
240
|
-
buffer.write('dsh-cad binary STL', 0, 22, 'latin1')
|
|
241
|
-
buffer.writeUInt32LE(triangleCount, 80)
|
|
242
|
-
let offset = 84
|
|
243
|
-
for (let triangle = 0; triangle < triangleCount; triangle++) {
|
|
244
|
-
const a = indices[triangle * 3] * 3
|
|
245
|
-
const b = indices[triangle * 3 + 1] * 3
|
|
246
|
-
const c = indices[triangle * 3 + 2] * 3
|
|
247
|
-
for (let component = 0; component < 3; component++) {
|
|
248
|
-
buffer.writeFloatLE(normals[a + component], offset)
|
|
249
|
-
offset += 4
|
|
250
|
-
}
|
|
251
|
-
for (const vertex of [a, b, c]) {
|
|
252
|
-
buffer.writeFloatLE(positions[vertex], offset)
|
|
253
|
-
buffer.writeFloatLE(positions[vertex + 1], offset + 4)
|
|
254
|
-
buffer.writeFloatLE(positions[vertex + 2], offset + 8)
|
|
255
|
-
offset += 12
|
|
256
|
-
}
|
|
257
|
-
offset += 2 // attribute byte count
|
|
258
|
-
}
|
|
259
|
-
return buffer
|
|
260
|
-
}
|
|
261
|
-
throw new Error(`unsupported export format: ${format}`)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ── loft / sweep ───────────────────────────────────────────────────────────
|
|
265
|
-
/**
|
|
266
|
-
* Closed polygon wire from flat [x,y,z, …] triplets. Loft sections carry
|
|
267
|
-
* explicit 3D coordinates (each section sits in its own plane), so unlike
|
|
268
|
-
* makeExtrudedProfile there is no implicit base plane here.
|
|
269
|
-
*/
|
|
270
|
-
function closedWire(points) {
|
|
271
|
-
if (points.length < 9 || points.length % 3 !== 0) {
|
|
272
|
-
throw new Error('a section needs at least 3 [x,y,z] triplets (≥9 numbers)')
|
|
273
|
-
}
|
|
274
|
-
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
275
|
-
for (let i = 0; i + 2 < points.length; i += 3) poly.Add_1(pnt(points[i], points[i + 1], points[i + 2]))
|
|
276
|
-
poly.Close()
|
|
277
|
-
if (!poly.IsDone()) throw new Error('section polygon is invalid (duplicate or collinear-only points)')
|
|
278
|
-
return poly.Wire()
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
// ── loft / sweep ───────────────────────────────────────────────────────────
|
|
282
|
-
/**
|
|
283
|
-
* Closed polygon wire from flat [x,y,z, …] triplets. Loft sections carry
|
|
284
|
-
* explicit 3D coordinates (each section sits in its own plane), so unlike
|
|
285
|
-
* makeExtrudedProfile there is no implicit base plane here.
|
|
286
|
-
*/
|
|
287
|
-
function closedWire(points) {
|
|
288
|
-
if (points.length < 9 || points.length % 3 !== 0) {
|
|
289
|
-
throw new Error('a section needs at least 3 [x,y,z] triplets (≥9 numbers)')
|
|
290
|
-
}
|
|
291
|
-
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
292
|
-
for (let i = 0; i + 2 < points.length; i += 3) poly.Add_1(pnt(points[i], points[i + 1], points[i + 2]))
|
|
293
|
-
poly.Close()
|
|
294
|
-
if (!poly.IsDone()) throw new Error('section polygon is invalid (duplicate or collinear-only points)')
|
|
295
|
-
return poly.Wire()
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/** A planar FACE in the given frame's plane (MakePipe needs a face, not a wire). */
|
|
299
|
-
function faceInFrame(ax3, points2d) {
|
|
300
|
-
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
301
|
-
const xd = ax3.XDirection()
|
|
302
|
-
const yd = ax3.YDirection()
|
|
303
|
-
const o = ax3.Location()
|
|
304
|
-
for (let i = 0; i + 1 < points2d.length; i += 2) {
|
|
305
|
-
const u = points2d[i]
|
|
306
|
-
const v = points2d[i + 1]
|
|
307
|
-
poly.Add_1(pnt(
|
|
308
|
-
o.X() + xd.X() * u + yd.X() * v,
|
|
309
|
-
o.Y() + xd.Y() * u + yd.Y() * v,
|
|
310
|
-
o.Z() + xd.Z() * u + yd.Z() * v,
|
|
311
|
-
))
|
|
312
|
-
}
|
|
313
|
-
poly.Close()
|
|
314
|
-
if (!poly.IsDone()) throw new Error('profile polygon is invalid')
|
|
315
|
-
const builder = new occt.BRepBuilderAPI_MakeFace_3(new occt.gp_Pln_2(ax3))
|
|
316
|
-
builder.Add(poly.Wire())
|
|
317
|
-
const face = builder.Face()
|
|
318
|
-
if (!builder.IsDone() || face.IsNull()) throw new Error('profile face construction failed')
|
|
319
|
-
return face
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Closed Catmull-Rom polyline through the given flat [x,y,z, …] points,
|
|
324
|
-
* sampled at `samples` chords per span. This build has no curve→edge
|
|
325
|
-
* binding, so the occt.ts backend's periodic B-spline section is carried
|
|
326
|
-
* here as a dense closed polyline (the same approximation the bspline
|
|
327
|
-
* profile segment uses) — the loft still smooths across sections.
|
|
328
|
-
*/
|
|
329
|
-
function smoothLoop(points, samples = 16) {
|
|
330
|
-
if (!Array.isArray(points) || points.length < 9 || points.length % 3 !== 0) {
|
|
331
|
-
throw new Error('a section needs at least 3 [x,y,z] triplets (≥9 numbers)')
|
|
332
|
-
}
|
|
333
|
-
const n = points.length / 3
|
|
334
|
-
const at = (i) => {
|
|
335
|
-
const k = ((i % n) + n) % n
|
|
336
|
-
return [points[k * 3], points[k * 3 + 1], points[k * 3 + 2]]
|
|
337
|
-
}
|
|
338
|
-
const out = []
|
|
339
|
-
for (let i = 0; i < n; i++) {
|
|
340
|
-
const p0 = at(i - 1)
|
|
341
|
-
const p1 = at(i)
|
|
342
|
-
const p2 = at(i + 1)
|
|
343
|
-
const p3 = at(i + 2)
|
|
344
|
-
for (let s = 0; s < samples; s++) {
|
|
345
|
-
const t = s / samples
|
|
346
|
-
const t2 = t * t
|
|
347
|
-
const t3 = t2 * t
|
|
348
|
-
for (let axis = 0; axis < 3; axis++) {
|
|
349
|
-
out.push(0.5 * (
|
|
350
|
-
2 * p1[axis]
|
|
351
|
-
+ (-p0[axis] + p2[axis]) * t
|
|
352
|
-
+ (2 * p0[axis] - 5 * p1[axis] + 4 * p2[axis] - p3[axis]) * t2
|
|
353
|
-
+ (-p0[axis] + 3 * p1[axis] - 3 * p2[axis] + p3[axis]) * t3
|
|
354
|
-
))
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
}
|
|
358
|
-
return closedWire(out)
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const sub3 = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]]
|
|
362
|
-
const dot3 = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
363
|
-
const cross3 = (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]]
|
|
364
|
-
const norm3 = (a) => {
|
|
365
|
-
const len = Math.hypot(a[0], a[1], a[2]) || 1
|
|
366
|
-
return [a[0] / len, a[1] / len, a[2] / len]
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
/** Plane frame (origin, in-plane X, in-plane Y) for a planar curve section. */
|
|
370
|
-
function sectionFrame(section) {
|
|
371
|
-
const at = section.at ?? [0, 0, 0]
|
|
372
|
-
if (!Array.isArray(at) || at.length !== 3) throw new Error('section.at must be [x,y,z]')
|
|
373
|
-
const n = norm3(Array.isArray(section.normal) ? section.normal : [0, 0, 1])
|
|
374
|
-
const seed = Array.isArray(section.xAxis) ? section.xAxis : [1, 0, 0]
|
|
375
|
-
let u = sub3(seed, n.map((c) => c * dot3(seed, n)))
|
|
376
|
-
if (Math.hypot(u[0], u[1], u[2]) < 1e-9) {
|
|
377
|
-
const alt = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
|
|
378
|
-
u = sub3(alt, n.map((c) => c * dot3(alt, n)))
|
|
379
|
-
}
|
|
380
|
-
u = norm3(u)
|
|
381
|
-
return [at, u, cross3(n, u)]
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/**
|
|
385
|
-
* One loft section → a closed wire.
|
|
386
|
-
* [x,y,z, …] flat point loop (polygon, or smooth spline)
|
|
387
|
-
* { points: […], smooth?: bool } same, with a per-section override
|
|
388
|
-
* { through: [x,y,z, …] } smooth curve through the points
|
|
389
|
-
* { profile, at?, normal?, xAxis? } 2D curve profile placed in a plane
|
|
390
|
-
*/
|
|
391
|
-
function sectionWire(section, smoothDefault) {
|
|
392
|
-
if (Array.isArray(section)) return smoothDefault ? smoothLoop(section) : closedWire(section)
|
|
393
|
-
if (section === null || typeof section !== 'object') {
|
|
394
|
-
throw new Error('a loft section must be a flat [x,y,z,…] loop or a {points|through|profile, …} object')
|
|
395
|
-
}
|
|
396
|
-
if (section.profile !== undefined) {
|
|
397
|
-
const [o3, u3, v3] = sectionFrame(section)
|
|
398
|
-
return profileWire(section.profile, o3, u3, v3)
|
|
399
|
-
}
|
|
400
|
-
if (section.through !== undefined) return smoothLoop(section.through)
|
|
401
|
-
if (section.points !== undefined) {
|
|
402
|
-
return (section.smooth ?? smoothDefault) ? smoothLoop(section.points) : closedWire(section.points)
|
|
403
|
-
}
|
|
404
|
-
throw new Error('a section object needs `points`, `through` or `profile`')
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Loft: skin a solid through successive closed sections (OCCT ThruSections).
|
|
409
|
-
* Sections are flat [x,y,z, …] loops or section objects (see sectionWire);
|
|
410
|
-
* `smooth` fits a periodic B-spline through plain point loops on the occt.ts
|
|
411
|
-
* backend, `solid` caps the ends, `ruled` keeps the sides straight.
|
|
412
|
-
*/
|
|
413
|
-
function makeLoft(sections, options = {}) {
|
|
414
|
-
if (!Array.isArray(sections) || sections.length < 2) throw new Error('a loft needs at least 2 sections')
|
|
415
|
-
const solid = options.solid ?? true
|
|
416
|
-
const ruled = options.ruled ?? false
|
|
417
|
-
const smooth = options.smooth === true
|
|
418
|
-
// Verified spelling: this build binds only the 3-argument constructor.
|
|
419
|
-
const thru = new occt.BRepOffsetAPI_ThruSections(solid, ruled, 1e-6)
|
|
420
|
-
for (const section of sections) thru.AddWire(sectionWire(section, smooth))
|
|
421
|
-
thru.Build()
|
|
422
|
-
if (!thru.IsDone()) throw new Error('loft failed (check that the sections are closed and non-degenerate)')
|
|
423
|
-
return thru.Shape()
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
/**
|
|
427
|
-
* Sweep: pipe a 2D profile along a 3D path (OCCT MakePipe). The profile is
|
|
428
|
-
* placed in the plane PERPENDICULAR TO THE PATH'S START TANGENT, so callers
|
|
429
|
-
* give a plain 2D outline plus a 3D path in any orientation.
|
|
430
|
-
*
|
|
431
|
-
* Note: MakePipeShell (the transition-aware variant) is unusable in this
|
|
432
|
-
* opencascade.js build, so a sharp direction change in the path with a
|
|
433
|
-
* section large relative to the corner yields a self-intersecting solid —
|
|
434
|
-
* `isValid()` reports that, and the tool surfaces it.
|
|
435
|
-
*/
|
|
436
|
-
function makeSweep(profile, pathPoints) {
|
|
437
|
-
const profileOk = Array.isArray(profile)
|
|
438
|
-
? profile.length >= 6 && profile.length % 2 === 0
|
|
439
|
-
: profile !== null && typeof profile === 'object'
|
|
440
|
-
if (!profileOk) throw new Error('the profile needs a flat [x,y,…] array (≥6 numbers) or a {start, segments}/{circle} object')
|
|
441
|
-
if (!Array.isArray(pathPoints) || pathPoints.length < 6 || pathPoints.length % 3 !== 0) {
|
|
442
|
-
throw new Error('the path needs at least 2 [x,y,z] triplets (≥6 numbers)')
|
|
443
|
-
}
|
|
444
|
-
const spine = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
445
|
-
for (let i = 0; i + 2 < pathPoints.length; i += 3) {
|
|
446
|
-
spine.Add_1(pnt(pathPoints[i], pathPoints[i + 1], pathPoints[i + 2]))
|
|
447
|
-
}
|
|
448
|
-
if (!spine.IsDone()) throw new Error('path polyline is invalid')
|
|
449
|
-
// Frame at the path start: +Z (the gp_Ax3 normal) along the initial
|
|
450
|
-
// tangent; the in-plane X axis is world X projected perpendicular to it
|
|
451
|
-
// (world Y when that degenerates), so a profile maps onto predictable
|
|
452
|
-
// world axes — for a +Z sweep the 2D outline lands exactly on world XY.
|
|
453
|
-
const tx = pathPoints[3] - pathPoints[0]
|
|
454
|
-
const ty = pathPoints[4] - pathPoints[1]
|
|
455
|
-
const tz = pathPoints[5] - pathPoints[2]
|
|
456
|
-
const tLen = Math.hypot(tx, ty, tz)
|
|
457
|
-
if (tLen < 1e-12) throw new Error('the path starts with a zero-length segment')
|
|
458
|
-
const n = [tx / tLen, ty / tLen, tz / tLen]
|
|
459
|
-
const axis = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
|
|
460
|
-
const d = axis[0] * n[0] + axis[1] * n[1] + axis[2] * n[2]
|
|
461
|
-
let vx = [axis[0] - n[0] * d, axis[1] - n[1] * d, axis[2] - n[2] * d]
|
|
462
|
-
const vxLen = Math.hypot(vx[0], vx[1], vx[2])
|
|
463
|
-
if (vxLen < 1e-12) throw new Error('could not build a profile frame for the path tangent')
|
|
464
|
-
vx = [vx[0] / vxLen, vx[1] / vxLen, vx[2] / vxLen]
|
|
465
|
-
const o3 = [pathPoints[0], pathPoints[1], pathPoints[2]]
|
|
466
|
-
const v3 = [vx[0], vx[1], vx[2]] // in-plane X = vx
|
|
467
|
-
const w3 = [n[1] * vx[2] - n[2] * vx[1], n[2] * vx[0] - n[0] * vx[2], n[0] * vx[1] - n[1] * vx[0]] // n × vx
|
|
468
|
-
const wire = profileWire(profile, o3, v3, w3)
|
|
469
|
-
const profileFace = faceFromWire(wire, o3, v3, w3)
|
|
470
|
-
const pipe = new occt.BRepOffsetAPI_MakePipe_1(spine.Wire(), profileFace)
|
|
471
|
-
pipe.Build()
|
|
472
|
-
if (!pipe.IsDone()) throw new Error('sweep failed (check the path and profile)')
|
|
473
|
-
return pipe.Shape()
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
/** BRepCheck_Analyzer verdict, or null when the check itself is unavailable. */
|
|
477
|
-
function isValid(shape) {
|
|
478
|
-
try {
|
|
479
|
-
const analyzer = new occt.BRepCheck_Analyzer(shape, true)
|
|
480
|
-
return analyzer.IsValid_1(shape) === true
|
|
481
|
-
} catch {
|
|
482
|
-
return null
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
// ── segment-based profiles (line / arc / bspline / circle) ────────────────
|
|
487
|
-
/**
|
|
488
|
-
* Build a CLOSED wire from a segment-based profile laid into the frame
|
|
489
|
-
* (origin o3, basis u3/v3). Segment forms:
|
|
490
|
-
* { type: 'line', to: [x,y] }
|
|
491
|
-
* { type: 'arc', to: [x,y], center: [cx,cy], ccw?: true }
|
|
492
|
-
* { type: 'bspline', through: [[x,y],…], samples?: n }
|
|
493
|
-
* A final line closes the chain back to the start. Verified spellings:
|
|
494
|
-
* gp_Circ_2(ax2,R) → MakeEdge_9(circ, a1, a2) for arcs.
|
|
495
|
-
*/
|
|
496
|
-
function to3Of(o3, u3, v3, p2) {
|
|
497
|
-
return pnt(o3[0] + u3[0] * p2[0] + v3[0] * p2[1], o3[1] + u3[1] * p2[0] + v3[1] * p2[1], o3[2] + u3[2] * p2[0] + v3[2] * p2[1])
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
function segmentWire(profile, o3, u3, v3) {
|
|
501
|
-
const segs = Array.isArray(profile.segments) ? profile.segments : []
|
|
502
|
-
if (segs.length === 0) throw new Error('a segment profile needs at least one segment')
|
|
503
|
-
if (!Array.isArray(profile.start) || profile.start.length !== 2) throw new Error('profile.start must be [x,y]')
|
|
504
|
-
const nrm = [
|
|
505
|
-
u3[1] * v3[2] - u3[2] * v3[1],
|
|
506
|
-
u3[2] * v3[0] - u3[0] * v3[2],
|
|
507
|
-
u3[0] * v3[1] - u3[1] * v3[0],
|
|
508
|
-
]
|
|
509
|
-
const mkWire = new occt.BRepBuilderAPI_MakeWire_1()
|
|
510
|
-
const addLine = (from2, to2) => {
|
|
511
|
-
mkWire.Add_1(new occt.BRepBuilderAPI_MakeEdge_3(to3Of(o3, u3, v3, from2), to3Of(o3, u3, v3, to2)).Edge())
|
|
512
|
-
}
|
|
513
|
-
let cur = [profile.start[0], profile.start[1]]
|
|
514
|
-
for (const seg of segs) {
|
|
515
|
-
if (seg === null || typeof seg !== 'object') throw new Error('each profile segment must be an object')
|
|
516
|
-
if (seg.type === 'line') {
|
|
517
|
-
if (!Array.isArray(seg.to) || seg.to.length !== 2) throw new Error("line segment needs 'to: [x,y]'")
|
|
518
|
-
addLine(cur, seg.to)
|
|
519
|
-
cur = seg.to
|
|
520
|
-
} else if (seg.type === 'arc') {
|
|
521
|
-
if (!Array.isArray(seg.to) || seg.to.length !== 2 || !Array.isArray(seg.center) || seg.center.length !== 2) {
|
|
522
|
-
throw new Error("arc segment needs 'to: [x,y]' and 'center: [cx,cy]'")
|
|
523
|
-
}
|
|
524
|
-
const c = seg.center
|
|
525
|
-
const r0 = Math.hypot(cur[0] - c[0], cur[1] - c[1])
|
|
526
|
-
const r1 = Math.hypot(seg.to[0] - c[0], seg.to[1] - c[1])
|
|
527
|
-
if (Math.abs(r0 - r1) > 1e-4 * Math.max(r0, r1) + 1e-6) {
|
|
528
|
-
throw new Error(`arc endpoints are not equidistant from the center (r=${r0.toFixed(4)} vs ${r1.toFixed(4)})`)
|
|
529
|
-
}
|
|
530
|
-
const a0 = Math.atan2(cur[1] - c[1], cur[0] - c[0])
|
|
531
|
-
let a1 = Math.atan2(seg.to[1] - c[1], seg.to[0] - c[0])
|
|
532
|
-
if (seg.ccw !== false) { if (a1 <= a0) a1 += Math.PI * 2 } else { if (a1 >= a0) a1 -= Math.PI * 2 }
|
|
533
|
-
const circ = new occt.gp_Circ_2(new occt.gp_Ax2_2(to3Of(o3, u3, v3, c), dir(nrm[0], nrm[1], nrm[2]), dir(u3[0], u3[1], u3[2])), r0)
|
|
534
|
-
mkWire.Add_1(new occt.BRepBuilderAPI_MakeEdge_9(circ, a0, a1).Edge())
|
|
535
|
-
cur = seg.to
|
|
536
|
-
} else if (seg.type === 'bspline') {
|
|
537
|
-
const through = Array.isArray(seg.through) ? seg.through : []
|
|
538
|
-
if (through.length < 2) throw new Error("bspline segment needs 'through: [[x,y],…]' (≥2 points)")
|
|
539
|
-
const samples = Math.max(8, Math.trunc(seg.samples ?? 24))
|
|
540
|
-
// Catmull-Rom through the given points, sampled into a dense polyline:
|
|
541
|
-
// this kernel build cannot extract points back from a Geom_BSplineCurve,
|
|
542
|
-
// so the smooth curve is carried as a high-density edge chain.
|
|
543
|
-
const pts = [cur, ...through]
|
|
544
|
-
for (let i = 0; i < pts.length - 1; i++) {
|
|
545
|
-
const p0 = pts[Math.max(0, i - 1)]
|
|
546
|
-
const p1 = pts[i]
|
|
547
|
-
const p2 = pts[i + 1]
|
|
548
|
-
const p3 = pts[Math.min(pts.length - 1, i + 2)]
|
|
549
|
-
for (let s = 1; s <= samples; s++) {
|
|
550
|
-
const t = s / samples
|
|
551
|
-
const t2 = t * t
|
|
552
|
-
const t3 = t2 * t
|
|
553
|
-
const x = 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3)
|
|
554
|
-
const y = 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3)
|
|
555
|
-
addLine(cur, [x, y])
|
|
556
|
-
cur = [x, y]
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
} else {
|
|
560
|
-
throw new Error(`unknown profile segment type: ${String(seg.type)}`)
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
if (Math.hypot(cur[0] - profile.start[0], cur[1] - profile.start[1]) > 1e-9) {
|
|
564
|
-
addLine(cur, profile.start)
|
|
565
|
-
}
|
|
566
|
-
if (!mkWire.IsDone()) throw new Error('profile wire construction failed')
|
|
567
|
-
return mkWire.Wire()
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
/** A closed circular wire (the profile = one circle). */
|
|
571
|
-
function circleWire(center2, radius, o3, u3, v3) {
|
|
572
|
-
const c3 = to3Of(o3, u3, v3, center2)
|
|
573
|
-
const nrm = [u3[1] * v3[2] - u3[2] * v3[1], u3[2] * v3[0] - u3[0] * v3[2], u3[0] * v3[1] - u3[1] * v3[0]]
|
|
574
|
-
const circ = new occt.gp_Circ_2(new occt.gp_Ax2_2(c3, dir(nrm[0], nrm[1], nrm[2]), dir(u3[0], u3[1], u3[2])), radius)
|
|
575
|
-
const mkWire = new occt.BRepBuilderAPI_MakeWire_1()
|
|
576
|
-
mkWire.Add_1(new occt.BRepBuilderAPI_MakeEdge_8(circ).Edge())
|
|
577
|
-
if (!mkWire.IsDone()) throw new Error('circle wire construction failed')
|
|
578
|
-
return mkWire.Wire()
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
/**
|
|
582
|
-
* A 2D profile in one of three forms, laid into the (o3, u3, v3) frame and
|
|
583
|
-
* returned as a closed wire:
|
|
584
|
-
* { start, segments: [...] } — segment chain (line/arc/bspline)
|
|
585
|
-
* { circle: { center, radius } } — a full circle
|
|
586
|
-
* [x0,y0, x1,y1, ...] — plain polyline (the historical form)
|
|
587
|
-
*/
|
|
588
|
-
function profileWire(profile, o3, u3, v3) {
|
|
589
|
-
if (Array.isArray(profile)) {
|
|
590
|
-
const pts = []
|
|
591
|
-
for (let i = 0; i + 1 < profile.length; i += 2) {
|
|
592
|
-
const p3 = to3Of(o3, u3, v3, [profile[i], profile[i + 1]])
|
|
593
|
-
pts.push(p3.X(), p3.Y(), p3.Z())
|
|
594
|
-
}
|
|
595
|
-
return closedWire(pts)
|
|
596
|
-
}
|
|
597
|
-
if (profile !== null && typeof profile === 'object') {
|
|
598
|
-
if (profile.circle !== undefined) {
|
|
599
|
-
const c = profile.circle
|
|
600
|
-
if (!Array.isArray(c.center) || typeof c.radius !== 'number') throw new Error("circle profile needs 'center: [x,y]' and 'radius'")
|
|
601
|
-
return circleWire(c.center, c.radius, o3, u3, v3)
|
|
602
|
-
}
|
|
603
|
-
return segmentWire(profile, o3, u3, v3)
|
|
604
|
-
}
|
|
605
|
-
throw new Error('a profile must be a flat points array or a {start, segments}/{circle} object')
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
/**
|
|
609
|
-
* Extrude any 2D profile form (segments / circle / flat points) from the
|
|
610
|
-
* plane z = base along +Z by height — the segment-curve generalization of
|
|
611
|
-
* makeExtrudedProfile.
|
|
612
|
-
*/
|
|
613
|
-
function extrudeProfile2D(profile, height, base = 0) {
|
|
614
|
-
if (height <= 0) throw new Error('the extrusion height must be positive')
|
|
615
|
-
const wire = profileWire(profile, [0, 0, base], [1, 0, 0], [0, 1, 0])
|
|
616
|
-
const face = faceFromWire(wire, [0, 0, base], [1, 0, 0], [0, 1, 0])
|
|
617
|
-
const algo = new occt.BRepPrimAPI_MakePrism_1(face, vec(0, 0, height), true, false)
|
|
618
|
-
return shapeOf(algo)
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
/**
|
|
622
|
-
* A planar FACE from a wire in the (o3, u3, v3) frame. `flip` reverses the
|
|
623
|
-
* face normal — revolve needs the axis×radial side for axis-touching
|
|
624
|
-
* profiles (a profile edge ON the axis fails from the other side).
|
|
625
|
-
*/
|
|
626
|
-
function faceFromWire(wire, o3, u3, v3, flip = false) {
|
|
627
|
-
const nrm = [
|
|
628
|
-
u3[1] * v3[2] - u3[2] * v3[1],
|
|
629
|
-
u3[2] * v3[0] - u3[0] * v3[2],
|
|
630
|
-
u3[0] * v3[1] - u3[1] * v3[0],
|
|
631
|
-
]
|
|
632
|
-
const sign = flip ? -1 : 1
|
|
633
|
-
const builder = new occt.BRepBuilderAPI_MakeFace_3(new occt.gp_Pln_2(new occt.gp_Ax3_3(pnt(o3[0], o3[1], o3[2]), dir(sign * nrm[0], sign * nrm[1], sign * nrm[2]), dir(u3[0], u3[1], u3[2]))))
|
|
634
|
-
builder.Add(wire)
|
|
635
|
-
const face = builder.Face()
|
|
636
|
-
if (!builder.IsDone() || face.IsNull()) throw new Error('profile face construction failed')
|
|
637
|
-
return face
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// ── revolve / chamfer / shell ──────────────────────────────────────────────
|
|
641
|
-
|
|
642
|
-
/**
|
|
643
|
-
* Revolve (旋转): sweep a 2D profile around an axis through `at` with
|
|
644
|
-
* direction `axis` by `angle` radians (default 2π). The profile's u axis is
|
|
645
|
-
* a radial direction perpendicular to the axis, v runs ALONG the axis —
|
|
646
|
-
* e.g. with the default +Z axis, profile [x,y] means (radius, height).
|
|
647
|
-
* Verified: BRepPrimAPI_MakeRevol_1(face, ax1, angle, copy=false).
|
|
648
|
-
*/
|
|
649
|
-
function makeRevolve(profile, options = {}) {
|
|
650
|
-
const axis = options.axis ?? [0, 0, 1]
|
|
651
|
-
const at = options.at ?? [0, 0, 0]
|
|
652
|
-
const angle = options.angle ?? Math.PI * 2
|
|
653
|
-
const alen = Math.hypot(axis[0], axis[1], axis[2])
|
|
654
|
-
if (alen < 1e-12) throw new Error('the revolve axis must be a non-zero direction')
|
|
655
|
-
const n = [axis[0] / alen, axis[1] / alen, axis[2] / alen]
|
|
656
|
-
const helper = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
|
|
657
|
-
const d = helper[0] * n[0] + helper[1] * n[1] + helper[2] * n[2]
|
|
658
|
-
const u = [helper[0] - n[0] * d, helper[1] - n[1] * d, helper[2] - n[2] * d]
|
|
659
|
-
const uLen = Math.hypot(u[0], u[1], u[2])
|
|
660
|
-
const radial = [u[0] / uLen, u[1] / uLen, u[2] / uLen]
|
|
661
|
-
const wire = profileWire(profile, at, radial, n)
|
|
662
|
-
const ax1 = new occt.gp_Ax1_2(pnt(at[0], at[1], at[2]), dir(n[0], n[1], n[2]))
|
|
663
|
-
// Orientation matrix (verified): the plain (radial, axis) face revolves
|
|
664
|
-
// validly at every angle with positive volume; the flipped face suits
|
|
665
|
-
// axis-touching profiles but yields INVALID partial revolves — so try the
|
|
666
|
-
// plain face first, fall back to flipped only when it fails outright.
|
|
667
|
-
const attempts = [false, true]
|
|
668
|
-
for (const flip of attempts) {
|
|
669
|
-
const algo = new occt.BRepPrimAPI_MakeRevol_1(faceFromWire(wire, at, radial, n, flip), ax1, angle, false)
|
|
670
|
-
algo.Build()
|
|
671
|
-
if (!algo.IsDone()) continue
|
|
672
|
-
const shape = algo.Shape()
|
|
673
|
-
if (isValid(shape) === false) continue
|
|
674
|
-
return volume(shape) < 0 ? (() => { try { return shape.Reversed() } catch { return shape } })() : shape
|
|
675
|
-
}
|
|
676
|
-
throw new Error('revolve failed (check that the profile stays on one side of the axis and is planar)')
|
|
677
|
-
}
|
|
678
|
-
|
|
679
|
-
/** Chamfer every sharp edge with one equal distance (mm). */
|
|
680
|
-
function chamferAll(shape, distance) {
|
|
681
|
-
const algo = new occt.BRepFilletAPI_MakeChamfer(shape)
|
|
682
|
-
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_EDGE, ENUM.TopAbs_SHAPE)
|
|
683
|
-
let edges = 0
|
|
684
|
-
while (explorer.More()) {
|
|
685
|
-
algo.Add_2(distance, castEdge(explorer.Current()))
|
|
686
|
-
edges++
|
|
687
|
-
explorer.Next()
|
|
688
|
-
}
|
|
689
|
-
if (edges === 0) throw new Error('no edges to chamfer')
|
|
690
|
-
algo.Build()
|
|
691
|
-
if (!algo.IsDone()) throw new Error('chamfer failed (distance may exceed the adjacent faces)')
|
|
692
|
-
return algo.Shape()
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
return {
|
|
696
|
-
pnt, dir, ENUM,
|
|
697
|
-
makePrim, makeExtrudedProfile, extrudeProfile2D, makeLoft, makeSweep, isValid,
|
|
698
|
-
profileWire, faceFromWire, makeRevolve, chamferAll,
|
|
699
|
-
boolean, filletAll, transform,
|
|
700
|
-
tessellate, faceNormals, exportFile, volume,
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
module.exports = { createAdapter }
|