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 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 → 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 |
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 primarily runs on the **occt.ts kernel** (opencascade.js as the fallback backend) (OCCT in the browser —
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 (opencascade.js WASM)
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
- (opencascade.js, full OCCT) are separate, both lazily started; the `_N`
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. New APIs over opencascade.js: `hiddenLines()` true hidden-line
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 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.
226
- opencascade.js remains as the FALLBACK backend (auto-selected when occt.ts
227
- cannot load). Assembly STEP export writes a **structured document** (occt.ts
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; the fallback backend also fuses). Instance separation stays in
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 → 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` 可显式指定。旧版的「抽壳后不可编辑」接缝消失——抽壳/拔模后可继续圆角/倒角/布尔 |
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
- 建模主要由 **occt.ts** 内核承担(opencascade.js 为降级后端)(浏览器内的 OCCT 内核,零安装);
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(opencascade.js WASM)
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)与建模(opencascade.js,
178
- 完整 OCCT)分离,均惰性启动;embind 重载构造器的 `_N` 后缀约定封装在
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 消隐,无替代引擎、缺失即报错。相对 opencascade.js 的新增 API:
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 直连与 opencascade.js 保留为回退层级(`DSH_CAD_KERNEL` 可显式指定,
205
- 动词级映射见 docs/cadorange-integration.md)。抽壳后继续编辑无缝。opencascade.js 保留为**降级后端**
206
- (occt.ts 加载失败时自动启用)。装配体 STEP 导出为**结构化文档**(occt.ts
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: 'opencascade.js 1.1.1 WASM worker; three.js typed-array rendering, zero install',
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 → opencascade.js until this backend holds the parity gate.
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: occt.ts is the PRIMARY backend (single-kernel modeling,
27
- // true B-splines, shell/draft native, direct HLR); opencascade.js stays as
28
- // the fallback when occt.ts cannot load. `adapter` carries whichever.
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, step? } — the live document. A body whose solid
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 same
520
- // occt.ts wasm) -> direct occt.ts -> opencascade.js. A cadorange boot
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 !== null) {
535
- adapter = createOcctTsAdapter(mod)
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 modeling kernel stays on opencascade.js (full raw surface); this bridge
8
- * serves one purpose: true-HLR drawing views. Geometry crosses kernels as
9
- * STEP bytes (adapter's proven MEMFS export → occt.ts readStep), and the
10
- * projected segments are remapped into the caller's screen frame (u = right,
11
- * v = up) before chaining into polylines, so the sheet layout consumes the
12
- * exact same view data the mesh-projected fallback produces.
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; opencascade.js stays installed only as the fallback
5
- * backend for environments where occt.ts cannot load.
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
@@ -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
- 'Runs on the occt.ts kernel (>= 0.3.0): the result becomes a HOSTED body — volume/export(.step)/display keep working, but opencascade.js-only edits (fillet/chamfer/loft/sweep/revolve/boolean) on it are refused, so do those before shelling.',
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: opencascade.js (WASM) in a worker, rendered with WebGL/three.js. */
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 opencascade.js WASM
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.0",
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/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')\"",
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 }