@faicad/cq-compat 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/assembly-compare.d.ts +135 -0
  2. package/dist/assembly-compare.d.ts.map +1 -0
  3. package/dist/assembly-compare.js +266 -0
  4. package/dist/assembly-compare.js.map +1 -0
  5. package/dist/assembly.d.ts +102 -0
  6. package/dist/assembly.d.ts.map +1 -0
  7. package/dist/assembly.js +403 -0
  8. package/dist/assembly.js.map +1 -0
  9. package/dist/browser.d.ts +13 -0
  10. package/dist/browser.d.ts.map +1 -0
  11. package/dist/browser.js +13 -0
  12. package/dist/browser.js.map +1 -0
  13. package/dist/gear-test-harness.d.ts +79 -0
  14. package/dist/gear-test-harness.d.ts.map +1 -0
  15. package/dist/gear-test-harness.js +112 -0
  16. package/dist/gear-test-harness.js.map +1 -0
  17. package/dist/gears.d.ts +226 -0
  18. package/dist/gears.d.ts.map +1 -0
  19. package/dist/gears.js +274 -0
  20. package/dist/gears.js.map +1 -0
  21. package/dist/geom-types.d.ts +7 -0
  22. package/dist/geom-types.d.ts.map +1 -0
  23. package/dist/geom-types.js +2 -0
  24. package/dist/geom-types.js.map +1 -0
  25. package/dist/index.d.ts +26 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +23 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/step-compare.d.ts +83 -0
  30. package/dist/step-compare.d.ts.map +1 -0
  31. package/dist/step-compare.js +140 -0
  32. package/dist/step-compare.js.map +1 -0
  33. package/dist/transpile.d.ts +42 -0
  34. package/dist/transpile.d.ts.map +1 -0
  35. package/dist/transpile.js +557 -0
  36. package/dist/transpile.js.map +1 -0
  37. package/dist/workplane.d.ts +1216 -0
  38. package/dist/workplane.d.ts.map +1 -0
  39. package/dist/workplane.js +4026 -0
  40. package/dist/workplane.js.map +1 -0
  41. package/package.json +48 -0
