dsh-cad 0.2.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +235 -190
- package/README.zh-CN.md +212 -173
- package/lib/client.js +174 -174
- package/lib/client.js.map +3 -3
- package/lib/index.js +6 -2
- package/lib/modeling/ansatz-bridge.js +108 -0
- package/lib/modeling/bin-store.js +15 -1
- package/lib/modeling/constraints.js +85 -0
- package/lib/modeling/document.js +22 -5
- package/lib/modeling/modeling-worker.cjs +350 -235
- package/lib/modeling/occt-adapter.cjs +617 -271
- package/lib/modeling/occt-bridge.cjs +250 -0
- package/lib/modeling/registry.js +219 -0
- package/lib/routes.js +70 -0
- package/lib/tools/cad-constraint.js +335 -0
- package/lib/tools/cad-model.js +595 -58
- package/lib/types/modeling/ansatz-bridge.d.ts +24 -0
- package/lib/types/modeling/bin-store.d.ts +2 -0
- package/lib/types/modeling/client.d.ts +50 -1
- package/lib/types/modeling/constraints.d.ts +63 -0
- package/lib/types/modeling/document.d.ts +11 -1
- package/lib/types/modeling/registry.d.ts +61 -0
- package/lib/types/routes.d.ts +11 -0
- package/lib/types/tools/cad-constraint.d.ts +25 -0
- package/lib/types/tools/cad-model.d.ts +5 -2
- package/package.json +4 -3
- package/cordis.patch.yml +0 -3
- package/lib/modeling/hlr.cjs +0 -328
|
@@ -1,271 +1,617 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OCCT modeling adapter (plain CJS JavaScript — runs inside the modeling
|
|
3
|
-
* worker). Encapsulates the opencascade.js binding surface verified in M0:
|
|
4
|
-
* overloaded constructors use the `_N` suffix convention and this file is the
|
|
5
|
-
* single place that knows those spellings.
|
|
6
|
-
*
|
|
7
|
-
* Document model: bodyId → TopoDS_Shape, in-memory in the worker. The main
|
|
8
|
-
* thread persists an operation log and replays it for restart recovery.
|
|
9
|
-
*/
|
|
10
|
-
'use strict'
|
|
11
|
-
|
|
12
|
-
function createAdapter(occt) {
|
|
13
|
-
// ── verified constructor spellings (see test/m0-kernel-check.cjs) ──────────
|
|
14
|
-
const pnt = (x, y, z) => new occt.gp_Pnt_3(x, y, z)
|
|
15
|
-
const dir = (x, y, z) => new occt.gp_Dir_4(x, y, z)
|
|
16
|
-
const vec = (x, y, z) => new occt.gp_Vec_4(x, y, z)
|
|
17
|
-
const identityTrsf = () => new occt.gp_Trsf_1()
|
|
18
|
-
const ENUM = occt.TopAbs_ShapeEnum
|
|
19
|
-
|
|
20
|
-
const shapeOf = (make) => make.Shape()
|
|
21
|
-
const volume = (shape) => {
|
|
22
|
-
const props = new occt.GProp_GProps_2(pnt(0, 0, 0))
|
|
23
|
-
occt.BRepGProp.VolumeProperties_1(shape, props, false, true, false)
|
|
24
|
-
return props.Mass()
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// ── primitives ─────────────────────────────────────────────────────────────
|
|
28
|
-
function makePrim(kind, params) {
|
|
29
|
-
const at = params.at ?? [0, 0, 0]
|
|
30
|
-
const p = params
|
|
31
|
-
switch (kind) {
|
|
32
|
-
case 'box': {
|
|
33
|
-
const dx = p.dx ?? 10, dy = p.dy ?? 10, dz = p.dz ?? 10
|
|
34
|
-
return shapeOf(new occt.BRepPrimAPI_MakeBox_3(pnt(at[0], at[1], at[2]), pnt(at[0] + dx, at[1] + dy, at[2] + dz)))
|
|
35
|
-
}
|
|
36
|
-
case 'cylinder': {
|
|
37
|
-
const axis = p.axis ?? [0, 0, 1]
|
|
38
|
-
const ax2 = new occt.gp_Ax2_3(pnt(at[0], at[1], at[2]), dir(axis[0], axis[1], axis[2]))
|
|
39
|
-
return shapeOf(new occt.BRepPrimAPI_MakeCylinder_3(ax2, p.radius ?? 5, p.height ?? 10))
|
|
40
|
-
}
|
|
41
|
-
case 'sphere': {
|
|
42
|
-
// Verified variants: only the pure-double ctors work in this build;
|
|
43
|
-
// placement (at/axis) is applied by an exact transform below.
|
|
44
|
-
return place(new occt.BRepPrimAPI_MakeSphere_1(p.radius ?? 5).Shape(), at, p.axis)
|
|
45
|
-
}
|
|
46
|
-
case 'cone': {
|
|
47
|
-
return place(new occt.BRepPrimAPI_MakeCone_1(p.radius1 ?? 5, p.radius2 ?? 0, p.height ?? 10).Shape(), at, p.axis)
|
|
48
|
-
}
|
|
49
|
-
case 'torus': {
|
|
50
|
-
return place(new occt.BRepPrimAPI_MakeTorus_1(p.majorRadius ?? 10, p.minorRadius ?? 2).Shape(), at, p.axis)
|
|
51
|
-
}
|
|
52
|
-
default:
|
|
53
|
-
throw new Error(`unknown primitive kind: ${kind}`)
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Place an origin-built primitive: rotate +Z onto `axis` (exact axis-angle
|
|
59
|
-
* rotation about the origin), then translate to `at`. Both default to
|
|
60
|
-
* identity when omitted/default.
|
|
61
|
-
*/
|
|
62
|
-
function place(shape, at, axis) {
|
|
63
|
-
const ax = axis ?? [0, 0, 1]
|
|
64
|
-
const len = Math.hypot(ax[0], ax[1], ax[2])
|
|
65
|
-
const u = len === 0 ? [0, 0, 1] : [ax[0] / len, ax[1] / len, ax[2] / len]
|
|
66
|
-
// Identity only when the axis IS +Z (e.g. [0,0,-1] must rotate by π).
|
|
67
|
-
const isDefaultAxis = Math.abs(u[0]) + Math.abs(u[1]) < 1e-12 && u[2] > 1 - 1e-9
|
|
68
|
-
let result = shape
|
|
69
|
-
if (!isDefaultAxis) {
|
|
70
|
-
const trsf = identityTrsf()
|
|
71
|
-
trsf.SetRotation_1(new occt.gp_Ax1_2(pnt(0, 0, 0), dir(u[0], u[1], u[2])), Math.acos(Math.min(1, Math.max(-1, u[2]))))
|
|
72
|
-
result = new occt.BRepBuilderAPI_Transform_2(result, trsf, true).Shape()
|
|
73
|
-
}
|
|
74
|
-
if (at !== undefined && (at[0] !== 0 || at[1] !== 0 || at[2] !== 0)) {
|
|
75
|
-
const move = identityTrsf()
|
|
76
|
-
move.SetTranslation_1(vec(at[0], at[1], at[2]))
|
|
77
|
-
result = new occt.BRepBuilderAPI_Transform_2(result, move, true).Shape()
|
|
78
|
-
}
|
|
79
|
-
return result
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// ── profile extrusion ─────────────────────────────────────────────────────
|
|
83
|
-
/** points: flat [x0,y0, x1,y1, ...] closed loop in the XY plane at z = base. */
|
|
84
|
-
function makeExtrudedProfile(points, height, base = 0) {
|
|
85
|
-
if (points.length < 6 || points.length % 2 !== 0) {
|
|
86
|
-
throw new Error('profile needs at least 3 points (6 flat numbers)')
|
|
87
|
-
}
|
|
88
|
-
// Verified path (the 1.1.1 build exposes no direct wire→face ctor):
|
|
89
|
-
// polygon wire → bound an infinite planar face with Add(wire) → extrude.
|
|
90
|
-
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
91
|
-
for (let i = 0; i + 1 < points.length; i += 2) {
|
|
92
|
-
poly.Add_1(pnt(points[i], points[i + 1], base))
|
|
93
|
-
}
|
|
94
|
-
poly.Close()
|
|
95
|
-
if (!poly.IsDone()) throw new Error('profile polygon is invalid (duplicate or collinear-only points)')
|
|
96
|
-
const planeAx3 = new occt.gp_Ax3_3(pnt(0, 0, base), dir(0, 0, 1), dir(1, 0, 0))
|
|
97
|
-
const faceBuilder = new occt.BRepBuilderAPI_MakeFace_3(new occt.gp_Pln_2(planeAx3))
|
|
98
|
-
faceBuilder.Add(poly.Wire())
|
|
99
|
-
const face = faceBuilder.Face()
|
|
100
|
-
if (!faceBuilder.IsDone() || face.IsNull()) throw new Error('profile face construction failed')
|
|
101
|
-
return shapeOf(new occt.BRepPrimAPI_MakePrism_1(face, vec(0, 0, height), true, false))
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// ── booleans / fillet / transform ──────────────────────────────────────────
|
|
105
|
-
function boolean(op, target, tools) {
|
|
106
|
-
let result = target
|
|
107
|
-
for (const tool of tools) {
|
|
108
|
-
// BRepAlgoAPI_*_3 is the verified (shape, shape) constructor.
|
|
109
|
-
const Ctor = op === 'fuse' ? occt.BRepAlgoAPI_Fuse_3 : op === 'cut' ? occt.BRepAlgoAPI_Cut_3 : occt.BRepAlgoAPI_Common_3
|
|
110
|
-
const algo = new Ctor(result, tool)
|
|
111
|
-
algo.Build()
|
|
112
|
-
if (!algo.IsDone()) throw new Error(`boolean ${op} failed`)
|
|
113
|
-
result = algo.Shape()
|
|
114
|
-
}
|
|
115
|
-
return result
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function filletAll(shape, radius) {
|
|
119
|
-
const algo = new occt.BRepFilletAPI_MakeFillet(shape, 1e-4)
|
|
120
|
-
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_EDGE, ENUM.TopAbs_SHAPE)
|
|
121
|
-
let edges = 0
|
|
122
|
-
while (explorer.More()) {
|
|
123
|
-
algo.Add_2(radius, castEdge(explorer.Current()))
|
|
124
|
-
edges++
|
|
125
|
-
explorer.Next()
|
|
126
|
-
}
|
|
127
|
-
if (edges === 0) throw new Error('no edges to fillet')
|
|
128
|
-
algo.Build()
|
|
129
|
-
if (!algo.IsDone()) throw new Error('fillet failed (radius may exceed the adjacent faces)')
|
|
130
|
-
return { shape: algo.Shape(), edges }
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function castEdge(shape) {
|
|
134
|
-
if (occt.TopoDS.Edge_s) return occt.TopoDS.Edge_s(shape)
|
|
135
|
-
return occt.TopoDS.Edge_2(shape)
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function castFace(shape) {
|
|
139
|
-
if (occt.TopoDS.Face_s) return occt.TopoDS.Face_s(shape)
|
|
140
|
-
return occt.TopoDS.Face_2(shape)
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
function transform(shape, { translate, rotate, mirror }) {
|
|
144
|
-
const trsf = identityTrsf()
|
|
145
|
-
if (translate !== undefined) trsf.SetTranslation_1(vec(translate[0], translate[1], translate[2]))
|
|
146
|
-
if (rotate !== undefined) {
|
|
147
|
-
const [rx, ry, rz] = rotate
|
|
148
|
-
if (rx) trsf.Multiply(rotationTrsf([1, 0, 0], rx * Math.PI / 180))
|
|
149
|
-
if (ry) trsf.Multiply(rotationTrsf([0, 1, 0], ry * Math.PI / 180))
|
|
150
|
-
if (rz) trsf.Multiply(rotationTrsf([0, 0, 1], rz * Math.PI / 180))
|
|
151
|
-
}
|
|
152
|
-
if (mirror !== undefined) {
|
|
153
|
-
const [mx, my, mz] = mirror
|
|
154
|
-
const mirrorTrsf = identityTrsf()
|
|
155
|
-
mirrorTrsf.SetMirror_1(new occt.gp_Ax2_3(pnt(0, 0, 0), dir(mx ?? 0, my ?? 0, mz ?? 1)))
|
|
156
|
-
trsf.Multiply(mirrorTrsf)
|
|
157
|
-
}
|
|
158
|
-
const algo = new occt.BRepBuilderAPI_Transform_2(shape, trsf, true)
|
|
159
|
-
return algo.Shape()
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function rotationTrsf(axis, radians) {
|
|
163
|
-
const t = identityTrsf()
|
|
164
|
-
t.SetRotation_1(new occt.gp_Ax1_2(pnt(0, 0, 0), dir(axis[0], axis[1], axis[2])), radians)
|
|
165
|
-
return t
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// ── tessellation ───────────────────────────────────────────────────────────
|
|
169
|
-
function tessellate(shape, linearDeflection = 0.3) {
|
|
170
|
-
const mesher = new occt.BRepMesh_IncrementalMesh_2(shape, linearDeflection, false, 0.5, true)
|
|
171
|
-
mesher.Perform_1()
|
|
172
|
-
const positions = []
|
|
173
|
-
const indices = []
|
|
174
|
-
const loc = new occt.TopLoc_Location_2(identityTrsf())
|
|
175
|
-
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_FACE, ENUM.TopAbs_SHAPE)
|
|
176
|
-
let vertexBase = 0
|
|
177
|
-
while (explorer.More()) {
|
|
178
|
-
const faceShape = explorer.Current()
|
|
179
|
-
const handle = occt.BRep_Tool.Triangulation(castFace(faceShape), loc)
|
|
180
|
-
const tri = handle && handle.IsNull && !handle.IsNull() ? handle.get() : handle
|
|
181
|
-
if (tri) {
|
|
182
|
-
// A REVERSED face renders its surface natural orientation backwards —
|
|
183
|
-
// flip the triangle winding so normals point out of the material.
|
|
184
|
-
const reversed = faceShape.Orientation_1 !== undefined && faceShape.Orientation_1().value === 1
|
|
185
|
-
const nodeCount = tri.NbNodes()
|
|
186
|
-
for (let i = 1; i <= nodeCount; i++) {
|
|
187
|
-
const node = tri.Node(i)
|
|
188
|
-
// Optional location transform on the triangulation.
|
|
189
|
-
const transformed = loc.IsIdentity() ? node : node.Transformed(loc.Transformation())
|
|
190
|
-
positions.push(transformed.X(), transformed.Y(), transformed.Z())
|
|
191
|
-
}
|
|
192
|
-
for (let i = 1; i <= tri.NbTriangles(); i++) {
|
|
193
|
-
const t = tri.Triangle(i)
|
|
194
|
-
// Poly_Triangle.Get() uses out-params embind cannot express; Value(1..3) returns the indices.
|
|
195
|
-
const n1 = vertexBase + t.Value(1) - 1
|
|
196
|
-
const n2 = vertexBase + t.Value(2) - 1
|
|
197
|
-
const n3 = vertexBase + t.Value(3) - 1
|
|
198
|
-
if (reversed) indices.push(n1, n3, n2)
|
|
199
|
-
else indices.push(n1, n2, n3)
|
|
200
|
-
}
|
|
201
|
-
vertexBase += nodeCount
|
|
202
|
-
}
|
|
203
|
-
explorer.Next()
|
|
204
|
-
}
|
|
205
|
-
return { positions: Float32Array.from(positions), indices: Uint32Array.from(indices) }
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
/** Flat per-triangle normals (mechanical-CAD look, no smooth-vertex table). */
|
|
209
|
-
function faceNormals(positions, indices) {
|
|
210
|
-
const normals = new Float32Array(positions.length)
|
|
211
|
-
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
212
|
-
const a = indices[i] * 3, b = indices[i + 1] * 3, c = indices[i + 2] * 3
|
|
213
|
-
const ux = positions[c] - positions[a], uy = positions[c + 1] - positions[a + 1], uz = positions[c + 2] - positions[a + 2]
|
|
214
|
-
const vx = positions[b] - positions[a], vy = positions[b + 1] - positions[a + 1], vz = positions[b + 2] - positions[a + 2]
|
|
215
|
-
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
216
|
-
const len = Math.hypot(nx, ny, nz) || 1
|
|
217
|
-
nx /= len; ny /= len; nz /= len
|
|
218
|
-
for (const corner of [a, b, c]) {
|
|
219
|
-
normals[corner] = nx; normals[corner + 1] = ny; normals[corner + 2] = nz
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
return normals
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── export: STEP via MEMFS, STL as direct binary bytes ───────────────────
|
|
226
|
-
function exportFile(shape, format) {
|
|
227
|
-
if (format === 'step') {
|
|
228
|
-
const writer = new occt.STEPControl_Writer_1()
|
|
229
|
-
writer.Transfer(shape, 0, true)
|
|
230
|
-
writer.Write('model.step')
|
|
231
|
-
return Buffer.from(occt.FS.readFile('model.step'))
|
|
232
|
-
}
|
|
233
|
-
if (format === 'stl') {
|
|
234
|
-
// StlAPI_Writer intermittently fails inside the WASM filesystem; the
|
|
235
|
-
// tessellated mesh is exact, so emit binary STL bytes directly.
|
|
236
|
-
const { positions, indices } = tessellate(shape, 0.1)
|
|
237
|
-
const normals = faceNormals(positions, indices)
|
|
238
|
-
const triangleCount = indices.length / 3
|
|
239
|
-
const buffer = Buffer.alloc(84 + triangleCount * 50)
|
|
240
|
-
buffer.write('dsh-cad binary STL', 0, 22, 'latin1')
|
|
241
|
-
buffer.writeUInt32LE(triangleCount, 80)
|
|
242
|
-
let offset = 84
|
|
243
|
-
for (let triangle = 0; triangle < triangleCount; triangle++) {
|
|
244
|
-
const a = indices[triangle * 3] * 3
|
|
245
|
-
const b = indices[triangle * 3 + 1] * 3
|
|
246
|
-
const c = indices[triangle * 3 + 2] * 3
|
|
247
|
-
for (let component = 0; component < 3; component++) {
|
|
248
|
-
buffer.writeFloatLE(normals[a + component], offset)
|
|
249
|
-
offset += 4
|
|
250
|
-
}
|
|
251
|
-
for (const vertex of [a, b, c]) {
|
|
252
|
-
buffer.writeFloatLE(positions[vertex], offset)
|
|
253
|
-
buffer.writeFloatLE(positions[vertex + 1], offset + 4)
|
|
254
|
-
buffer.writeFloatLE(positions[vertex + 2], offset + 8)
|
|
255
|
-
offset += 12
|
|
256
|
-
}
|
|
257
|
-
offset += 2 // attribute byte count
|
|
258
|
-
}
|
|
259
|
-
return buffer
|
|
260
|
-
}
|
|
261
|
-
throw new Error(`unsupported export format: ${format}`)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
1
|
+
/**
|
|
2
|
+
* OCCT modeling adapter (plain CJS JavaScript — runs inside the modeling
|
|
3
|
+
* worker). Encapsulates the opencascade.js binding surface verified in M0:
|
|
4
|
+
* overloaded constructors use the `_N` suffix convention and this file is the
|
|
5
|
+
* single place that knows those spellings.
|
|
6
|
+
*
|
|
7
|
+
* Document model: bodyId → TopoDS_Shape, in-memory in the worker. The main
|
|
8
|
+
* thread persists an operation log and replays it for restart recovery.
|
|
9
|
+
*/
|
|
10
|
+
'use strict'
|
|
11
|
+
|
|
12
|
+
function createAdapter(occt) {
|
|
13
|
+
// ── verified constructor spellings (see test/m0-kernel-check.cjs) ──────────
|
|
14
|
+
const pnt = (x, y, z) => new occt.gp_Pnt_3(x, y, z)
|
|
15
|
+
const dir = (x, y, z) => new occt.gp_Dir_4(x, y, z)
|
|
16
|
+
const vec = (x, y, z) => new occt.gp_Vec_4(x, y, z)
|
|
17
|
+
const identityTrsf = () => new occt.gp_Trsf_1()
|
|
18
|
+
const ENUM = occt.TopAbs_ShapeEnum
|
|
19
|
+
|
|
20
|
+
const shapeOf = (make) => make.Shape()
|
|
21
|
+
const volume = (shape) => {
|
|
22
|
+
const props = new occt.GProp_GProps_2(pnt(0, 0, 0))
|
|
23
|
+
occt.BRepGProp.VolumeProperties_1(shape, props, false, true, false)
|
|
24
|
+
return props.Mass()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── primitives ─────────────────────────────────────────────────────────────
|
|
28
|
+
function makePrim(kind, params) {
|
|
29
|
+
const at = params.at ?? [0, 0, 0]
|
|
30
|
+
const p = params
|
|
31
|
+
switch (kind) {
|
|
32
|
+
case 'box': {
|
|
33
|
+
const dx = p.dx ?? 10, dy = p.dy ?? 10, dz = p.dz ?? 10
|
|
34
|
+
return shapeOf(new occt.BRepPrimAPI_MakeBox_3(pnt(at[0], at[1], at[2]), pnt(at[0] + dx, at[1] + dy, at[2] + dz)))
|
|
35
|
+
}
|
|
36
|
+
case 'cylinder': {
|
|
37
|
+
const axis = p.axis ?? [0, 0, 1]
|
|
38
|
+
const ax2 = new occt.gp_Ax2_3(pnt(at[0], at[1], at[2]), dir(axis[0], axis[1], axis[2]))
|
|
39
|
+
return shapeOf(new occt.BRepPrimAPI_MakeCylinder_3(ax2, p.radius ?? 5, p.height ?? 10))
|
|
40
|
+
}
|
|
41
|
+
case 'sphere': {
|
|
42
|
+
// Verified variants: only the pure-double ctors work in this build;
|
|
43
|
+
// placement (at/axis) is applied by an exact transform below.
|
|
44
|
+
return place(new occt.BRepPrimAPI_MakeSphere_1(p.radius ?? 5).Shape(), at, p.axis)
|
|
45
|
+
}
|
|
46
|
+
case 'cone': {
|
|
47
|
+
return place(new occt.BRepPrimAPI_MakeCone_1(p.radius1 ?? 5, p.radius2 ?? 0, p.height ?? 10).Shape(), at, p.axis)
|
|
48
|
+
}
|
|
49
|
+
case 'torus': {
|
|
50
|
+
return place(new occt.BRepPrimAPI_MakeTorus_1(p.majorRadius ?? 10, p.minorRadius ?? 2).Shape(), at, p.axis)
|
|
51
|
+
}
|
|
52
|
+
default:
|
|
53
|
+
throw new Error(`unknown primitive kind: ${kind}`)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Place an origin-built primitive: rotate +Z onto `axis` (exact axis-angle
|
|
59
|
+
* rotation about the origin), then translate to `at`. Both default to
|
|
60
|
+
* identity when omitted/default.
|
|
61
|
+
*/
|
|
62
|
+
function place(shape, at, axis) {
|
|
63
|
+
const ax = axis ?? [0, 0, 1]
|
|
64
|
+
const len = Math.hypot(ax[0], ax[1], ax[2])
|
|
65
|
+
const u = len === 0 ? [0, 0, 1] : [ax[0] / len, ax[1] / len, ax[2] / len]
|
|
66
|
+
// Identity only when the axis IS +Z (e.g. [0,0,-1] must rotate by π).
|
|
67
|
+
const isDefaultAxis = Math.abs(u[0]) + Math.abs(u[1]) < 1e-12 && u[2] > 1 - 1e-9
|
|
68
|
+
let result = shape
|
|
69
|
+
if (!isDefaultAxis) {
|
|
70
|
+
const trsf = identityTrsf()
|
|
71
|
+
trsf.SetRotation_1(new occt.gp_Ax1_2(pnt(0, 0, 0), dir(u[0], u[1], u[2])), Math.acos(Math.min(1, Math.max(-1, u[2]))))
|
|
72
|
+
result = new occt.BRepBuilderAPI_Transform_2(result, trsf, true).Shape()
|
|
73
|
+
}
|
|
74
|
+
if (at !== undefined && (at[0] !== 0 || at[1] !== 0 || at[2] !== 0)) {
|
|
75
|
+
const move = identityTrsf()
|
|
76
|
+
move.SetTranslation_1(vec(at[0], at[1], at[2]))
|
|
77
|
+
result = new occt.BRepBuilderAPI_Transform_2(result, move, true).Shape()
|
|
78
|
+
}
|
|
79
|
+
return result
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ── profile extrusion ─────────────────────────────────────────────────────
|
|
83
|
+
/** points: flat [x0,y0, x1,y1, ...] closed loop in the XY plane at z = base. */
|
|
84
|
+
function makeExtrudedProfile(points, height, base = 0) {
|
|
85
|
+
if (points.length < 6 || points.length % 2 !== 0) {
|
|
86
|
+
throw new Error('profile needs at least 3 points (6 flat numbers)')
|
|
87
|
+
}
|
|
88
|
+
// Verified path (the 1.1.1 build exposes no direct wire→face ctor):
|
|
89
|
+
// polygon wire → bound an infinite planar face with Add(wire) → extrude.
|
|
90
|
+
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
91
|
+
for (let i = 0; i + 1 < points.length; i += 2) {
|
|
92
|
+
poly.Add_1(pnt(points[i], points[i + 1], base))
|
|
93
|
+
}
|
|
94
|
+
poly.Close()
|
|
95
|
+
if (!poly.IsDone()) throw new Error('profile polygon is invalid (duplicate or collinear-only points)')
|
|
96
|
+
const planeAx3 = new occt.gp_Ax3_3(pnt(0, 0, base), dir(0, 0, 1), dir(1, 0, 0))
|
|
97
|
+
const faceBuilder = new occt.BRepBuilderAPI_MakeFace_3(new occt.gp_Pln_2(planeAx3))
|
|
98
|
+
faceBuilder.Add(poly.Wire())
|
|
99
|
+
const face = faceBuilder.Face()
|
|
100
|
+
if (!faceBuilder.IsDone() || face.IsNull()) throw new Error('profile face construction failed')
|
|
101
|
+
return shapeOf(new occt.BRepPrimAPI_MakePrism_1(face, vec(0, 0, height), true, false))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── booleans / fillet / transform ──────────────────────────────────────────
|
|
105
|
+
function boolean(op, target, tools) {
|
|
106
|
+
let result = target
|
|
107
|
+
for (const tool of tools) {
|
|
108
|
+
// BRepAlgoAPI_*_3 is the verified (shape, shape) constructor.
|
|
109
|
+
const Ctor = op === 'fuse' ? occt.BRepAlgoAPI_Fuse_3 : op === 'cut' ? occt.BRepAlgoAPI_Cut_3 : occt.BRepAlgoAPI_Common_3
|
|
110
|
+
const algo = new Ctor(result, tool)
|
|
111
|
+
algo.Build()
|
|
112
|
+
if (!algo.IsDone()) throw new Error(`boolean ${op} failed`)
|
|
113
|
+
result = algo.Shape()
|
|
114
|
+
}
|
|
115
|
+
return result
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function filletAll(shape, radius) {
|
|
119
|
+
const algo = new occt.BRepFilletAPI_MakeFillet(shape, 1e-4)
|
|
120
|
+
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_EDGE, ENUM.TopAbs_SHAPE)
|
|
121
|
+
let edges = 0
|
|
122
|
+
while (explorer.More()) {
|
|
123
|
+
algo.Add_2(radius, castEdge(explorer.Current()))
|
|
124
|
+
edges++
|
|
125
|
+
explorer.Next()
|
|
126
|
+
}
|
|
127
|
+
if (edges === 0) throw new Error('no edges to fillet')
|
|
128
|
+
algo.Build()
|
|
129
|
+
if (!algo.IsDone()) throw new Error('fillet failed (radius may exceed the adjacent faces)')
|
|
130
|
+
return { shape: algo.Shape(), edges }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function castEdge(shape) {
|
|
134
|
+
if (occt.TopoDS.Edge_s) return occt.TopoDS.Edge_s(shape)
|
|
135
|
+
return occt.TopoDS.Edge_2(shape)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function castFace(shape) {
|
|
139
|
+
if (occt.TopoDS.Face_s) return occt.TopoDS.Face_s(shape)
|
|
140
|
+
return occt.TopoDS.Face_2(shape)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function transform(shape, { translate, rotate, mirror }) {
|
|
144
|
+
const trsf = identityTrsf()
|
|
145
|
+
if (translate !== undefined) trsf.SetTranslation_1(vec(translate[0], translate[1], translate[2]))
|
|
146
|
+
if (rotate !== undefined) {
|
|
147
|
+
const [rx, ry, rz] = rotate
|
|
148
|
+
if (rx) trsf.Multiply(rotationTrsf([1, 0, 0], rx * Math.PI / 180))
|
|
149
|
+
if (ry) trsf.Multiply(rotationTrsf([0, 1, 0], ry * Math.PI / 180))
|
|
150
|
+
if (rz) trsf.Multiply(rotationTrsf([0, 0, 1], rz * Math.PI / 180))
|
|
151
|
+
}
|
|
152
|
+
if (mirror !== undefined) {
|
|
153
|
+
const [mx, my, mz] = mirror
|
|
154
|
+
const mirrorTrsf = identityTrsf()
|
|
155
|
+
mirrorTrsf.SetMirror_1(new occt.gp_Ax2_3(pnt(0, 0, 0), dir(mx ?? 0, my ?? 0, mz ?? 1)))
|
|
156
|
+
trsf.Multiply(mirrorTrsf)
|
|
157
|
+
}
|
|
158
|
+
const algo = new occt.BRepBuilderAPI_Transform_2(shape, trsf, true)
|
|
159
|
+
return algo.Shape()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function rotationTrsf(axis, radians) {
|
|
163
|
+
const t = identityTrsf()
|
|
164
|
+
t.SetRotation_1(new occt.gp_Ax1_2(pnt(0, 0, 0), dir(axis[0], axis[1], axis[2])), radians)
|
|
165
|
+
return t
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── tessellation ───────────────────────────────────────────────────────────
|
|
169
|
+
function tessellate(shape, linearDeflection = 0.3) {
|
|
170
|
+
const mesher = new occt.BRepMesh_IncrementalMesh_2(shape, linearDeflection, false, 0.5, true)
|
|
171
|
+
mesher.Perform_1()
|
|
172
|
+
const positions = []
|
|
173
|
+
const indices = []
|
|
174
|
+
const loc = new occt.TopLoc_Location_2(identityTrsf())
|
|
175
|
+
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_FACE, ENUM.TopAbs_SHAPE)
|
|
176
|
+
let vertexBase = 0
|
|
177
|
+
while (explorer.More()) {
|
|
178
|
+
const faceShape = explorer.Current()
|
|
179
|
+
const handle = occt.BRep_Tool.Triangulation(castFace(faceShape), loc)
|
|
180
|
+
const tri = handle && handle.IsNull && !handle.IsNull() ? handle.get() : handle
|
|
181
|
+
if (tri) {
|
|
182
|
+
// A REVERSED face renders its surface natural orientation backwards —
|
|
183
|
+
// flip the triangle winding so normals point out of the material.
|
|
184
|
+
const reversed = faceShape.Orientation_1 !== undefined && faceShape.Orientation_1().value === 1
|
|
185
|
+
const nodeCount = tri.NbNodes()
|
|
186
|
+
for (let i = 1; i <= nodeCount; i++) {
|
|
187
|
+
const node = tri.Node(i)
|
|
188
|
+
// Optional location transform on the triangulation.
|
|
189
|
+
const transformed = loc.IsIdentity() ? node : node.Transformed(loc.Transformation())
|
|
190
|
+
positions.push(transformed.X(), transformed.Y(), transformed.Z())
|
|
191
|
+
}
|
|
192
|
+
for (let i = 1; i <= tri.NbTriangles(); i++) {
|
|
193
|
+
const t = tri.Triangle(i)
|
|
194
|
+
// Poly_Triangle.Get() uses out-params embind cannot express; Value(1..3) returns the indices.
|
|
195
|
+
const n1 = vertexBase + t.Value(1) - 1
|
|
196
|
+
const n2 = vertexBase + t.Value(2) - 1
|
|
197
|
+
const n3 = vertexBase + t.Value(3) - 1
|
|
198
|
+
if (reversed) indices.push(n1, n3, n2)
|
|
199
|
+
else indices.push(n1, n2, n3)
|
|
200
|
+
}
|
|
201
|
+
vertexBase += nodeCount
|
|
202
|
+
}
|
|
203
|
+
explorer.Next()
|
|
204
|
+
}
|
|
205
|
+
return { positions: Float32Array.from(positions), indices: Uint32Array.from(indices) }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Flat per-triangle normals (mechanical-CAD look, no smooth-vertex table). */
|
|
209
|
+
function faceNormals(positions, indices) {
|
|
210
|
+
const normals = new Float32Array(positions.length)
|
|
211
|
+
for (let i = 0; i + 2 < indices.length; i += 3) {
|
|
212
|
+
const a = indices[i] * 3, b = indices[i + 1] * 3, c = indices[i + 2] * 3
|
|
213
|
+
const ux = positions[c] - positions[a], uy = positions[c + 1] - positions[a + 1], uz = positions[c + 2] - positions[a + 2]
|
|
214
|
+
const vx = positions[b] - positions[a], vy = positions[b + 1] - positions[a + 1], vz = positions[b + 2] - positions[a + 2]
|
|
215
|
+
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
216
|
+
const len = Math.hypot(nx, ny, nz) || 1
|
|
217
|
+
nx /= len; ny /= len; nz /= len
|
|
218
|
+
for (const corner of [a, b, c]) {
|
|
219
|
+
normals[corner] = nx; normals[corner + 1] = ny; normals[corner + 2] = nz
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return normals
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// ── export: STEP via MEMFS, STL as direct binary bytes ───────────────────
|
|
226
|
+
function exportFile(shape, format) {
|
|
227
|
+
if (format === 'step') {
|
|
228
|
+
const writer = new occt.STEPControl_Writer_1()
|
|
229
|
+
writer.Transfer(shape, 0, true)
|
|
230
|
+
writer.Write('model.step')
|
|
231
|
+
return Buffer.from(occt.FS.readFile('model.step'))
|
|
232
|
+
}
|
|
233
|
+
if (format === 'stl') {
|
|
234
|
+
// StlAPI_Writer intermittently fails inside the WASM filesystem; the
|
|
235
|
+
// tessellated mesh is exact, so emit binary STL bytes directly.
|
|
236
|
+
const { positions, indices } = tessellate(shape, 0.1)
|
|
237
|
+
const normals = faceNormals(positions, indices)
|
|
238
|
+
const triangleCount = indices.length / 3
|
|
239
|
+
const buffer = Buffer.alloc(84 + triangleCount * 50)
|
|
240
|
+
buffer.write('dsh-cad binary STL', 0, 22, 'latin1')
|
|
241
|
+
buffer.writeUInt32LE(triangleCount, 80)
|
|
242
|
+
let offset = 84
|
|
243
|
+
for (let triangle = 0; triangle < triangleCount; triangle++) {
|
|
244
|
+
const a = indices[triangle * 3] * 3
|
|
245
|
+
const b = indices[triangle * 3 + 1] * 3
|
|
246
|
+
const c = indices[triangle * 3 + 2] * 3
|
|
247
|
+
for (let component = 0; component < 3; component++) {
|
|
248
|
+
buffer.writeFloatLE(normals[a + component], offset)
|
|
249
|
+
offset += 4
|
|
250
|
+
}
|
|
251
|
+
for (const vertex of [a, b, c]) {
|
|
252
|
+
buffer.writeFloatLE(positions[vertex], offset)
|
|
253
|
+
buffer.writeFloatLE(positions[vertex + 1], offset + 4)
|
|
254
|
+
buffer.writeFloatLE(positions[vertex + 2], offset + 8)
|
|
255
|
+
offset += 12
|
|
256
|
+
}
|
|
257
|
+
offset += 2 // attribute byte count
|
|
258
|
+
}
|
|
259
|
+
return buffer
|
|
260
|
+
}
|
|
261
|
+
throw new Error(`unsupported export format: ${format}`)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── loft / sweep ───────────────────────────────────────────────────────────
|
|
265
|
+
/**
|
|
266
|
+
* Closed polygon wire from flat [x,y,z, …] triplets. Loft sections carry
|
|
267
|
+
* explicit 3D coordinates (each section sits in its own plane), so unlike
|
|
268
|
+
* makeExtrudedProfile there is no implicit base plane here.
|
|
269
|
+
*/
|
|
270
|
+
function closedWire(points) {
|
|
271
|
+
if (points.length < 9 || points.length % 3 !== 0) {
|
|
272
|
+
throw new Error('a section needs at least 3 [x,y,z] triplets (≥9 numbers)')
|
|
273
|
+
}
|
|
274
|
+
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
275
|
+
for (let i = 0; i + 2 < points.length; i += 3) poly.Add_1(pnt(points[i], points[i + 1], points[i + 2]))
|
|
276
|
+
poly.Close()
|
|
277
|
+
if (!poly.IsDone()) throw new Error('section polygon is invalid (duplicate or collinear-only points)')
|
|
278
|
+
return poly.Wire()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── loft / sweep ───────────────────────────────────────────────────────────
|
|
282
|
+
/**
|
|
283
|
+
* Closed polygon wire from flat [x,y,z, …] triplets. Loft sections carry
|
|
284
|
+
* explicit 3D coordinates (each section sits in its own plane), so unlike
|
|
285
|
+
* makeExtrudedProfile there is no implicit base plane here.
|
|
286
|
+
*/
|
|
287
|
+
function closedWire(points) {
|
|
288
|
+
if (points.length < 9 || points.length % 3 !== 0) {
|
|
289
|
+
throw new Error('a section needs at least 3 [x,y,z] triplets (≥9 numbers)')
|
|
290
|
+
}
|
|
291
|
+
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
292
|
+
for (let i = 0; i + 2 < points.length; i += 3) poly.Add_1(pnt(points[i], points[i + 1], points[i + 2]))
|
|
293
|
+
poly.Close()
|
|
294
|
+
if (!poly.IsDone()) throw new Error('section polygon is invalid (duplicate or collinear-only points)')
|
|
295
|
+
return poly.Wire()
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** A planar FACE in the given frame's plane (MakePipe needs a face, not a wire). */
|
|
299
|
+
function faceInFrame(ax3, points2d) {
|
|
300
|
+
const poly = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
301
|
+
const xd = ax3.XDirection()
|
|
302
|
+
const yd = ax3.YDirection()
|
|
303
|
+
const o = ax3.Location()
|
|
304
|
+
for (let i = 0; i + 1 < points2d.length; i += 2) {
|
|
305
|
+
const u = points2d[i]
|
|
306
|
+
const v = points2d[i + 1]
|
|
307
|
+
poly.Add_1(pnt(
|
|
308
|
+
o.X() + xd.X() * u + yd.X() * v,
|
|
309
|
+
o.Y() + xd.Y() * u + yd.Y() * v,
|
|
310
|
+
o.Z() + xd.Z() * u + yd.Z() * v,
|
|
311
|
+
))
|
|
312
|
+
}
|
|
313
|
+
poly.Close()
|
|
314
|
+
if (!poly.IsDone()) throw new Error('profile polygon is invalid')
|
|
315
|
+
const builder = new occt.BRepBuilderAPI_MakeFace_3(new occt.gp_Pln_2(ax3))
|
|
316
|
+
builder.Add(poly.Wire())
|
|
317
|
+
const face = builder.Face()
|
|
318
|
+
if (!builder.IsDone() || face.IsNull()) throw new Error('profile face construction failed')
|
|
319
|
+
return face
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Loft: skin a solid through successive closed sections (OCCT ThruSections).
|
|
324
|
+
* `sections` is a list of flat [x,y,z, …] loops, in order along the loft.
|
|
325
|
+
* `solid` caps the ends, `ruled` keeps the sides straight (no smoothing).
|
|
326
|
+
*/
|
|
327
|
+
function makeLoft(sections, options = {}) {
|
|
328
|
+
if (!Array.isArray(sections) || sections.length < 2) throw new Error('a loft needs at least 2 sections')
|
|
329
|
+
const solid = options.solid ?? true
|
|
330
|
+
const ruled = options.ruled ?? false
|
|
331
|
+
// Verified spelling: this build binds only the 3-argument constructor.
|
|
332
|
+
const thru = new occt.BRepOffsetAPI_ThruSections(solid, ruled, 1e-6)
|
|
333
|
+
for (const section of sections) thru.AddWire(closedWire(section))
|
|
334
|
+
thru.Build()
|
|
335
|
+
if (!thru.IsDone()) throw new Error('loft failed (check that the sections are closed and non-degenerate)')
|
|
336
|
+
return thru.Shape()
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Sweep: pipe a 2D profile along a 3D path (OCCT MakePipe). The profile is
|
|
341
|
+
* placed in the plane PERPENDICULAR TO THE PATH'S START TANGENT, so callers
|
|
342
|
+
* give a plain 2D outline plus a 3D path in any orientation.
|
|
343
|
+
*
|
|
344
|
+
* Note: MakePipeShell (the transition-aware variant) is unusable in this
|
|
345
|
+
* opencascade.js build, so a sharp direction change in the path with a
|
|
346
|
+
* section large relative to the corner yields a self-intersecting solid —
|
|
347
|
+
* `isValid()` reports that, and the tool surfaces it.
|
|
348
|
+
*/
|
|
349
|
+
function makeSweep(profile, pathPoints) {
|
|
350
|
+
const profileOk = Array.isArray(profile)
|
|
351
|
+
? profile.length >= 6 && profile.length % 2 === 0
|
|
352
|
+
: profile !== null && typeof profile === 'object'
|
|
353
|
+
if (!profileOk) throw new Error('the profile needs a flat [x,y,…] array (≥6 numbers) or a {start, segments}/{circle} object')
|
|
354
|
+
if (!Array.isArray(pathPoints) || pathPoints.length < 6 || pathPoints.length % 3 !== 0) {
|
|
355
|
+
throw new Error('the path needs at least 2 [x,y,z] triplets (≥6 numbers)')
|
|
356
|
+
}
|
|
357
|
+
const spine = new occt.BRepBuilderAPI_MakePolygon_1()
|
|
358
|
+
for (let i = 0; i + 2 < pathPoints.length; i += 3) {
|
|
359
|
+
spine.Add_1(pnt(pathPoints[i], pathPoints[i + 1], pathPoints[i + 2]))
|
|
360
|
+
}
|
|
361
|
+
if (!spine.IsDone()) throw new Error('path polyline is invalid')
|
|
362
|
+
// Frame at the path start: +Z (the gp_Ax3 normal) along the initial
|
|
363
|
+
// tangent; the in-plane X axis is world X projected perpendicular to it
|
|
364
|
+
// (world Y when that degenerates), so a profile maps onto predictable
|
|
365
|
+
// world axes — for a +Z sweep the 2D outline lands exactly on world XY.
|
|
366
|
+
const tx = pathPoints[3] - pathPoints[0]
|
|
367
|
+
const ty = pathPoints[4] - pathPoints[1]
|
|
368
|
+
const tz = pathPoints[5] - pathPoints[2]
|
|
369
|
+
const tLen = Math.hypot(tx, ty, tz)
|
|
370
|
+
if (tLen < 1e-12) throw new Error('the path starts with a zero-length segment')
|
|
371
|
+
const n = [tx / tLen, ty / tLen, tz / tLen]
|
|
372
|
+
const axis = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
|
|
373
|
+
const d = axis[0] * n[0] + axis[1] * n[1] + axis[2] * n[2]
|
|
374
|
+
let vx = [axis[0] - n[0] * d, axis[1] - n[1] * d, axis[2] - n[2] * d]
|
|
375
|
+
const vxLen = Math.hypot(vx[0], vx[1], vx[2])
|
|
376
|
+
if (vxLen < 1e-12) throw new Error('could not build a profile frame for the path tangent')
|
|
377
|
+
vx = [vx[0] / vxLen, vx[1] / vxLen, vx[2] / vxLen]
|
|
378
|
+
const o3 = [pathPoints[0], pathPoints[1], pathPoints[2]]
|
|
379
|
+
const v3 = [vx[0], vx[1], vx[2]] // in-plane X = vx
|
|
380
|
+
const w3 = [n[1] * vx[2] - n[2] * vx[1], n[2] * vx[0] - n[0] * vx[2], n[0] * vx[1] - n[1] * vx[0]] // n × vx
|
|
381
|
+
const wire = profileWire(profile, o3, v3, w3)
|
|
382
|
+
const profileFace = faceFromWire(wire, o3, v3, w3)
|
|
383
|
+
const pipe = new occt.BRepOffsetAPI_MakePipe_1(spine.Wire(), profileFace)
|
|
384
|
+
pipe.Build()
|
|
385
|
+
if (!pipe.IsDone()) throw new Error('sweep failed (check the path and profile)')
|
|
386
|
+
return pipe.Shape()
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** BRepCheck_Analyzer verdict, or null when the check itself is unavailable. */
|
|
390
|
+
function isValid(shape) {
|
|
391
|
+
try {
|
|
392
|
+
const analyzer = new occt.BRepCheck_Analyzer(shape, true)
|
|
393
|
+
return analyzer.IsValid_1(shape) === true
|
|
394
|
+
} catch {
|
|
395
|
+
return null
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ── segment-based profiles (line / arc / bspline / circle) ────────────────
|
|
400
|
+
/**
|
|
401
|
+
* Build a CLOSED wire from a segment-based profile laid into the frame
|
|
402
|
+
* (origin o3, basis u3/v3). Segment forms:
|
|
403
|
+
* { type: 'line', to: [x,y] }
|
|
404
|
+
* { type: 'arc', to: [x,y], center: [cx,cy], ccw?: true }
|
|
405
|
+
* { type: 'bspline', through: [[x,y],…], samples?: n }
|
|
406
|
+
* A final line closes the chain back to the start. Verified spellings:
|
|
407
|
+
* gp_Circ_2(ax2,R) → MakeEdge_9(circ, a1, a2) for arcs.
|
|
408
|
+
*/
|
|
409
|
+
function to3Of(o3, u3, v3, p2) {
|
|
410
|
+
return pnt(o3[0] + u3[0] * p2[0] + v3[0] * p2[1], o3[1] + u3[1] * p2[0] + v3[1] * p2[1], o3[2] + u3[2] * p2[0] + v3[2] * p2[1])
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function segmentWire(profile, o3, u3, v3) {
|
|
414
|
+
const segs = Array.isArray(profile.segments) ? profile.segments : []
|
|
415
|
+
if (segs.length === 0) throw new Error('a segment profile needs at least one segment')
|
|
416
|
+
if (!Array.isArray(profile.start) || profile.start.length !== 2) throw new Error('profile.start must be [x,y]')
|
|
417
|
+
const nrm = [
|
|
418
|
+
u3[1] * v3[2] - u3[2] * v3[1],
|
|
419
|
+
u3[2] * v3[0] - u3[0] * v3[2],
|
|
420
|
+
u3[0] * v3[1] - u3[1] * v3[0],
|
|
421
|
+
]
|
|
422
|
+
const mkWire = new occt.BRepBuilderAPI_MakeWire_1()
|
|
423
|
+
const addLine = (from2, to2) => {
|
|
424
|
+
mkWire.Add_1(new occt.BRepBuilderAPI_MakeEdge_3(to3Of(o3, u3, v3, from2), to3Of(o3, u3, v3, to2)).Edge())
|
|
425
|
+
}
|
|
426
|
+
let cur = [profile.start[0], profile.start[1]]
|
|
427
|
+
for (const seg of segs) {
|
|
428
|
+
if (seg === null || typeof seg !== 'object') throw new Error('each profile segment must be an object')
|
|
429
|
+
if (seg.type === 'line') {
|
|
430
|
+
if (!Array.isArray(seg.to) || seg.to.length !== 2) throw new Error("line segment needs 'to: [x,y]'")
|
|
431
|
+
addLine(cur, seg.to)
|
|
432
|
+
cur = seg.to
|
|
433
|
+
} else if (seg.type === 'arc') {
|
|
434
|
+
if (!Array.isArray(seg.to) || seg.to.length !== 2 || !Array.isArray(seg.center) || seg.center.length !== 2) {
|
|
435
|
+
throw new Error("arc segment needs 'to: [x,y]' and 'center: [cx,cy]'")
|
|
436
|
+
}
|
|
437
|
+
const c = seg.center
|
|
438
|
+
const r0 = Math.hypot(cur[0] - c[0], cur[1] - c[1])
|
|
439
|
+
const r1 = Math.hypot(seg.to[0] - c[0], seg.to[1] - c[1])
|
|
440
|
+
if (Math.abs(r0 - r1) > 1e-4 * Math.max(r0, r1) + 1e-6) {
|
|
441
|
+
throw new Error(`arc endpoints are not equidistant from the center (r=${r0.toFixed(4)} vs ${r1.toFixed(4)})`)
|
|
442
|
+
}
|
|
443
|
+
const a0 = Math.atan2(cur[1] - c[1], cur[0] - c[0])
|
|
444
|
+
let a1 = Math.atan2(seg.to[1] - c[1], seg.to[0] - c[0])
|
|
445
|
+
if (seg.ccw !== false) { if (a1 <= a0) a1 += Math.PI * 2 } else { if (a1 >= a0) a1 -= Math.PI * 2 }
|
|
446
|
+
const circ = new occt.gp_Circ_2(new occt.gp_Ax2_2(to3Of(o3, u3, v3, c), dir(nrm[0], nrm[1], nrm[2]), dir(u3[0], u3[1], u3[2])), r0)
|
|
447
|
+
mkWire.Add_1(new occt.BRepBuilderAPI_MakeEdge_9(circ, a0, a1).Edge())
|
|
448
|
+
cur = seg.to
|
|
449
|
+
} else if (seg.type === 'bspline') {
|
|
450
|
+
const through = Array.isArray(seg.through) ? seg.through : []
|
|
451
|
+
if (through.length < 2) throw new Error("bspline segment needs 'through: [[x,y],…]' (≥2 points)")
|
|
452
|
+
const samples = Math.max(8, Math.trunc(seg.samples ?? 24))
|
|
453
|
+
// Catmull-Rom through the given points, sampled into a dense polyline:
|
|
454
|
+
// this kernel build cannot extract points back from a Geom_BSplineCurve,
|
|
455
|
+
// so the smooth curve is carried as a high-density edge chain.
|
|
456
|
+
const pts = [cur, ...through]
|
|
457
|
+
for (let i = 0; i < pts.length - 1; i++) {
|
|
458
|
+
const p0 = pts[Math.max(0, i - 1)]
|
|
459
|
+
const p1 = pts[i]
|
|
460
|
+
const p2 = pts[i + 1]
|
|
461
|
+
const p3 = pts[Math.min(pts.length - 1, i + 2)]
|
|
462
|
+
for (let s = 1; s <= samples; s++) {
|
|
463
|
+
const t = s / samples
|
|
464
|
+
const t2 = t * t
|
|
465
|
+
const t3 = t2 * t
|
|
466
|
+
const x = 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * t + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3)
|
|
467
|
+
const y = 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * t + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3)
|
|
468
|
+
addLine(cur, [x, y])
|
|
469
|
+
cur = [x, y]
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} else {
|
|
473
|
+
throw new Error(`unknown profile segment type: ${String(seg.type)}`)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (Math.hypot(cur[0] - profile.start[0], cur[1] - profile.start[1]) > 1e-9) {
|
|
477
|
+
addLine(cur, profile.start)
|
|
478
|
+
}
|
|
479
|
+
if (!mkWire.IsDone()) throw new Error('profile wire construction failed')
|
|
480
|
+
return mkWire.Wire()
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** A closed circular wire (the profile = one circle). */
|
|
484
|
+
function circleWire(center2, radius, o3, u3, v3) {
|
|
485
|
+
const c3 = to3Of(o3, u3, v3, center2)
|
|
486
|
+
const nrm = [u3[1] * v3[2] - u3[2] * v3[1], u3[2] * v3[0] - u3[0] * v3[2], u3[0] * v3[1] - u3[1] * v3[0]]
|
|
487
|
+
const circ = new occt.gp_Circ_2(new occt.gp_Ax2_2(c3, dir(nrm[0], nrm[1], nrm[2]), dir(u3[0], u3[1], u3[2])), radius)
|
|
488
|
+
const mkWire = new occt.BRepBuilderAPI_MakeWire_1()
|
|
489
|
+
mkWire.Add_1(new occt.BRepBuilderAPI_MakeEdge_8(circ).Edge())
|
|
490
|
+
if (!mkWire.IsDone()) throw new Error('circle wire construction failed')
|
|
491
|
+
return mkWire.Wire()
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* A 2D profile in one of three forms, laid into the (o3, u3, v3) frame and
|
|
496
|
+
* returned as a closed wire:
|
|
497
|
+
* { start, segments: [...] } — segment chain (line/arc/bspline)
|
|
498
|
+
* { circle: { center, radius } } — a full circle
|
|
499
|
+
* [x0,y0, x1,y1, ...] — plain polyline (the historical form)
|
|
500
|
+
*/
|
|
501
|
+
function profileWire(profile, o3, u3, v3) {
|
|
502
|
+
if (Array.isArray(profile)) {
|
|
503
|
+
const pts = []
|
|
504
|
+
for (let i = 0; i + 1 < profile.length; i += 2) {
|
|
505
|
+
const p3 = to3Of(o3, u3, v3, [profile[i], profile[i + 1]])
|
|
506
|
+
pts.push(p3.X(), p3.Y(), p3.Z())
|
|
507
|
+
}
|
|
508
|
+
return closedWire(pts)
|
|
509
|
+
}
|
|
510
|
+
if (profile !== null && typeof profile === 'object') {
|
|
511
|
+
if (profile.circle !== undefined) {
|
|
512
|
+
const c = profile.circle
|
|
513
|
+
if (!Array.isArray(c.center) || typeof c.radius !== 'number') throw new Error("circle profile needs 'center: [x,y]' and 'radius'")
|
|
514
|
+
return circleWire(c.center, c.radius, o3, u3, v3)
|
|
515
|
+
}
|
|
516
|
+
return segmentWire(profile, o3, u3, v3)
|
|
517
|
+
}
|
|
518
|
+
throw new Error('a profile must be a flat points array or a {start, segments}/{circle} object')
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Extrude any 2D profile form (segments / circle / flat points) from the
|
|
523
|
+
* plane z = base along +Z by height — the segment-curve generalization of
|
|
524
|
+
* makeExtrudedProfile.
|
|
525
|
+
*/
|
|
526
|
+
function extrudeProfile2D(profile, height, base = 0) {
|
|
527
|
+
if (height <= 0) throw new Error('the extrusion height must be positive')
|
|
528
|
+
const wire = profileWire(profile, [0, 0, base], [1, 0, 0], [0, 1, 0])
|
|
529
|
+
const face = faceFromWire(wire, [0, 0, base], [1, 0, 0], [0, 1, 0])
|
|
530
|
+
const algo = new occt.BRepPrimAPI_MakePrism_1(face, vec(0, 0, height), true, false)
|
|
531
|
+
return shapeOf(algo)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* A planar FACE from a wire in the (o3, u3, v3) frame. `flip` reverses the
|
|
536
|
+
* face normal — revolve needs the axis×radial side for axis-touching
|
|
537
|
+
* profiles (a profile edge ON the axis fails from the other side).
|
|
538
|
+
*/
|
|
539
|
+
function faceFromWire(wire, o3, u3, v3, flip = false) {
|
|
540
|
+
const nrm = [
|
|
541
|
+
u3[1] * v3[2] - u3[2] * v3[1],
|
|
542
|
+
u3[2] * v3[0] - u3[0] * v3[2],
|
|
543
|
+
u3[0] * v3[1] - u3[1] * v3[0],
|
|
544
|
+
]
|
|
545
|
+
const sign = flip ? -1 : 1
|
|
546
|
+
const builder = new occt.BRepBuilderAPI_MakeFace_3(new occt.gp_Pln_2(new occt.gp_Ax3_3(pnt(o3[0], o3[1], o3[2]), dir(sign * nrm[0], sign * nrm[1], sign * nrm[2]), dir(u3[0], u3[1], u3[2]))))
|
|
547
|
+
builder.Add(wire)
|
|
548
|
+
const face = builder.Face()
|
|
549
|
+
if (!builder.IsDone() || face.IsNull()) throw new Error('profile face construction failed')
|
|
550
|
+
return face
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// ── revolve / chamfer / shell ──────────────────────────────────────────────
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Revolve (旋转): sweep a 2D profile around an axis through `at` with
|
|
557
|
+
* direction `axis` by `angle` radians (default 2π). The profile's u axis is
|
|
558
|
+
* a radial direction perpendicular to the axis, v runs ALONG the axis —
|
|
559
|
+
* e.g. with the default +Z axis, profile [x,y] means (radius, height).
|
|
560
|
+
* Verified: BRepPrimAPI_MakeRevol_1(face, ax1, angle, copy=false).
|
|
561
|
+
*/
|
|
562
|
+
function makeRevolve(profile, options = {}) {
|
|
563
|
+
const axis = options.axis ?? [0, 0, 1]
|
|
564
|
+
const at = options.at ?? [0, 0, 0]
|
|
565
|
+
const angle = options.angle ?? Math.PI * 2
|
|
566
|
+
const alen = Math.hypot(axis[0], axis[1], axis[2])
|
|
567
|
+
if (alen < 1e-12) throw new Error('the revolve axis must be a non-zero direction')
|
|
568
|
+
const n = [axis[0] / alen, axis[1] / alen, axis[2] / alen]
|
|
569
|
+
const helper = Math.abs(n[0]) < 0.9 ? [1, 0, 0] : [0, 1, 0]
|
|
570
|
+
const d = helper[0] * n[0] + helper[1] * n[1] + helper[2] * n[2]
|
|
571
|
+
const u = [helper[0] - n[0] * d, helper[1] - n[1] * d, helper[2] - n[2] * d]
|
|
572
|
+
const uLen = Math.hypot(u[0], u[1], u[2])
|
|
573
|
+
const radial = [u[0] / uLen, u[1] / uLen, u[2] / uLen]
|
|
574
|
+
const wire = profileWire(profile, at, radial, n)
|
|
575
|
+
const ax1 = new occt.gp_Ax1_2(pnt(at[0], at[1], at[2]), dir(n[0], n[1], n[2]))
|
|
576
|
+
// Orientation matrix (verified): the plain (radial, axis) face revolves
|
|
577
|
+
// validly at every angle with positive volume; the flipped face suits
|
|
578
|
+
// axis-touching profiles but yields INVALID partial revolves — so try the
|
|
579
|
+
// plain face first, fall back to flipped only when it fails outright.
|
|
580
|
+
const attempts = [false, true]
|
|
581
|
+
for (const flip of attempts) {
|
|
582
|
+
const algo = new occt.BRepPrimAPI_MakeRevol_1(faceFromWire(wire, at, radial, n, flip), ax1, angle, false)
|
|
583
|
+
algo.Build()
|
|
584
|
+
if (!algo.IsDone()) continue
|
|
585
|
+
const shape = algo.Shape()
|
|
586
|
+
if (isValid(shape) === false) continue
|
|
587
|
+
return volume(shape) < 0 ? (() => { try { return shape.Reversed() } catch { return shape } })() : shape
|
|
588
|
+
}
|
|
589
|
+
throw new Error('revolve failed (check that the profile stays on one side of the axis and is planar)')
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Chamfer every sharp edge with one equal distance (mm). */
|
|
593
|
+
function chamferAll(shape, distance) {
|
|
594
|
+
const algo = new occt.BRepFilletAPI_MakeChamfer(shape)
|
|
595
|
+
const explorer = new occt.TopExp_Explorer_2(shape, ENUM.TopAbs_EDGE, ENUM.TopAbs_SHAPE)
|
|
596
|
+
let edges = 0
|
|
597
|
+
while (explorer.More()) {
|
|
598
|
+
algo.Add_2(distance, castEdge(explorer.Current()))
|
|
599
|
+
edges++
|
|
600
|
+
explorer.Next()
|
|
601
|
+
}
|
|
602
|
+
if (edges === 0) throw new Error('no edges to chamfer')
|
|
603
|
+
algo.Build()
|
|
604
|
+
if (!algo.IsDone()) throw new Error('chamfer failed (distance may exceed the adjacent faces)')
|
|
605
|
+
return algo.Shape()
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return {
|
|
609
|
+
pnt, dir, ENUM,
|
|
610
|
+
makePrim, makeExtrudedProfile, extrudeProfile2D, makeLoft, makeSweep, isValid,
|
|
611
|
+
profileWire, faceFromWire, makeRevolve, chamferAll,
|
|
612
|
+
boolean, filletAll, transform,
|
|
613
|
+
tessellate, faceNormals, exportFile, volume,
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
module.exports = { createAdapter }
|