@@ -0,0 +1,4026 @@
1
+ /**
2
+ * @faicad/cq-compat — CadQuery API compatibility layer for faijs.
3
+ *
4
+ * Implements a Workplane carrier (geometry hidden in .shape) and
5
+ * CadQuery-style methods. All methods are async and return a new Workplane
6
+ * (immutable updates). The carrier uses a custom prototype so compatOp's
7
+ * `borrowDeep` does not traverse its fields when the library is auto-lifted.
8
+ *
9
+ * Design doc: docs/plans/2026-09-06-cadquery-compat-and-multifile-faijs.md
10
+ */
11
+ import { createApiNamespace } from '@faicad/faijs/api/api-namespace';
12
+ import { brepjsCompat } from '@faicad/faijs/api';
13
+ import { borrowBrepjsShape, adoptBrepjsProduct } from '@faicad/faijs/api/internal/l3-bridge';
14
+ import { fromHandle } from '@faicad/faijs/sdk';
15
+ import { brepOf, isShape } from '@faicad/faijs/shape';
16
+ import { getKernel } from '@faicad/faijs/occt-kernel/occtKernel';
17
+ // ── cad namespace singleton (created once at module load) ──────────────────
18
+ const cad = createApiNamespace();
19
+ // ── compatOp 提升边界归一(GOTCHA:borrowDeep 把实参 Shape 换成借用视图)──
20
+ //
21
+ // 当 cq-compat 命名空间被 registerLib 提升(无 dual-op → autoLift=true)时,
22
+ // 每个裸导出函数的实参先经 borrowDeep:faijs Shape → 借用 brepjs 视图
23
+ // `{ wrapped, disposed, delete, onDispose }`(`isShape=false`、`brepOf=undefined`)。
24
+ // 直接调用(测试进程内)拿到的则是真实 Shape。两个形态都必须能消费:
25
+ //
26
+ // asBrepShape(v) —
27
+ // - 真实 Shape → 原样返回;
28
+ // - 借用视图(有 `.wrapped`)→ 提取原始 OCCT 句柄,fromHandle 还原为真实
29
+ // Shape(mesh 三角化 + BREP 身份槽登记,brepOf 可恢复),按视图对象缓存
30
+ // (同句柄多次调用不重复三角化);
31
+ // - 其余 → 原样返回(调用方自行判空/报错)。
32
+ //
33
+ // 所有权:归一出的新 Shape 与原 part Shape 的 slot 指向同一 OCCT 句柄,但 slot
34
+ // 按 Shape 对象各自持有(fromBrep 写的是新 Shape 的 slot),无共享释放路径——
35
+ // 与 vendored 投影的 adoptBrepjsProduct 收编模式同构,不引入双重释放。
36
+ // 视图 `.wrapped` 是 OcctWasmHandle 对象({ id, type, __occtWasm }),内核只收
37
+ // 数字 id → 解包方式与 l3-bridge.adoptBrepjsProduct 一致(`'id' in wrapped` 分支)。
38
+ const borrowedShapeCache = new WeakMap();
39
+ /**
40
+ * 把可能是借用视图的几何输入归一为真实 faijs Shape(见上方注释)。
41
+ * @param v 真实 Shape、借用视图(`{ wrapped }`)或其他原样透传的输入。
42
+ * @returns 真实 faijs `Shape`(借用视图经 `fromHandle` 还原并缓存)。
43
+ */
44
+ export function asBrepShape(v) {
45
+ if (isShape(v))
46
+ return v;
47
+ if (v !== null && typeof v === 'object' && 'wrapped' in v) {
48
+ const view = v;
49
+ const hit = borrowedShapeCache.get(view);
50
+ if (hit)
51
+ return hit;
52
+ const wrappedAny = view.wrapped;
53
+ const handle = typeof wrappedAny === 'object' && wrappedAny !== null && 'id' in wrappedAny
54
+ ? wrappedAny.id
55
+ : wrappedAny;
56
+ const s = fromHandle(handle);
57
+ borrowedShapeCache.set(view, s);
58
+ return s;
59
+ }
60
+ return v;
61
+ }
62
+ /** Custom prototype — borrowDeep skips objects with non-Object prototype. */
63
+ const WP_PROTO = { __isCqWorkplane: true };
64
+ // ── Internal helpers ───────────────────────────────────────────────────────
65
+ function vadd(a, b) {
66
+ return [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
67
+ }
68
+ function vscale(a, s) {
69
+ return [a[0] * s, a[1] * s, a[2] * s];
70
+ }
71
+ function vdot(a, b) {
72
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
73
+ }
74
+ function vsub(a, b) {
75
+ return [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
76
+ }
77
+ /**
78
+ * Local → world mapping for axis-aligned workplane normals, verified against
79
+ * installed CadQuery 2.8.0 (`faces(sel).workplane(...).plane.xDir`):
80
+ *
81
+ * normal +Z → xDir +X, yDir +Y normal -Z → xDir +X, yDir -Y
82
+ * normal +Y → xDir -X, yDir +Z normal -Y → xDir +X, yDir +Z
83
+ * normal +X → xDir +Y, yDir +Z normal -X → xDir -Y, yDir +Z
84
+ */
85
+ const FACE_AXES = {
86
+ '0,0,1': { x: [1, 0, 0], y: [0, 1, 0] },
87
+ '0,0,-1': { x: [1, 0, 0], y: [0, -1, 0] },
88
+ '0,1,0': { x: [-1, 0, 0], y: [0, 0, 1] },
89
+ '0,-1,0': { x: [1, 0, 0], y: [0, 0, 1] },
90
+ '1,0,0': { x: [0, 1, 0], y: [0, 0, 1] },
91
+ '-1,0,0': { x: [0, -1, 0], y: [0, 0, 1] },
92
+ };
93
+ /** Get the local axes for an axis-aligned normal (throws for arbitrary normals). */
94
+ function faceAxes(normal) {
95
+ const key = `${normal[0]},${normal[1]},${normal[2]}`;
96
+ const axes = FACE_AXES[key];
97
+ if (!axes) {
98
+ throw new Error(`[cq-compat] unsupported workplane normal ${key} (axis-aligned only)`);
99
+ }
100
+ return axes;
101
+ }
102
+ /** Map workplane-local (px, py) offsets to world coordinates. */
103
+ function localToWorld(wp, px, py) {
104
+ return vadd(wp.origin, vadd(vscale(wp.xDir, px), vscale(wp.yDir, py)));
105
+ }
106
+ /**
107
+ * Reference points for eachpoint-style ops (box/sphere/cylinder/rect/circle/
108
+ * polygon). Upstream positions each new object at whatever sits on the stack:
109
+ * pushPoints() points win, otherwise the CURRENT DRAFTING POINT set by
110
+ * moveTo/move (e.g. `workplane.rect(1,1).extrude(2).moveTo(0,2).rect(1,1)` —
111
+ * `Workplane.testGlue`), otherwise the plane origin.
112
+ */
113
+ function eachPoints(wp) {
114
+ if (Array.isArray(wp.pts) && wp.pts.length > 0)
115
+ return wp.pts;
116
+ if (wp.currentPoint)
117
+ return [wp.currentPoint];
118
+ return [[0, 0]];
119
+ }
120
+ /** Snapshot the current plane so a pending wire survives later workplane() moves. */
121
+ function planeOf(wp) {
122
+ return {
123
+ origin: [...wp.origin],
124
+ xDir: [...wp.xDir],
125
+ yDir: [...wp.yDir],
126
+ normal: [...wp.normal],
127
+ };
128
+ }
129
+ /** Create an empty workplane on the given plane (CadQuery named-plane axes). */
130
+ function makeWorkplane(plane) {
131
+ // Full named-plane table verified against cadquery 2.8.0 (Plane.named):
132
+ // 'front' == XY, 'bottom' == XZ, etc. The old 3-entry table silently fell
133
+ // back to XY for any other name — e.g. "front"→XY was luck, but "top" would
134
+ // have been wrong. Unknown names now throw like upstream.
135
+ const axes = {
136
+ XY: { n: [0, 0, 1], x: [1, 0, 0] },
137
+ YZ: { n: [1, 0, 0], x: [0, 1, 0] },
138
+ ZX: { n: [0, 1, 0], x: [0, 0, 1] },
139
+ XZ: { n: [0, -1, 0], x: [1, 0, 0] },
140
+ YX: { n: [0, 0, -1], x: [0, 1, 0] },
141
+ ZY: { n: [-1, 0, 0], x: [0, 0, 1] },
142
+ front: { n: [0, 0, 1], x: [1, 0, 0] },
143
+ back: { n: [0, 0, -1], x: [-1, 0, 0] },
144
+ left: { n: [-1, 0, 0], x: [0, 0, 1] },
145
+ right: { n: [1, 0, 0], x: [0, 0, -1] },
146
+ top: { n: [0, 1, 0], x: [1, 0, 0] },
147
+ bottom: { n: [0, -1, 0], x: [1, 0, 0] },
148
+ };
149
+ const a = axes[plane];
150
+ if (!a) {
151
+ throw new Error(`[cq-compat] unknown plane "${plane}" (upstream names: XY/YZ/ZX/XZ/YX/ZY/front/back/left/right/top/bottom)`);
152
+ }
153
+ const yDir = [
154
+ a.n[1] * a.x[2] - a.n[2] * a.x[1],
155
+ a.n[2] * a.x[0] - a.n[0] * a.x[2],
156
+ a.n[0] * a.x[1] - a.n[1] * a.x[0],
157
+ ];
158
+ return Object.assign(Object.create(WP_PROTO), {
159
+ __cq: true,
160
+ plane,
161
+ origin: [0, 0, 0],
162
+ normal: a.n,
163
+ xDir: a.x,
164
+ yDir,
165
+ shape: null,
166
+ faceSel: null,
167
+ edgeSel: null,
168
+ vertexSel: null,
169
+ pts: [],
170
+ forConstruction: false,
171
+ });
172
+ }
173
+ /** Clone a workplane with overrides (preserves custom prototype). */
174
+ function clone(wp, overrides) {
175
+ return Object.assign(Object.create(WP_PROTO), wp, overrides);
176
+ }
177
+ /**
178
+ * Unwrap a vendored brepjs `Result` into its value, throwing on `Err`.
179
+ *
180
+ * The vendored boolean ops return `Result<T>` (`{ ok: true, value }` /
181
+ * `{ ok: false, error }`). Silently swallowing an `Err` here would leave the
182
+ * workplane carrying stale geometry, so failures must surface.
183
+ */
184
+ function unwrapBrepResult(result) {
185
+ if (result && typeof result === 'object' && 'ok' in result) {
186
+ const r = result;
187
+ if (!r.ok) {
188
+ const detail = typeof r.error === 'string'
189
+ ? r.error
190
+ : JSON.stringify(r.error, (_k, v) => (typeof v === 'bigint' ? String(v) : v)) ??
191
+ String(r.error);
192
+ throw new Error(`[cq-compat] brep boolean op failed: ${detail}`);
193
+ }
194
+ return r.value;
195
+ }
196
+ return result;
197
+ }
198
+ /** Call a `brepjsCompat` member by name (namespace is typed loosely here). */
199
+ function compatFn(name) {
200
+ const fn = brepjsCompat[name];
201
+ if (!fn)
202
+ throw new Error(`[cq-compat] brepjsCompat.${name} is not available`);
203
+ return fn;
204
+ }
205
+ /** Merge same-domain faces/edges after a boolean (CadQuery `clean=True`). */
206
+ async function cleanShapes(shape) {
207
+ const simplified = unwrapBrepResult(compatFn('simplify')(borrowBrepjsShape(shape)));
208
+ return adoptBrepjsProduct(simplified);
209
+ }
210
+ /**
211
+ * Fuse two shapes into ONE solid via the vendored brepjs fuse.
212
+ *
213
+ * Rationale: `cad.union` (defineOp → booleanBrep → fromBrep repack) has been
214
+ * observed to return a compound of two disjoint solids instead of a fused
215
+ * single solid (see docs/analysis/2026-09-08-cq-compat-union-compound-bug.md).
216
+ * The vendored `fuse` preserves the first operand's solid type, so the result
217
+ * stays a single solid. See docs/analysis/2026-09-08-cq-compat-union-compound-bug.md.
218
+ */
219
+ async function fuseShapes(a, b, clean = true) {
220
+ const product = unwrapBrepResult(compatFn('fuse')(borrowBrepjsShape(a), borrowBrepjsShape(b)));
221
+ const fused = adoptBrepjsProduct(product);
222
+ // CadQuery ops take a `clean` flag (default True); clean=False preserves the
223
+ // boolean splitter faces (verified vs 2.8.0: testNoClean wedge vol 10.650718
224
+ // vs testClean 9.079922 — the kernel unify pass is NOT volume-preserving).
225
+ return clean ? cleanShapes(fused) : fused;
226
+ }
227
+ /** Cut a tool shape out of a base shape via the vendored brepjs cut. */
228
+ async function cutShapes(base, tool) {
229
+ const product = unwrapBrepResult(compatFn('cut')(borrowBrepjsShape(base), borrowBrepjsShape(tool)));
230
+ return cleanShapes(adoptBrepjsProduct(product));
231
+ }
232
+ /** Intersect two shapes via the vendored brepjs intersect. */
233
+ async function intersectShapes(a, b) {
234
+ const product = unwrapBrepResult(compatFn('intersect')(borrowBrepjsShape(a), borrowBrepjsShape(b)));
235
+ return cleanShapes(adoptBrepjsProduct(product));
236
+ }
237
+ /** Get bbox max of a shape (via cad.bboxMax — synchronous). */
238
+ function bboxMax(shape) {
239
+ return cad.bboxMax(shape);
240
+ }
241
+ /** Get bbox min of a shape. */
242
+ function bboxMin(shape) {
243
+ return cad.bboxMin(shape);
244
+ }
245
+ /**
246
+ * Resolve a face selector to a world-space point (face center) and normal.
247
+ *
248
+ * CadQuery semantics: `faces(">Z")` selects the face(s) at the extreme of the
249
+ * axis, and `workplane()` places the origin at the selected face's center —
250
+ * `centerOption: "CenterOfMass"` (default) uses the face's surface centroid,
251
+ * `"CenterOfBoundBox"` uses the face's bounding-box center.
252
+ *
253
+ * Supported forms: ">Z", "<Z", ">X", "<X", ">Y", "<Y", "+Z"/"-Z" aliases, each
254
+ * with an optional CadQuery-style index suffix like ">Z[-2]". The six CadQuery
255
+ * named views ("front"/"back"/"left"/"right"/"top"/"bottom") are accepted and
256
+ * normalised to their axis equivalent (front=>">Z", back=>"<Z", left=>"<X",
257
+ * right=>">X", top=>">Y", bottom=>"<Y"). This
258
+ * implementation enumerates the actual BREP faces and picks the one whose
259
+ * bbox-center is the extreme along the selector axis (ties broken by larger
260
+ * surface area, so a main face wins over a small coplanar boss face). When the
261
+ * shape has no BREP handle or the kernel is unavailable, it falls back to the
262
+ * whole-shape bounding-box approximation.
263
+ *
264
+ * Indexed selectors (`">Z[-2]"`) follow CadQuery's DirectionMinMaxSelector
265
+ * indexing, verified against the installed cadquery 2.8.0: `">A[k]"` lists all
266
+ * faces ASCENDING along axis A (`[0]` = lowest, `[-1]` = highest); `"<A[k]"`
267
+ * lists them DESCENDING (`[0]` = highest). The picked face's normal is the
268
+ * outward direction (away from the shape bbox center).
269
+ *
270
+ * @param shape - Shape whose BREP faces are enumerated for selection.
271
+ * @param sel - Selector string, e.g. ">Z", "<X", ">Z[-2]", or a named view
272
+ * ("front"/"back"/"left"/"right"/"top"/"bottom").
273
+ * @param centerOption - Optional center computation option forwarded to the
274
+ * face-center evaluation.
275
+ * @returns Promise resolving to the selected face's center point and outward
276
+ * normal.
277
+ */
278
+ /**
279
+ * CadQuery named views → axis selector (cadquery/selectors.py:687-694).
280
+ * Verified against installed cadquery 2.8.0 by evaluating
281
+ * `Workplane().rect(1,1).extrude(1).faces(n).val()` for each name.
282
+ */
283
+ const NAMED_VIEW_TO_AXIS = {
284
+ front: '>Z',
285
+ back: '<Z',
286
+ left: '<X',
287
+ right: '>X',
288
+ top: '>Y',
289
+ bottom: '<Y',
290
+ };
291
+ /**
292
+ * Resolve a CadQuery-style face selector string to the selected face's center
293
+ * point and outward normal.
294
+ *
295
+ * Supported forms: ">Z", "<Z", ">X", "<X", ">Y", "<Y", each with an optional
296
+ * CadQuery-style index suffix like ">Z[-2]", plus the six named views
297
+ * ("front"/"back"/"left"/"right"/"top"/"bottom") which are aliases for the
298
+ * corresponding axis selectors per cadquery/selectors.py:687-694.
299
+ * @param shape - Shape whose BREP faces are enumerated for selection.
300
+ * @param sel - Selector string, e.g. ">Z", "front", ">Z[-2]".
301
+ * @param centerOption - Optional center computation option forwarded to the
302
+ * face-center evaluation.
303
+ * @returns Promise resolving to the selected face's center point and outward
304
+ * normal.
305
+ * @throws Error when the selector matches no face or has unknown syntax
306
+ * (never falls back silently).
307
+ */
308
+ export async function resolveFaceSelector(shape, sel, centerOption) {
309
+ // compatOp 提升边界:实参可能是借用视图(见 asBrepShape 注释)——归一为真实
310
+ // Shape,否则 brepOf 为 undefined 会落到 bbox 兜底并在 cad.bboxMax 崩溃。
311
+ shape = asBrepShape(shape);
312
+ // CadQuery named views are aliases for an axis DirectionMinMaxSelector
313
+ // (cadquery/selectors.py:687-694):
314
+ // front=>(0,0,1,max) back=>(0,0,1,min) left=>(1,0,0,min)
315
+ // right=>(1,0,0,max) top=>(0,1,0,max) bottom=>(0,1,0,min)
316
+ // Normalise once so every branch below sees a plain axis selector; without
317
+ // this the lookup misses and the whole-shape bbox fallback silently returns
318
+ // the shape centre instead of the face plane (half-a-hole volume error).
319
+ sel = NAMED_VIEW_TO_AXIS[sel.trim().toLowerCase()] ?? sel;
320
+ // Strip index suffix like [-2]
321
+ const baseSel = sel.replace(/\[-?\d+\]$/, '');
322
+ const axisDir = {
323
+ '>Z': { axis: 2, sign: 1 }, '+Z': { axis: 2, sign: 1 },
324
+ '<Z': { axis: 2, sign: -1 }, '-Z': { axis: 2, sign: -1 },
325
+ '>X': { axis: 0, sign: 1 }, '+X': { axis: 0, sign: 1 },
326
+ '<X': { axis: 0, sign: -1 }, '-X': { axis: 0, sign: -1 },
327
+ '>Y': { axis: 1, sign: 1 }, '+Y': { axis: 1, sign: 1 },
328
+ '<Y': { axis: 1, sign: -1 }, '-Y': { axis: 1, sign: -1 },
329
+ };
330
+ const normals = {
331
+ '>Z': [0, 0, 1], '+Z': [0, 0, 1], '<Z': [0, 0, -1], '-Z': [0, 0, -1],
332
+ '>X': [1, 0, 0], '+X': [1, 0, 0], '<X': [-1, 0, 0], '-X': [-1, 0, 0],
333
+ '>Y': [0, 1, 0], '+Y': [0, 1, 0], '<Y': [0, -1, 0], '-Y': [0, -1, 0],
334
+ };
335
+ const dir = axisDir[baseSel];
336
+ const fallbackNormal = normals[baseSel] ?? [0, 0, 1];
337
+ // ── Multi-axis direction selectors ("+XY", ">XZ", "-YZ" … cadquery
338
+ // selectors.py:625 axes table XY=(1,1,0) XZ=(1,0,1) YZ=(0,1,1)) ──
339
+ // '+'/'-' → DirectionSelector: only faces whose outward normal is PARALLEL
340
+ // to the (±) direction (angle < 1e-4 rad, selectors.py:234). '>'/'<' →
341
+ // DirectionMinMaxSelector (selectors.py:399): the face whose center of MASS
342
+ // is farthest along the direction. Index suffixes mean DirectionNthSelector
343
+ // there — not supported, throw instead of silently approximating.
344
+ const multi = /^([<>+-])(XY|XZ|YZ)$/.exec(baseSel);
345
+ if (multi) {
346
+ if (/\[-?\d+\]$/.test(sel.trim())) {
347
+ throw new Error(`[cq-compat] selector "${sel}": indexed multi-axis selectors not supported`);
348
+ }
349
+ const axesTable = {
350
+ XY: [1, 1, 0],
351
+ XZ: [1, 0, 1],
352
+ YZ: [0, 1, 1],
353
+ };
354
+ let d = axesTable[multi[2]];
355
+ if (multi[1] === '-')
356
+ d = [-d[0], -d[1], -d[2]];
357
+ const dLen = Math.hypot(d[0], d[1], d[2]);
358
+ const dirV = [d[0] / dLen, d[1] / dLen, d[2] / dLen];
359
+ const handleM = brepOf(shape);
360
+ if (!handleM) {
361
+ throw new Error(`[cq-compat] selector "${sel}": BREP unavailable`);
362
+ }
363
+ const kernelM = getKernel();
364
+ const faceList = kernelM.getSubShapes(handleM, 'face');
365
+ if (faceList.length === 0) {
366
+ throw new Error(`[cq-compat] selector "${sel}": shape has no faces`);
367
+ }
368
+ const faceNormalOf = (f) => {
369
+ const uv = kernelM.uvBounds(f);
370
+ const n = kernelM.surfaceNormal(f, (uv.uMin + uv.uMax) / 2, (uv.vMin + uv.vMax) / 2);
371
+ return [n.x, n.y, n.z];
372
+ };
373
+ const comOf = (f) => {
374
+ const c = kernelM.getSurfaceCenterOfMass(f);
375
+ return [c.x, c.y, c.z];
376
+ };
377
+ const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
378
+ if (multi[1] === '+' || multi[1] === '-') {
379
+ // DirectionSelector: angle(normal, dir) < 1e-4 rad (selectors.py:235).
380
+ const PAR_TOL = Math.cos(1e-4);
381
+ const found = faceList.find((f) => dot(faceNormalOf(f), dirV) > PAR_TOL);
382
+ if (!found) {
383
+ throw new Error(`[cq-compat] selector "${sel}": no face with normal parallel to the direction`);
384
+ }
385
+ return { center: comOf(found), normal: faceNormalOf(found) };
386
+ }
387
+ // '>'/'<': DirectionMinMaxSelector over center-of-mass projections.
388
+ let bestF = null;
389
+ let bestVal = 0;
390
+ for (const f of faceList) {
391
+ const val = dot(comOf(f), dirV);
392
+ if (!bestF || (multi[1] === '>' ? val > bestVal + 1e-9 : val < bestVal - 1e-9)) {
393
+ bestF = f;
394
+ bestVal = val;
395
+ }
396
+ }
397
+ return { center: comOf(bestF), normal: faceNormalOf(bestF) };
398
+ }
399
+ // ── Face-based selection (BREP kernel available) ──
400
+ try {
401
+ const handle = brepOf(shape);
402
+ const kernel = getKernel();
403
+ if (handle && dir) {
404
+ const faces = kernel.getSubShapes(handle, 'face');
405
+ const cands = [];
406
+ for (const f of faces) {
407
+ const bb = kernel.getBoundingBox(f);
408
+ cands.push({
409
+ handle: f,
410
+ center: [
411
+ (bb.xmin + bb.xmax) / 2,
412
+ (bb.ymin + bb.ymax) / 2,
413
+ (bb.zmin + bb.zmax) / 2,
414
+ ],
415
+ });
416
+ }
417
+ let best = null;
418
+ let normal = fallbackNormal;
419
+ // Extract the index suffix, if any.
420
+ const idxMatch = /\[(-?\d+)\]$/.exec(sel.trim());
421
+ if (idxMatch) {
422
+ // CadQuery face indexing (verified vs cadquery 2.8.0):
423
+ // '>A[k]' / '<A[k]' DirectionMinMaxSelector: '>' ascending, '<' descending
424
+ // '+A[k]' / '-A[k]' DirectionSelector: list the extreme face first, then
425
+ // inward — '-' ascending, '+' descending along A
426
+ // (faces("-Y")[1] is the 2nd -Y face from the -Y extreme,
427
+ // NOT the +Y extreme face).
428
+ // Only faces PERPENDICULAR to the axis participate (bbox thin along the axis).
429
+ // For '+'/'-' selectors we additionally keep only faces whose outward normal
430
+ // is parallel to the selector axis with the matching sign (CadQuery filters
431
+ // by exact normal direction), so a boss face and its base sibling don't
432
+ // collide in the index.
433
+ const sc = baseSel[0];
434
+ const isDirSelector = sc === '+' || sc === '-';
435
+ const perp = cands.filter((cd) => {
436
+ const bb = kernel.getBoundingBox(cd.handle);
437
+ const ext = [bb.xmax - bb.xmin, bb.ymax - bb.ymin, bb.zmax - bb.zmin][dir.axis];
438
+ if (ext > 0.1)
439
+ return false;
440
+ if (isDirSelector) {
441
+ const uv = kernel.uvBounds(cd.handle);
442
+ const n = kernel.surfaceNormal(cd.handle, (uv.uMin + uv.uMax) / 2, (uv.vMin + uv.vMax) / 2);
443
+ const nv = [n.x, n.y, n.z];
444
+ if (Math.abs(nv[dir.axis]) < 0.999)
445
+ return false;
446
+ if (Math.sign(nv[dir.axis]) !== dir.sign)
447
+ return false;
448
+ }
449
+ return true;
450
+ });
451
+ const idx = parseInt(idxMatch[1], 10);
452
+ const asc = sc === '>' || sc === '-';
453
+ const sorted = perp
454
+ .slice()
455
+ .sort((a, b) => (asc ? a.center[dir.axis] - b.center[dir.axis] : b.center[dir.axis] - a.center[dir.axis]));
456
+ const pick = idx < 0 ? sorted.length + idx : idx;
457
+ if (pick < 0 || pick >= sorted.length) {
458
+ throw new Error(`[cq-compat] selector "${sel}": index ${idx} out of range (${sorted.length} faces)`);
459
+ }
460
+ best = sorted[pick];
461
+ if (isDirSelector) {
462
+ // DirectionSelector: the outward normal is exactly the selector axis/sign.
463
+ normal = fallbackNormal;
464
+ }
465
+ else {
466
+ // DirectionMinMaxSelector: outward normal from face position vs shape centre.
467
+ const max = bboxMax(shape);
468
+ const min = bboxMin(shape);
469
+ const shapeCenter = [(max[0] + min[0]) / 2, (max[1] + min[1]) / 2, (max[2] + min[2]) / 2];
470
+ normal = [0, 0, 0];
471
+ normal[dir.axis] = best.center[dir.axis] >= shapeCenter[dir.axis] ? 1 : -1;
472
+ }
473
+ }
474
+ else {
475
+ for (const cd of cands) {
476
+ const val = cd.center[dir.axis];
477
+ if (!best) {
478
+ best = cd;
479
+ continue;
480
+ }
481
+ const bestVal = best.center[dir.axis];
482
+ if (dir.sign === 1 ? val > bestVal + 1e-6 : val < bestVal - 1e-6) {
483
+ best = cd;
484
+ }
485
+ else if (Math.abs(val - bestVal) <= 1e-6) {
486
+ // Tie (e.g. coplanar faces at the same extreme): prefer the larger face
487
+ const area = kernel.getSurfaceArea(cd.handle);
488
+ if (area > kernel.getSurfaceArea(best.handle))
489
+ best = cd;
490
+ }
491
+ }
492
+ }
493
+ if (best) {
494
+ let origin;
495
+ if (centerOption === 'CenterOfBoundBox') {
496
+ origin = best.center;
497
+ }
498
+ else {
499
+ // CenterOfMass: surface (area-weighted) centroid
500
+ const com = kernel.getSurfaceCenterOfMass(best.handle);
501
+ origin = [com.x, com.y, com.z];
502
+ }
503
+ return { center: origin, normal };
504
+ }
505
+ }
506
+ }
507
+ catch (e) {
508
+ if (e instanceof Error && e.message.includes('out of range'))
509
+ throw e;
510
+ // No BREP / kernel not ready — fall through to bbox approximation
511
+ }
512
+ // ── Fallback: whole-shape bbox (previous behavior) ──
513
+ const max = bboxMax(shape);
514
+ const min = bboxMin(shape);
515
+ const center = [
516
+ (max[0] + min[0]) / 2,
517
+ (max[1] + min[1]) / 2,
518
+ (max[2] + min[2]) / 2,
519
+ ];
520
+ const m = /^([<>+-])([XYZ])(?:\[(-?\d+)\])?$/.exec(sel.trim());
521
+ if (!m) {
522
+ // Default: return center
523
+ return { center, normal: [0, 0, 1] };
524
+ }
525
+ const [, sign, axisChar, idxStr] = m;
526
+ const axis = axisChar === 'X' ? 0 : axisChar === 'Y' ? 1 : 2;
527
+ // '>' and '+' select the max side; '<' and '-' select the min side.
528
+ const maxDir = sign === '>' || sign === '+';
529
+ const normal = [0, 0, 0];
530
+ normal[axis] = maxDir ? 1 : -1;
531
+ if (idxStr === undefined) {
532
+ const extreme = [center[0], center[1], center[2]];
533
+ extreme[axis] = maxDir ? max[axis] : min[axis];
534
+ return { center: extreme, normal };
535
+ }
536
+ // Indexed selector — enumerate real faces and sort by bbox center along axis.
537
+ // CadQuery semantics: '>A[k]' ascending, '<A[k]' descending (see doc above).
538
+ const idx = parseInt(idxStr, 10);
539
+ const faces = compatFn('getFaces')(borrowBrepjsShape(shape));
540
+ const entries = faces.map((f) => {
541
+ const b = compatFn('getBounds')(f);
542
+ return {
543
+ c: [(b.xMin + b.xMax) / 2, (b.yMin + b.yMax) / 2, (b.zMin + b.zMax) / 2],
544
+ bounds: b,
545
+ };
546
+ });
547
+ if (entries.length === 0) {
548
+ throw new Error(`[cq-compat] selector "${sel}": shape has no faces`);
549
+ }
550
+ entries.sort((a, b) => (maxDir ? a.c[axis] - b.c[axis] : b.c[axis] - a.c[axis]));
551
+ const pick = idx < 0 ? entries.length + idx : idx;
552
+ if (pick < 0 || pick >= entries.length) {
553
+ throw new Error(`[cq-compat] selector "${sel}": index ${idx} out of range (${entries.length} faces)`);
554
+ }
555
+ const fb = entries[pick].bounds;
556
+ const faceCenter = [
557
+ (fb.xMin + fb.xMax) / 2,
558
+ (fb.yMin + fb.yMax) / 2,
559
+ (fb.zMin + fb.zMax) / 2,
560
+ ];
561
+ // Outward normal: away from the shape bbox center along the axis.
562
+ normal[axis] = faceCenter[axis] >= center[axis] ? 1 : -1;
563
+ return { center: faceCenter, normal };
564
+ }
565
+ /**
566
+ * Build a regular-polygon prism on a workplane (vendored polygon face +
567
+ * extrude). CadQuery `polygon(n, d)`: n-gon inscribed in a circle of diameter
568
+ * `d`, first vertex on the workplane local +X.
569
+ */
570
+ async function makePolygonPrismAt(wp, poly, length, dir) {
571
+ const pts = [];
572
+ for (let i = 0; i < poly.n; i++) {
573
+ const a = (2 * Math.PI * i) / poly.n;
574
+ pts.push(localToWorld(wp, Math.cos(a) * (poly.d / 2), Math.sin(a) * (poly.d / 2)));
575
+ }
576
+ const face = unwrapBrepResult(compatFn('polygon')(pts));
577
+ const vec = [dir[0] * length, dir[1] * length, dir[2] * length];
578
+ const prism = unwrapBrepResult(compatFn('extrude')(face, vec));
579
+ return adoptBrepjsProduct(prism);
580
+ }
581
+ /**
582
+ * Rotate a Z-axis-aligned primitive so its local +Z maps to `d` — ANY direction,
583
+ * not just axis-aligned (verified vs cadquery 2.8.0: angled holes drill along
584
+ * the transformed workplane normal). Euler decomposition that maps +Z onto d:
585
+ * θy = asin(dx), θx = atan2(−dy, dz) (three.js XYZ-intrinsic, R = Rx·Ry)
586
+ * Rx(θx)·Ry(θy)·(0,0,1) = (dx, dy, dz)
587
+ */
588
+ async function orientZTo(shape, d) {
589
+ const len = Math.hypot(d[0], d[1], d[2]);
590
+ const dx = d[0] / len;
591
+ const dy = d[1] / len;
592
+ const dz = d[2] / len;
593
+ const thetaX = Math.atan2(-dy, dz);
594
+ const thetaY = Math.asin(Math.max(-1, Math.min(1, dx)));
595
+ const anglesDeg = [
596
+ (thetaX * 180) / Math.PI,
597
+ (thetaY * 180) / Math.PI,
598
+ 0,
599
+ ];
600
+ return cad.rotate_euler(shape, { anglesDeg });
601
+ }
602
+ /**
603
+ * Create a cone whose base (radius rBase) sits at the workplane origin and
604
+ * whose apex side extends `height` along `wp.normal`-direction `d`.
605
+ * Used for the full-cone countersink cut of cskHole (CadQuery semantics:
606
+ * h = cskRadius / tan(cskAngle/2), cone from rBase to apex).
607
+ */
608
+ async function makeConeAt(wp, rBase, height, d) {
609
+ const cone = await cad.cone(rBase, 0, height, { centered: true });
610
+ const oriented = await orientZTo(cone, d);
611
+ const center = vadd(wp.origin, vscale(d, height / 2));
612
+ return cad.translate(oriented, { offset: center });
613
+ }
614
+ /**
615
+ * Create a cylinder at the workplane origin, oriented along normal.
616
+ * Used for hole/cutBlind implementations.
617
+ */
618
+ async function makeCylinderAt(wp, radius, height) {
619
+ const cyl = await cad.cylinder(radius, height, { centered: true });
620
+ const n = Array.isArray(wp.normal) ? wp.normal : [0, 0, 1];
621
+ const o = Array.isArray(wp.origin) ? wp.origin : [0, 0, 0];
622
+ // Rotate the Z-axis cylinder so its axis aligns with the workplane normal.
623
+ const oriented = await orientZTo(cyl, n);
624
+ const center = [o[0] + n[0] * height / 2, o[1] + n[1] * height / 2, o[2] + n[2] * height / 2];
625
+ return cad.translate(oriented, { offset: center });
626
+ }
627
+ /**
628
+ * Create a box at the workplane origin: `w` along the workplane xDir, `d`
629
+ * along yDir, `h` along the normal (CadQuery rect+extrude tool semantics).
630
+ * The tool is built axis-aligned with the world extents implied by the
631
+ * workplane basis, so it is correct for any axis-aligned normal.
632
+ */
633
+ async function makeBoxAt(wp, w, d, h) {
634
+ const n = Array.isArray(wp.normal) ? wp.normal : [0, 0, 1];
635
+ const o = Array.isArray(wp.origin) ? wp.origin : [0, 0, 0];
636
+ const axes = faceAxes(n);
637
+ // World-space extents of a w×d×h box aligned to the (axis-aligned) basis.
638
+ const sx = w * Math.abs(axes.x[0]) + d * Math.abs(axes.y[0]) + h * Math.abs(n[0]);
639
+ const sy = w * Math.abs(axes.x[1]) + d * Math.abs(axes.y[1]) + h * Math.abs(n[1]);
640
+ const sz = w * Math.abs(axes.x[2]) + d * Math.abs(axes.y[2]) + h * Math.abs(n[2]);
641
+ const box = await cad.box(sx, sy, sz, { centered: true });
642
+ const center = [o[0] + n[0] * h / 2, o[1] + n[1] * h / 2, o[2] + n[2] * h / 2];
643
+ return cad.translate(box, { offset: center });
644
+ }
645
+ /** Normalize a `centered` parameter to a per-axis triple. */
646
+ function resolveCentered(c) {
647
+ return typeof c === 'boolean' ? [c, c, c] : c;
648
+ }
649
+ /** Build a compound Shape from several Shapes (brepjs makeCompound projection). */
650
+ function makeCompoundShape(shapes) {
651
+ if (shapes.length === 1)
652
+ return shapes[0];
653
+ const product = unwrapBrepResult(compatFn('makeCompound')(shapes.map((s) => borrowBrepjsShape(s))));
654
+ return adoptBrepjsProduct(product);
655
+ }
656
+ /**
657
+ * Finish an eachpoint-style op (box/sphere/cylinder): `combine=True` (CadQuery
658
+ * default) fuses the created bodies with each other and with the existing
659
+ * solid on the workplane; `combine=False` leaves them as separate solids in a
660
+ * compound (verified vs cadquery 2.8.0: testSpherePointList -> 4 solids).
661
+ */
662
+ async function combineEachpoint(wp, shapes, combine, clean = true) {
663
+ let shape;
664
+ if (combine) {
665
+ shape = shapes[0];
666
+ for (let i = 1; i < shapes.length; i++) {
667
+ shape = await fuseShapes(shape, shapes[i], clean);
668
+ }
669
+ if (wp.shape)
670
+ shape = await fuseShapes(wp.shape, shape, clean);
671
+ }
672
+ else {
673
+ shape = makeCompoundShape(shapes);
674
+ }
675
+ return clone(wp, { shape, faceSel: null, edgeSel: null, vertexSel: null, pts: [] });
676
+ }
677
+ /**
678
+ * Workplane
679
+ * @param plane - string
680
+ * @returns Workplane
681
+ */
682
+ export function Workplane(plane = 'XY') {
683
+ return makeWorkplane(plane);
684
+ }
685
+ /**
686
+ * add
687
+ * @param wp - Workplane
688
+ * @param shape - Shape
689
+ * @returns Promise<Workplane>
690
+ */
691
+ export async function add(wp, shape) {
692
+ return clone(wp, { shape });
693
+ }
694
+ /**
695
+ * box
696
+ * @param wp - Workplane
697
+ * @param w - number
698
+ * @param d - number
699
+ * @param h - number
700
+ * @param opts - { centered?; combine? }
701
+ * @returns Promise<Workplane>
702
+ *
703
+ * CadQuery semantics (verified vs cadquery 2.8.0 `Workplane.box`): with the
704
+ * default `centered=(True, True, True)` the box is centered on the workplane
705
+ * origin in ALL three axes — including the normal direction. The old
706
+ * "sit on the face" behaviour belonged to the makeBoxAt tool-body helper and
707
+ * leaked into this public op (found by the parity harness, testBoxDefaults).
708
+ *
709
+ * Each-point semantics (verified vs 2.8.0): box() is eachpoint-based — with
710
+ * points pushed on the stack a box is created at every point; `combine=True`
711
+ * (default) fuses them with the existing solid, `combine=False` leaves them
712
+ * as separate solids in a compound (test_getitem / testBoxPointList).
713
+ */
714
+ export async function box(wp, w, d, h, opts) {
715
+ const n = Array.isArray(wp.normal) ? wp.normal : [0, 0, 1];
716
+ const axes = faceAxes(n);
717
+ // World-space extents of a w×d×h box aligned to the (axis-aligned) basis.
718
+ const sx = w * Math.abs(axes.x[0]) + d * Math.abs(axes.y[0]) + h * Math.abs(n[0]);
719
+ const sy = w * Math.abs(axes.x[1]) + d * Math.abs(axes.y[1]) + h * Math.abs(n[1]);
720
+ const sz = w * Math.abs(axes.x[2]) + d * Math.abs(axes.y[2]) + h * Math.abs(n[2]);
721
+ const boxShape = await cad.box(sx, sy, sz, { centered: true });
722
+ const c = resolveCentered(opts?.centered ?? true);
723
+ // Uncentered axis: bbox corner sits on the point (offset by half the extent).
724
+ const off = [
725
+ c[0] ? 0 : (w / 2) * axes.x[0] + (d / 2) * axes.y[0] + (h / 2) * n[0],
726
+ c[1] ? 0 : (w / 2) * axes.x[1] + (d / 2) * axes.y[1] + (h / 2) * n[1],
727
+ c[2] ? 0 : (w / 2) * axes.x[2] + (d / 2) * axes.y[2] + (h / 2) * n[2],
728
+ ];
729
+ const points = eachPoints(wp);
730
+ const shapes = [];
731
+ for (const [px, py] of points) {
732
+ const center = vadd(localToWorld(wp, px, py), off);
733
+ shapes.push(await cad.translate(boxShape, { offset: center }));
734
+ }
735
+ return combineEachpoint(wp, shapes, opts?.combine ?? true);
736
+ }
737
+ /**
738
+ * sphere
739
+ * @param wp - Workplane
740
+ * @param radius - number
741
+ * @param opts - { centered?; combine? }
742
+ * @returns Promise<Workplane>
743
+ *
744
+ * CadQuery semantics (verified vs cadquery 2.8.0 `Workplane.sphere`): a sphere
745
+ * is created for every point on the stack (or the workplane origin); per-axis
746
+ * `centered=false` puts the sphere's bbox corner on the point. Only full
747
+ * spheres are supported (angle1/angle2/angle3 partial sweeps are not
748
+ * expressible with the cad.sphere primitive — upstream testSphereCustom stays
749
+ * blocked on that).
750
+ */
751
+ export async function sphere(wp, radius, opts) {
752
+ const c = resolveCentered(opts?.centered ?? true);
753
+ const n = Array.isArray(wp.normal) ? wp.normal : [0, 0, 1];
754
+ const x = Array.isArray(wp.xDir) ? wp.xDir : [1, 0, 0];
755
+ const y = Array.isArray(wp.yDir) ? wp.yDir : [0, 1, 0];
756
+ // Local-frame offset: uncentered axis -> bbox corner on the point.
757
+ const offLocal = [c[0] ? 0 : radius, c[1] ? 0 : radius, c[2] ? 0 : radius];
758
+ const off = vadd(vadd(vscale(x, offLocal[0]), vscale(y, offLocal[1])), vscale(n, offLocal[2]));
759
+ const points = eachPoints(wp);
760
+ const shapes = [];
761
+ for (const [px, py] of points) {
762
+ const center = vadd(localToWorld(wp, px, py), off);
763
+ shapes.push(await cad.sphere({ radius, center }));
764
+ }
765
+ return combineEachpoint(wp, shapes, opts?.combine ?? true);
766
+ }
767
+ /**
768
+ * wedge — CadQuery `Workplane.wedge` parity.
769
+ *
770
+ * OCCT `BRepPrimAPI_MakeWedge(dx, dy, dz, xmin, zmin, xmax, zmax)` geometry:
771
+ * the bottom face (local y=0) spans the full [0,dx]×[0,dz] rectangle and the
772
+ * top face (local y=dy) spans [xmin,xmax]×[zmin,zmax]; all six faces are
773
+ * planar. Built here as a RULED loft between the two rectangles — geometrically
774
+ * identical to the OCCT primitive (verified vs cadquery 2.8.0: testClean
775
+ * wedge-with-sphere union vol 9.079922 / testNoClean 10.650718).
776
+ *
777
+ * `centered=True` (default) shifts by (−dx/2, −dy/2, −dz/2) along the LOCAL
778
+ * workplane axes, mirroring upstream's `offset` computation. Limitation: the
779
+ * kernel has no makeWedge primitive, and upstream composes the wedge in WORLD
780
+ * axes before the eachpoint location transform — for the default XY plane the
781
+ * two agree; rotated planes are not exercised by any current mirror.
782
+ *
783
+ * @param wp - Workplane acting as the eachpoint carrier
784
+ * @param dx - Bottom-face extent along local X
785
+ * @param dy - Wedge height along local Y
786
+ * @param dz - Bottom-face extent along local Z
787
+ * @param xmin - Top-face minimum along local X
788
+ * @param zmin - Top-face minimum along local Z
789
+ * @param xmax - Top-face maximum along local X
790
+ * @param zmax - Top-face maximum along local Z
791
+ * @param opts - { centered?: Centered3; combine?: boolean; clean?: boolean }
792
+ * @returns Promise<Workplane> carrying the wedge solid
793
+ */
794
+ export async function wedge(wp, dx, dy, dz, xmin, zmin, xmax, zmax, opts) {
795
+ const pl = {
796
+ origin: Array.isArray(wp.origin) ? wp.origin : [0, 0, 0],
797
+ xDir: Array.isArray(wp.xDir) ? wp.xDir : [1, 0, 0],
798
+ yDir: Array.isArray(wp.yDir) ? wp.yDir : [0, 1, 0],
799
+ normal: Array.isArray(wp.normal) ? wp.normal : [0, 0, 1],
800
+ };
801
+ const p3 = (x, y, z) => vadd(pl.origin, vadd(vadd(vscale(pl.xDir, x), vscale(pl.yDir, y)), vscale(pl.normal, z)));
802
+ const rectWire = (pts) => {
803
+ const edges = [];
804
+ for (let i = 0; i < 4; i++) {
805
+ edges.push(unwrapBrepResult(compatFn('makeLine')(pts[i], pts[(i + 1) % 4])));
806
+ }
807
+ return unwrapBrepResult(compatFn('assembleWire')(edges));
808
+ };
809
+ const c = resolveCentered(opts?.centered ?? true);
810
+ const ox = c[0] ? -dx / 2 : 0;
811
+ const oy = c[1] ? -dy / 2 : 0;
812
+ const oz = c[2] ? -dz / 2 : 0;
813
+ // Bottom (local y=0): full [0,dx]×[0,dz]. Top (local y=dy): [xmin,xmax]×[zmin,zmax].
814
+ // Corner order matches on both rectangles so the ruled loft pairs the right vertices.
815
+ const bottom = rectWire([
816
+ p3(ox, oy, oz),
817
+ p3(ox + dx, oy, oz),
818
+ p3(ox + dx, oy, oz + dz),
819
+ p3(ox, oy, oz + dz),
820
+ ]);
821
+ const top = rectWire([
822
+ p3(ox + xmin, oy + dy, oz + zmin),
823
+ p3(ox + xmax, oy + dy, oz + zmin),
824
+ p3(ox + xmax, oy + dy, oz + zmax),
825
+ p3(ox + xmin, oy + dy, oz + zmax),
826
+ ]);
827
+ const solid = adoptBrepjsProduct(unwrapBrepResult(compatFn('loft')([bottom, top], { ruled: true })));
828
+ const points = eachPoints(wp);
829
+ const shapes = [];
830
+ for (const [px, py] of points) {
831
+ shapes.push(await cad.translate(solid, { offset: localToWorld(wp, px, py) }));
832
+ }
833
+ return combineEachpoint(wp, shapes, opts?.combine ?? true, opts?.clean ?? true);
834
+ }
835
+ /**
836
+ * Rotation R mapping local +Z onto `d`, reproducing OCCT `gp_Ax3(P, D)`
837
+ * auto-XDirection (verified vs cadquery 2.8.0 by measuring
838
+ * `Workplane.cylinder(..., direct=...)` center offsets for all six axis
839
+ * directions). Applied to the per-axis `centered` offsets before the
840
+ * workplane mapping, exactly as upstream `s.moved(Plane(...).location)` does.
841
+ */
842
+ function ax3Rotation(d) {
843
+ const key = `${d[0]},${d[1]},${d[2]}`;
844
+ // rows: R·ex, R·ey, R·ez
845
+ const table = {
846
+ '1,0,0': [[0, 0, 1], [0, -1, 0], [1, 0, 0]],
847
+ '-1,0,0': [[0, 0, -1], [0, -1, 0], [-1, 0, 0]],
848
+ '0,1,0': [[0, 0, 1], [1, 0, 0], [0, 1, 0]],
849
+ '0,-1,0': [[0, 0, -1], [1, 0, 0], [0, -1, 0]],
850
+ '0,0,1': [[1, 0, 0], [0, 1, 0], [0, 0, 1]],
851
+ '0,0,-1': [[-1, 0, 0], [0, 1, 0], [0, 0, -1]],
852
+ };
853
+ const r = table[key];
854
+ if (!r)
855
+ throw new Error(`[cq-compat] cylinder direct ${key} not supported (axis directions only)`);
856
+ return r;
857
+ }
858
+ /**
859
+ * cylinder
860
+ * @param wp - Workplane
861
+ * @param height - number
862
+ * @param radius - number
863
+ * @param opts - { direct?; centered?; combine? }
864
+ * @returns Promise<Workplane>
865
+ *
866
+ * CadQuery semantics (verified vs cadquery 2.8.0 `Workplane.cylinder`): a
867
+ * cylinder for every point on the stack; per-axis `centered` offsets are
868
+ * applied in the LOCAL frame (xDir/yDir/normal), then rotated by the
869
+ * `direct` plane orientation (ax3Rotation table), then mapped by the
870
+ * workplane basis. `angle != 360` pie-slice sweeps are not supported.
871
+ */
872
+ export async function cylinder(wp, height, radius, opts) {
873
+ const c = resolveCentered(opts?.centered ?? true);
874
+ const d = opts?.direct ?? [0, 0, 1];
875
+ if (opts?.angle !== undefined && opts.angle !== 360) {
876
+ throw new Error('[cq-compat] cylinder angle != 360 is not supported');
877
+ }
878
+ const R = ax3Rotation(d);
879
+ const rot = (v) => [
880
+ R[0][0] * v[0] + R[1][0] * v[1] + R[2][0] * v[2],
881
+ R[0][1] * v[0] + R[1][1] * v[1] + R[2][1] * v[2],
882
+ R[0][2] * v[0] + R[1][2] * v[1] + R[2][2] * v[2],
883
+ ];
884
+ const n = Array.isArray(wp.normal) ? wp.normal : [0, 0, 1];
885
+ const x = Array.isArray(wp.xDir) ? wp.xDir : [1, 0, 0];
886
+ const y = Array.isArray(wp.yDir) ? wp.yDir : [0, 1, 0];
887
+ // Map a local vector through the workplane basis.
888
+ const map = (v) => vadd(vadd(vscale(x, v[0]), vscale(y, v[1])), vscale(n, v[2]));
889
+ // Uncentered axis -> base offset by half the local extent.
890
+ const offLocal = [
891
+ c[0] ? 0 : radius,
892
+ c[1] ? 0 : radius,
893
+ c[2] ? -height / 2 : 0,
894
+ ];
895
+ const off = map(rot(offLocal));
896
+ const axis = map(d);
897
+ const axisLen = Math.hypot(axis[0], axis[1], axis[2]);
898
+ const axisUnit = [axis[0] / axisLen, axis[1] / axisLen, axis[2] / axisLen];
899
+ const points = eachPoints(wp);
900
+ const shapes = [];
901
+ for (const [px, py] of points) {
902
+ const base = vadd(localToWorld(wp, px, py), off);
903
+ const bodyCenter = vadd(base, vscale(axisUnit, height / 2));
904
+ const cyl = await cad.cylinder({ radius, height, centered: true });
905
+ const oriented = await orientZTo(cyl, axisUnit);
906
+ shapes.push(await cad.translate(oriented, { offset: bodyCenter }));
907
+ }
908
+ return combineEachpoint(wp, shapes, opts?.combine ?? true);
909
+ }
910
+ /**
911
+ * torus — CadQuery free-function analogue (occ_impl.shapes.torus).
912
+ *
913
+ * Upstream takes DIAMETERS and builds a full torus centred at the origin,
914
+ * axis +Z: `torus(d1, d2)` -> R = d1/2, r = d2/2, V = 2π²·R·r²
915
+ * (`torus(10, 2)` -> 98.696, ref-verified against cadquery 2.8.0).
916
+ *
917
+ * @param wp - Workplane carrier (fresh `Workplane()` for the free function).
918
+ * @param d1 - Major DIAMETER.
919
+ * @param d2 - Minor DIAMETER.
920
+ * @param opts - { combine?: boolean }
921
+ * @returns Promise<Workplane> carrying the torus solid.
922
+ */
923
+ export async function torus(wp, d1, d2, opts) {
924
+ const product = unwrapBrepResult(compatFn('torus')(d1 / 2, d2 / 2));
925
+ const shape = adoptBrepjsProduct(product);
926
+ return combineEachpoint(wp, [shape], opts?.combine ?? true);
927
+ }
928
+ /**
929
+ * cone — CadQuery free-function analogue (occ_impl.shapes.cone).
930
+ *
931
+ * Upstream takes DIAMETERS with the base centred on the origin at z=0, axis
932
+ * +Z: `cone(d1, d2, h)` -> R = d1/2, r = d2/2, V = π/3·h·(R²+Rr+r²)
933
+ * (`cone(2, 1, 1)` -> 1.8326, ref-verified against cadquery 2.8.0). The
934
+ * 2-arg upstream form `cone(d, h)` is the full cone — pass `d2 = 0`.
935
+ *
936
+ * @param wp - Workplane carrier (fresh `Workplane()` for the free function).
937
+ * @param d1 - Base DIAMETER.
938
+ * @param d2 - Top DIAMETER (0 for a full cone).
939
+ * @param h - Height along +Z.
940
+ * @param opts - { combine?: boolean }
941
+ * @returns Promise<Workplane> carrying the cone solid.
942
+ */
943
+ export async function cone(wp, d1, d2, h, opts) {
944
+ const base = await cad.cone(d1 / 2, d2 / 2, h, { centered: true });
945
+ // Kernel cone with centered:true is centred at the origin mid-height; lift
946
+ // by h/2 so the base circle sits on z=0 (upstream free-function semantics).
947
+ const shape = await cad.translate(base, { offset: [0, 0, h / 2] });
948
+ return combineEachpoint(wp, [shape], opts?.combine ?? true);
949
+ }
950
+ /**
951
+ * rarray
952
+ * @param wp - Workplane
953
+ * @param xSpacing - number
954
+ * @param ySpacing - number
955
+ * @param xCount - number
956
+ * @param yCount - number
957
+ * @param center - boolean | [boolean, boolean]
958
+ * @returns Workplane
959
+ *
960
+ * CadQuery semantics (verified vs cadquery 2.8.0 `Workplane.rarray`): pushes
961
+ * an xCount×yCount grid of points; per-axis `center=true` centers the grid on
962
+ * the workplane origin, `false` puts the lower corner on it.
963
+ */
964
+ export function rarray(wp, xSpacing, ySpacing, xCount, yCount, center = true) {
965
+ if (xCount < 1 || yCount < 1 || (xSpacing <= 0 && ySpacing <= 0)) {
966
+ throw new Error('[cq-compat] rarray: spacing and count must be > 0 in at least one direction');
967
+ }
968
+ const [cx, cy] = typeof center === 'boolean' ? [center, center] : center;
969
+ const ox = cx ? (-(xCount - 1) * xSpacing) / 2 : 0;
970
+ const oy = cy ? (-(yCount - 1) * ySpacing) / 2 : 0;
971
+ const pts = [];
972
+ for (let i = 0; i < xCount; i++) {
973
+ for (let j = 0; j < yCount; j++) {
974
+ pts.push([i * xSpacing + ox, j * ySpacing + oy]);
975
+ }
976
+ }
977
+ return clone(wp, { pts });
978
+ }
979
+ /**
980
+ * rect
981
+ * @param wp - Workplane
982
+ * @param w - number
983
+ * @param d - number
984
+ * @param opts - { forConstruction?: boolean }
985
+ * @returns Workplane
986
+ */
987
+ export function rect(wp, w, d, opts) {
988
+ // Upstream (cadquery 2.8.0 Workplane.rect): centered may be a bool or a
989
+ // per-axis 2-tuple; centered=false puts the CORNER on the reference point,
990
+ // extending in the +x/+y directions (offset is +len/2 even for negatives).
991
+ const centered = opts?.centered ?? true;
992
+ const [cxOn, cyOn] = Array.isArray(centered) ? centered : [centered, centered];
993
+ const ox = cxOn ? 0 : w / 2;
994
+ const oy = cyOn ? 0 : d / 2;
995
+ const at = eachPoints(wp);
996
+ if (opts?.forConstruction) {
997
+ // Construction rect: store corners for vertices() and edge midpoints for edges()
998
+ return clone(wp, {
999
+ forConstruction: true,
1000
+ pendingRect: { w, d },
1001
+ pendingWires: [
1002
+ ...(wp.pendingWires ?? []),
1003
+ ...at.map(([px, py]) => ({ kind: 'rect', w, d, cx: px + ox, cy: py + oy, construction: true, plane: planeOf(wp) })),
1004
+ ],
1005
+ pts: [
1006
+ [ox - w / 2, oy - d / 2],
1007
+ [ox + w / 2, oy - d / 2],
1008
+ [ox + w / 2, oy + d / 2],
1009
+ [ox - w / 2, oy + d / 2],
1010
+ ],
1011
+ edgePts: [
1012
+ [ox, oy - d / 2],
1013
+ [ox + w / 2, oy],
1014
+ [ox, oy + d / 2],
1015
+ [ox - w / 2, oy],
1016
+ ],
1017
+ });
1018
+ }
1019
+ // Non-construction rect: store profile for extrude()/cutBlind()
1020
+ return clone(wp, {
1021
+ forConstruction: false,
1022
+ pendingRect: { w, d },
1023
+ pendingWires: [
1024
+ ...(wp.pendingWires ?? []),
1025
+ ...at.map(([px, py]) => ({ kind: 'rect', w, d, cx: px + ox, cy: py + oy, construction: false, plane: planeOf(wp) })),
1026
+ ],
1027
+ });
1028
+ }
1029
+ /**
1030
+ * circle
1031
+ * @param wp - Workplane
1032
+ * @param radius - number
1033
+ * @returns Workplane
1034
+ */
1035
+ export function circle(wp, radius) {
1036
+ // CadQuery eachpoint semantics: with pushed points / selected vertices the
1037
+ // circle is created at EVERY point, and all of them land in pendingWires.
1038
+ const at = eachPoints(wp);
1039
+ return clone(wp, {
1040
+ forConstruction: false,
1041
+ pendingCircle: { radius },
1042
+ pendingWires: [
1043
+ ...(wp.pendingWires ?? []),
1044
+ ...at.map(([cx, cy]) => ({ kind: 'circle', radius, cx, cy, construction: false, plane: planeOf(wp) })),
1045
+ ],
1046
+ });
1047
+ }
1048
+ /**
1049
+ * ellipse — CadQuery `Workplane.ellipse(x_radius, y_radius)` parity.
1050
+ *
1051
+ * `x_radius` lies on the workplane X axis and `y_radius` on Y — upstream puts
1052
+ * no ordering constraint on them (`testEdgeTypesFilter` uses `ellipse(3, 4)`).
1053
+ * The kernel's `makeEllipseEdge` requires major >= minor, ignores the plane's
1054
+ * own axes and lays the major axis on the global X direction, so a "tall"
1055
+ * ellipse is built as a wide one and then rotated 90° about the workplane
1056
+ * normal through its centre (see `buildProfileWire`).
1057
+ *
1058
+ * @param wp - Workplane
1059
+ * @param x_radius - radius along the workplane X axis
1060
+ * @param y_radius - radius along the workplane Y axis
1061
+ * @returns Workplane
1062
+ */
1063
+ export function ellipse(wp, x_radius, y_radius) {
1064
+ // CadQuery eachpoint semantics, same as circle(): one pending wire per
1065
+ // pushed point / selected vertex.
1066
+ const at = eachPoints(wp);
1067
+ const base = planeOf(wp);
1068
+ const flip = y_radius > x_radius;
1069
+ return clone(wp, {
1070
+ forConstruction: false,
1071
+ pendingWires: [
1072
+ ...(wp.pendingWires ?? []),
1073
+ ...at.map(([cx, cy]) => ({
1074
+ kind: 'ellipse',
1075
+ majorRadius: flip ? y_radius : x_radius,
1076
+ minorRadius: flip ? x_radius : y_radius,
1077
+ cx,
1078
+ cy,
1079
+ construction: false,
1080
+ plane: base,
1081
+ flip,
1082
+ })),
1083
+ ],
1084
+ });
1085
+ }
1086
+ /**
1087
+ * polygon
1088
+ * @param wp - Workplane
1089
+ * @param n - number
1090
+ * @param d - number
1091
+ * @returns Workplane
1092
+ */
1093
+ export function polygon(wp, n, d) {
1094
+ // CadQuery polygon(nSides, diameter): regular n-gon inscribed in a circle of
1095
+ // the given diameter, first vertex on local +X. The prism is materialized
1096
+ // when consumed by extrude()/cutBlind() (makePolygonPrismAt).
1097
+ const at = eachPoints(wp);
1098
+ return clone(wp, {
1099
+ forConstruction: false,
1100
+ pendingPolygon: { n, d },
1101
+ pendingWires: [
1102
+ ...(wp.pendingWires ?? []),
1103
+ ...at.map(([cx, cy]) => ({ kind: 'polygon', n, d, cx, cy, construction: false, plane: planeOf(wp) })),
1104
+ ],
1105
+ });
1106
+ }
1107
+ /**
1108
+ * Implicit workplane for a pending face selection.
1109
+ *
1110
+ * CadQuery semantics (verified against cadquery 2.8.0): after `faces(sel)`, a
1111
+ * 2D profile followed by extrude/cut operates on the SELECTED face's plane,
1112
+ * exactly as if `workplane()` had been called — even though `Workplane.plane`
1113
+ * still reports the original origin. Measured on `box(1,1,1)`:
1114
+ * - `box(1,1,1).rect(1,.5).cutBlind(-0.2)` -> CoM z = +0.011111
1115
+ * (slot at z in [-0.2, 0])
1116
+ * - `box(1,1,1).faces(">Z").rect(1,.5).cutBlind(-0.2)` -> CoM z = -0.044444
1117
+ * (slot at z in [0.3, 0.5])
1118
+ * - `...faces(">Z")...cutBlind(+0.2)` -> volume unchanged (1.0):
1119
+ * the cut starts at z=0.5 and misses the solid entirely.
1120
+ * Without this step the profile is built on the un-lifted workplane and the
1121
+ * feature lands half a body away.
1122
+ *
1123
+ * NOTE ON TYPES: `workplane()` never touches `.shape`, only origin/axes. But
1124
+ * reassigning `wp` resets TypeScript's narrowing of `wp.shape`, so call sites
1125
+ * must capture the shape in a local BEFORE calling this helper.
1126
+ *
1127
+ * @param wp - Workplane possibly carrying a face selection.
1128
+ * @returns Promise<Workplane> with the face plane applied, or `wp` unchanged.
1129
+ */
1130
+ async function applyPendingFacePlane(wp) {
1131
+ if (!wp.faceSel)
1132
+ return wp;
1133
+ return workplane(wp);
1134
+ }
1135
+ // ── Pending-wire profiles (CadQuery pendingWires parity) ───────────────────
1136
+ /** Local-space (workplane 2D) bounding box of a pending wire. */
1137
+ function wireBBox(w) {
1138
+ if (w.kind === 'circle') {
1139
+ return {
1140
+ minX: w.cx - w.radius,
1141
+ minY: w.cy - w.radius,
1142
+ maxX: w.cx + w.radius,
1143
+ maxY: w.cy + w.radius,
1144
+ };
1145
+ }
1146
+ if (w.kind === 'ellipse') {
1147
+ // flip: the built ellipse is rotated 90° in plane, so the local X extent is
1148
+ // the minor radius and the local Y extent the major one.
1149
+ const rx = w.flip ? w.minorRadius : w.majorRadius;
1150
+ const ry = w.flip ? w.majorRadius : w.minorRadius;
1151
+ return {
1152
+ minX: w.cx - rx,
1153
+ minY: w.cy - ry,
1154
+ maxX: w.cx + rx,
1155
+ maxY: w.cy + ry,
1156
+ };
1157
+ }
1158
+ if (w.kind === 'rect') {
1159
+ return {
1160
+ minX: w.cx - w.w / 2,
1161
+ minY: w.cy - w.d / 2,
1162
+ maxX: w.cx + w.w / 2,
1163
+ maxY: w.cy + w.d / 2,
1164
+ };
1165
+ }
1166
+ if (w.kind === 'path') {
1167
+ let minX = Infinity;
1168
+ let minY = Infinity;
1169
+ let maxX = -Infinity;
1170
+ let maxY = -Infinity;
1171
+ const consider = (p) => {
1172
+ minX = Math.min(minX, p[0]);
1173
+ minY = Math.min(minY, p[1]);
1174
+ maxX = Math.max(maxX, p[0]);
1175
+ maxY = Math.max(maxY, p[1]);
1176
+ };
1177
+ for (const p of w.pts)
1178
+ consider(p);
1179
+ // Arc bulges: include the through/mid points (for arc3 exact sweep max
1180
+ // needs the circle; the mid point is the standard chord-mid correction and
1181
+ // keeps cut-tool envelopes from under-covering bulged profiles).
1182
+ if (w.edges) {
1183
+ for (const e of w.edges) {
1184
+ if (e.kind === 'arc3')
1185
+ consider(e.mid);
1186
+ }
1187
+ }
1188
+ return { minX, minY, maxX, maxY };
1189
+ }
1190
+ // polygon: circumradius = d/2 (n-gon inscribed in a circle of diameter d)
1191
+ const r = w.d / 2;
1192
+ return { minX: w.cx - r, minY: w.cy - r, maxX: w.cx + r, maxY: w.cy + r };
1193
+ }
1194
+ /**
1195
+ * Group pending wires into faces: every outermost wire becomes one face and
1196
+ * the wires it encloses become that face's holes.
1197
+ *
1198
+ * Mirrors cadquery 2.8.0 (measured, `sortWiresByBuildOrder`-like behaviour):
1199
+ * `pushPoints([p1,p2]).circle(4).circle(2)` -> TWO annuli (2 solids)
1200
+ * `rect(2,2)` + 4 corner circles -> ONE plate with 4 holes
1201
+ *
1202
+ * Nesting is decided by bbox containment; area ties keep declaration order so
1203
+ * disjoint wires of equal size never swallow each other.
1204
+ */
1205
+ function groupPendingWires(wires) {
1206
+ const boxes = wires.map(wireBBox);
1207
+ const area = (i) => (boxes[i].maxX - boxes[i].minX) * (boxes[i].maxY - boxes[i].minY);
1208
+ const order = wires.map((_, i) => i).sort((a, b) => area(b) - area(a) || a - b);
1209
+ const groups = [];
1210
+ const claimed = new Set();
1211
+ for (const i of order) {
1212
+ if (claimed.has(i))
1213
+ continue;
1214
+ claimed.add(i);
1215
+ const ob = boxes[i];
1216
+ const holes = [];
1217
+ for (const j of order) {
1218
+ if (claimed.has(j))
1219
+ continue;
1220
+ const hb = boxes[j];
1221
+ if (hb.minX >= ob.minX && hb.maxX <= ob.maxX && hb.minY >= ob.minY && hb.maxY <= ob.maxY) {
1222
+ claimed.add(j);
1223
+ holes.push(wires[j]);
1224
+ }
1225
+ }
1226
+ groups.push({ outer: wires[i], holes });
1227
+ }
1228
+ return groups;
1229
+ }
1230
+ // ── 2D drafting (CadQuery moveTo/lineTo/close/wire parity) ─────────────────
1231
+ /**
1232
+ * Current drawing point in local coordinates.
1233
+ *
1234
+ * Upstream `_findFromPoint` returns the end point of the last stack object, or
1235
+ * `plane.origin` when the stack is empty — hence the `[0, 0]` fallback.
1236
+ */
1237
+ function currentLocalPoint(wp) {
1238
+ return wp.currentPoint ?? [0, 0];
1239
+ }
1240
+ /**
1241
+ * Draft one straight edge from the current point to `to` and advance there.
1242
+ *
1243
+ * `forConstruction` edges still move the current point (upstream calls
1244
+ * `newObject([edge])` unconditionally) but are NOT queued into `pendingEdges`
1245
+ * and never set `firstPoint` — upstream only queues via `_addPendingEdge`.
1246
+ */
1247
+ function draftEdge(wp, to, forConstruction) {
1248
+ const from = currentLocalPoint(wp);
1249
+ if (forConstruction) {
1250
+ return clone(wp, { currentPoint: to });
1251
+ }
1252
+ const edges = [...(wp.pendingEdges ?? []), { kind: 'line', from, to }];
1253
+ return clone(wp, {
1254
+ pendingEdges: edges,
1255
+ currentPoint: to,
1256
+ firstPoint: wp.firstPoint ?? from,
1257
+ });
1258
+ }
1259
+ /**
1260
+ * Queue one arc edge descriptor (shared by threePointArc/sagittaArc/radiusArc)
1261
+ * with the same forConstruction semantics as draftEdge.
1262
+ */
1263
+ function draftArc3(wp, mid, to, forConstruction) {
1264
+ const from = currentLocalPoint(wp);
1265
+ if (forConstruction) {
1266
+ return clone(wp, { currentPoint: to });
1267
+ }
1268
+ const edges = [...(wp.pendingEdges ?? []), { kind: 'arc3', from, mid, to }];
1269
+ return clone(wp, {
1270
+ pendingEdges: edges,
1271
+ currentPoint: to,
1272
+ firstPoint: wp.firstPoint ?? from,
1273
+ });
1274
+ }
1275
+ /**
1276
+ * Tangent of the last pending edge at its end point, in local coordinates —
1277
+ * the analytic analogue of upstream `previousEdge.tangentAt(1)`.
1278
+ *
1279
+ * - line: chord direction.
1280
+ * - arc3: perpendicular to the end radius of the circumcircle through
1281
+ * (from, mid, to), oriented along the travel direction.
1282
+ * - tangentArc: perpendicular to the end radius of the circle through `from`
1283
+ * with tangent `tgt`, oriented along the travel direction (center side `s`
1284
+ * selects the sweep orientation).
1285
+ * - spline: uses the stored end tangent (captured from the kernel at creation).
1286
+ */
1287
+ function lastEdgeEndTangent(wp) {
1288
+ const edges = wp.pendingEdges ?? [];
1289
+ if (edges.length === 0) {
1290
+ throw new Error('[cq-compat] tangentArcPoint: no previous edge to continue tangentially');
1291
+ }
1292
+ const e = edges[edges.length - 1];
1293
+ if (e.kind === 'line') {
1294
+ const dx = e.to[0] - e.from[0];
1295
+ const dy = e.to[1] - e.from[1];
1296
+ const len = Math.hypot(dx, dy);
1297
+ if (len < 1e-12)
1298
+ throw new Error('[cq-compat] tangentArcPoint: degenerate previous line');
1299
+ return [dx / len, dy / len];
1300
+ }
1301
+ if (e.kind === 'spline') {
1302
+ if (e.endTgt)
1303
+ return e.endTgt;
1304
+ throw new Error('[cq-compat] tangentArcPoint: spline edge has no stored end tangent');
1305
+ }
1306
+ // Circumcenter of (from, mid, to) for arc3, or of (from, tangent-constraint)
1307
+ // for tangentArc — both reduce to: circle through `from` and `to` whose
1308
+ // tangent at `from` is known.
1309
+ let cx, cy;
1310
+ if (e.kind === 'arc3') {
1311
+ const [ax, ay] = e.from;
1312
+ const [mx, my] = e.mid;
1313
+ const [bx, by] = e.to;
1314
+ const d = 2 * (ax * (my - by) + mx * (by - ay) + bx * (ay - my));
1315
+ if (Math.abs(d) < 1e-12) {
1316
+ throw new Error('[cq-compat] tangentArcPoint: previous arc is degenerate (collinear)');
1317
+ }
1318
+ const a2 = ax * ax + ay * ay;
1319
+ const m2 = mx * mx + my * my;
1320
+ const b2 = bx * bx + by * by;
1321
+ cx = (a2 * (by - my) + m2 * (ay - by) + b2 * (my - ay)) / d;
1322
+ cy = (a2 * (mx - bx) + m2 * (bx - ax) + b2 * (ax - mx)) / d;
1323
+ }
1324
+ else {
1325
+ // tangentArc: center = from + s·n̂ with n̂ = perp(tgt), s = |d|²/(2·d·n̂)
1326
+ const tLen = Math.hypot(e.tgt[0], e.tgt[1]);
1327
+ const tx = e.tgt[0] / tLen;
1328
+ const ty = e.tgt[1] / tLen;
1329
+ const nx = -ty;
1330
+ const ny = tx;
1331
+ const dx = e.to[0] - e.from[0];
1332
+ const dy = e.to[1] - e.from[1];
1333
+ const dn = dx * nx + dy * ny;
1334
+ if (Math.abs(dn) < 1e-12) {
1335
+ throw new Error('[cq-compat] tangentArcPoint: previous arc is degenerate (straight)');
1336
+ }
1337
+ const s = (dx * dx + dy * dy) / (2 * dn);
1338
+ cx = e.from[0] + s * nx;
1339
+ cy = e.from[1] + s * ny;
1340
+ }
1341
+ // End radius → end tangent (perpendicular), oriented along travel. The
1342
+ // sweep orientation comes from where the circle center sits relative to the
1343
+ // travel: center on the LEFT of the direction of motion ⇒ CCW sweep (for
1344
+ // arc3 the mid point breaks the tie; for tangentArc the center side s does).
1345
+ // cross(from−C, to−C) alone is degenerate for half circles.
1346
+ const px = e.to[0] - cx;
1347
+ const py = e.to[1] - cy;
1348
+ const plen = Math.hypot(px, py);
1349
+ if (plen < 1e-12)
1350
+ throw new Error('[cq-compat] tangentArcPoint: previous arc has zero radius');
1351
+ let ccw;
1352
+ if (e.kind === 'arc3') {
1353
+ ccw = (e.mid[0] - cx) * py - (e.mid[1] - cy) * px >= 0;
1354
+ }
1355
+ else {
1356
+ // tangentArc: n̂ = perp(tgt) points LEFT of travel; s > 0 ⇒ center left ⇒ CCW.
1357
+ const tLen2 = Math.hypot(e.tgt[0], e.tgt[1]);
1358
+ const nx = -e.tgt[1] / tLen2;
1359
+ const ny = e.tgt[0] / tLen2;
1360
+ const dx = e.to[0] - e.from[0];
1361
+ const dy = e.to[1] - e.from[1];
1362
+ ccw = dx * nx + dy * ny >= 0;
1363
+ }
1364
+ return ccw ? [-py / plen, px / plen] : [py / plen, -px / plen];
1365
+ }
1366
+ /**
1367
+ * Queue one tangent-continuation arc descriptor (tangentArcPoint).
1368
+ */
1369
+ function draftTangentArc(wp, tgt, to, forConstruction) {
1370
+ const from = currentLocalPoint(wp);
1371
+ if (forConstruction) {
1372
+ return clone(wp, { currentPoint: to });
1373
+ }
1374
+ const edges = [
1375
+ ...(wp.pendingEdges ?? []),
1376
+ { kind: 'tangentArc', from, tgt, to },
1377
+ ];
1378
+ return clone(wp, {
1379
+ pendingEdges: edges,
1380
+ currentPoint: to,
1381
+ firstPoint: wp.firstPoint ?? from,
1382
+ });
1383
+ }
1384
+ /**
1385
+ * threePointArc — draft an arc from the current point through `point1`,
1386
+ * ending at `point2` (CadQuery `Workplane.threePointArc`).
1387
+ * @param wp - Workplane
1388
+ * @param point1 - intermediate point the arc passes through (local 2D)
1389
+ * @param point2 - end point of the arc (local 2D)
1390
+ * @param forConstruction - edge is reference geometry only (default false)
1391
+ * @returns Workplane
1392
+ */
1393
+ export function threePointArc(wp, point1, point2, forConstruction = false) {
1394
+ return draftArc3(wp, point1, point2, forConstruction);
1395
+ }
1396
+ /**
1397
+ * sagittaArc — arc from the current point to `endPoint` with sagitta `sag`
1398
+ * (CadQuery `Workplane.sagittaArc`). Positive sag bulges to the LEFT of the
1399
+ * start→end direction (convex for a clockwise contour), negative to the right.
1400
+ * Mirrors the upstream sag-vector rotation in cq.py sagittaArc.
1401
+ * @param wp - Workplane
1402
+ * @param endPoint - end point (local 2D)
1403
+ * @param sag - sagitta (perpendicular distance from arc midpoint to the chord)
1404
+ * @param forConstruction - edge is reference geometry only (default false)
1405
+ * @returns Workplane
1406
+ */
1407
+ export function sagittaArc(wp, endPoint, sag, forConstruction = false) {
1408
+ const start = currentLocalPoint(wp);
1409
+ const dx = endPoint[0] - start[0];
1410
+ const dy = endPoint[1] - start[1];
1411
+ const len = Math.hypot(dx, dy);
1412
+ if (len < 1e-12) {
1413
+ throw new Error('[cq-compat] sagittaArc: start and end points coincide');
1414
+ }
1415
+ const nx = dx / len;
1416
+ const ny = dy / len;
1417
+ const mag = Math.abs(sag);
1418
+ // sag > 0: rotate unit chord direction +90° (x,y)→(−y,x); sag < 0: −90°.
1419
+ const sx = sag > 0 ? -ny * mag : ny * mag;
1420
+ const sy = sag > 0 ? nx * mag : -nx * mag;
1421
+ const mid = [(start[0] + endPoint[0]) / 2 + sx, (start[1] + endPoint[1]) / 2 + sy];
1422
+ return draftArc3(wp, mid, endPoint, forConstruction);
1423
+ }
1424
+ /**
1425
+ * radiusArc — arc from the current point to `endPoint` with radius `radius`
1426
+ * (CadQuery `Workplane.radiusArc`). Positive radius = convex arc (for a
1427
+ * clockwise contour), negative = concave. The sagitta is derived exactly as
1428
+ * upstream: sag = |r| − sqrt(r² − (len/2)²).
1429
+ * @param wp - Workplane
1430
+ * @param endPoint - end point (local 2D)
1431
+ * @param radius - arc radius (sign selects the bulge side)
1432
+ * @param forConstruction - edge is reference geometry only (default false)
1433
+ * @returns Workplane
1434
+ */
1435
+ export function radiusArc(wp, endPoint, radius, forConstruction = false) {
1436
+ const start = currentLocalPoint(wp);
1437
+ const halfLen = Math.hypot(endPoint[0] - start[0], endPoint[1] - start[1]) / 2;
1438
+ const TOL = 1e-6;
1439
+ const r2l2 = radius * radius - halfLen * halfLen;
1440
+ if (r2l2 < -TOL) {
1441
+ throw new Error('[cq-compat] radiusArc: arc radius is not large enough to reach the end point');
1442
+ }
1443
+ let sag = Math.abs(radius);
1444
+ if (Math.abs(r2l2) >= TOL)
1445
+ sag -= Math.sqrt(r2l2);
1446
+ return sagittaArc(wp, endPoint, radius > 0 ? sag : -sag, forConstruction);
1447
+ }
1448
+ /**
1449
+ * tangentArcPoint — arc tangent to the end of the last drafted edge, ending at
1450
+ * `endpoint` (CadQuery `Workplane.tangentArcPoint`).
1451
+ * @param wp - Workplane
1452
+ * @param endpoint - end point (local 2D; relative to the current point when
1453
+ * `relative` is true)
1454
+ * @param forConstruction - edge is reference geometry only (default false)
1455
+ * @param relative - interpret `endpoint` relative to the current point (default true)
1456
+ * @returns Workplane
1457
+ */
1458
+ export function tangentArcPoint(wp, endpoint, forConstruction = false, relative = true) {
1459
+ const cur = currentLocalPoint(wp);
1460
+ const to = relative ? [cur[0] + endpoint[0], cur[1] + endpoint[1]] : [endpoint[0], endpoint[1]];
1461
+ const tgt = lastEdgeEndTangent(wp);
1462
+ return draftTangentArc(wp, tgt, to, forConstruction);
1463
+ }
1464
+ /**
1465
+ * spline — cubic B-spline edge interpolated exactly through `points`
1466
+ * (CadQuery `Workplane.spline`, includeCurrent=false default: the edge starts
1467
+ * at points[0], NOT at the current point — upstream `_toVectors` only prepends
1468
+ * the current point when includeCurrent is set). The current point becomes the
1469
+ * spline end. `includeCurrent` prepends the current point; the resulting edge
1470
+ * stores its kernel-measured end tangent so a following tangentArcPoint can
1471
+ * continue the curve.
1472
+ * @param wp - Workplane
1473
+ * @param points - interpolation points (local 2D; 3D z=0)
1474
+ * @param opts - { forConstruction?; includeCurrent?; periodic?; makeWire? }
1475
+ * @returns Workplane
1476
+ */
1477
+ export function spline(wp, points, opts) {
1478
+ if (!Array.isArray(points) || points.length < 2) {
1479
+ throw new Error('[cq-compat] spline: at least 2 points are required');
1480
+ }
1481
+ const includeCurrent = opts?.includeCurrent === true;
1482
+ const all = includeCurrent ? [currentLocalPoint(wp), ...points] : points;
1483
+ const end = all[all.length - 1];
1484
+ // Build the spline edge ONCE here and keep a strong reference to it in the
1485
+ // descriptor. Creating a throwaway edge just to measure the tangent and
1486
+ // dropping it is NOT safe: brepjs registers every kernel shape in a
1487
+ // FinalizationRegistry, and when GC collects the discarded wrapper the
1488
+ // arena slot is freed and recycled — the next tangent-arc handle can dangle
1489
+ // (observed as curvePointAt returning nulls + FACE_BUILD_FAILED).
1490
+ if (opts?.forConstruction) {
1491
+ return clone(wp, { currentPoint: end });
1492
+ }
1493
+ const world = all.map(([x, y]) => localToWorld(wp, x, y));
1494
+ const builtEdge = unwrapBrepResult(compatFn('makeBSplineInterpolation')(world, { periodic: false }));
1495
+ const endTgt = splineEndTangent(builtEdge, wp);
1496
+ const edges = [
1497
+ ...(wp.pendingEdges ?? []),
1498
+ { kind: 'spline', from: all[0], pts: all, to: end, endTgt, builtEdge },
1499
+ ];
1500
+ let next = clone(wp, {
1501
+ pendingEdges: edges,
1502
+ currentPoint: end,
1503
+ firstPoint: wp.firstPoint ?? all[0],
1504
+ });
1505
+ if (opts?.makeWire) {
1506
+ next = wire(next);
1507
+ }
1508
+ return next;
1509
+ }
1510
+ /** Kernel-measured end tangent of a built spline edge, in workplane-local 2D. */
1511
+ function splineEndTangent(edge, wp) {
1512
+ // curveTangentAt returns a plain [x, y, z] ARRAY (vendored curveFns →
1513
+ // curveOps.curveTangent(...).tangent), not an {x,y,z} vector — indexing it
1514
+ // with .x yields undefined → NaN → a corrupt tangent-arc edge downstream.
1515
+ const tRaw = compatFn('curveTangentAt')(edge, 1);
1516
+ const t = Array.isArray(tRaw)
1517
+ ? { x: tRaw[0], y: tRaw[1], z: tRaw[2] }
1518
+ : tRaw;
1519
+ if (![t.x, t.y, t.z].every(Number.isFinite)) {
1520
+ throw new Error('[cq-compat] spline: kernel returned a non-finite end tangent');
1521
+ }
1522
+ // Back to workplane-local 2D.
1523
+ const o = wp.origin;
1524
+ const bx = t.x - o[0];
1525
+ const by = t.y - o[1];
1526
+ const bz = t.z - o[2];
1527
+ const lx = bx * wp.xDir[0] + by * wp.xDir[1] + bz * wp.xDir[2];
1528
+ const ly = bx * wp.yDir[0] + by * wp.yDir[1] + bz * wp.yDir[2];
1529
+ const len = Math.hypot(lx, ly);
1530
+ if (len < 1e-12)
1531
+ throw new Error('[cq-compat] spline: zero end tangent');
1532
+ return [lx / len, ly / len];
1533
+ }
1534
+ /**
1535
+ * moveTo — move the current point without drawing (CadQuery `Workplane.moveTo`).
1536
+ * @param wp - Workplane
1537
+ * @param x - target x in local coords (default 0)
1538
+ * @param y - target y in local coords (default 0)
1539
+ * @returns Workplane
1540
+ */
1541
+ export function moveTo(wp, x = 0, y = 0) {
1542
+ return clone(wp, { currentPoint: [x, y] });
1543
+ }
1544
+ /**
1545
+ * move2D — relative version of `moveTo` (CadQuery `Workplane.move`).
1546
+ *
1547
+ * NOTE: upstream spells this `move`, but the Shape-level `move` (the in-place
1548
+ * twin of `moved`, which takes `Location` arguments) already owns that name in
1549
+ * cq-compat, so the 2D drafting variant is exported as `move2D`.
1550
+ *
1551
+ * @param wp - Workplane
1552
+ * @param xDist - x offset from the current point (default 0)
1553
+ * @param yDist - y offset from the current point (default 0)
1554
+ * @returns Workplane
1555
+ */
1556
+ export function move2D(wp, xDist = 0, yDist = 0) {
1557
+ const p = currentLocalPoint(wp);
1558
+ return moveTo(wp, p[0] + xDist, p[1] + yDist);
1559
+ }
1560
+ /**
1561
+ * lineTo — draft a straight edge to an absolute local point
1562
+ * (CadQuery `Workplane.lineTo`).
1563
+ * @param wp - Workplane
1564
+ * @param x - target x in local coords
1565
+ * @param y - target y in local coords
1566
+ * @param forConstruction - edge is reference geometry only (default false)
1567
+ * @returns Workplane
1568
+ */
1569
+ export function lineTo(wp, x, y, forConstruction = false) {
1570
+ return draftEdge(wp, [x, y], forConstruction);
1571
+ }
1572
+ /**
1573
+ * line — draft a straight edge by a relative offset (CadQuery `Workplane.line`).
1574
+ * @param wp - Workplane
1575
+ * @param xDist - x offset from the current point
1576
+ * @param yDist - y offset from the current point
1577
+ * @param forConstruction - edge is reference geometry only (default false)
1578
+ * @returns Workplane
1579
+ */
1580
+ export function line(wp, xDist, yDist, forConstruction = false) {
1581
+ const p = currentLocalPoint(wp);
1582
+ return draftEdge(wp, [p[0] + xDist, p[1] + yDist], forConstruction);
1583
+ }
1584
+ /**
1585
+ * vLine — vertical (local +Y) relative line (CadQuery `Workplane.vLine`).
1586
+ *
1587
+ * @param wp - Workplane
1588
+ * @param distance - signed length along local +Y
1589
+ * @param forConstruction - edge is reference geometry only (default false)
1590
+ * @returns Workplane
1591
+ */
1592
+ export function vLine(wp, distance, forConstruction = false) {
1593
+ return line(wp, 0, distance, forConstruction);
1594
+ }
1595
+ /**
1596
+ * hLine — horizontal (local +X) relative line (CadQuery `Workplane.hLine`).
1597
+ *
1598
+ * @param wp - Workplane
1599
+ * @param distance - signed length along local +X
1600
+ * @param forConstruction - edge is reference geometry only (default false)
1601
+ * @returns Workplane
1602
+ */
1603
+ export function hLine(wp, distance, forConstruction = false) {
1604
+ return line(wp, distance, 0, forConstruction);
1605
+ }
1606
+ /**
1607
+ * vLineTo — vertical line to an absolute local y (CadQuery `Workplane.vLineTo`).
1608
+ *
1609
+ * @param wp - Workplane
1610
+ * @param yCoord - absolute local y to end at
1611
+ * @param forConstruction - edge is reference geometry only (default false)
1612
+ * @returns Workplane
1613
+ */
1614
+ export function vLineTo(wp, yCoord, forConstruction = false) {
1615
+ return lineTo(wp, currentLocalPoint(wp)[0], yCoord, forConstruction);
1616
+ }
1617
+ /**
1618
+ * hLineTo — horizontal line to an absolute local x (CadQuery `Workplane.hLineTo`).
1619
+ *
1620
+ * @param wp - Workplane
1621
+ * @param xCoord - absolute local x to end at
1622
+ * @param forConstruction - edge is reference geometry only (default false)
1623
+ * @returns Workplane
1624
+ */
1625
+ export function hLineTo(wp, xCoord, forConstruction = false) {
1626
+ return lineTo(wp, xCoord, currentLocalPoint(wp)[1], forConstruction);
1627
+ }
1628
+ /**
1629
+ * polyline — draft a chain of edges through the given local points
1630
+ * (CadQuery `Workplane.polyline`).
1631
+ *
1632
+ * `includeCurrent=false` (upstream default) treats the FIRST point as an
1633
+ * implicit moveTo and only draws from it onward.
1634
+ *
1635
+ * @param wp - Workplane
1636
+ * @param pts - local 2D points
1637
+ * @param forConstruction - edges are reference geometry only (default false)
1638
+ * @param includeCurrent - start from the current point (default false)
1639
+ * @returns Workplane
1640
+ */
1641
+ export function polyline(wp, pts, forConstruction = false, includeCurrent = false) {
1642
+ if (!Array.isArray(pts) || pts.length === 0)
1643
+ return wp;
1644
+ let cur = wp;
1645
+ if (includeCurrent) {
1646
+ for (const p of pts)
1647
+ cur = draftEdge(cur, [p[0], p[1]], forConstruction);
1648
+ return cur;
1649
+ }
1650
+ // Upstream: startPoint = pts[0] (no edge drawn), then edges to pts[1:].
1651
+ cur = clone(cur, { currentPoint: [pts[0][0], pts[0][1]] });
1652
+ for (const p of pts.slice(1))
1653
+ cur = draftEdge(cur, [p[0], p[1]], forConstruction);
1654
+ return cur;
1655
+ }
1656
+ /**
1657
+ * wire — combine all pending edges into one pending wire
1658
+ * (CadQuery `Workplane.wire`). No-op when there are no free edges (upstream
1659
+ * returns self unchanged in that case).
1660
+ *
1661
+ * @param wp - Workplane
1662
+ * @param forConstruction - keep the wire out of the solid profile (default false)
1663
+ * @returns Workplane
1664
+ */
1665
+ export function wire(wp, forConstruction = false) {
1666
+ const edges = wp.pendingEdges ?? [];
1667
+ if (edges.length === 0)
1668
+ return wp;
1669
+ const pts = [];
1670
+ for (const e of edges) {
1671
+ if (pts.length === 0)
1672
+ pts.push([e.from[0], e.from[1]]);
1673
+ pts.push([e.to[0], e.to[1]]);
1674
+ }
1675
+ // close() may already have appended the segment back to the first point;
1676
+ // drop the duplicated vertex so the ring has no zero-length edge.
1677
+ if (pts.length > 1) {
1678
+ const a = pts[0];
1679
+ const b = pts[pts.length - 1];
1680
+ if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-9)
1681
+ pts.pop();
1682
+ }
1683
+ const w = {
1684
+ kind: 'path',
1685
+ pts,
1686
+ // Full edge descriptors (incl. arc3/tangentArc/spline) so wire assembly
1687
+ // rebuilds the exact curves; pts is the vertex ring used for bbox only.
1688
+ edges: edges.map((e) => ({ ...e })),
1689
+ construction: forConstruction,
1690
+ plane: planeOf(wp),
1691
+ };
1692
+ return clone(wp, {
1693
+ pendingEdges: [],
1694
+ pendingWires: forConstruction ? wp.pendingWires : [...(wp.pendingWires ?? []), w],
1695
+ });
1696
+ }
1697
+ /**
1698
+ * close — end drafting and build a closed wire (CadQuery `Workplane.close`).
1699
+ * Appends the closing segment when the end point is more than 1e-6 away from
1700
+ * the first point (upstream threshold), then delegates to `wire()`.
1701
+ *
1702
+ * @param wp - Workplane
1703
+ * @returns Workplane
1704
+ */
1705
+ export function close(wp) {
1706
+ const end = currentLocalPoint(wp);
1707
+ const start = wp.firstPoint;
1708
+ if (!start) {
1709
+ throw new Error('[cq-compat] close: No start point specified - cannot close');
1710
+ }
1711
+ let cur = wp;
1712
+ if (Math.hypot(end[0] - start[0], end[1] - start[1]) > 1e-6) {
1713
+ cur = draftEdge(cur, [start[0], start[1]], false);
1714
+ }
1715
+ cur = clone(cur, { firstPoint: undefined });
1716
+ return wire(cur);
1717
+ }
1718
+ /**
1719
+ * Reorder edge descriptors so the kernel's sequential wire builder accepts all
1720
+ * of them. `makeWire` adds edges one by one and silently DROPS an edge when it
1721
+ * connects to neither open end of the wire built so far — upstream CadQuery
1722
+ * 2.8 avoids this via the MakeWire list-Add overload (BRepBuilderAPI_MakeWire
1723
+ * with TopTools_ListOfShape), which keeps even disconnected edges. We can't
1724
+ * reach that overload from the wasm surface, but when the edge set forms a
1725
+ * single (possibly gappy) chain there is an ordering in which every edge
1726
+ * attaches to an open end when added (e.g. [line,line,spline,line] ->
1727
+ * [spline,closing,line,line]); find it with DFS over shared endpoints. The
1728
+ * kernel auto-orients reversed edges, so no descriptor reversal is needed.
1729
+ * Returns the input order unchanged when no full chain exists (multi-run gap —
1730
+ * the kernel then drops the stray run exactly as before).
1731
+ */
1732
+ function reorderForWireAssembly(edges) {
1733
+ const n = edges.length;
1734
+ if (n < 3)
1735
+ return edges;
1736
+ const key = (p) => `${Math.round(p[0] * 1e6) / 1e6},${Math.round(p[1] * 1e6) / 1e6}`;
1737
+ const ends = edges.map((e) => [key(e.from), key(e.to)]);
1738
+ const used = new Array(n).fill(false);
1739
+ const order = [];
1740
+ const dfs = (tailKey) => {
1741
+ if (order.length === n)
1742
+ return true;
1743
+ for (let j = 0; j < n; j++) {
1744
+ if (used[j])
1745
+ continue;
1746
+ if (ends[j][0] !== tailKey && ends[j][1] !== tailKey)
1747
+ continue;
1748
+ used[j] = true;
1749
+ order.push(j);
1750
+ if (dfs(ends[j][0] === tailKey ? ends[j][1] : ends[j][0]))
1751
+ return true;
1752
+ used[j] = false;
1753
+ order.pop();
1754
+ }
1755
+ return false;
1756
+ };
1757
+ for (let s = 0; s < n; s++) {
1758
+ order.length = 0;
1759
+ used.fill(false);
1760
+ used[s] = true;
1761
+ order.push(s);
1762
+ if (dfs(ends[s][1]))
1763
+ return order.map((i) => edges[i]);
1764
+ }
1765
+ return edges;
1766
+ }
1767
+ /** Build a brepjs wire for a pending 2D profile, in world coordinates. */
1768
+ async function buildProfileWire(wp, w) {
1769
+ // Use the wire's own creation-plane snapshot when present (loft sections can
1770
+ // live on different planes after intermediate workplane()/transformed calls).
1771
+ const pl = w.plane ?? {
1772
+ origin: wp.origin,
1773
+ xDir: wp.xDir,
1774
+ yDir: wp.yDir,
1775
+ normal: wp.normal,
1776
+ };
1777
+ const n = Array.isArray(pl.normal) ? pl.normal : [0, 0, 1];
1778
+ if (w.kind === 'circle') {
1779
+ const center = localToWorld(pl, w.cx, w.cy);
1780
+ const edge = unwrapBrepResult(compatFn('makeCircle')(w.radius, center, n));
1781
+ return unwrapBrepResult(compatFn('assembleWire')([edge]));
1782
+ }
1783
+ if (w.kind === 'ellipse') {
1784
+ const center = localToWorld(pl, w.cx, w.cy);
1785
+ if (w.flip) {
1786
+ // "Tall" ellipse (y_radius > x_radius) — NOT reproducible today.
1787
+ //
1788
+ // The kernel lays the major axis on the GLOBAL X direction, ignores the
1789
+ // plane's own axes, and rejects major < minor ("gp_Elips: invalid
1790
+ // construction parameters"), so the only way to a tall ellipse is to
1791
+ // rotate a wide one. Every rotation path available re-approximates the
1792
+ // curve: `applyMatrix`/`generalTransformWithHistory` drifts 0.4% in
1793
+ // volume (testEdgeTypesFilter), and the plain `transform` / `rotate`
1794
+ // kernel entries produce a wire `makeFace` then rejects as non-planar.
1795
+ // Failing loudly instead of silently emitting an approximated ellipse.
1796
+ throw new Error('[cq-compat] ellipse: y_radius > x_radius is not reproducible (kernel ellipse is always major-on-X and rotations re-approximate it)');
1797
+ }
1798
+ const edge = unwrapBrepResult(compatFn('makeEllipseEdge')(w.majorRadius, w.minorRadius, center, n));
1799
+ return unwrapBrepResult(compatFn('assembleWire')([edge]));
1800
+ }
1801
+ if (w.kind === 'path') {
1802
+ // Drafted ring. When edge descriptors are present (arcs/splines), rebuild
1803
+ // the exact curves; otherwise consecutive ring points are line edges, with
1804
+ // the last one closing back to the first. Zero-length segments (a close()
1805
+ // that landed exactly on the start point) are skipped — OCCT rejects them
1806
+ // in a wire.
1807
+ const edges = [];
1808
+ if (w.edges && w.edges.length > 0) {
1809
+ // Drop zero-length segments (a close() that landed exactly on the start
1810
+ // point — OCCT rejects them in a wire), then reorder so the sequential
1811
+ // makeWire builder keeps every edge (see reorderForWireAssembly).
1812
+ const descs = reorderForWireAssembly(w.edges.filter((e) => e.kind !== 'line' || Math.hypot(e.to[0] - e.from[0], e.to[1] - e.from[1]) >= 1e-9));
1813
+ for (const e of descs) {
1814
+ if (e.kind === 'line') {
1815
+ edges.push(unwrapBrepResult(compatFn('makeLine')(localToWorld(pl, e.from[0], e.from[1]), localToWorld(pl, e.to[0], e.to[1]))));
1816
+ }
1817
+ else if (e.kind === 'arc3') {
1818
+ edges.push(compatFn('makeThreePointArc')(localToWorld(pl, e.from[0], e.from[1]), localToWorld(pl, e.mid[0], e.mid[1]), localToWorld(pl, e.to[0], e.to[1])));
1819
+ }
1820
+ else if (e.kind === 'tangentArc') {
1821
+ const tLen = Math.hypot(e.tgt[0], e.tgt[1]);
1822
+ const t = [
1823
+ (pl.xDir[0] * e.tgt[0] + pl.yDir[0] * e.tgt[1]) / tLen,
1824
+ (pl.xDir[1] * e.tgt[0] + pl.yDir[1] * e.tgt[1]) / tLen,
1825
+ (pl.xDir[2] * e.tgt[0] + pl.yDir[2] * e.tgt[1]) / tLen,
1826
+ ];
1827
+ edges.push(compatFn('makeTangentArc')(localToWorld(pl, e.from[0], e.from[1]), t, localToWorld(pl, e.to[0], e.to[1])));
1828
+ }
1829
+ else if (e.builtEdge) {
1830
+ // Reuse the edge built at op time (strongly held — see spline()).
1831
+ edges.push(e.builtEdge);
1832
+ }
1833
+ else {
1834
+ // spline
1835
+ const world = e.pts.map(([x, y]) => localToWorld(pl, x, y));
1836
+ edges.push(unwrapBrepResult(compatFn('makeBSplineInterpolation')(world, { periodic: false })));
1837
+ }
1838
+ }
1839
+ }
1840
+ else {
1841
+ for (let i = 0; i < w.pts.length; i++) {
1842
+ const a = w.pts[i];
1843
+ const b = w.pts[(i + 1) % w.pts.length];
1844
+ if (Math.hypot(a[0] - b[0], a[1] - b[1]) < 1e-9)
1845
+ continue;
1846
+ edges.push(unwrapBrepResult(compatFn('makeLine')(localToWorld(pl, a[0], a[1]), localToWorld(pl, b[0], b[1]))));
1847
+ }
1848
+ }
1849
+ if (edges.length === 0)
1850
+ throw new Error('[cq-compat] buildProfileWire: degenerate path wire');
1851
+ return unwrapBrepResult(compatFn('assembleWire')(edges));
1852
+ }
1853
+ const ring = w.kind === 'rect'
1854
+ ? [
1855
+ [w.cx - w.w / 2, w.cy - w.d / 2],
1856
+ [w.cx + w.w / 2, w.cy - w.d / 2],
1857
+ [w.cx + w.w / 2, w.cy + w.d / 2],
1858
+ [w.cx - w.w / 2, w.cy + w.d / 2],
1859
+ ]
1860
+ : Array.from({ length: w.n }, (_, i) => {
1861
+ const a = (2 * Math.PI * i) / w.n;
1862
+ return [
1863
+ w.cx + Math.cos(a) * (w.d / 2),
1864
+ w.cy + Math.sin(a) * (w.d / 2),
1865
+ ];
1866
+ });
1867
+ const edges = [];
1868
+ for (let i = 0; i < ring.length; i++) {
1869
+ const a = ring[i];
1870
+ const b = ring[(i + 1) % ring.length];
1871
+ edges.push(unwrapBrepResult(compatFn('makeLine')(localToWorld(pl, a[0], a[1]), localToWorld(pl, b[0], b[1]))));
1872
+ }
1873
+ return unwrapBrepResult(compatFn('assembleWire')(edges));
1874
+ }
1875
+ /**
1876
+ * Extrude the pending wire LIST: one face per outermost wire, enclosed wires
1877
+ * punched as holes, all faces extruded by `height` along the workplane normal
1878
+ * and unioned. Only used when more than one solid wire is pending — the
1879
+ * single-wire case keeps the legacy prism path (zero regression risk).
1880
+ */
1881
+ /** Rebuild a pending wire translated by `shift` in world space (used to place cut tools). */
1882
+ function shiftWire(w, wp, shift) {
1883
+ const base = w.plane ?? {
1884
+ origin: wp.origin,
1885
+ xDir: wp.xDir,
1886
+ yDir: wp.yDir,
1887
+ normal: wp.normal,
1888
+ };
1889
+ return {
1890
+ ...w,
1891
+ plane: {
1892
+ origin: [base.origin[0] + shift[0], base.origin[1] + shift[1], base.origin[2] + shift[2]],
1893
+ xDir: base.xDir,
1894
+ yDir: base.yDir,
1895
+ normal: base.normal,
1896
+ },
1897
+ };
1898
+ }
1899
+ async function pendingPathPrism(wp, vec, shift) {
1900
+ const all = (wp.pendingWires ?? []).filter((w) => !w.construction);
1901
+ const groups = groupPendingWires(all);
1902
+ let result = null;
1903
+ for (const g of groups) {
1904
+ const outer = await buildProfileWire(wp, shift ? shiftWire(g.outer, wp, shift) : g.outer);
1905
+ const holeWires = [];
1906
+ for (const h of g.holes) {
1907
+ holeWires.push(await buildProfileWire(wp, shift ? shiftWire(h, wp, shift) : h));
1908
+ }
1909
+ const face = unwrapBrepResult(compatFn('makeFace')(outer, holeWires));
1910
+ const prism = unwrapBrepResult(compatFn('extrude')(face, vec));
1911
+ const solid = adoptBrepjsProduct(prism);
1912
+ result = result ? await fuseShapes(result, solid) : solid;
1913
+ }
1914
+ if (!result)
1915
+ throw new Error('[cq-compat] extrude: no pending wire to extrude');
1916
+ return result;
1917
+ }
1918
+ async function extrudePendingWires(wp, height) {
1919
+ const dir = Array.isArray(wp.normal) ? wp.normal : [0, 0, 1];
1920
+ const vec = [dir[0] * height, dir[1] * height, dir[2] * height];
1921
+ return pendingPathPrism(wp, vec);
1922
+ }
1923
+ /** True when a drafted (moveTo/lineTo/close) wire is pending — legacy paths never use it. */
1924
+ function hasPathWire(wp) {
1925
+ return (wp.pendingWires ?? []).some((w) => !w.construction && w.kind === 'path');
1926
+ }
1927
+ /**
1928
+ * extrude
1929
+ * @param wp - Workplane
1930
+ * @param height - number
1931
+ * @param combine - true (default): fuse the new solid with the carried shape;
1932
+ * false: the carrier holds ONLY the freshly extruded solid (upstream
1933
+ * `extrude(..., False)` — verified: testSolidReferenceCombineFalse exports the
1934
+ * lone boss, Compound vol 0.03125). The "cut"/"s" modes are NOT supported yet.
1935
+ * @returns Promise<Workplane>
1936
+ */
1937
+ /** Extract the numeric kernel id from a brepjs shape wrapper (or raw handle). */
1938
+ function rawShapeId(shapeWrapper) {
1939
+ const w = shapeWrapper.wrapped ?? shapeWrapper;
1940
+ if (w && typeof w === 'object' && w.__occtWasm) {
1941
+ return w.id;
1942
+ }
1943
+ return w;
1944
+ }
1945
+ /**
1946
+ * extrude — CadQuery `Workplane.extrude` parity.
1947
+ *
1948
+ * Pulls the pending profile wire(s) along the workplane normal by `height`;
1949
+ * a negative height extrudes the other way. `taper` (degrees, default 0)
1950
+ * narrows the section towards the top and is limited to a single
1951
+ * non-construction pending wire.
1952
+ *
1953
+ * @param wp - Workplane
1954
+ * @param height - extrusion distance along the workplane normal
1955
+ * @param combine - fuse the result with the carried shape (default true)
1956
+ * @param opts - { taper?: number } draft angle in degrees
1957
+ * @returns Promise<Workplane>
1958
+ */
1959
+ export async function extrude(wp, height, combine = true, opts) {
1960
+ const taper = opts?.taper ?? 0;
1961
+ if (taper !== 0) {
1962
+ // Tapered prism via the kernel draftPrism (BRepOffsetAPI_MakeDraft shell
1963
+ // equivalent; sign convention verified: positive angle narrows, matching
1964
+ // upstream `extrude(taper=20)` top-face < bottom-face).
1965
+ const solidWires = (wp.pendingWires ?? []).filter((w) => !w.construction);
1966
+ if (solidWires.length !== 1) {
1967
+ throw new Error('[cq-compat] extrude: taper requires exactly one pending profile wire');
1968
+ }
1969
+ const base = wp.shape;
1970
+ wp = await applyPendingFacePlane(wp);
1971
+ const profile = await buildProfileWire(wp, solidWires[0]);
1972
+ const face = unwrapBrepResult(compatFn('makeFace')(profile));
1973
+ const kernel = getKernel();
1974
+ const n = Array.isArray(wp.normal) ? wp.normal : [0, 0, 1];
1975
+ const raw = kernel.draftPrism(rawShapeId(face), n[0] * height, n[1] * height, n[2] * height, taper);
1976
+ const prism = fromHandle(raw);
1977
+ const shape = combine === false || !base ? prism : await fuseShapes(base, prism);
1978
+ return clone(wp, {
1979
+ shape,
1980
+ pendingWires: [],
1981
+ pendingPolygon: undefined,
1982
+ pendingRect: undefined,
1983
+ pendingCircle: undefined,
1984
+ faceSel: null,
1985
+ edgeSel: null,
1986
+ vertexSel: null,
1987
+ pts: [],
1988
+ });
1989
+ }
1990
+ // CadQuery pendingWires LIST: nested wires form one holed face per outermost
1991
+ // wire. Single-wire cases fall through to the legacy prism path unchanged.
1992
+ const solidWires = (wp.pendingWires ?? []).filter((w) => !w.construction);
1993
+ // A drafted path wire can never take the legacy single-slot path (there is no
1994
+ // pendingRect/pendingCircle/pendingPolygon for it), so it always goes through
1995
+ // the pendingWires LIST path. Ellipse wires likewise have no legacy slot and
1996
+ // are materialized via buildProfileWire. Everything else keeps the old condition.
1997
+ if (solidWires.length > 1 || hasPathWire(wp) || solidWires.some((w) => w.kind === 'ellipse')) {
1998
+ const base = wp.shape;
1999
+ wp = await applyPendingFacePlane(wp);
2000
+ const prism = await extrudePendingWires(wp, height);
2001
+ const shape = combine === false || !base ? prism : await fuseShapes(base, prism);
2002
+ return clone(wp, {
2003
+ shape,
2004
+ pendingWires: [],
2005
+ pendingPolygon: undefined,
2006
+ pendingRect: undefined,
2007
+ pendingCircle: undefined,
2008
+ faceSel: null,
2009
+ edgeSel: null,
2010
+ vertexSel: null,
2011
+ pts: [],
2012
+ });
2013
+ }
2014
+ // If there's a pending 2D profile (rect/circle/polygon) and no existing shape, create the 3D solid
2015
+ if (wp.pendingPolygon && !wp.shape) {
2016
+ const shape = await makePolygonPrismAt(wp, wp.pendingPolygon, height, wp.normal);
2017
+ return clone(wp, { shape, pendingWires: [], pendingPolygon: undefined, faceSel: null, edgeSel: null, vertexSel: null, pts: [] });
2018
+ }
2019
+ if (wp.pendingRect && !wp.shape) {
2020
+ const { w, d } = wp.pendingRect;
2021
+ const shape = await makeBoxAt(wp, w, d, height);
2022
+ return clone(wp, { shape, pendingWires: [], pendingRect: undefined, faceSel: null, edgeSel: null, vertexSel: null, pts: [] });
2023
+ }
2024
+ if (wp.pendingCircle && !wp.shape) {
2025
+ const { radius } = wp.pendingCircle;
2026
+ const shape = await makeCylinderAt(wp, radius, height);
2027
+ return clone(wp, { shape, pendingWires: [], pendingCircle: undefined, faceSel: null, edgeSel: null, vertexSel: null, pts: [] });
2028
+ }
2029
+ // Boss extrude on existing shape: create profile at each workplane point and union
2030
+ if (wp.shape) {
2031
+ // A pending faces(sel) lifts the profile plane (CadQuery implicit workplane).
2032
+ const base = wp.shape;
2033
+ wp = await applyPendingFacePlane(wp);
2034
+ const n = Array.isArray(wp.normal) ? wp.normal : [0, 0, 1];
2035
+ // Slight overlap ensures OCCT fuse merges coplanar faces into one solid
2036
+ const OVERLAP = 0.1;
2037
+ // If we have a pending profile on an existing shape, create and union
2038
+ if (wp.pendingPolygon || wp.pendingRect || wp.pendingCircle) {
2039
+ const points = eachPoints(wp);
2040
+ let shape = base;
2041
+ let separate = null;
2042
+ for (const [px, py] of points) {
2043
+ const bossWp = { ...wp, origin: localToWorld(wp, px, py) };
2044
+ // combine=false keeps the new solid standalone: no OVERLAP padding (it
2045
+ // exists only to make the fuse merge coplanar faces) and no fuse.
2046
+ const h = combine === false ? height : height + OVERLAP;
2047
+ let boss;
2048
+ if (wp.pendingPolygon) {
2049
+ boss = await makePolygonPrismAt(bossWp, wp.pendingPolygon, h, wp.normal);
2050
+ }
2051
+ else if (wp.pendingRect) {
2052
+ const { w, d } = wp.pendingRect;
2053
+ boss = await makeBoxAt(bossWp, w, d, h);
2054
+ }
2055
+ else {
2056
+ boss = await makeCylinderAt(bossWp, wp.pendingCircle.radius, h);
2057
+ }
2058
+ if (combine === false) {
2059
+ separate = separate ? await fuseShapes(separate, boss) : boss;
2060
+ continue;
2061
+ }
2062
+ const shifted = await cad.translate(boss, {
2063
+ offset: [-n[0] * OVERLAP, -n[1] * OVERLAP, -n[2] * OVERLAP],
2064
+ });
2065
+ shape = await fuseShapes(shape, shifted);
2066
+ // The OVERLAP padding extends the boss BELOW the face plane so the OCCT
2067
+ // fuse merges the coplanar contact. When the profile overhangs the base
2068
+ // (e.g. a boss centred on a corner, testWorkplaneCenterMove), that
2069
+ // padding leaves stray material OUTSIDE the base under the face plane —
2070
+ // upstream keeps nothing there. Remove exactly that region:
2071
+ // (shifted boss \ base) ∩ half-space below the face plane.
2072
+ // Profiles fully inside the base's cross-section can never overhang,
2073
+ // so the cheap bbox test skips the two extra booleans entirely.
2074
+ const bossB = compatFn('getBounds')(borrowBrepjsShape(boss));
2075
+ const baseB = compatFn('getBounds')(borrowBrepjsShape(base));
2076
+ const PAD_EPS = 1e-6;
2077
+ const axisOf = (v) => Math.abs(v[0]) > 0.5 ? 0 : Math.abs(v[1]) > 0.5 ? 1 : 2;
2078
+ // In-plane axes = the two axes perpendicular to the face normal.
2079
+ const overhangs = [0, 1, 2]
2080
+ .filter((i) => i !== axisOf(n))
2081
+ .some((i) => {
2082
+ const key = ['x', 'y', 'z'];
2083
+ return (bossB[`${key[i]}Min`] < baseB[`${key[i]}Min`] - PAD_EPS ||
2084
+ bossB[`${key[i]}Max`] > baseB[`${key[i]}Max`] + PAD_EPS);
2085
+ });
2086
+ if (overhangs) {
2087
+ const stray = await cutShapes(shifted, base);
2088
+ // Slab covering the half-space below the face plane (local z <= 0).
2089
+ const o = Array.isArray(wp.origin) ? wp.origin : [0, 0, 0];
2090
+ const BIG = 2 *
2091
+ Math.max(baseB.xMax - baseB.xMin, baseB.yMax - baseB.yMin, baseB.zMax - baseB.zMin, bossB.xMax - bossB.xMin, bossB.yMax - bossB.yMin, bossB.zMax - bossB.zMin) +
2092
+ 10;
2093
+ const belowWp = {
2094
+ ...wp,
2095
+ origin: [o[0] - n[0] * BIG, o[1] - n[1] * BIG, o[2] - n[2] * BIG],
2096
+ };
2097
+ const slab = await makeBoxAt(belowWp, BIG, BIG, BIG);
2098
+ const strayBelow = await intersectShapes(stray, slab);
2099
+ shape = await cutShapes(shape, strayBelow);
2100
+ }
2101
+ }
2102
+ if (combine === false && separate) {
2103
+ return clone(wp, {
2104
+ shape: separate,
2105
+ pendingWires: [],
2106
+ pendingPolygon: undefined,
2107
+ pendingRect: undefined,
2108
+ pendingCircle: undefined,
2109
+ faceSel: null,
2110
+ edgeSel: null,
2111
+ vertexSel: null,
2112
+ pts: [],
2113
+ });
2114
+ }
2115
+ return clone(wp, {
2116
+ shape,
2117
+ pendingWires: [],
2118
+ pendingPolygon: undefined,
2119
+ pendingRect: undefined,
2120
+ pendingCircle: undefined,
2121
+ faceSel: null,
2122
+ edgeSel: null,
2123
+ vertexSel: null,
2124
+ pts: [],
2125
+ });
2126
+ }
2127
+ // No pending profile — return unchanged
2128
+ return wp;
2129
+ }
2130
+ // No shape and no pending profile — return unchanged
2131
+ return wp;
2132
+ }
2133
+ /**
2134
+ * revolve — CadQuery `Workplane.revolve` parity.
2135
+ *
2136
+ * Consumes the pending wire LIST (same grouping as extrude: one holed face per
2137
+ * outermost wire) and revolves each face around an axis. Axis endpoints are
2138
+ * LOCAL workplane coordinates (verified against cadquery 2.8.0
2139
+ * `Workplane.revolve`): start defaults to the plane origin; when only start is
2140
+ * given, end defaults to `(0, start.y)` if `start.y != 0` else `(0, 1)` — i.e.
2141
+ * the local +Y direction. Angle 0 is normalized to 360 (OCCT cannot do a
2142
+ * 0-degree revolve).
2143
+ *
2144
+ * @param wp - Workplane
2145
+ * @param angleDegrees - revolution angle (default 360)
2146
+ * @param axisStart - axis start point in local 2D coords
2147
+ * @param axisEnd - axis end point in local 2D coords
2148
+ * @param combine - true: fuse with base; "cut": subtract from base; false: keep separate
2149
+ * @returns Promise<Workplane>
2150
+ */
2151
+ export async function revolve(wp, angleDegrees = 360, axisStart, axisEnd, combine = true) {
2152
+ let angle = ((angleDegrees % 360) + 360) % 360;
2153
+ if (angle === 0)
2154
+ angle = 360;
2155
+ const sLocal = axisStart ? [axisStart[0], axisStart[1]] : [0, 0];
2156
+ const eLocal = axisEnd
2157
+ ? [axisEnd[0], axisEnd[1]]
2158
+ : sLocal[1] !== 0
2159
+ ? [0, sLocal[1]]
2160
+ : [0, 1];
2161
+ const startW = localToWorld(wp, sLocal[0], sLocal[1]);
2162
+ const endW = localToWorld(wp, eLocal[0], eLocal[1]);
2163
+ const axis = [endW[0] - startW[0], endW[1] - startW[1], endW[2] - startW[2]];
2164
+ const len = Math.hypot(axis[0], axis[1], axis[2]);
2165
+ if (len === 0)
2166
+ throw new Error('[cq-compat] revolve: axis start and end coincide');
2167
+ const dir = [axis[0] / len, axis[1] / len, axis[2] / len];
2168
+ const all = (wp.pendingWires ?? []).filter((w) => !w.construction);
2169
+ if (all.length === 0)
2170
+ throw new Error('[cq-compat] revolve: no pending wire to revolve');
2171
+ wp = await applyPendingFacePlane(wp);
2172
+ const rad = (angle * Math.PI) / 180;
2173
+ let result = null;
2174
+ for (const g of groupPendingWires(all)) {
2175
+ const outer = await buildProfileWire(wp, g.outer);
2176
+ const holeWires = [];
2177
+ for (const h of g.holes)
2178
+ holeWires.push(await buildProfileWire(wp, h));
2179
+ const face = unwrapBrepResult(compatFn('makeFace')(outer, holeWires));
2180
+ const revolved = unwrapBrepResult(compatFn('revolve')(face, { at: startW, axis: dir, angle: rad }));
2181
+ const solid = adoptBrepjsProduct(revolved);
2182
+ result = result ? await fuseShapes(result, solid) : solid;
2183
+ }
2184
+ const base = wp.shape;
2185
+ let shape = result;
2186
+ if (combine === 'cut' && base)
2187
+ shape = await cutShapes(base, shape);
2188
+ else if (combine === true && base)
2189
+ shape = await fuseShapes(base, shape);
2190
+ // combine === false → keep the revolved solid alone
2191
+ return clone(wp, {
2192
+ shape,
2193
+ pendingWires: [],
2194
+ pendingPolygon: undefined,
2195
+ pendingRect: undefined,
2196
+ pendingCircle: undefined,
2197
+ faceSel: null,
2198
+ edgeSel: null,
2199
+ vertexSel: null,
2200
+ pts: [],
2201
+ });
2202
+ }
2203
+ /** True when the carrier holds at least one solid (upstream `findSolid()` gate). */
2204
+ function hasSolidBase(shape) {
2205
+ try {
2206
+ return brepjsCompat.getSolids(borrowBrepjsShape(shape)).length > 0;
2207
+ }
2208
+ catch {
2209
+ return false;
2210
+ }
2211
+ }
2212
+ /** Collect loft sections from a workplane: pending wires first, then stacked faces. */
2213
+ async function collectLoftSections(wp, sections) {
2214
+ const wires = (wp.pendingWires ?? []).filter((w) => !w.construction);
2215
+ for (const w of wires) {
2216
+ sections.push(await buildProfileWire(wp, w));
2217
+ }
2218
+ if (wires.length === 0 && wp.shape) {
2219
+ // Upstream `Workplane().add(face)...loft()` lofts the stacked faces of
2220
+ // wp.shape: each face's outer wire becomes a loft section (holes are
2221
+ // intentionally dropped — upstream section extraction is outerWire-only).
2222
+ const shapeFaces = compatFn('getFaces')(borrowBrepjsShape(wp.shape));
2223
+ for (const f of shapeFaces) {
2224
+ sections.push(compatFn('outerWire')(f));
2225
+ }
2226
+ }
2227
+ }
2228
+ /**
2229
+ * loft — CadQuery `Workplane.loft` parity.
2230
+ *
2231
+ * Consumes the pending wire LIST as loft sections (each wire built on its own
2232
+ * creation-plane snapshot, so intermediate workplane(offset)/transformed moves
2233
+ * are honored). Upstream default is a smooth (ruled=False) loft.
2234
+ *
2235
+ * Additional workplanes may be passed positionally (upstream free-function form
2236
+ * `loft(w1, w2, w3)`): each contributes its own pending wires, or — when it
2237
+ * carries no pending wire — the outer wires of its stacked faces.
2238
+ *
2239
+ * @param wp - Workplane
2240
+ * @param rest - extra section workplanes, plus at most one options object
2241
+ * @returns Promise<Workplane>
2242
+ */
2243
+ export async function loft(wp, ...rest) {
2244
+ const opts = rest.find((x) => !x.__cq);
2245
+ const extraWps = rest.filter((x) => x.__cq === true);
2246
+ const sections = [];
2247
+ await collectLoftSections(wp, sections);
2248
+ for (const other of extraWps) {
2249
+ await collectLoftSections(other, sections);
2250
+ }
2251
+ if (sections.length === 0 && !opts?.startPoint && !opts?.endPoint) {
2252
+ throw new Error('[cq-compat] loft: no pending wire sections');
2253
+ }
2254
+ wp = await applyPendingFacePlane(wp);
2255
+ const loftCfg = { ruled: opts?.ruled ?? false };
2256
+ if (opts?.startPoint)
2257
+ loftCfg.startPoint = opts.startPoint;
2258
+ if (opts?.endPoint)
2259
+ loftCfg.endPoint = opts.endPoint;
2260
+ const solid = adoptBrepjsProduct(unwrapBrepResult(compatFn('loft')(sections, loftCfg)));
2261
+ const base = wp.shape;
2262
+ let shape = solid;
2263
+ const combine = opts?.combine ?? true;
2264
+ if (combine === 'cut' && base)
2265
+ shape = await cutShapes(base, shape);
2266
+ else if (combine === true && base && hasSolidBase(base))
2267
+ shape = await fuseShapes(base, shape);
2268
+ // combine === false → keep the loft alone
2269
+ // a non-solid carrier (e.g. `Workplane().add(face)`) is left alone too:
2270
+ // upstream `Workplane.loft` only fuses when the stack holds a solid
2271
+ // (`findSolid()` returns None otherwise — test_loft_face).
2272
+ return clone(wp, {
2273
+ shape,
2274
+ pendingWires: [],
2275
+ pendingPolygon: undefined,
2276
+ pendingRect: undefined,
2277
+ pendingCircle: undefined,
2278
+ faceSel: null,
2279
+ edgeSel: null,
2280
+ vertexSel: null,
2281
+ pts: [],
2282
+ });
2283
+ }
2284
+ /**
2285
+ * cutBlind
2286
+ * @param wp - Workplane
2287
+ * @param depth - number
2288
+ * @param opts - { w?: number; d?: number; radius?: number }
2289
+ * @returns Promise<Workplane>
2290
+ */
2291
+ export async function cutBlind(wp, depth, opts) {
2292
+ if (!wp.shape)
2293
+ return wp;
2294
+ const base = wp.shape;
2295
+ wp = await applyPendingFacePlane(wp);
2296
+ const absDepth = Math.abs(depth);
2297
+ const invNormal = [-wp.normal[0], -wp.normal[1], -wp.normal[2]];
2298
+ let result = base;
2299
+ if (opts?.taper) {
2300
+ // Tapered pocket via kernel draftPrism (same sign convention as extrude:
2301
+ // positive angle narrows along the extrusion direction, i.e. the pocket
2302
+ // opening is the profile and the bottom is smaller). Verified vs 2.8.0:
2303
+ // rect(2,2).extrude(2).faces(">Z").workplane().rect(1,1).cutBlind(-1,
2304
+ // taper=5) -> vol 7.2.
2305
+ const solidWires = (wp.pendingWires ?? []).filter((w) => !w.construction);
2306
+ if (solidWires.length !== 1) {
2307
+ throw new Error('[cq-compat] cutBlind: taper requires exactly one pending profile wire');
2308
+ }
2309
+ const profile = await buildProfileWire(wp, solidWires[0]);
2310
+ const face = unwrapBrepResult(compatFn('makeFace')(profile));
2311
+ const kernel = getKernel();
2312
+ const raw = kernel.draftPrism(rawShapeId(face), invNormal[0] * absDepth, invNormal[1] * absDepth, invNormal[2] * absDepth, opts.taper);
2313
+ const tool = fromHandle(raw);
2314
+ result = await cutShapes(base, tool);
2315
+ return clone(wp, {
2316
+ shape: result,
2317
+ faceSel: null,
2318
+ edgeSel: null,
2319
+ pts: [],
2320
+ pendingWires: [],
2321
+ pendingRect: undefined,
2322
+ pendingCircle: undefined,
2323
+ pendingPolygon: undefined,
2324
+ });
2325
+ }
2326
+ // CadQuery semantics: pushPoints() before cutBlind() repeats the cut at every
2327
+ // point. With no pushed points, the cut happens at the workplane origin.
2328
+ const ptsArr = eachPoints(wp);
2329
+ for (const [px, py] of ptsArr) {
2330
+ const cutWp = {
2331
+ ...wp,
2332
+ origin: localToWorld(wp, px, py),
2333
+ normal: invNormal,
2334
+ };
2335
+ let tool;
2336
+ if (hasPathWire(wp)) {
2337
+ // Drafted wire (moveTo/lineTo/polyline + close): extrude the profile into
2338
+ // the cut tool along the (inverted) workplane normal.
2339
+ tool = await pendingPathPrism(wp, [
2340
+ invNormal[0] * absDepth,
2341
+ invNormal[1] * absDepth,
2342
+ invNormal[2] * absDepth,
2343
+ ]);
2344
+ }
2345
+ else if (wp.pendingCircle) {
2346
+ tool = await makeCylinderAt(cutWp, wp.pendingCircle.radius, absDepth);
2347
+ }
2348
+ else if (wp.pendingRect) {
2349
+ tool = await makeBoxAt(cutWp, wp.pendingRect.w, wp.pendingRect.d, absDepth);
2350
+ }
2351
+ else if (wp.pendingPolygon) {
2352
+ tool = await makePolygonPrismAt(cutWp, wp.pendingPolygon, absDepth, invNormal);
2353
+ }
2354
+ else if (opts?.radius !== undefined) {
2355
+ tool = await makeCylinderAt(cutWp, opts.radius, absDepth);
2356
+ }
2357
+ else if (opts?.w !== undefined && opts?.d !== undefined) {
2358
+ tool = await makeBoxAt(cutWp, opts.w, opts.d, absDepth);
2359
+ }
2360
+ else {
2361
+ tool = await makeBoxAt(cutWp, 1000, 1000, absDepth);
2362
+ }
2363
+ result = await cutShapes(result, tool);
2364
+ }
2365
+ return clone(wp, { shape: result, faceSel: null, edgeSel: null, pts: [], pendingWires: [], pendingRect: undefined, pendingCircle: undefined, pendingPolygon: undefined });
2366
+ }
2367
+ /**
2368
+ * cutThruAll
2369
+ * @param wp - Workplane
2370
+ * @returns Promise<Workplane>
2371
+ *
2372
+ * CadQuery semantics (verified vs cadquery 2.8.0 `Workplane.cutThruAll`):
2373
+ * uses the pending 2D profile to cut through ALL material in BOTH normal
2374
+ * directions of the workplane. The tool body spans the whole solid along
2375
+ * the workplane normal (computed from the shape bounding box), so it is
2376
+ * exact for any profile depth.
2377
+ */
2378
+ export async function cutThruAll(wp) {
2379
+ if (!wp.shape)
2380
+ return wp;
2381
+ const base = wp.shape;
2382
+ wp = await applyPendingFacePlane(wp);
2383
+ if (!wp.pendingCircle && !wp.pendingRect && !wp.pendingPolygon && !hasPathWire(wp)) {
2384
+ throw new Error('[cq-compat] cutThruAll requires a pending 2D profile');
2385
+ }
2386
+ const n = Array.isArray(wp.normal) ? wp.normal : [0, 0, 1];
2387
+ const o = Array.isArray(wp.origin) ? wp.origin : [0, 0, 0];
2388
+ const bmin = bboxMin(base);
2389
+ const bmax = bboxMax(base);
2390
+ // Span: farthest bbox corner from the workplane origin along the normal.
2391
+ let span = 0;
2392
+ for (const cx of [bmin[0], bmax[0]]) {
2393
+ for (const cy of [bmin[1], bmax[1]]) {
2394
+ for (const cz of [bmin[2], bmax[2]]) {
2395
+ span = Math.max(span, Math.abs((cx - o[0]) * n[0] + (cy - o[1]) * n[1] + (cz - o[2]) * n[2]));
2396
+ }
2397
+ }
2398
+ }
2399
+ const B = span + 1;
2400
+ // CadQuery semantics: pushPoints() before cutThruAll() repeats the cut at
2401
+ // every point (mirrors cutBlind). With no pushed points, one cut at the
2402
+ // workplane origin.
2403
+ const ptsArr = eachPoints(wp);
2404
+ let shape = base;
2405
+ for (const [px, py] of ptsArr) {
2406
+ // Tool base at point - n·B, extending 2B along +n — covers both directions.
2407
+ const thruWp = { ...wp, origin: vsub(localToWorld(wp, px, py), vscale(n, B)) };
2408
+ let tool;
2409
+ if (hasPathWire(wp)) {
2410
+ // Drafted wire: the tool must span the whole solid along the normal and
2411
+ // start B below the workplane — same envelope as the primitive tools.
2412
+ // The profile lives on its own creation plane, so the offset goes into
2413
+ // the prism base, not into wp.origin.
2414
+ tool = await pendingPathPrism(wp, [n[0] * 2 * B, n[1] * 2 * B, n[2] * 2 * B], vscale(n, -B));
2415
+ }
2416
+ else if (wp.pendingCircle) {
2417
+ tool = await makeCylinderAt(thruWp, wp.pendingCircle.radius, 2 * B);
2418
+ }
2419
+ else if (wp.pendingRect) {
2420
+ tool = await makeBoxAt(thruWp, wp.pendingRect.w, wp.pendingRect.d, 2 * B);
2421
+ }
2422
+ else {
2423
+ tool = await makePolygonPrismAt(thruWp, wp.pendingPolygon, 2 * B, n);
2424
+ }
2425
+ shape = await cutShapes(shape, tool);
2426
+ }
2427
+ return clone(wp, { shape, faceSel: null, edgeSel: null, pts: [], pendingWires: [], pendingRect: undefined, pendingCircle: undefined, pendingPolygon: undefined });
2428
+ }
2429
+ /**
2430
+ * hole
2431
+ * @param wp - Workplane
2432
+ * @param diameter - number
2433
+ * @param depth - number
2434
+ * @returns Promise<Workplane>
2435
+ */
2436
+ export async function hole(wp, diameter, depth) {
2437
+ if (!wp.shape)
2438
+ return wp;
2439
+ const radius = diameter / 2;
2440
+ const max = await bboxMax(wp.shape);
2441
+ const min = await bboxMin(wp.shape);
2442
+ // Through-hole with margin, measured along the workplane normal.
2443
+ const ext = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
2444
+ const totalHeight = Math.abs(vdot(ext, wp.normal)) + 4;
2445
+ const holeHeight = depth ?? totalHeight;
2446
+ const points = eachPoints(wp);
2447
+ let result = wp.shape;
2448
+ for (const [px, py] of points) {
2449
+ const holeOrigin = localToWorld(wp, px, py);
2450
+ const invNormal = [-wp.normal[0], -wp.normal[1], -wp.normal[2]];
2451
+ const cyl = await makeCylinderAt({ ...wp, origin: holeOrigin, normal: invNormal }, radius, holeHeight);
2452
+ result = await cutShapes(result, cyl);
2453
+ }
2454
+ return clone(wp, { shape: result, faceSel: null, edgeSel: null, vertexSel: null, pts: [] });
2455
+ }
2456
+ /**
2457
+ * cboreHole
2458
+ * @param wp - Workplane
2459
+ * @param diameter - number
2460
+ * @param cboreDiameter - number
2461
+ * @param cboreDepth - number
2462
+ * @param depth - number | undefined (bore depth; undefined drills through, upstream depth=None)
2463
+ * @returns Promise<Workplane>
2464
+ */
2465
+ export async function cboreHole(wp, diameter, cboreDiameter, cboreDepth, depth) {
2466
+ // hole() consumes and clears wp.pts — snapshot them first so the counterbore
2467
+ // lands on every pushed point, not just the origin fallback [0, 0].
2468
+ const savedPts = Array.isArray(wp.pts) ? [...wp.pts] : [];
2469
+ let result = await hole(wp, diameter, depth);
2470
+ // Counterbore: larger shallow hole. Upstream (verified vs cadquery 2.8.0
2471
+ // Workplane.cboreHole) cuts EXACTLY cboreDepth below the workplane — the old
2472
+ // "+1 safety margin" over-cut every counterbore by 1 mm (parity harness,
2473
+ // testCounterBores__c2).
2474
+ if (result.shape) {
2475
+ const cboreRadius = cboreDiameter / 2;
2476
+ const points = savedPts.length > 0 ? savedPts : [[0, 0]];
2477
+ let shape = result.shape;
2478
+ for (const [px, py] of points) {
2479
+ const origin = localToWorld(result, px, py);
2480
+ const invNormal = [-result.normal[0], -result.normal[1], -result.normal[2]];
2481
+ const cyl = await makeCylinderAt({ ...result, origin, normal: invNormal }, cboreRadius, cboreDepth);
2482
+ shape = await cutShapes(shape, cyl);
2483
+ }
2484
+ result = clone(result, { shape });
2485
+ }
2486
+ return clone(result, { pts: [] });
2487
+ }
2488
+ /**
2489
+ * cskHole
2490
+ * @param wp - Workplane
2491
+ * @param diameter - number
2492
+ * @param cskDiameter - number
2493
+ * @param cskAngle - number
2494
+ * @returns Promise<Workplane>
2495
+ */
2496
+ export async function cskHole(wp, diameter, cskDiameter, cskAngle) {
2497
+ // hole() consumes and clears wp.pts — snapshot them first (same as cboreHole).
2498
+ const savedPts = Array.isArray(wp.pts) ? [...wp.pts] : [];
2499
+ let result = await hole(wp, diameter);
2500
+ if (result.shape) {
2501
+ const cskRadius = cskDiameter / 2;
2502
+ // CadQuery cskHole: full cone from cskRadius at the surface to an apex,
2503
+ // depth h = cskRadius / tan(cskAngle/2).
2504
+ const cskDepth = cskRadius / Math.tan((cskAngle * Math.PI) / 360);
2505
+ const points = savedPts.length > 0 ? savedPts : [[0, 0]];
2506
+ let shape = result.shape;
2507
+ for (const [px, py] of points) {
2508
+ const origin = localToWorld(result, px, py);
2509
+ const invNormal = [-result.normal[0], -result.normal[1], -result.normal[2]];
2510
+ const cone = await makeConeAt({ ...result, origin }, cskRadius, cskDepth, invNormal);
2511
+ shape = await cutShapes(shape, cone);
2512
+ }
2513
+ result = clone(result, { shape });
2514
+ }
2515
+ return clone(result, { pts: [] });
2516
+ }
2517
+ /**
2518
+ * threadedHole
2519
+ * @param wp - Workplane
2520
+ * @param diameterOrFastener - number | unknown
2521
+ * @param depth - number
2522
+ * @returns Promise<Workplane>
2523
+ */
2524
+ export async function threadedHole(wp, diameterOrFastener, depth) {
2525
+ const diameter = typeof diameterOrFastener === 'number' ? diameterOrFastener : 5;
2526
+ return hole(wp, diameter, depth);
2527
+ }
2528
+ /**
2529
+ * faces
2530
+ * @param wp - Workplane
2531
+ * @param sel - string
2532
+ * @returns Workplane
2533
+ */
2534
+ export function faces(wp, sel) {
2535
+ return clone(wp, { faceSel: sel, edgeSel: null, vertexSel: null });
2536
+ }
2537
+ /**
2538
+ * edges
2539
+ * @param wp - Workplane
2540
+ * @param sel - string | { slice?: [number, number]; index?: number }
2541
+ * @returns Workplane
2542
+ */
2543
+ export function edges(wp, sel) {
2544
+ let pts = wp.edgePts ?? (Array.isArray(wp.pts) ? wp.pts : []);
2545
+ if (sel && typeof sel === 'object') {
2546
+ if (sel.slice) {
2547
+ const [start, end] = sel.slice;
2548
+ pts = pts.slice(start === undefined ? 0 : start, end === undefined ? pts.length : end);
2549
+ }
2550
+ else if (sel.index !== undefined) {
2551
+ const idx = sel.index < 0 ? pts.length + sel.index : sel.index;
2552
+ pts = [pts[idx]];
2553
+ }
2554
+ }
2555
+ return clone(wp, { edgeSel: typeof sel === 'string' ? sel : '', faceSel: null, vertexSel: null, pts: [...pts] });
2556
+ }
2557
+ /**
2558
+ * vertices
2559
+ * @param wp - Workplane
2560
+ * @param sel - string | { slice?: [number, number]; index?: number }
2561
+ * @returns Workplane
2562
+ */
2563
+ export function vertices(wp, sel) {
2564
+ let pts = Array.isArray(wp.pts) ? wp.pts : [];
2565
+ if (sel && typeof sel === 'object') {
2566
+ if (sel.slice) {
2567
+ const [start, end] = sel.slice;
2568
+ pts = pts.slice(start === undefined ? 0 : start, end === undefined ? pts.length : end);
2569
+ }
2570
+ else if (sel.index !== undefined) {
2571
+ const idx = sel.index < 0 ? pts.length + sel.index : sel.index;
2572
+ pts = [pts[idx]];
2573
+ }
2574
+ }
2575
+ return clone(wp, { vertexSel: typeof sel === 'string' ? sel : '', faceSel: null, edgeSel: null, pts: [...pts] });
2576
+ }
2577
+ /**
2578
+ * solids — CadQuery `Workplane.solids(selector)` parity (selector forms not
2579
+ * supported; bare `solids()` only).
2580
+ *
2581
+ * Upstream returns a new Workplane whose stack holds each solid of the current
2582
+ * compound as a separate object, so `val()` is the FIRST solid (verified vs
2583
+ * cadquery 2.8.0: test_map_apply_filter_sort w.val() = vol 1.0 solid). The
2584
+ * cq-compat carrier keeps a single `.shape`, so `solids()` mirrors the
2585
+ * observable contract: the carrier shape becomes the first solid of the
2586
+ * compound (a single-solid shape passes through unchanged).
2587
+ *
2588
+ * @param wp - Workplane
2589
+ * @returns Workplane whose carried shape is the compound's first solid
2590
+ */
2591
+ export function solids(wp) {
2592
+ if (!wp.shape)
2593
+ return wp;
2594
+ const handle = brepOf(wp.shape);
2595
+ if (handle === undefined)
2596
+ return wp;
2597
+ const kernel = getKernel();
2598
+ const sub = kernel.getSubShapes(handle, 'solid');
2599
+ if (sub.length === 0)
2600
+ return wp;
2601
+ // Adopt the first solid into faijs ownership; release the rest (raw kernel
2602
+ // getSubShapes copies each sub-shape into its own arena slot — same contract
2603
+ // the kernel's own makeWireFromMixed wrapper honors).
2604
+ for (let i = 1; i < sub.length; i++)
2605
+ kernel.release(sub[i]);
2606
+ return clone(wp, { shape: fromHandle(sub[0]) });
2607
+ }
2608
+ /**
2609
+ * workplane
2610
+ * @param wp - Workplane
2611
+ * @param opts - { centerOption?: string; offset?: number }
2612
+ * @returns Promise<Workplane>
2613
+ */
2614
+ export async function workplane(wp, opts) {
2615
+ // Upstream (cadquery 2.8.0 Workplane.workplane): invert flips the plane
2616
+ // normal (Plane.invert keeps xDir, flips zDir, yDir = zDir × xDir flips
2617
+ // accordingly); the offset is then applied along the (possibly inverted)
2618
+ // normal.
2619
+ const invert = opts?.invert === true;
2620
+ const flip = (n) => [
2621
+ -n[0],
2622
+ -n[1],
2623
+ -n[2],
2624
+ ];
2625
+ if (!wp.shape || !wp.faceSel) {
2626
+ // No face selected — just apply offset
2627
+ const normal = invert ? flip(wp.normal) : wp.normal;
2628
+ if (opts?.offset) {
2629
+ const offset = vscale(normal, opts.offset);
2630
+ return clone(wp, { origin: vadd(wp.origin, offset), faceSel: null });
2631
+ }
2632
+ if (invert) {
2633
+ const yDir = [
2634
+ normal[1] * wp.xDir[2] - normal[2] * wp.xDir[1],
2635
+ normal[2] * wp.xDir[0] - normal[0] * wp.xDir[2],
2636
+ normal[0] * wp.xDir[1] - normal[1] * wp.xDir[0],
2637
+ ];
2638
+ return clone(wp, { normal, yDir, faceSel: null });
2639
+ }
2640
+ return clone(wp, { faceSel: null });
2641
+ }
2642
+ const { center, normal: faceNormal } = await resolveFaceSelector(wp.shape, wp.faceSel, opts?.centerOption);
2643
+ // CadQuery default centerOption is "ProjectedOrigin": project the current
2644
+ // origin onto the face plane. "CenterOfBoundBox"/"CenterOfMass" keep the
2645
+ // face centroid returned by resolveFaceSelector.
2646
+ let newOrigin;
2647
+ if (opts?.centerOption && opts.centerOption !== 'ProjectedOrigin') {
2648
+ newOrigin = center;
2649
+ }
2650
+ else {
2651
+ const t = vdot(vsub(center, wp.origin), faceNormal);
2652
+ newOrigin = vadd(wp.origin, vscale(faceNormal, t));
2653
+ }
2654
+ const normal = invert ? flip(faceNormal) : faceNormal;
2655
+ if (opts?.offset) {
2656
+ newOrigin = vadd(newOrigin, vscale(normal, opts.offset));
2657
+ }
2658
+ // Upstream Workplane.workplane `_computeXdir`: xDir = (0,0,1)×normal, or
2659
+ // (1,0,0) when the face is parallel with the XY plane (degenerate cross);
2660
+ // then yDir = normal×xDir. Verified to reproduce FACE_AXES for all six axis
2661
+ // normals while also supporting arbitrary (e.g. faces("+XY") diagonal) ones.
2662
+ let xDir = [1, 0, 0];
2663
+ const crLen = Math.hypot(normal[1], normal[0]);
2664
+ if (crLen > 1e-9) {
2665
+ xDir = [-normal[1] / crLen, normal[0] / crLen, 0];
2666
+ }
2667
+ const yDir = [
2668
+ normal[1] * xDir[2] - normal[2] * xDir[1],
2669
+ normal[2] * xDir[0] - normal[0] * xDir[2],
2670
+ normal[0] * xDir[1] - normal[1] * xDir[0],
2671
+ ];
2672
+ return clone(wp, {
2673
+ origin: newOrigin,
2674
+ normal,
2675
+ xDir,
2676
+ yDir,
2677
+ faceSel: null,
2678
+ edgeSel: null,
2679
+ vertexSel: null,
2680
+ pts: [],
2681
+ });
2682
+ }
2683
+ /**
2684
+ * center
2685
+ * @param wp - Workplane
2686
+ * @param x - number
2687
+ * @param y - number
2688
+ * @returns Workplane
2689
+ */
2690
+ export function center(wp, x, y) {
2691
+ // CadQuery semantics: offset along the workplane LOCAL x/y axes.
2692
+ return clone(wp, { origin: localToWorld(wp, x, y) });
2693
+ }
2694
+ /**
2695
+ * pushPoints
2696
+ * @param wp - Workplane
2697
+ * @param pts - [number, number][]
2698
+ * @returns Workplane
2699
+ */
2700
+ export function pushPoints(wp, pts) {
2701
+ const existing = Array.isArray(wp.pts) ? wp.pts : [];
2702
+ return clone(wp, { pts: [...existing, ...pts] });
2703
+ }
2704
+ /**
2705
+ * translate
2706
+ * @param wp - Workplane
2707
+ * @param v - [number, number, number]
2708
+ * @returns Promise<Workplane>
2709
+ */
2710
+ export async function translate(wp, v) {
2711
+ if (!wp.shape)
2712
+ return clone(wp, { origin: vadd(wp.origin, v) });
2713
+ const shape = await cad.translate(wp.shape, { offset: v });
2714
+ return clone(wp, { shape, origin: vadd(wp.origin, v) });
2715
+ }
2716
+ /**
2717
+ * rotate
2718
+ * @param wp - Workplane
2719
+ * @param axis - [number, number, number]
2720
+ * @param angle - number
2721
+ * @returns Promise<Workplane>
2722
+ */
2723
+ export async function rotate(wp, axis, angle) {
2724
+ if (!wp.shape)
2725
+ return wp;
2726
+ const anglesDeg = [
2727
+ axis[0] * angle,
2728
+ axis[1] * angle,
2729
+ axis[2] * angle,
2730
+ ];
2731
+ const shape = await cad.rotate_euler(wp.shape, { anglesDeg });
2732
+ return clone(wp, { shape });
2733
+ }
2734
+ /**
2735
+ * Mirror-plane normals for the string form (upstream `Shape.mirror`,
2736
+ * cadquery 2.8.0: both spellings of a plane map to the SAME mirror plane —
2737
+ * 'YX' has normal (0,-1,0) but mirrors through the same y=0 plane as 'XZ').
2738
+ */
2739
+ const MIRROR_PLANE_NORMALS = {
2740
+ XY: [0, 0, 1],
2741
+ YX: [0, 0, 1],
2742
+ XZ: [0, 1, 0],
2743
+ ZX: [0, 1, 0],
2744
+ YZ: [1, 0, 0],
2745
+ ZY: [1, 0, 0],
2746
+ };
2747
+ /**
2748
+ * mirror — full upstream `Workplane.mirror` semantics (cadquery 2.8.0, verified
2749
+ * against cq.py:1113):
2750
+ * - string form: 'XY'..'ZY' named mirror planes
2751
+ * - vector form: plane normal, mirrored about `basePointVector` (default origin)
2752
+ * - Workplane form (upstream Face form): normal + center of the selected face;
2753
+ * basePointVector only overrides the center when explicitly given
2754
+ * - `union`: fuse the mirrored copy with the original (upstream `self.union(newS)`)
2755
+ *
2756
+ * The kernel projection is `cad.mirror(shape, { normal, at })` — the previous
2757
+ * implementation passed `{ plane }`, which MirrorOptions does not know, so every
2758
+ * mirror silently used the default normal [1,0,0] (latent bug, found while
2759
+ * writing the test_mirror mirrors).
2760
+ *
2761
+ * @param wp - Workplane
2762
+ * @param mirrorPlane - 'XY'..'ZY' | plane normal vector | Workplane carrying a face selection (default 'XY')
2763
+ * @param basePointVector - point the mirror plane passes through (default: the selected face centre for the Workplane form, otherwise the world origin)
2764
+ * @param union - fuse the mirrored copy with the original (default false)
2765
+ * @returns Promise<Workplane> carrying the mirrored (or unioned) shape
2766
+ */
2767
+ export async function mirror(wp, mirrorPlane, basePointVector, union) {
2768
+ if (!wp.shape)
2769
+ return wp;
2770
+ let normal;
2771
+ let at;
2772
+ if (mirrorPlane && typeof mirrorPlane === 'object' && !Array.isArray(mirrorPlane)) {
2773
+ // Workplane carrying a face selection (upstream Face form).
2774
+ const fp = mirrorPlane;
2775
+ if (!fp.faceSel || !fp.shape)
2776
+ return wp;
2777
+ const resolved = await resolveFaceSelector(fp.shape, fp.faceSel);
2778
+ normal = resolved.normal;
2779
+ at = basePointVector ?? resolved.center;
2780
+ }
2781
+ else if (Array.isArray(mirrorPlane)) {
2782
+ normal = [mirrorPlane[0] ?? 0, mirrorPlane[1] ?? 0, mirrorPlane[2] ?? 0];
2783
+ at = basePointVector ?? [0, 0, 0];
2784
+ }
2785
+ else {
2786
+ const key = String(mirrorPlane ?? 'XY').toUpperCase();
2787
+ normal = MIRROR_PLANE_NORMALS[key] ?? [0, 0, 1];
2788
+ at = basePointVector ?? [0, 0, 0];
2789
+ }
2790
+ const mirrored = await cad.mirror(wp.shape, { normal, at });
2791
+ const shape = union ? await fuseShapes(wp.shape, mirrored) : mirrored;
2792
+ // Upstream returns a newObject stack holding only the mirrored/unioned
2793
+ // objects — pending selectors do not survive a mirror.
2794
+ return clone(wp, { shape, faceSel: null, edgeSel: null, vertexSel: null });
2795
+ }
2796
+ /**
2797
+ * faceCompound — extract the faces picked by a direction selector as a
2798
+ * standalone compound Shape (upstream module-level `Shape.faces(">Z")`, which
2799
+ * returns a Compound of faces — unlike `Workplane.faces()`, which only records
2800
+ * the selection). Needed by test_single_ent_selector where the exported var IS
2801
+ * the face compound (ref: Compound, area 2 = two unit-box top faces).
2802
+ *
2803
+ * `sel = 'all'` picks EVERY face of the shape — the upstream
2804
+ * `compound(shape.Faces())` free-function form (test_constructors c1/c2).
2805
+ *
2806
+ * @param wp - Workplane
2807
+ * @param sel - direction selector (">Z", "<X", …) or 'all' for every face
2808
+ * @returns Promise<Workplane> carrying the face compound
2809
+ */
2810
+ export async function faceCompound(wp, sel) {
2811
+ if (!wp.shape)
2812
+ return wp;
2813
+ const s = NAMED_VIEW_TO_AXIS[sel.trim().toLowerCase()] ?? sel;
2814
+ if (s.trim().toLowerCase() === 'all') {
2815
+ const faces = compatFn('getFaces')(borrowBrepjsShape(wp.shape));
2816
+ if (faces.length === 0) {
2817
+ throw new Error('[cq-compat] faceCompound "all": shape has no faces');
2818
+ }
2819
+ const product = compatFn('makeCompound')(faces);
2820
+ const shape = adoptBrepjsProduct(unwrapBrepResult(product));
2821
+ return clone(wp, { shape, faceSel: null, edgeSel: null, vertexSel: null });
2822
+ }
2823
+ const m = /^([<>])([XYZ])(?:\[-?\d+\])?$/.exec(s.trim());
2824
+ if (!m) {
2825
+ throw new Error(`[cq-compat] unsupported face selector for faceCompound "${sel}"`);
2826
+ }
2827
+ const axis = m[2] === 'X' ? 0 : m[2] === 'Y' ? 1 : 2;
2828
+ const sign = m[1] === '>' ? 1 : -1;
2829
+ const bounds = (h) => compatFn('getBounds')(h);
2830
+ const faces = compatFn('getFaces')(borrowBrepjsShape(wp.shape));
2831
+ // DirectionMinMaxSelector: among faces perpendicular to the axis, take ALL
2832
+ // faces whose center sits at the extremum (ties included — the two-boxes
2833
+ // compound exports BOTH top faces).
2834
+ const perp = faces.filter((f) => {
2835
+ const b = bounds(f);
2836
+ return [b.xMax - b.xMin, b.yMax - b.yMin, b.zMax - b.zMin][axis] <= 0.1;
2837
+ });
2838
+ if (perp.length === 0) {
2839
+ throw new Error(`[cq-compat] no planar face for selector "${sel}"`);
2840
+ }
2841
+ const center = (b) => [(b.xMin + b.xMax) / 2, (b.yMin + b.yMax) / 2, (b.zMin + b.zMax) / 2][axis];
2842
+ const extremum = perp
2843
+ .map((f) => center(bounds(f)))
2844
+ .reduce((best, c) => (sign * c > sign * best ? c : best));
2845
+ const picked = perp.filter((f) => Math.abs(center(bounds(f)) - extremum) <= 1e-6);
2846
+ const product = compatFn('makeCompound')(picked);
2847
+ const shape = adoptBrepjsProduct(unwrapBrepResult(product));
2848
+ return clone(wp, { shape, faceSel: null, edgeSel: null, vertexSel: null });
2849
+ }
2850
+ /**
2851
+ * edgeCompound — extract the edges picked by a direction selector as a
2852
+ * standalone compound Shape (upstream `shape.edges(">Z")` on a Solid, which
2853
+ * returns a Compound of edges). Needed by TestCQSelectors.testShape where the
2854
+ * exported var IS the edge compound (ref: Compound of the 4 top edges).
2855
+ *
2856
+ * Semantics (upstream DirectionMinMaxSelector = CenterNthSelector n=-1):
2857
+ * order ALL edges by their center-of-mass projection onto the axis and take
2858
+ * the extremum cluster (ties included). For a centered box the vertical edges'
2859
+ * centers sit at z=0 while the top edges sit at z=+h/2 — so `">Z"` picks
2860
+ * exactly the 4 top edges.
2861
+ *
2862
+ * @param wp - Workplane
2863
+ * @param sel - direction selector (">Z", "<X", …) picking the extremum edge cluster
2864
+ * @returns Promise<Workplane> carrying the edge compound
2865
+ */
2866
+ export async function edgeCompound(wp, sel) {
2867
+ if (!wp.shape)
2868
+ return wp;
2869
+ const s = NAMED_VIEW_TO_AXIS[sel.trim().toLowerCase()] ?? sel;
2870
+ const m = /^([<>])([XYZ])(?:\[-?\d+\])?$/.exec(s.trim());
2871
+ if (!m) {
2872
+ throw new Error(`[cq-compat] unsupported face selector for edgeCompound "${sel}"`);
2873
+ }
2874
+ const axis = m[2] === 'X' ? 0 : m[2] === 'Y' ? 1 : 2;
2875
+ const sign = m[1] === '>' ? 1 : -1;
2876
+ const bounds = (h) => compatFn('getBounds')(h);
2877
+ const edges = compatFn('getEdges')(borrowBrepjsShape(wp.shape));
2878
+ const center = (b) => [(b.xMin + b.xMax) / 2, (b.yMin + b.yMax) / 2, (b.zMin + b.zMax) / 2][axis];
2879
+ const extremum = edges
2880
+ .map((e) => center(bounds(e)))
2881
+ .reduce((best, c) => (sign * c > sign * best ? c : best));
2882
+ const picked = edges.filter((e) => Math.abs(center(bounds(e)) - extremum) <= 1e-6);
2883
+ const product = compatFn('makeCompound')(picked);
2884
+ const shape = adoptBrepjsProduct(unwrapBrepResult(product));
2885
+ return clone(wp, { shape, faceSel: null, edgeSel: null, vertexSel: null });
2886
+ }
2887
+ function locNum(v) {
2888
+ return typeof v === 'number' && Number.isFinite(v) ? v : 0;
2889
+ }
2890
+ function locVec3(a) {
2891
+ if (Array.isArray(a))
2892
+ return [locNum(a[0]), locNum(a[1]), locNum(a[2])];
2893
+ return [0, 0, 0];
2894
+ }
2895
+ /**
2896
+ * Type guard for a `Location` produced by {@link Location}.
2897
+ *
2898
+ * @param v - value to test
2899
+ * @returns True when `v` is a cq-compat Location
2900
+ */
2901
+ export function isLocation(v) {
2902
+ return typeof v === 'object' && v !== null && v.__cqLocation === true;
2903
+ }
2904
+ /**
2905
+ * Location — CadQuery `Location` constructor.
2906
+ *
2907
+ * Accepted forms (all verified against the upstream overloads used by
2908
+ * `tests/test_free_functions.py::test_moved`):
2909
+ * `Location([x, y, z])`
2910
+ * `Location([x, y, z], [rx, ry, rz])`
2911
+ * `Location(x, y, z)` / `Location(x, y, z, rx, ry, rz)`
2912
+ * `Location({ x, y, z, rx, ry, rz })` ← the `.moved(z=-1)` keyword form
2913
+ *
2914
+ * @param args - overload payload: `[pos]`, `[pos, rot]`, `(x, y, z[, rx, ry, rz])`, or the keyword object
2915
+ * @returns CqLocation (position in mm, rotation in degrees)
2916
+ */
2917
+ export function Location(...args) {
2918
+ const nums = args.filter((a) => typeof a === 'number');
2919
+ const arrs = args.filter((a) => Array.isArray(a));
2920
+ const obj = args.find((a) => typeof a === 'object' && a !== null && !Array.isArray(a));
2921
+ let pos = [0, 0, 0];
2922
+ let rot = [0, 0, 0];
2923
+ if (obj) {
2924
+ pos = [locNum(obj.x), locNum(obj.y), locNum(obj.z)];
2925
+ rot = [locNum(obj.rx), locNum(obj.ry), locNum(obj.rz)];
2926
+ }
2927
+ else if (nums.length >= 6) {
2928
+ pos = [nums[0], nums[1], nums[2]];
2929
+ rot = [nums[3], nums[4], nums[5]];
2930
+ }
2931
+ else if (nums.length >= 3) {
2932
+ pos = [nums[0], nums[1], nums[2]];
2933
+ }
2934
+ else if (arrs.length >= 2) {
2935
+ pos = locVec3(arrs[0]);
2936
+ rot = locVec3(arrs[1]);
2937
+ }
2938
+ else if (arrs.length === 1) {
2939
+ pos = locVec3(arrs[0]);
2940
+ }
2941
+ return { __cqLocation: true, pos, rot };
2942
+ }
2943
+ /**
2944
+ * composeLocations(a, b) — the Location product `a * b` (upstream `Location.__mul__`):
2945
+ * apply `b` first, then `a`. Result: R = Ra·Rb, t = Ra·t_b + t_a.
2946
+ *
2947
+ * Mirrors need this because a Workplane whose carried shape is a **compound**
2948
+ * cannot be fed back into `moved` — the faijs runtime only re-attaches the BREP
2949
+ * handle across statement boundaries for solids (see `moved`'s KNOWN LIMITATION
2950
+ * note), so `bs1.moved(l3, l4)` has to be written as one `moved` over the
2951
+ * composed locations instead of two chained ones.
2952
+ *
2953
+ * @param a - outer location (applied second)
2954
+ * @param b - inner location (applied first)
2955
+ * @returns CqLocation holding the product a·b
2956
+ */
2957
+ export function composeLocations(a, b) {
2958
+ const ra = rotationMatrixDeg(a.rot);
2959
+ const rb = rotationMatrixDeg(b.rot);
2960
+ const mul = (m, n) => {
2961
+ const out = new Array(9).fill(0);
2962
+ for (let i = 0; i < 3; i++) {
2963
+ for (let j = 0; j < 3; j++) {
2964
+ out[i * 3 + j] = m[i * 3] * n[j] + m[i * 3 + 1] * n[3 + j] + m[i * 3 + 2] * n[6 + j];
2965
+ }
2966
+ }
2967
+ return out;
2968
+ };
2969
+ const r = mul(ra, rb);
2970
+ const tb = b.pos;
2971
+ const t = [
2972
+ ra[0] * tb[0] + ra[1] * tb[1] + ra[2] * tb[2] + a.pos[0],
2973
+ ra[3] * tb[0] + ra[4] * tb[1] + ra[5] * tb[2] + a.pos[1],
2974
+ ra[6] * tb[0] + ra[7] * tb[1] + ra[8] * tb[2] + a.pos[2],
2975
+ ];
2976
+ // recover Euler angles from the composed matrix (gp_Extrinsic_XYZ: R = Rz·Ry·Rx)
2977
+ const rot = [0, 0, 0];
2978
+ const cy = Math.hypot(r[0], r[3]);
2979
+ if (cy > 1e-12) {
2980
+ rot[1] = (Math.atan2(-r[6], cy) * 180) / Math.PI;
2981
+ rot[2] = (Math.atan2(r[3], r[0]) * 180) / Math.PI;
2982
+ rot[0] = (Math.atan2(r[7], r[8]) * 180) / Math.PI;
2983
+ }
2984
+ else {
2985
+ rot[1] = (Math.atan2(-r[6], cy) * 180) / Math.PI;
2986
+ rot[2] = 0;
2987
+ rot[0] = (Math.atan2(-r[5], r[4]) * 180) / Math.PI;
2988
+ }
2989
+ const zero = (v) => (v === 0 ? 0 : v);
2990
+ return {
2991
+ __cqLocation: true,
2992
+ pos: [zero(t[0]), zero(t[1]), zero(t[2])],
2993
+ rot: [zero(rot[0]), zero(rot[1]), zero(rot[2])],
2994
+ };
2995
+ }
2996
+ /**
2997
+ * Normalise the variadic argument list of `moved`/`move` into a Location list.
2998
+ *
2999
+ * Upstream dispatch (`Shape.moved`, cadquery 2.8.0) — the forms a mirror needs:
3000
+ * `moved(loc)` / `moved(loc1, loc2, …)` / `moved([loc1, loc2])`
3001
+ * `moved((0,0,1))` / `moved((0,0,1), (0,0,-1))` / `moved([(0,0,1), (0,0,-1)])`
3002
+ * `moved(0, 0, -1)` / `moved(z=-1)`
3003
+ */
3004
+ function toLocations(args) {
3005
+ if (args.length === 0)
3006
+ return [];
3007
+ if (args.every((a) => typeof a === 'number')) {
3008
+ const n = args;
3009
+ return [
3010
+ {
3011
+ __cqLocation: true,
3012
+ pos: [locNum(n[0]), locNum(n[1]), locNum(n[2])],
3013
+ rot: [locNum(n[3]), locNum(n[4]), locNum(n[5])],
3014
+ },
3015
+ ];
3016
+ }
3017
+ const out = [];
3018
+ for (const a of args) {
3019
+ if (isLocation(a)) {
3020
+ out.push(a);
3021
+ }
3022
+ else if (Array.isArray(a)) {
3023
+ if (a.length > 0 && isLocation(a[0])) {
3024
+ out.push(...a);
3025
+ }
3026
+ else if (a.length > 0 && Array.isArray(a[0])) {
3027
+ for (const v of a)
3028
+ out.push(Location(v));
3029
+ }
3030
+ else {
3031
+ out.push(Location(a));
3032
+ }
3033
+ }
3034
+ else if (a && typeof a === 'object') {
3035
+ out.push(Location(a));
3036
+ }
3037
+ }
3038
+ return out;
3039
+ }
3040
+ /**
3041
+ * Row-major 3x3 rotation for Euler angles in degrees, `gp_Extrinsic_XYZ` order
3042
+ * (rotations about the FIXED axes X, then Y, then Z: R = Rz·Ry·Rx) — the order
3043
+ * upstream `Location` uses.
3044
+ */
3045
+ function rotationMatrixDeg(rot) {
3046
+ const rad = Math.PI / 180;
3047
+ const [rx, ry, rz] = rot.map((d) => d * rad);
3048
+ const cx = Math.cos(rx);
3049
+ const sx = Math.sin(rx);
3050
+ const cy = Math.cos(ry);
3051
+ const sy = Math.sin(ry);
3052
+ const cz = Math.cos(rz);
3053
+ const sz = Math.sin(rz);
3054
+ const Rx = [1, 0, 0, 0, cx, -sx, 0, sx, cx];
3055
+ const Ry = [cy, 0, sy, 0, 1, 0, -sy, 0, cy];
3056
+ const Rz = [cz, -sz, 0, sz, cz, 0, 0, 0, 1];
3057
+ const mul = (a, b) => {
3058
+ const out = new Array(9).fill(0);
3059
+ for (let i = 0; i < 3; i++) {
3060
+ for (let j = 0; j < 3; j++) {
3061
+ out[i * 3 + j] = a[i * 3] * b[j] + a[i * 3 + 1] * b[3 + j] + a[i * 3 + 2] * b[6 + j];
3062
+ }
3063
+ }
3064
+ return out;
3065
+ };
3066
+ return mul(Rz, mul(Ry, Rx));
3067
+ }
3068
+ /**
3069
+ * Apply one Location to a shape: rotate about the world origin, then translate
3070
+ * (p -> R·p + t, matching upstream `gp_Trsf.SetRotation` + `SetTranslationPart`).
3071
+ *
3072
+ * Uses the kernel `applyMatrix` projection rather than `cad.translate` /
3073
+ * `cad.rotate_euler`: the latter two are solid-only and throw
3074
+ * "input is not BREP" on a compound, which is exactly what `moved` produces
3075
+ * when it is given more than one location.
3076
+ */
3077
+ async function applyLocation(shape, loc) {
3078
+ const [rx, ry, rz] = loc.rot;
3079
+ const [x, y, z] = loc.pos;
3080
+ if (rx === 0 && ry === 0 && rz === 0 && x === 0 && y === 0 && z === 0)
3081
+ return shape;
3082
+ // Two paths, chosen by the number of solids in the carrier:
3083
+ //
3084
+ // - a SINGLE solid goes through the faijs `cad.rotate_euler` / `cad.translate`
3085
+ // defineOps. Those re-register the OCCT handle in a way that survives a
3086
+ // statement boundary, so the result exports as a true BREP STEP.
3087
+ // - a COMPOUND must use the kernel `applyMatrix` projection (the defineOps
3088
+ // are solid-only and reject it). That product does NOT keep its BREP slot
3089
+ // across a statement boundary — the STEP then falls back to a
3090
+ // TESSELLATED_SOLID — so mirrors avoid feeding a compound back into
3091
+ // `moved` (they fold the locations with composeLocations instead).
3092
+ const solids = (() => {
3093
+ try {
3094
+ return brepjsCompat.getSolids(borrowBrepjsShape(shape)).length;
3095
+ }
3096
+ catch {
3097
+ return 0;
3098
+ }
3099
+ })();
3100
+ if (solids <= 1) {
3101
+ let s = shape;
3102
+ if (rx !== 0 || ry !== 0 || rz !== 0) {
3103
+ s = await cad.rotate_euler(s, { anglesDeg: [rx, ry, rz] });
3104
+ }
3105
+ if (x !== 0 || y !== 0 || z !== 0) {
3106
+ s = await cad.translate(s, { offset: [x, y, z] });
3107
+ }
3108
+ return s;
3109
+ }
3110
+ const product = unwrapBrepResult(compatFn('applyMatrix')(borrowBrepjsShape(shape), {
3111
+ linear: rotationMatrixDeg(loc.rot),
3112
+ translation: [x, y, z],
3113
+ }));
3114
+ return adoptBrepjsProduct(product);
3115
+ }
3116
+ /**
3117
+ * moved — apply one or more Locations to the carried geometry.
3118
+ *
3119
+ * Upstream is `Shape.moved(*locs)`: one location returns a moved copy, several
3120
+ * return a **compound** holding one copy per location (no boolean union —
3121
+ * `test_moved` asserts `bs1.Volume() == 2` and `len(bs1.Solids()) == 2` for two
3122
+ * disjoint unit boxes, which only holds for a compound).
3123
+ *
3124
+ * @param wp - Workplane
3125
+ * @param locs - Location | [x,y,z] | {x,y,z,rx,ry,rz} | list thereof
3126
+ * @returns Promise<Workplane>
3127
+ */
3128
+ export async function moved(wp, ...locs) {
3129
+ if (!wp.shape)
3130
+ return wp;
3131
+ const resolved = toLocations(locs);
3132
+ const copies = [];
3133
+ for (const l of resolved)
3134
+ copies.push(await applyLocation(wp.shape, l));
3135
+ let shape;
3136
+ if (copies.length === 0) {
3137
+ shape = wp.shape;
3138
+ }
3139
+ else if (copies.length === 1) {
3140
+ shape = copies[0];
3141
+ }
3142
+ else {
3143
+ // Upstream `_compound_or_shape` groups the copies without any boolean or
3144
+ // clean pass — mirroring that keeps the topology (face/solid counts) equal
3145
+ // to upstream, which the STEP comparison gates on.
3146
+ const handles = copies.map((c) => borrowBrepjsShape(c));
3147
+ shape = adoptBrepjsProduct(unwrapBrepResult(compatFn('makeCompound')(handles)));
3148
+ }
3149
+ return clone(wp, {
3150
+ shape,
3151
+ faceSel: null,
3152
+ edgeSel: null,
3153
+ vertexSel: null,
3154
+ pts: [],
3155
+ pendingWires: [],
3156
+ });
3157
+ }
3158
+ /**
3159
+ * move — upstream mutates the shape in place; cq-compat carriers are immutable
3160
+ * so this is an alias of {@link moved}.
3161
+ *
3162
+ * @param wp - Workplane
3163
+ * @param locs - same forms as {@link moved}
3164
+ * @returns Promise<Workplane>
3165
+ */
3166
+ export async function move(wp, ...locs) {
3167
+ return moved(wp, ...locs);
3168
+ }
3169
+ /**
3170
+ * union
3171
+ * @param wp - Workplane
3172
+ * @param other - Workplane | Shape
3173
+ * @returns Promise<Workplane>
3174
+ */
3175
+ export async function union(wp, other) {
3176
+ if (!wp.shape)
3177
+ return wp;
3178
+ const otherShape = 'shape' in other ? other.shape : other;
3179
+ if (!otherShape)
3180
+ return wp;
3181
+ const shape = await fuseShapes(wp.shape, otherShape);
3182
+ return clone(wp, { shape });
3183
+ }
3184
+ /**
3185
+ * combine
3186
+ * @param wp - Workplane
3187
+ * @returns Promise<Workplane>
3188
+ *
3189
+ * CadQuery semantics note (verified vs cadquery 2.8.0): upstream `combine()`
3190
+ * fuses all stack items. cq-compat fuses eagerly inside the building ops
3191
+ * (extrude / eachpoint with combine=True), so by the time combine() runs the
3192
+ * stack holds a single fused solid — the op degenerates to a `clean()` pass
3193
+ * (same-face merge), which matches the upstream test expectations
3194
+ * (testCombine: 11 faces either way).
3195
+ */
3196
+ export async function combine(wp) {
3197
+ if (!wp.shape)
3198
+ return wp;
3199
+ const shape = await cleanShapes(wp.shape);
3200
+ return clone(wp, { shape });
3201
+ }
3202
+ /**
3203
+ * cut
3204
+ * @param wp - Workplane
3205
+ * @param other - Workplane | Shape
3206
+ * @returns Promise<Workplane>
3207
+ */
3208
+ export async function cut(wp, other) {
3209
+ if (!wp.shape)
3210
+ return wp;
3211
+ const otherShape = 'shape' in other ? other.shape : other;
3212
+ if (!otherShape)
3213
+ return wp;
3214
+ const shape = await cad.subtract(wp.shape, otherShape);
3215
+ return clone(wp, { shape });
3216
+ }
3217
+ /**
3218
+ * face — materialize the pending wire LIST as one planar face per outermost
3219
+ * wire (upstream module-level `face(*wires)` free function). Enclosed wires
3220
+ * become holes of the enclosing face — the same outer/hole grouping `extrude`
3221
+ * uses. Several disjoint outer wires yield a Compound of faces.
3222
+ *
3223
+ * @param wp - Workplane carrying the pending wires
3224
+ * @returns Promise<Workplane> whose shape is the face (or face compound)
3225
+ */
3226
+ export async function face(wp) {
3227
+ const all = (wp.pendingWires ?? []).filter((w) => !w.construction);
3228
+ if (all.length === 0)
3229
+ throw new Error('[cq-compat] face: no pending wire to build a face from');
3230
+ const groups = groupPendingWires(all);
3231
+ const faces = [];
3232
+ for (const g of groups) {
3233
+ const outer = await buildProfileWire(wp, g.outer);
3234
+ const holeWires = [];
3235
+ for (const h of g.holes) {
3236
+ holeWires.push(await buildProfileWire(wp, h));
3237
+ }
3238
+ faces.push(adoptBrepjsProduct(unwrapBrepResult(compatFn('makeFace')(outer, holeWires))));
3239
+ }
3240
+ const shape = faces.length === 1 ? faces[0] : makeCompoundShape(faces);
3241
+ return clone(wp, {
3242
+ shape,
3243
+ pendingWires: [],
3244
+ pendingRect: undefined,
3245
+ pendingCircle: undefined,
3246
+ pendingPolygon: undefined,
3247
+ faceSel: null,
3248
+ edgeSel: null,
3249
+ vertexSel: null,
3250
+ pts: [],
3251
+ });
3252
+ }
3253
+ /**
3254
+ * vertex — upstream module-level `vertex(x, y, z)` free function: a single
3255
+ * point shape. Used as a degenerate loft section (`loft(face, vertex(0,0,1))`)
3256
+ * and inside compounds.
3257
+ *
3258
+ * @param x - world x (default 0)
3259
+ * @param y - world y (default 0)
3260
+ * @param z - world z (default 0)
3261
+ * @returns Shape holding the vertex
3262
+ */
3263
+ export function vertex(x = 0, y = 0, z = 0) {
3264
+ return adoptBrepjsProduct(unwrapBrepResult(compatFn('makeVertex')([x, y, z])));
3265
+ }
3266
+ /**
3267
+ * compound — upstream module-level `compound(*shapes)` free function: bundle
3268
+ * several shapes into a single Compound WITHOUT any boolean operation. Needed
3269
+ * by test_history_bool (imprint result = base solid + tool solid as a
3270
+ * compound) and test_union_compound-style cases.
3271
+ *
3272
+ * Accepts Shapes and Workplanes (their current shape is used); null/empty
3273
+ * entries are skipped. Returns a Shape whose value is the compound itself, so
3274
+ * mirrors write `let result = c` directly.
3275
+ *
3276
+ * @param items - Shapes and/or Workplanes to bundle (null/undefined entries are skipped)
3277
+ * @returns Compound Shape, or null when no item carries geometry
3278
+ */
3279
+ export function compound(...items) {
3280
+ const shapes = items
3281
+ .map((it) => (it && typeof it === 'object' && 'shape' in it ? it.shape : it))
3282
+ .filter((s) => Boolean(s));
3283
+ if (shapes.length === 0)
3284
+ return null;
3285
+ return makeCompoundShape(shapes);
3286
+ }
3287
+ /**
3288
+ * intersect
3289
+ * @param wp - Workplane
3290
+ * @param other - Workplane | Shape
3291
+ * @returns Promise<Workplane>
3292
+ */
3293
+ export async function intersect(wp, other) {
3294
+ if (!wp.shape)
3295
+ return wp;
3296
+ const otherShape = 'shape' in other ? other.shape : other;
3297
+ if (!otherShape)
3298
+ return wp;
3299
+ const shape = await intersectShapes(wp.shape, otherShape);
3300
+ return clone(wp, { shape });
3301
+ }
3302
+ /**
3303
+ * fillet
3304
+ * @param wp - Workplane
3305
+ * @param radius - number
3306
+ * @returns Promise<Workplane>
3307
+ */
3308
+ /**
3309
+ * Resolve a CadQuery edge selector string to concrete edge handles.
3310
+ *
3311
+ * Supports CadQuery's parallel-axis selectors "|X" / "|Y" / "|Z" (edges whose
3312
+ * bounding box is thin across the two perpendicular axes) and an empty / absent
3313
+ * selector meaning "all edges". Face/edge geometry beyond axis-parallel lines
3314
+ * is out of scope for this layer.
3315
+ */
3316
+ function resolveEdgeSelection(shape, sel) {
3317
+ const edges = compatFn('getEdges')(borrowBrepjsShape(shape));
3318
+ if (!sel || sel === '')
3319
+ return edges;
3320
+ const m = /^\|([XYZ])$/.exec(sel.trim());
3321
+ if (!m) {
3322
+ throw new Error(`[cq-compat] unsupported edge selector "${sel}" (supported: |X |Y |Z)`);
3323
+ }
3324
+ const axisIdx = m[1] === 'X' ? 0 : m[1] === 'Y' ? 1 : 2;
3325
+ const perp = [0, 1, 2].filter((i) => i !== axisIdx);
3326
+ // The kernel inflates edge bounding boxes by ~0.1mm of tolerance padding, so
3327
+ // an axis-parallel edge is identified RELATIVELY: its extent along the axis
3328
+ // must dominate the two perpendicular extents (which stay padding-sized).
3329
+ const PAD = 0.5; // mm — max perpendicular extent for an axis-parallel edge
3330
+ return edges.filter((e) => {
3331
+ const b = compatFn('getBounds')(e);
3332
+ const min = [b.xMin, b.yMin, b.zMin];
3333
+ const max = [b.xMax, b.yMax, b.zMax];
3334
+ const extents = [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
3335
+ const axisExtent = extents[axisIdx];
3336
+ return (perp.every((i) => extents[i] <= PAD) &&
3337
+ axisExtent > 2 * Math.max(extents[perp[0]], extents[perp[1]]));
3338
+ });
3339
+ }
3340
+ /**
3341
+ * Fillet selected edges of the current shape.
3342
+ *
3343
+ * Edge resolution mirrors chamfer: an explicit `|Z`-style / empty edgeSel goes
3344
+ * through `resolveEdgeSelection`; a pending face selection
3345
+ * (`.faces(">Z").fillet(r)`) fillets THE SELECTED FACE's edges via
3346
+ * `resolveFaceEdgeSelection` (upstream `.faces("+Z").edges().fillet(r)`
3347
+ * semantics — the missing faceSel branch made testTopFaceFillet fillet all 12
3348
+ * edges instead of the 4 top ones). Failures propagate — silently returning
3349
+ * the unfilleted shape previously produced plates whose fillets were missing
3350
+ * entirely (bp/mb/mt/tp diagnosis, 2026-09-08).
3351
+ *
3352
+ * @param wp - Workplane whose current shape is filleted; consumes `edgeSel`/`faceSel`.
3353
+ * @param radius - Fillet radius in world units.
3354
+ * @returns Promise resolving to a new Workplane holding the filleted shape.
3355
+ */
3356
+ export async function fillet(wp, radius) {
3357
+ if (!wp.shape)
3358
+ return wp;
3359
+ let edges;
3360
+ if (wp.edgeSel !== null && wp.edgeSel !== undefined) {
3361
+ edges = resolveEdgeSelection(wp.shape, wp.edgeSel);
3362
+ }
3363
+ else if (wp.faceSel) {
3364
+ edges = resolveFaceEdgeSelection(wp.shape, wp.faceSel);
3365
+ }
3366
+ else {
3367
+ edges = resolveEdgeSelection(wp.shape, undefined);
3368
+ }
3369
+ const result = compatFn('fillet')(borrowBrepjsShape(wp.shape), edges, radius);
3370
+ const product = unwrapBrepResult(result);
3371
+ const shape = adoptBrepjsProduct(product);
3372
+ return clone(wp, { shape, edgeSel: null, faceSel: null });
3373
+ }
3374
+ /**
3375
+ * chamfer
3376
+ * @param wp - Workplane
3377
+ * @param length - number
3378
+ * @param length2 - number | undefined
3379
+ * @returns Promise<Workplane>
3380
+ *
3381
+ * CadQuery semantics (verified vs cadquery 2.8.0 `Workplane.chamfer`):
3382
+ * chamfers the selected edges of the current shape. Edge resolution order:
3383
+ * explicit `edges("|Z")` selector; else, if a face selector is pending
3384
+ * (`.faces(">Z").chamfer(l)`), the edges OF the selected face; else all
3385
+ * edges. LIMITATION: asymmetric `length2` is NOT supported — the occt-wasm
3386
+ * kernel chamfer takes a single uniform distance (resolveUniformRadius
3387
+ * degrades a pair to d1), so length2 throws instead of silently producing a
3388
+ * symmetric chamfer.
3389
+ */
3390
+ export async function chamfer(wp, length, length2) {
3391
+ if (length2 !== undefined) {
3392
+ throw new Error('[cq-compat] chamfer length2 (asymmetric) is not supported by the occt-wasm kernel');
3393
+ }
3394
+ if (!wp.shape)
3395
+ return wp;
3396
+ let edges;
3397
+ if (wp.edgeSel !== null && wp.edgeSel !== undefined) {
3398
+ edges = resolveEdgeSelection(wp.shape, wp.edgeSel);
3399
+ }
3400
+ else if (wp.faceSel) {
3401
+ edges = resolveFaceEdgeSelection(wp.shape, wp.faceSel);
3402
+ }
3403
+ else {
3404
+ edges = resolveEdgeSelection(wp.shape, undefined);
3405
+ }
3406
+ const result = compatFn('chamfer')(borrowBrepjsShape(wp.shape), edges, length);
3407
+ const product = unwrapBrepResult(result);
3408
+ const shape = adoptBrepjsProduct(product);
3409
+ return clone(wp, { shape, edgeSel: null, faceSel: null });
3410
+ }
3411
+ /**
3412
+ * Resolve the edges belonging to the face picked by a direction selector
3413
+ * (upstream `.faces(">Z").chamfer(l)` chamfers the edges of that face).
3414
+ * Reuses the direction-minimum/maximum rule of resolveFaceSelector: among
3415
+ * faces perpendicular to the axis, the extremal one along it wins; its edges
3416
+ * are those whose bounding box lies inside the face's (kernel pads bounds by
3417
+ * ~0.1mm of tolerance, so a small positive slack is used).
3418
+ */
3419
+ function resolveFaceEdgeSelection(shape, sel) {
3420
+ // '+'/'-' are accepted as aliases of '>'/'<' (CadQuery allows both spellings;
3421
+ // resolveFaceSelector's axis table does the same).
3422
+ const m = /^([<>+-])([XYZ])(?:\[-?\d+\])?$/.exec(sel.trim());
3423
+ if (!m) {
3424
+ throw new Error(`[cq-compat] unsupported face selector for chamfer "${sel}"`);
3425
+ }
3426
+ const axis = m[2] === 'X' ? 0 : m[2] === 'Y' ? 1 : 2;
3427
+ const sign = m[1] === '>' || m[1] === '+' ? 1 : -1;
3428
+ const bounds = (h) => compatFn('getBounds')(h);
3429
+ const faces = compatFn('getFaces')(borrowBrepjsShape(shape));
3430
+ const perp = faces.filter((f) => {
3431
+ const b = bounds(f);
3432
+ return [b.xMax - b.xMin, b.yMax - b.yMin, b.zMax - b.zMin][axis] <= 0.1;
3433
+ });
3434
+ if (perp.length === 0) {
3435
+ throw new Error(`[cq-compat] no planar face for selector "${sel}"`);
3436
+ }
3437
+ const faceCenter = (b) => [(b.xMin + b.xMax) / 2, (b.yMin + b.yMax) / 2, (b.zMin + b.zMax) / 2][axis];
3438
+ const target = perp
3439
+ .map((f) => ({ f, b: bounds(f) }))
3440
+ .reduce((best, cur) => (sign * (faceCenter(cur.b) - faceCenter(best.b)) > 0 ? cur : best));
3441
+ const fc = faceCenter(target.b);
3442
+ // Edge bounds carry ±0.1mm kernel tolerance padding (measured: a unit-box
3443
+ // edge reports extents inflated by 0.2), while face bounds are tight. An
3444
+ // edge of the face lies IN its plane, so along the axis it is thin (pure
3445
+ // padding) and its center coincides with the face center.
3446
+ const EDGE_AXIS_MAX = 0.25;
3447
+ const EDGE_CENTER_TOL = 0.15;
3448
+ const edges = compatFn('getEdges')(borrowBrepjsShape(shape));
3449
+ return edges.filter((e) => {
3450
+ const b = bounds(e);
3451
+ const ext = [b.xMax - b.xMin, b.yMax - b.yMin, b.zMax - b.zMin][axis];
3452
+ const c = [(b.xMin + b.xMax) / 2, (b.yMin + b.yMax) / 2, (b.zMin + b.zMax) / 2][axis];
3453
+ return ext <= EDGE_AXIS_MAX && Math.abs(c - fc) <= EDGE_CENTER_TOL;
3454
+ });
3455
+ }
3456
+ /**
3457
+ * shell
3458
+ *
3459
+ * CadQuery `Workplane.shell(thickness)` parity: shells the solid found on the
3460
+ * stack, removing the faces selected by a preceding `faces(sel)` (empty
3461
+ * selection = `Shape.hollow` — no faces removed, the solid is hollowed into a
3462
+ * closed shell, verified vs cadquery 2.8.0: `box(2,2,2).shell(-0.1)` → 12
3463
+ * faces, vol 2.168). The kernel call is `OcctKernel.shell` =
3464
+ * `BRepOffsetAPI_MakeThickSolidByJoin` (negative thickness → walls inward,
3465
+ * positive → walls outward, mirroring upstream sign semantics).
3466
+ *
3467
+ * Face removal set: single-axis selectors (">Z"/"<Z"/"+Z"/"-Z" …) pick the
3468
+ * faces perpendicular to the axis whose bbox center sits at the extreme —
3469
+ * the same criteria `resolveFaceSelector` uses. Multi-axis and indexed
3470
+ * selectors are not supported here yet (the blocked mirrors that need them
3471
+ * are out of this phase's scope).
3472
+ *
3473
+ * @param wp - Workplane
3474
+ * @param thickness - number (negative: inward hollow)
3475
+ * @returns Promise<Workplane>
3476
+ */
3477
+ export async function shell(wp, thickness) {
3478
+ if (!wp.shape)
3479
+ return wp;
3480
+ const handle = brepOf(wp.shape);
3481
+ if (handle === undefined)
3482
+ return wp;
3483
+ const kernel = getKernel();
3484
+ const facesToRemove = [];
3485
+ if (wp.faceSel) {
3486
+ facesToRemove.push(...selectFaceHandlesForRemoval(kernel, handle, wp.faceSel));
3487
+ }
3488
+ const h = handle;
3489
+ let shape;
3490
+ if (thickness < 0) {
3491
+ if (facesToRemove.length > 0) {
3492
+ // Walls inward with openings: the kernel call IS MakeThickSolidByJoin
3493
+ // semantics (remove faces, offset remaining inward by |thickness|).
3494
+ shape = fromHandle(kernel.shell(h, facesToRemove, -thickness, 1e-3));
3495
+ }
3496
+ else {
3497
+ // Closed hollow (upstream Shape.hollow): kernel.shell with NO removed
3498
+ // faces degenerates to the inward-offset solid (measured: box(2,2,2)
3499
+ // +0.1 -> 1.8^3 = 5.832), so the wall solid is original minus offset.
3500
+ const inner = fromHandle(kernel.shell(h, [], -thickness, 1e-3));
3501
+ shape = await cutShapes(wp.shape, inner);
3502
+ }
3503
+ }
3504
+ else {
3505
+ if (facesToRemove.length > 0) {
3506
+ // Walls outward with openings. Upstream routes this through
3507
+ // MakeThickSolidByJoin (offset + remove + join), which the kernel does
3508
+ // not expose: `offset` alone leaves the removed face closed. Cutting the
3509
+ // swept slab off each removed face (the earlier heuristic here) produced
3510
+ // geometry that does not match any upstream reference, so fail loudly
3511
+ // rather than emit an approximation — see testSimpleShell__s1/s3 in
3512
+ // tests/mark-blocked.ts.
3513
+ throw new Error('[cq-compat] shell: positive thickness (walls outward) with removed faces is not supported');
3514
+ }
3515
+ // Walls outward: rounded outward offset (arc-joined corners) minus the
3516
+ // original solid (verified vs 2.8.0: box(2,2,2).shell(0.1) -> 32 faces,
3517
+ // vol 2.592684356757526, bbox +-1.1).
3518
+ const outer = fromHandle(kernel.offset(h, thickness, 1e-3));
3519
+ shape = await cutShapes(outer, wp.shape);
3520
+ }
3521
+ return clone(wp, { shape, faceSel: null, edgeSel: null, vertexSel: null });
3522
+ }
3523
+ /**
3524
+ * Enumerate the solid's faces and return the removal set for `shell()` for a
3525
+ * single-axis selector string. A face participates when its bbox is thin along
3526
+ * the axis (perpendicular face) and its bbox center sits at the extreme end
3527
+ * picked by the selector (ties collected, matching upstream's multi-face
3528
+ * `faces("+Z")` selection semantics).
3529
+ */
3530
+ function selectFaceHandlesForRemoval(kernel, handle, sel) {
3531
+ const m = /^([<>+-])([XYZ])$/.exec(sel.trim());
3532
+ if (!m) {
3533
+ throw new Error(`[cq-compat] shell: face selector "${sel}" not supported (single-axis >Z/<Z/+Z/-Z only)`);
3534
+ }
3535
+ const axisMap = { X: 0, Y: 1, Z: 2 };
3536
+ const axis = axisMap[m[2]];
3537
+ const sign = m[1] === '<' || m[1] === '-' ? -1 : 1;
3538
+ const faces = kernel.getSubShapes(handle, 'face');
3539
+ const perp = [];
3540
+ for (const f of faces) {
3541
+ const bb = kernel.getBoundingBox(f);
3542
+ const ext = [bb.xmax - bb.xmin, bb.ymax - bb.ymin, bb.zmax - bb.zmin][axis];
3543
+ if (ext > 0.1)
3544
+ continue;
3545
+ const c = [(bb.xmin + bb.xmax) / 2, (bb.ymin + bb.ymax) / 2, (bb.zmin + bb.zmax) / 2][axis];
3546
+ perp.push({ h: f, c });
3547
+ }
3548
+ if (perp.length === 0) {
3549
+ throw new Error(`[cq-compat] shell: no face perpendicular to axis for selector "${sel}"`);
3550
+ }
3551
+ const best = sign === 1 ? Math.max(...perp.map((p) => p.c)) : Math.min(...perp.map((p) => p.c));
3552
+ const TOL = 1e-6;
3553
+ const picked = perp.filter((p) => Math.abs(p.c - best) <= TOL).map((p) => p.h);
3554
+ if (picked.length === faces.length) {
3555
+ throw new Error(`[cq-compat] shell: selector "${sel}" would remove every face`);
3556
+ }
3557
+ return picked;
3558
+ }
3559
+ /**
3560
+ * val
3561
+ * @param wp - Workplane
3562
+ * @returns Shape | null
3563
+ */
3564
+ export function val(wp) {
3565
+ return wp.shape;
3566
+ }
3567
+ /**
3568
+ * vals
3569
+ * @param wp - Workplane
3570
+ * @returns (Shape | null)[]
3571
+ */
3572
+ export function vals(wp) {
3573
+ return wp.shape ? [wp.shape] : [];
3574
+ }
3575
+ /**
3576
+ * transformed
3577
+ * @param wp - Workplane
3578
+ * @param opts - { offset?: [number, number, number]; rotate?: [number, number, number] }
3579
+ * @returns Promise<Workplane>
3580
+ */
3581
+ export async function transformed(wp, opts) {
3582
+ let result = wp;
3583
+ if (opts.offset) {
3584
+ // CadQuery applies the offset in LOCAL coordinates:
3585
+ // world offset = x·xDir + y·yDir + z·normal.
3586
+ const [x, y, z] = opts.offset;
3587
+ const world = vadd(wp.origin, vadd(vscale(wp.xDir, x), vadd(vscale(wp.yDir, y), vscale(wp.normal, z))));
3588
+ result = clone(result, { origin: world });
3589
+ }
3590
+ if (opts.rotate) {
3591
+ // Upstream semantics (cadquery 2.8.0 Plane.rotated, verified): the plane's
3592
+ // DIRECTION vectors are rotated about the plane's own basis axes — x about
3593
+ // xDir, y about yDir, z about the normal — composed as T = Tx·Ty·Tz. The
3594
+ // origin is unaffected and the shape is NOT touched. The previous
3595
+ // implementation called rotate() (an op that rotates the shape with euler
3596
+ // angles) which silently returned the plane unchanged / rotated geometry.
3597
+ const [rxd, ryd, rzd] = opts.rotate;
3598
+ const rad = Math.PI / 180;
3599
+ const ax = [...wp.xDir];
3600
+ const ay = [...wp.yDir];
3601
+ const az = [...wp.normal];
3602
+ const rotAbout = (v, a, ang) => {
3603
+ const c = Math.cos(ang);
3604
+ const s = Math.sin(ang);
3605
+ const cross = [
3606
+ a[1] * v[2] - a[2] * v[1],
3607
+ a[2] * v[0] - a[0] * v[2],
3608
+ a[0] * v[1] - a[1] * v[0],
3609
+ ];
3610
+ const dot = a[0] * v[0] + a[1] * v[1] + a[2] * v[2];
3611
+ return [
3612
+ v[0] * c + cross[0] * s + a[0] * dot * (1 - c),
3613
+ v[1] * c + cross[1] * s + a[1] * dot * (1 - c),
3614
+ v[2] * c + cross[2] * s + a[2] * dot * (1 - c),
3615
+ ];
3616
+ };
3617
+ const apply = (v) => rotAbout(rotAbout(rotAbout(v, az, rzd * rad), ay, ryd * rad), ax, rxd * rad);
3618
+ const newX = apply(ax);
3619
+ const newZ = apply(az);
3620
+ const newY = [
3621
+ newZ[1] * newX[2] - newZ[2] * newX[1],
3622
+ newZ[2] * newX[0] - newZ[0] * newX[2],
3623
+ newZ[0] * newX[1] - newZ[1] * newX[0],
3624
+ ];
3625
+ result = clone(result, { xDir: newX, yDir: newY, normal: newZ });
3626
+ }
3627
+ return result;
3628
+ }
3629
+ /**
3630
+ * setColor
3631
+ * @param wp - Workplane
3632
+ * @param color - RGB
3633
+ * @returns Workplane
3634
+ */
3635
+ export function setColor(wp, color) {
3636
+ return clone(wp, { color });
3637
+ }
3638
+ // ── Gear-extension primitives (E1–E4) ───────────────────────────────────────
3639
+ // These four ops are required by the fai_cq_gears port (see
3640
+ // docs/plans/2026-09-11-cq-compat-gears-extensions-e1-e4.md). All call the
3641
+ // occt-wasm kernel directly via `getKernel()` — the same singleton fai_cq_gears
3642
+ // uses — so their `ShapeHandle`s are compatible with the rest of cq-compat.
3643
+ // occt-wasm already exposes `bsplineSurface` / `makeHelixWire` / `split` /
3644
+ // `halfSpace` natively (node_modules/occt-wasm/dist/index.d.ts:77/112/189/390),
3645
+ // so no vendored-layer extension is needed.
3646
+ //
3647
+ // NOTE: occt-wasm's JS wrapper reads `.x/.y/.z` off point arguments
3648
+ // (dist/index.js:169/396/485 and `#flattenPoints` at :1479) — it requires plain
3649
+ // `{x,y,z}` objects, NOT the `[x,y,z]` tuples faijs uses internally. `v3`
3650
+ // converts a tuple to the shape occt-wasm expects.
3651
+ const v3 = (t) => ({
3652
+ x: t[0],
3653
+ y: t[1],
3654
+ z: t[2],
3655
+ });
3656
+ /**
3657
+ * splineFace — build a B-spline surface face from a regular point grid and set
3658
+ * it as the workplane's current shape. CadQuery analog: `Face.makeSplineApprox`
3659
+ * (`Part.makeSplineSurface`) over the same `rows × cols` point grid.
3660
+ *
3661
+ * Two strategies are available via `opts.strategy`:
3662
+ *
3663
+ * - `'row-approx-loft'` (**default**): each grid row becomes a curve through
3664
+ * `approximatePoints(row, tolerance)`, the row wires are skinned with
3665
+ * `loft(wires, false, false)`, and the single resulting face is returned.
3666
+ * This matches CadQuery `makeSplineApprox` to 4.2e-11 (straight) / 5.6e-7
3667
+ * (helical) relative area on gear tooth grids — ≈3 orders better than
3668
+ * `'grid'` — because the curve-level tolerance carries the same meaning as
3669
+ * CadQuery's `spline_approx_tol`.
3670
+ * - `'grid'`: one-shot `bsplineSurface(flat, rows, cols)` over the whole grid.
3671
+ * occt-wasm exposes no DegMin/DegMax/Tol3D arguments here, so it runs with
3672
+ * kernel defaults; that measurably diverges from CadQuery's explicit
3673
+ * `(3, 8, 1e-2)` (≈2.3e-4 relative area on a gear tooth grid).
3674
+ *
3675
+ * Points are world-space and row-major (length `rows * cols`); the workplane's
3676
+ * plane/origin are not consulted — it is only the returned carrier.
3677
+ *
3678
+ * @param wp - Workplane carrier
3679
+ * @param grid - world-space points, row-major (length must equal `rows*cols`)
3680
+ * @param opts - `{ rows; cols; tolerance?; strategy? }`. `tolerance` is the
3681
+ * per-row curve approximation tolerance (default `1e-2`, matching CadQuery's
3682
+ * `spline_approx_tol`); `strategy` defaults to `'row-approx-loft'`
3683
+ * @returns Workplane with the spline face as `.shape`
3684
+ */
3685
+ export async function splineFace(wp, grid, opts) {
3686
+ const { rows, cols } = opts;
3687
+ if (!Number.isInteger(rows) || !Number.isInteger(cols) || rows < 2 || cols < 2) {
3688
+ throw new Error('[cq-compat] splineFace: rows and cols must be integers >= 2');
3689
+ }
3690
+ if (grid.length !== rows * cols) {
3691
+ throw new Error(`[cq-compat] splineFace: grid length ${grid.length} != rows*cols (${rows * cols})`);
3692
+ }
3693
+ const kernel = getKernel();
3694
+ const k = kernel;
3695
+ if (opts.strategy === 'grid') {
3696
+ return clone(wp, { shape: fromHandle(k.bsplineSurface(grid.map(v3), rows, cols)) });
3697
+ }
3698
+ const tol = opts.tolerance ?? 1e-2;
3699
+ const wires = [];
3700
+ for (let r = 0; r < rows; r++) {
3701
+ const row = grid.slice(r * cols, (r + 1) * cols).map(v3);
3702
+ wires.push(k.makeWire([k.approximatePoints(row, tol)]));
3703
+ }
3704
+ const skinned = k.loft(wires, false, false);
3705
+ let face;
3706
+ if (k.isFace(skinned)) {
3707
+ face = skinned;
3708
+ }
3709
+ else {
3710
+ const faces = k.getSubShapes(skinned, 'face');
3711
+ if (faces.length !== 1) {
3712
+ throw new Error(`[cq-compat] splineFace: expected a single face from the row loft, got ${faces.length}`);
3713
+ }
3714
+ face = faces[0];
3715
+ }
3716
+ return clone(wp, { shape: fromHandle(face) });
3717
+ }
3718
+ /**
3719
+ * helix — create a helical wire on the workplane (origin = `wp.origin`, axis =
3720
+ * `wp.normal`). Equivalent to CadQuery `Workplane().makeHelix(pitch, height,
3721
+ * radius, ...)`.
3722
+ *
3723
+ * @param wp - Workplane (origin + normal define the helix axis)
3724
+ * @param pitch - axial advance per full turn (mm)
3725
+ * @param height - total helix height (mm)
3726
+ * @param radius - helix radius (mm)
3727
+ * @param opts - `{ leftHanded?: boolean }` (default right-handed)
3728
+ * @returns Workplane with the helix wire as `.shape`
3729
+ */
3730
+ export async function helix(wp, pitch, height, radius, opts) {
3731
+ const kernel = getKernel();
3732
+ const axis = opts?.leftHanded
3733
+ ? [-wp.normal[0], -wp.normal[1], -wp.normal[2]]
3734
+ : wp.normal;
3735
+ const raw = kernel.makeHelixWire(v3(wp.origin), v3(axis), pitch, height, radius);
3736
+ return clone(wp, { shape: fromHandle(raw) });
3737
+ }
3738
+ /**
3739
+ * splitFace — split the workplane's current shape by a plane and keep one side.
3740
+ * Equivalent to CadQuery `face.split(plane)` / `split(keepTop)`.
3741
+ *
3742
+ * Internally builds a half-space tool (`occt-wasm` `halfSpace`) from the plane
3743
+ * and runs `BOPAlgo_Splitter` (`split`); the kept fragment is selected by the
3744
+ * signed distance of its bounding-box centre to the plane.
3745
+ *
3746
+ * @param wp - Workplane whose `.shape` is the face/solid to split
3747
+ * @param plane - splitting plane as `{ origin: Vec3; normal: Vec3 }`
3748
+ * @param keep - `'top'` (normal side, default) | `'bottom'` (opposite side)
3749
+ * @returns Workplane with the kept fragment as `.shape`
3750
+ */
3751
+ export async function splitFace(wp, plane, keep = 'top') {
3752
+ if (!wp.shape)
3753
+ throw new Error('[cq-compat] splitFace: wp.shape is required');
3754
+ const handle = brepOf(wp.shape);
3755
+ if (!handle)
3756
+ throw new Error('[cq-compat] splitFace: BREP unavailable');
3757
+ const kernel = getKernel();
3758
+ const n = plane.normal;
3759
+ const nLen = Math.hypot(n[0], n[1], n[2]) || 1;
3760
+ const un = [n[0] / nLen, n[1] / nLen, n[2] / nLen];
3761
+ const tool = kernel.halfSpace(v3(plane.origin), v3(un));
3762
+ const compound = kernel.split(handle, [tool]);
3763
+ const subType = hasSolidBase(wp.shape) ? 'solid' : 'face';
3764
+ let frags = kernel.getSubShapes(compound, subType);
3765
+ if (!frags || frags.length === 0) {
3766
+ frags = kernel.getSubShapes(compound, 'face');
3767
+ }
3768
+ const signedDist = (f) => {
3769
+ const bb = kernel.getBoundingBox(f);
3770
+ const cx = (bb.xmin + bb.xmax) / 2;
3771
+ const cy = (bb.ymin + bb.ymax) / 2;
3772
+ const cz = (bb.zmin + bb.zmax) / 2;
3773
+ return (cx - plane.origin[0]) * un[0] + (cy - plane.origin[1]) * un[1] + (cz - plane.origin[2]) * un[2];
3774
+ };
3775
+ const chosen = frags.filter((f) => (keep === 'top' ? signedDist(f) >= 0 : signedDist(f) < 0));
3776
+ if (chosen.length === 0) {
3777
+ throw new Error('[cq-compat] splitFace: no fragment on the kept side');
3778
+ }
3779
+ // Robust for a planar split: keep the single fragment, or the one whose
3780
+ // centre is furthest from the plane when several match.
3781
+ const result = chosen.reduce((a, b) => (Math.abs(signedDist(b)) > Math.abs(signedDist(a)) ? b : a));
3782
+ // NOTE: we intentionally do NOT release `compound` / unchosen fragments here —
3783
+ // the kept `result` is adopted by fromHandle; freeing the arena slots would
3784
+ // invalidate it. The leak is bounded per call (one split).
3785
+ return clone(wp, { shape: fromHandle(result) });
3786
+ }
3787
+ /**
3788
+ * twistExtrude — extrude a profile while twisting it about the extrusion axis
3789
+ * by `angle` (deg) over `height` (mm). Equivalent to CadQuery
3790
+ * `Workplane().twistExtrude(profile, angle, height, ...)`.
3791
+ *
3792
+ * Implemented by sweeping `steps`+1 rotated+translated copies of the profile
3793
+ * through `loft` (a smooth, ruled=False loft). The twist axis is `wp.normal`.
3794
+ *
3795
+ * @param wp - Workplane carrying the profile: either `.shape` (face/wire) or a
3796
+ * pending 2D profile (`rect`/`circle`/`pendingWires`), as upstream accepts
3797
+ * @param angle - total twist angle over height (deg)
3798
+ * @param height - extrusion height (mm)
3799
+ * @param opts - `{ steps?: number }` (section count; default scales with |angle|)
3800
+ * @returns Workplane with the twisted solid as `.shape`
3801
+ */
3802
+ export async function twistExtrude(wp, angle, height, opts) {
3803
+ // Accept either an explicit profile shape or a pending 2D profile
3804
+ // (rect/circle/polygon/pendingWires) — mirrors upstream
3805
+ // `Workplane().rect(...).twistExtrude(...)`, which reads the pending profile.
3806
+ let src = wp;
3807
+ if (!wp.shape) {
3808
+ const hasPendingProfile = (wp.pendingWires ?? []).some((w) => !w.construction);
3809
+ if (!hasPendingProfile) {
3810
+ throw new Error('[cq-compat] twistExtrude: profile required (set wp.shape or add a pending rect/circle/wire)');
3811
+ }
3812
+ src = await face(wp);
3813
+ }
3814
+ const profile = src.shape;
3815
+ if (!profile)
3816
+ throw new Error('[cq-compat] twistExtrude: profile required');
3817
+ const raw = brepOf(profile);
3818
+ if (raw === undefined)
3819
+ throw new Error('[cq-compat] twistExtrude: BREP unavailable');
3820
+ const handle = raw;
3821
+ const steps = opts?.steps ?? Math.max(8, Math.ceil(Math.abs(angle) / 15));
3822
+ const axis = src.normal;
3823
+ const kernel = getKernel();
3824
+ const k = kernel;
3825
+ // Native rotate takes radians; rotate about the extrusion axis through the
3826
+ // workplane origin so the profile twists in place (no translation drift).
3827
+ const base = k.copy(handle);
3828
+ const DEG2RAD = Math.PI / 180;
3829
+ const sections = [];
3830
+ for (let i = 0; i <= steps; i++) {
3831
+ const t = i / steps;
3832
+ const rot = k.rotate(base, { point: v3(src.origin), direction: v3(axis) }, angle * t * DEG2RAD);
3833
+ const tr = k.translate(rot, axis[0] * height * t, axis[1] * height * t, axis[2] * height * t);
3834
+ sections.push(clone(src, { shape: fromHandle(tr), pendingWires: [] }));
3835
+ }
3836
+ return loft(sections[0], ...sections.slice(1), { ruled: false });
3837
+ }
3838
+ /**
3839
+ * solidFromFaces — sew a closed set of faces into a solid on the workplane.
3840
+ * Equivalent to CadQuery `cq.Shell.makeShell(faces)` + `Solid.makeSolid(...)`
3841
+ * (BRepBuilderAPI_Sewing + BRepBuilderAPI_MakeSolid + orientation fix).
3842
+ *
3843
+ * This is cq-compat extension E5 (fai_cq_gears port plan §13-6): the existing
3844
+ * `shell` op is hollowing (thickening a solid), not sewing face patches into
3845
+ * a solid, and gears need the latter after their tooth-face/cap faces are built.
3846
+ *
3847
+ * @param wp - Workplane providing the result's coordinate frame (origin/normal)
3848
+ * @param faces - Workplanes whose `.shape` are the faces to sew (each must be a face)
3849
+ * @param opts - `{ sewingTolerance?: number (default 1e-2, cq shell_sewing_tol);
3850
+ * fixOrientations?: boolean (default true) }`. `sew` does not guarantee
3851
+ * consistent face orientation — a loft-skinned tooth face can come out
3852
+ * inward-facing, making the sewn solid carry negative volume — so
3853
+ * `fixFaceOrientations` runs by default. If the fixed shape degrades back to
3854
+ * a shell (observed on micro-gap shells that only close via the sewing
3855
+ * tolerance), the pre-fix `makeSolid` result is kept instead.
3856
+ * @returns Workplane with the sewn solid as `.shape`
3857
+ */
3858
+ export async function solidFromFaces(wp, faces, opts) {
3859
+ if (faces.length === 0)
3860
+ throw new Error('[cq-compat] solidFromFaces: faces must be non-empty');
3861
+ const handles = faces.map((f, i) => {
3862
+ if (!f.shape)
3863
+ throw new Error(`[cq-compat] solidFromFaces: faces[${i}].shape is required`);
3864
+ const h = brepOf(f.shape);
3865
+ if (h === undefined)
3866
+ throw new Error(`[cq-compat] solidFromFaces: faces[${i}] BREP unavailable`);
3867
+ return h;
3868
+ });
3869
+ const kernel = getKernel();
3870
+ const k = kernel;
3871
+ const tol = opts?.sewingTolerance ?? 1e-2;
3872
+ const shell = k.sew(handles, tol);
3873
+ if (!k.isShell(shell) && !k.isSolid(shell)) {
3874
+ throw new Error(`[cq-compat] solidFromFaces: sew did not produce a shell (got ${k.getShapeType(shell)})`);
3875
+ }
3876
+ const solid = k.makeSolid(shell);
3877
+ let result = solid;
3878
+ if (opts?.fixOrientations !== false && k.isSolid(solid)) {
3879
+ const fixed = k.fixFaceOrientations(solid);
3880
+ if (k.isSolid(fixed))
3881
+ result = fixed;
3882
+ }
3883
+ if (!k.isSolid(result)) {
3884
+ throw new Error(`[cq-compat] solidFromFaces: result is not a solid (got ${k.getShapeType(result)})`);
3885
+ }
3886
+ return clone(wp, { shape: fromHandle(result), pendingWires: [] });
3887
+ }
3888
+ /** Endpoints of a curve edge (parameter-space — B-spline edges carry no
3889
+ * explicit vertices, so `curveParameters` + `curvePointAtParam` is the only
3890
+ * reliable endpoint path; the vertex fallback covers degenerate edges). */
3891
+ function edgeEndsRaw(k, edge) {
3892
+ try {
3893
+ const { first, last } = k.curveParameters(edge);
3894
+ return { edge, a: k.curvePointAtParam(edge, first), b: k.curvePointAtParam(edge, last) };
3895
+ }
3896
+ catch {
3897
+ const vs = k.getSubShapes(edge, 'vertex');
3898
+ if (vs.length < 2) {
3899
+ const p = k.vertexPosition(vs[0]);
3900
+ return { edge, a: p, b: p };
3901
+ }
3902
+ return { edge, a: k.vertexPosition(vs[0]), b: k.vertexPosition(vs[1]) };
3903
+ }
3904
+ }
3905
+ /**
3906
+ * planarCap — build a planar cap face from the boundary edges of the given
3907
+ * faces that lie on the plane `origin · normal = d`, then set it as the
3908
+ * workplane shape. Equivalent to CadQuery gears' `planarCapAtZ` /
3909
+ * `Face.makeFromWires(Wire.combine(boundaryEdges, tol))`.
3910
+ *
3911
+ * This is cq-compat extension E6 (fai_cq_gears port plan §13-6): the existing
3912
+ * `wire`/`face` ops only consume pending drawing descriptors, not edges that
3913
+ * already exist inside kernel shapes — gears need to close their tooth-face
3914
+ * patches with end caps built from those edges.
3915
+ *
3916
+ * Edge chaining ports the proven TS re-implementation of OCCT's
3917
+ * `ShapeAnalysis_FreeBounds::ConnectEdgesToWires`: unordered edges are chained
3918
+ * by endpoint proximity within `tol` (kernel `makeWire` silently drops edges
3919
+ * when gaps exceed OCCT precision, so in-tolerance gaps are bridged with a
3920
+ * line segment — same as upstream).
3921
+ *
3922
+ * @param wp - Workplane providing the result's coordinate frame
3923
+ * @param faces - Workplanes whose `.shape` are the faces supplying boundary edges
3924
+ * @param plane - cap plane: `{ origin, normal }`; the plane offset is taken
3925
+ * from `origin` (edges whose bounding box lies within `pickTolerance` of the
3926
+ * plane are collected)
3927
+ * @param opts - `{ combineTolerance?: number (default 1e-2, cq wire_comb_tol);
3928
+ * pickTolerance?: number (default 1e-6) }`
3929
+ * @returns Workplane with the cap face as `.shape`
3930
+ */
3931
+ export async function planarCap(wp, faces, plane, opts) {
3932
+ if (faces.length === 0)
3933
+ throw new Error('[cq-compat] planarCap: faces must be non-empty');
3934
+ const kernel = getKernel();
3935
+ const k = kernel;
3936
+ const n = plane.normal;
3937
+ const nLen = Math.hypot(n[0], n[1], n[2]) || 1;
3938
+ const un = [n[0] / nLen, n[1] / nLen, n[2] / nLen];
3939
+ const d = plane.origin[0] * un[0] + plane.origin[1] * un[1] + plane.origin[2] * un[2];
3940
+ const pickTol = opts?.pickTolerance ?? 1e-6;
3941
+ const tol = opts?.combineTolerance ?? 1e-2;
3942
+ // 1) Collect boundary edges lying on the plane (signed distance of the
3943
+ // edge bbox centre within pickTol). Faces share their common edges, so
3944
+ // deduplicate by the numeric kernel handle.
3945
+ const seen = new Set();
3946
+ const onPlane = [];
3947
+ for (const f of faces) {
3948
+ if (!f.shape)
3949
+ throw new Error('[cq-compat] planarCap: faces[i].shape is required');
3950
+ const fh = brepOf(f.shape);
3951
+ if (fh === undefined)
3952
+ throw new Error('[cq-compat] planarCap: faces[i] BREP unavailable');
3953
+ for (const e of k.getSubShapes(fh, 'edge')) {
3954
+ const id = e;
3955
+ if (seen.has(id))
3956
+ continue;
3957
+ const bb = k.getBoundingBox(e);
3958
+ const cx = (bb.xmin + bb.xmax) / 2;
3959
+ const cy = (bb.ymin + bb.ymax) / 2;
3960
+ const cz = (bb.zmin + bb.zmax) / 2;
3961
+ // Max plane distance over the whole bbox = |centre·n − d| + projection
3962
+ // of the half-extents onto the normal. Requiring this ≤ pickTol keeps
3963
+ // only edges that lie entirely flat on the plane (a merely-centred or
3964
+ // crossing vertical edge is rejected).
3965
+ const hx = (bb.xmax - bb.xmin) / 2;
3966
+ const hy = (bb.ymax - bb.ymin) / 2;
3967
+ const hz = (bb.zmax - bb.zmin) / 2;
3968
+ const dist = Math.abs(cx * un[0] + cy * un[1] + cz * un[2] - d)
3969
+ + hx * Math.abs(un[0]) + hy * Math.abs(un[1]) + hz * Math.abs(un[2]);
3970
+ if (dist <= pickTol) {
3971
+ seen.add(id);
3972
+ onPlane.push(e);
3973
+ }
3974
+ }
3975
+ }
3976
+ if (onPlane.length === 0)
3977
+ throw new Error('[cq-compat] planarCap: no boundary edges found on plane');
3978
+ // 2) Chain unordered edges into closed wires (ConnectEdgesToWires port).
3979
+ const pool = onPlane.map((e) => edgeEndsRaw(k, e));
3980
+ const dist3 = (a, b) => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
3981
+ const used = new Array(pool.length).fill(false);
3982
+ const wires = [];
3983
+ for (let start = 0; start < pool.length; start++) {
3984
+ if (used[start])
3985
+ continue;
3986
+ used[start] = true;
3987
+ const chain = [pool[start].edge];
3988
+ let tail = pool[start].b;
3989
+ const head = pool[start].a;
3990
+ for (;;) {
3991
+ let found = -1;
3992
+ let best = Infinity;
3993
+ for (let j = 0; j < pool.length; j++) {
3994
+ if (used[j])
3995
+ continue;
3996
+ const dj = Math.min(dist3(pool[j].a, tail), dist3(pool[j].b, tail));
3997
+ if (dj <= tol && dj < best) {
3998
+ best = dj;
3999
+ found = j;
4000
+ }
4001
+ }
4002
+ if (found < 0)
4003
+ break;
4004
+ used[found] = true;
4005
+ const e = pool[found];
4006
+ const flip = dist3(e.a, tail) <= dist3(e.b, tail);
4007
+ if (best > 1e-7)
4008
+ chain.push(k.makeLineEdge(tail, flip ? e.a : e.b));
4009
+ chain.push(flip ? e.edge : k.reverseShape(e.edge));
4010
+ tail = flip ? e.b : e.a;
4011
+ if (dist3(tail, head) <= tol)
4012
+ break;
4013
+ }
4014
+ const endGap = dist3(tail, head);
4015
+ if (chain.length > 1 && endGap > 1e-7 && endGap <= tol)
4016
+ chain.push(k.makeLineEdge(tail, head));
4017
+ wires.push(k.makeWire(chain));
4018
+ }
4019
+ if (wires.length !== 1) {
4020
+ throw new Error(`[cq-compat] planarCap: expected one closed loop on plane, got ${wires.length} wires from ${onPlane.length} edges`);
4021
+ }
4022
+ // 3) Heal + make the cap face.
4023
+ const face = k.makeFace(k.healWire(wires[0], tol));
4024
+ return clone(wp, { shape: fromHandle(face), pendingWires: [] });
4025
+ }
4026
+ //# sourceMappingURL=workplane.js.map