dsh-cad 0.16.2 → 0.17.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 +9 -0
- package/README.zh-CN.md +9 -0
- package/lib/client.js +31 -31
- package/lib/client.js.map +3 -3
- package/lib/modeling/assembly.js +318 -215
- package/lib/modeling/cadorange-adapter.cjs +75 -0
- package/lib/modeling/cadorange-bridge.cjs +10 -0
- package/lib/modeling/joints.js +442 -0
- package/lib/modeling/modeling-worker.cjs +88 -0
- package/lib/tools/cad-model.js +2282 -1797
- package/lib/types/modeling/assembly.d.ts +106 -78
- package/lib/types/modeling/client.d.ts +279 -227
- package/lib/types/modeling/joints.d.ts +129 -0
- package/lib/types/tools/cad-model.d.ts +16 -16
- package/package.json +2 -2
|
@@ -59,6 +59,16 @@ function candidateDistDirs() {
|
|
|
59
59
|
// resolved to TypeScript sources, so distDirOf also accepts those roots.
|
|
60
60
|
push(require.resolve('cadorange'))
|
|
61
61
|
} catch { /* cadorange not installed as a dependency */ }
|
|
62
|
+
// Manual node_modules walk. cadorange 0.10.0's published package.json
|
|
63
|
+
// points its exports map at ./src/*.ts while the tarball ships only dist/
|
|
64
|
+
// — require.resolve cannot resolve that (and an exports map without
|
|
65
|
+
// './package.json' blocks resolving the root too), so probe the classic
|
|
66
|
+
// <node_modules>/cadorange/dist layout directly. Symlinks (pnpm) resolve.
|
|
67
|
+
for (let dir = __dirname; ; dir = path.dirname(dir)) {
|
|
68
|
+
push(path.join(dir, 'node_modules', 'cadorange'))
|
|
69
|
+
const parent = path.dirname(dir)
|
|
70
|
+
if (parent === dir) break
|
|
71
|
+
}
|
|
62
72
|
push(path.resolve(__dirname, '..', '..', '..', 'cadorange', 'packages', 'cadorange', 'dist'))
|
|
63
73
|
return dirs
|
|
64
74
|
}
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
export const JOINT_TYPES = ['rigid', 'revolute', 'linear', 'cylindrical', 'ball', 'planar', 'polar'];
|
|
2
|
+
/** build123d's pairing table: parent (fixed) joint kind → allowed child kinds. */
|
|
3
|
+
const PAIRINGS = {
|
|
4
|
+
rigid: ['rigid'],
|
|
5
|
+
revolute: ['rigid'],
|
|
6
|
+
linear: ['rigid', 'revolute'],
|
|
7
|
+
cylindrical: ['rigid'],
|
|
8
|
+
ball: ['rigid'],
|
|
9
|
+
planar: ['rigid'],
|
|
10
|
+
polar: ['rigid'],
|
|
11
|
+
};
|
|
12
|
+
const VALUE_KEYS = {
|
|
13
|
+
rigid: [],
|
|
14
|
+
revolute: ['angle'],
|
|
15
|
+
linear: ['linear'],
|
|
16
|
+
cylindrical: ['angle', 'linear'],
|
|
17
|
+
ball: ['angles'],
|
|
18
|
+
planar: ['angle', 'position'],
|
|
19
|
+
polar: ['angle', 'radial'],
|
|
20
|
+
};
|
|
21
|
+
/** The per-type motion keys a bare number routes to (undefined = object only). */
|
|
22
|
+
const BARE_KEY = { revolute: 'angle', linear: 'linear' };
|
|
23
|
+
const DEG = Math.PI / 180;
|
|
24
|
+
/** Row-major rotation of XYZ Euler degrees — Rx·Ry·Rz (cad_transform). */
|
|
25
|
+
export function eulerMatrix(rotate) {
|
|
26
|
+
const [rx, ry, rz] = rotate;
|
|
27
|
+
const cx = Math.cos(rx * DEG), sx = Math.sin(rx * DEG);
|
|
28
|
+
const cy = Math.cos(ry * DEG), sy = Math.sin(ry * DEG);
|
|
29
|
+
const cz = Math.cos(rz * DEG), sz = Math.sin(rz * DEG);
|
|
30
|
+
return [
|
|
31
|
+
cy * cz, -cy * sz, sy,
|
|
32
|
+
cx * sz + sx * sy * cz, cx * cz - sx * sy * sz, -sx * cy,
|
|
33
|
+
sx * sz - cx * sy * cz, sx * cz + cx * sy * sz, cx * cy,
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
/** Euler degrees back out of a row-major Rx·Ry·Rz rotation (gimbal at ±90° ry). */
|
|
37
|
+
export function matrixEuler(r) {
|
|
38
|
+
const sy = Math.min(1, Math.max(-1, r[2] ?? 0));
|
|
39
|
+
const ry = Math.asin(sy) / DEG;
|
|
40
|
+
if (Math.abs(Math.abs(sy) - 1) < 1e-9) {
|
|
41
|
+
// Gimbal lock (ry = ±90°): rx and rz share one axis — fold rz into rx.
|
|
42
|
+
// At +90° rx + rz = atan2(r10, r11); at −90° rz − rx = atan2(r10, r11).
|
|
43
|
+
const rx = ((sy > 0 ? 1 : -1) * Math.atan2(r[3] ?? 0, r[4] ?? 0)) / DEG;
|
|
44
|
+
return [rx, ry, 0];
|
|
45
|
+
}
|
|
46
|
+
const rx = Math.atan2(-(r[5] ?? 0), (r[8] ?? 0)) / DEG;
|
|
47
|
+
const rz = Math.atan2(-(r[1] ?? 0), (r[0] ?? 0)) / DEG;
|
|
48
|
+
return [rx, ry, rz];
|
|
49
|
+
}
|
|
50
|
+
/** a ∘ b — apply b first, then a. */
|
|
51
|
+
export function composeMat(a, b) {
|
|
52
|
+
const r = new Array(9);
|
|
53
|
+
for (let row = 0; row < 3; row++) {
|
|
54
|
+
for (let col = 0; col < 3; col++) {
|
|
55
|
+
r[row * 3 + col] =
|
|
56
|
+
(a.r[row * 3] ?? 0) * (b.r[col] ?? 0) +
|
|
57
|
+
(a.r[row * 3 + 1] ?? 0) * (b.r[3 + col] ?? 0) +
|
|
58
|
+
(a.r[row * 3 + 2] ?? 0) * (b.r[6 + col] ?? 0);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const t = [
|
|
62
|
+
(a.r[0] ?? 0) * b.t[0] + (a.r[1] ?? 0) * b.t[1] + (a.r[2] ?? 0) * b.t[2] + a.t[0],
|
|
63
|
+
(a.r[3] ?? 0) * b.t[0] + (a.r[4] ?? 0) * b.t[1] + (a.r[5] ?? 0) * b.t[2] + a.t[1],
|
|
64
|
+
(a.r[6] ?? 0) * b.t[0] + (a.r[7] ?? 0) * b.t[1] + (a.r[8] ?? 0) * b.t[2] + a.t[2],
|
|
65
|
+
];
|
|
66
|
+
return { r, t };
|
|
67
|
+
}
|
|
68
|
+
export function invertMat(a) {
|
|
69
|
+
// Rotation transpose + −Rᵀt.
|
|
70
|
+
const r = [
|
|
71
|
+
a.r[0] ?? 0, a.r[3] ?? 0, a.r[6] ?? 0,
|
|
72
|
+
a.r[1] ?? 0, a.r[4] ?? 0, a.r[7] ?? 0,
|
|
73
|
+
a.r[2] ?? 0, a.r[5] ?? 0, a.r[8] ?? 0,
|
|
74
|
+
];
|
|
75
|
+
const t = [
|
|
76
|
+
-((r[0] ?? 0) * (a.t[0] ?? 0) + (r[1] ?? 0) * (a.t[1] ?? 0) + (r[2] ?? 0) * (a.t[2] ?? 0)),
|
|
77
|
+
-((r[3] ?? 0) * (a.t[0] ?? 0) + (r[4] ?? 0) * (a.t[1] ?? 0) + (r[5] ?? 0) * (a.t[2] ?? 0)),
|
|
78
|
+
-((r[6] ?? 0) * (a.t[0] ?? 0) + (r[7] ?? 0) * (a.t[1] ?? 0) + (r[8] ?? 0) * (a.t[2] ?? 0)),
|
|
79
|
+
];
|
|
80
|
+
return { r, t };
|
|
81
|
+
}
|
|
82
|
+
export function placementMat(p) {
|
|
83
|
+
return { r: eulerMatrix(p.rotate), t: [...p.translate] };
|
|
84
|
+
}
|
|
85
|
+
export function matPlacement(m) {
|
|
86
|
+
return { translate: [...m.t], rotate: matrixEuler(m.r) };
|
|
87
|
+
}
|
|
88
|
+
// ── frames & motions ─────────────────────────────────────────────────────────
|
|
89
|
+
const normalize3 = (v) => {
|
|
90
|
+
const len = Math.hypot(v[0] ?? 0, v[1] ?? 0, v[2] ?? 0);
|
|
91
|
+
if (len < 1e-12)
|
|
92
|
+
throw new Error('方向向量为零 — 需要非零轴');
|
|
93
|
+
return [(v[0] ?? 0) / len, (v[1] ?? 0) / len, (v[2] ?? 0) / len];
|
|
94
|
+
};
|
|
95
|
+
const cross3 = (a, b) => [
|
|
96
|
+
(a[1] ?? 0) * (b[2] ?? 0) - (a[2] ?? 0) * (b[1] ?? 0),
|
|
97
|
+
(a[2] ?? 0) * (b[0] ?? 0) - (a[0] ?? 0) * (b[2] ?? 0),
|
|
98
|
+
(a[0] ?? 0) * (b[1] ?? 0) - (a[1] ?? 0) * (b[0] ?? 0),
|
|
99
|
+
];
|
|
100
|
+
const dot3 = (a, b) => (a[0] ?? 0) * (b[0] ?? 0) + (a[1] ?? 0) * (b[1] ?? 0) + (a[2] ?? 0) * (b[2] ?? 0);
|
|
101
|
+
/** A unit vector ⊥ z, seeded deterministically (the frame fallback X). */
|
|
102
|
+
function perpendicularSeed(z) {
|
|
103
|
+
const seed = Math.abs(z[0] ?? 0) < 0.9 ? [1, 0, 0] : [0, 1, 0];
|
|
104
|
+
const d = dot3(seed, z);
|
|
105
|
+
return normalize3([seed[0] - (z[0] ?? 0) * d, seed[1] - (z[1] ?? 0) * d, seed[2] - (z[2] ?? 0) * d]);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The joint's frame in the owner's local coordinates: local X → xDir (or the
|
|
109
|
+
* deterministic fallback), local Z → axis, origin → at. Columns are the frame
|
|
110
|
+
* axes, so frameMat maps joint-local coordinates onto owner-local ones.
|
|
111
|
+
*/
|
|
112
|
+
export function jointFrameMat(spec) {
|
|
113
|
+
const z = normalize3(spec.axis ?? [0, 0, 1]);
|
|
114
|
+
let x;
|
|
115
|
+
if (spec.xDir === undefined) {
|
|
116
|
+
x = perpendicularSeed(z);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
const raw = normalize3(spec.xDir);
|
|
120
|
+
const d = dot3(raw, z);
|
|
121
|
+
const ortho = [(raw[0] ?? 0) - (z[0] ?? 0) * d, (raw[1] ?? 0) - (z[1] ?? 0) * d, (raw[2] ?? 0) - (z[2] ?? 0) * d];
|
|
122
|
+
x = Math.hypot(ortho[0] ?? 0, ortho[1] ?? 0, ortho[2] ?? 0) < 1e-9 ? perpendicularSeed(z) : normalize3(ortho);
|
|
123
|
+
}
|
|
124
|
+
const y = cross3(z, x);
|
|
125
|
+
const at = spec.at ?? [0, 0, 0];
|
|
126
|
+
return { r: [x[0], y[0], z[0], x[1], y[1], z[1], x[2], y[2], z[2]], t: [...at] };
|
|
127
|
+
}
|
|
128
|
+
const translationMat = (t) => ({ r: [1, 0, 0, 0, 1, 0, 0, 0, 1], t: [...t] });
|
|
129
|
+
/** The joint's motion at its current value, in joint-local coordinates. */
|
|
130
|
+
export function jointMotionMat(spec) {
|
|
131
|
+
const value = valueOf(spec);
|
|
132
|
+
switch (spec.type) {
|
|
133
|
+
case 'rigid':
|
|
134
|
+
return translationMat([0, 0, 0]);
|
|
135
|
+
case 'revolute':
|
|
136
|
+
return { r: eulerMatrix([0, 0, value.angle ?? 0]), t: [0, 0, 0] };
|
|
137
|
+
case 'linear':
|
|
138
|
+
return translationMat([0, 0, value.linear ?? 0]);
|
|
139
|
+
case 'cylindrical':
|
|
140
|
+
// Spin after the axial slide (both about local Z — the screw).
|
|
141
|
+
return composeMat({ r: eulerMatrix([0, 0, value.angle ?? 0]), t: [0, 0, 0] }, translationMat([0, 0, value.linear ?? 0]));
|
|
142
|
+
case 'ball':
|
|
143
|
+
return { r: eulerMatrix(value.angles ?? [0, 0, 0]), t: [0, 0, 0] };
|
|
144
|
+
case 'planar':
|
|
145
|
+
// Spin, then slide [u,v] in the joint frame's plane.
|
|
146
|
+
return composeMat(translationMat([value.position?.[0] ?? 0, value.position?.[1] ?? 0, 0]), { r: eulerMatrix([0, 0, value.angle ?? 0]), t: [0, 0, 0] });
|
|
147
|
+
case 'polar':
|
|
148
|
+
// Spin, then slide `radial` along local X.
|
|
149
|
+
return composeMat(translationMat([value.radial ?? 0, 0, 0]), { r: eulerMatrix([0, 0, value.angle ?? 0]), t: [0, 0, 0] });
|
|
150
|
+
default:
|
|
151
|
+
throw new Error(`unknown joint type: ${String(spec.type)}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** The spec's value as an object (a bare number routed per type). */
|
|
155
|
+
export function valueOf(spec) {
|
|
156
|
+
if (spec.value === undefined)
|
|
157
|
+
return {};
|
|
158
|
+
if (typeof spec.value === 'number') {
|
|
159
|
+
const key = BARE_KEY[spec.type];
|
|
160
|
+
if (key === undefined) {
|
|
161
|
+
throw new Error(`"${spec.type}" joints need an object value ({ ${VALUE_KEYS[spec.type].join(', ')} }), not a bare number`);
|
|
162
|
+
}
|
|
163
|
+
return { [key]: spec.value };
|
|
164
|
+
}
|
|
165
|
+
return spec.value;
|
|
166
|
+
}
|
|
167
|
+
// ── validation ───────────────────────────────────────────────────────────────
|
|
168
|
+
function checkRange(value, range, what) {
|
|
169
|
+
if (range !== undefined && (value < range[0] || value > range[1])) {
|
|
170
|
+
throw new Error(`${what} = ${value} 超出范围 [${range[0]}, ${range[1]}]`);
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
function validateValue(type, value, angularRange, linearRange, where) {
|
|
175
|
+
const allowed = VALUE_KEYS[type];
|
|
176
|
+
const keys = typeof value === 'number' ? (BARE_KEY[type] === undefined ? [] : [BARE_KEY[type]]) : Object.keys(value);
|
|
177
|
+
if (typeof value === 'number' && BARE_KEY[type] === undefined && type !== 'rigid') {
|
|
178
|
+
throw new Error(`${where}: "${type}" needs an object value ({ ${allowed.join(', ')} }), not a bare number`);
|
|
179
|
+
}
|
|
180
|
+
for (const key of keys) {
|
|
181
|
+
if (!allowed.includes(key)) {
|
|
182
|
+
throw new Error(`${where}: "${type}" joints do not take "${key}" — allowed: ${allowed.join(', ') || 'none (rigid)'}`);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (type === 'rigid' && (typeof value !== 'number' ? Object.keys(value).length > 0 : true)) {
|
|
186
|
+
throw new Error(`${where}: rigid joints take no motion value`);
|
|
187
|
+
}
|
|
188
|
+
if (typeof value === 'number') {
|
|
189
|
+
if (type === 'revolute')
|
|
190
|
+
checkRange(value, angularRange, `${where}.angle`);
|
|
191
|
+
if (type === 'linear')
|
|
192
|
+
checkRange(value, linearRange, `${where}.linear`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (value.angle !== undefined && ['revolute', 'cylindrical', 'planar', 'polar'].includes(type)) {
|
|
196
|
+
checkRange(value.angle, angularRange, `${where}.angle`);
|
|
197
|
+
}
|
|
198
|
+
if (value.linear !== undefined && (type === 'cylindrical' || type === 'linear')) {
|
|
199
|
+
checkRange(value.linear, linearRange, `${where}.linear`);
|
|
200
|
+
}
|
|
201
|
+
if (value.angles !== undefined && type === 'ball') {
|
|
202
|
+
if (!Array.isArray(value.angles) || value.angles.length !== 3)
|
|
203
|
+
throw new Error(`${where}.angles must be [ax,ay,az] degrees`);
|
|
204
|
+
}
|
|
205
|
+
if (value.position !== undefined && type === 'planar') {
|
|
206
|
+
if (!Array.isArray(value.position) || value.position.length !== 2)
|
|
207
|
+
throw new Error(`${where}.position must be [u,v] mm`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const tuple3 = (raw, what) => {
|
|
211
|
+
if (!Array.isArray(raw) || raw.length !== 3 || !raw.every((v) => typeof v === 'number' && Number.isFinite(v))) {
|
|
212
|
+
throw new Error(`${what} must be [x,y,z] numbers`);
|
|
213
|
+
}
|
|
214
|
+
return raw;
|
|
215
|
+
};
|
|
216
|
+
/** Validate + normalize a raw cad_assembly_joint payload into a JointSpec. */
|
|
217
|
+
export function normalizeJointSpec(raw, where) {
|
|
218
|
+
if (raw === null || typeof raw !== 'object')
|
|
219
|
+
throw new Error(`${where}: a joint must be an object { type, at?, axis?, xDir?, value?, … }`);
|
|
220
|
+
const input = raw;
|
|
221
|
+
if (typeof input.type !== 'string' || !JOINT_TYPES.includes(input.type)) {
|
|
222
|
+
throw new Error(`${where}.type must be one of ${JOINT_TYPES.join(' / ')}`);
|
|
223
|
+
}
|
|
224
|
+
const type = input.type;
|
|
225
|
+
const spec = { type };
|
|
226
|
+
if (input.at !== undefined)
|
|
227
|
+
spec.at = tuple3(input.at, `${where}.at`);
|
|
228
|
+
if (input.axis !== undefined) {
|
|
229
|
+
spec.axis = tuple3(input.axis, `${where}.axis`);
|
|
230
|
+
if (Math.hypot(...spec.axis) < 1e-9)
|
|
231
|
+
throw new Error(`${where}.axis must be a non-zero direction`);
|
|
232
|
+
}
|
|
233
|
+
if (input.xDir !== undefined)
|
|
234
|
+
spec.xDir = tuple3(input.xDir, `${where}.xDir`);
|
|
235
|
+
if (input.angularRange !== undefined) {
|
|
236
|
+
const r = input.angularRange;
|
|
237
|
+
if (!Array.isArray(r) || r.length !== 2 || !r.every((v) => typeof v === 'number'))
|
|
238
|
+
throw new Error(`${where}.angularRange must be [min,max] degrees`);
|
|
239
|
+
spec.angularRange = r;
|
|
240
|
+
}
|
|
241
|
+
if (input.linearRange !== undefined) {
|
|
242
|
+
const r = input.linearRange;
|
|
243
|
+
if (!Array.isArray(r) || r.length !== 2 || !r.every((v) => typeof v === 'number'))
|
|
244
|
+
throw new Error(`${where}.linearRange must be [min,max] mm`);
|
|
245
|
+
spec.linearRange = r;
|
|
246
|
+
}
|
|
247
|
+
if (input.value !== undefined)
|
|
248
|
+
validateValue(type, input.value, spec.angularRange, spec.linearRange, `${where}.value`);
|
|
249
|
+
if (input.value !== undefined)
|
|
250
|
+
spec.value = input.value;
|
|
251
|
+
return spec;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Merge a motion value into a spec (driving a joint): object values overlay
|
|
255
|
+
* their keys onto the current value, a bare number replaces the single DOF.
|
|
256
|
+
*/
|
|
257
|
+
export function mergeJointValue(spec, value, where) {
|
|
258
|
+
const previous = valueOf(spec);
|
|
259
|
+
const next = typeof value === 'number'
|
|
260
|
+
? value
|
|
261
|
+
: { ...previous, ...value };
|
|
262
|
+
validateValue(spec.type, next, spec.angularRange, spec.linearRange, where);
|
|
263
|
+
return { ...spec, value: next };
|
|
264
|
+
}
|
|
265
|
+
/** Check a parent↔child pairing (build123d's table). */
|
|
266
|
+
export function checkPairing(parentType, childType, where) {
|
|
267
|
+
const allowed = PAIRINGS[parentType];
|
|
268
|
+
if (!allowed.includes(childType)) {
|
|
269
|
+
throw new Error(`${where}: a ${parentType} joint connects to ${allowed.join('/')}, but the child joint is ${childType}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Merge a connect-time value into the pairing (build123d semantics): the
|
|
274
|
+
* value drives the PARENT joint — except a linear→revolute pair, where
|
|
275
|
+
* `angle` routes to the child revolute and the rest goes to the parent.
|
|
276
|
+
*/
|
|
277
|
+
export function applyConnectValue(parentSpec, childSpec, value, where) {
|
|
278
|
+
if (parentSpec.type === 'linear' && childSpec.type === 'revolute' && typeof value === 'object' && value !== null && 'angle' in value) {
|
|
279
|
+
const { angle, ...rest } = value;
|
|
280
|
+
const parent = Object.keys(rest).length > 0 ? mergeJointValue(parentSpec, rest, `${where} (parent)`) : parentSpec;
|
|
281
|
+
const child = angle !== undefined ? mergeJointValue(childSpec, { angle }, `${where} (child)`) : childSpec;
|
|
282
|
+
return { parent, child };
|
|
283
|
+
}
|
|
284
|
+
return { parent: mergeJointValue(parentSpec, value, where), child: childSpec };
|
|
285
|
+
}
|
|
286
|
+
// ── connect & solve ──────────────────────────────────────────────────────────
|
|
287
|
+
/**
|
|
288
|
+
* Solve the child's world placement so the two joint frames coincide (with
|
|
289
|
+
* both motions applied) — cadorange's `connectTo` in the flat world-placed
|
|
290
|
+
* model. The parent stays fixed; the child is repositioned.
|
|
291
|
+
*/
|
|
292
|
+
export function solveConnection(parent, parentSpec, childSpec) {
|
|
293
|
+
const world = composeMat(composeMat(composeMat(composeMat(placementMat(parent), jointFrameMat(parentSpec)), jointMotionMat(parentSpec)), jointMotionMat(childSpec)), invertMat(jointFrameMat(childSpec)));
|
|
294
|
+
return matPlacement(world);
|
|
295
|
+
}
|
|
296
|
+
/**
|
|
297
|
+
* Replay every connection at the joints' CURRENT values (recorded order — a
|
|
298
|
+
* connection whose parent was itself placed by an earlier connection chains
|
|
299
|
+
* through its fresh placement). Returns the solved placement per child.
|
|
300
|
+
*/
|
|
301
|
+
export function solveConnections(instances, joints, connections) {
|
|
302
|
+
const placements = new Map(instances.map((instance) => [instance.instanceId, {
|
|
303
|
+
translate: [...instance.translate],
|
|
304
|
+
rotate: [...instance.rotate],
|
|
305
|
+
}]));
|
|
306
|
+
for (const connection of connections) {
|
|
307
|
+
const parentInstance = instances.find((instance) => instance.instanceId === connection.parent);
|
|
308
|
+
const parentSpec = joints.get(connection.parent)?.[connection.parentLabel];
|
|
309
|
+
const childSpec = joints.get(connection.child)?.[connection.childLabel];
|
|
310
|
+
if (parentInstance === undefined || parentSpec === undefined || childSpec === undefined)
|
|
311
|
+
continue; // stale edge (instance/joint gone)
|
|
312
|
+
const current = placements.get(connection.parent) ?? {
|
|
313
|
+
translate: [...parentInstance.translate],
|
|
314
|
+
rotate: [...parentInstance.rotate],
|
|
315
|
+
};
|
|
316
|
+
placements.set(connection.child, solveConnection(current, parentSpec, childSpec));
|
|
317
|
+
}
|
|
318
|
+
return placements;
|
|
319
|
+
}
|
|
320
|
+
// ── exploded view ────────────────────────────────────────────────────────────
|
|
321
|
+
/** Axis-aligned bbox of a body mesh (local coordinates). */
|
|
322
|
+
function meshBBox(mesh) {
|
|
323
|
+
const p = mesh.positions;
|
|
324
|
+
if (p === undefined || p.length < 3)
|
|
325
|
+
return null;
|
|
326
|
+
const min = [Infinity, Infinity, Infinity];
|
|
327
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
328
|
+
for (let i = 0; i + 2 < p.length; i += 3) {
|
|
329
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
330
|
+
const v = p[i + axis] ?? 0;
|
|
331
|
+
if (v < (min[axis] ?? Infinity))
|
|
332
|
+
min[axis] = v;
|
|
333
|
+
if (v > (max[axis] ?? -Infinity))
|
|
334
|
+
max[axis] = v;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
return Number.isFinite(min[0]) ? { min, max } : null;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Exploded-view offsets per instance (cadorange's `explode` in the flat
|
|
341
|
+
* model): every instance is pushed radially away from the assembly's world
|
|
342
|
+
* bbox center — direction = instance world center − assembly center,
|
|
343
|
+
* magnitude = factor × that distance. Returns instanceId → world offset
|
|
344
|
+
* (empty when factor is 0 or no two centers differ).
|
|
345
|
+
*/
|
|
346
|
+
export function explodeOffsets(bodies, instances, factor) {
|
|
347
|
+
const offsets = new Map();
|
|
348
|
+
if (!Number.isFinite(factor) || Math.abs(factor) < 1e-12 || instances.length === 0)
|
|
349
|
+
return offsets;
|
|
350
|
+
const centers = [];
|
|
351
|
+
const worldMin = [Infinity, Infinity, Infinity];
|
|
352
|
+
const worldMax = [-Infinity, -Infinity, -Infinity];
|
|
353
|
+
for (const instance of instances) {
|
|
354
|
+
const body = bodies.get(instance.bodyId);
|
|
355
|
+
const local = body === undefined ? null : meshBBox(body);
|
|
356
|
+
if (local === null)
|
|
357
|
+
continue;
|
|
358
|
+
const m = placementMat(instance);
|
|
359
|
+
// World bbox corners of the placed local box.
|
|
360
|
+
const corner = (i) => {
|
|
361
|
+
const p = [
|
|
362
|
+
i & 1 ? local.max[0] : local.min[0],
|
|
363
|
+
i & 2 ? local.max[1] : local.min[1],
|
|
364
|
+
i & 4 ? local.max[2] : local.min[2],
|
|
365
|
+
];
|
|
366
|
+
return [
|
|
367
|
+
(m.r[0] ?? 0) * p[0] + (m.r[1] ?? 0) * p[1] + (m.r[2] ?? 0) * p[2] + m.t[0],
|
|
368
|
+
(m.r[3] ?? 0) * p[0] + (m.r[4] ?? 0) * p[1] + (m.r[5] ?? 0) * p[2] + m.t[1],
|
|
369
|
+
(m.r[6] ?? 0) * p[0] + (m.r[7] ?? 0) * p[1] + (m.r[8] ?? 0) * p[2] + m.t[2],
|
|
370
|
+
];
|
|
371
|
+
};
|
|
372
|
+
const instanceMin = [Infinity, Infinity, Infinity];
|
|
373
|
+
const instanceMax = [-Infinity, -Infinity, -Infinity];
|
|
374
|
+
for (let i = 0; i < 8; i++) {
|
|
375
|
+
const c = corner(i);
|
|
376
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
377
|
+
const v = c[axis] ?? 0;
|
|
378
|
+
if (v < (instanceMin[axis] ?? Infinity))
|
|
379
|
+
instanceMin[axis] = v;
|
|
380
|
+
if (v > (instanceMax[axis] ?? -Infinity))
|
|
381
|
+
instanceMax[axis] = v;
|
|
382
|
+
if (v < (worldMin[axis] ?? Infinity))
|
|
383
|
+
worldMin[axis] = v;
|
|
384
|
+
if (v > (worldMax[axis] ?? -Infinity))
|
|
385
|
+
worldMax[axis] = v;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
centers.push({
|
|
389
|
+
instanceId: instance.instanceId,
|
|
390
|
+
center: [(instanceMin[0] + instanceMax[0]) / 2, (instanceMin[1] + instanceMax[1]) / 2, (instanceMin[2] + instanceMax[2]) / 2],
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
if (centers.length === 0)
|
|
394
|
+
return offsets;
|
|
395
|
+
const assemblyCenter = [
|
|
396
|
+
(worldMin[0] + worldMax[0]) / 2,
|
|
397
|
+
(worldMin[1] + worldMax[1]) / 2,
|
|
398
|
+
(worldMin[2] + worldMax[2]) / 2,
|
|
399
|
+
];
|
|
400
|
+
for (const entry of centers) {
|
|
401
|
+
const delta = [
|
|
402
|
+
entry.center[0] - assemblyCenter[0],
|
|
403
|
+
entry.center[1] - assemblyCenter[1],
|
|
404
|
+
entry.center[2] - assemblyCenter[2],
|
|
405
|
+
];
|
|
406
|
+
const dist = Math.hypot(delta[0], delta[1], delta[2]);
|
|
407
|
+
if (dist < 1e-9)
|
|
408
|
+
continue; // centered parts stay put
|
|
409
|
+
offsets.set(entry.instanceId, [delta[0] * factor, delta[1] * factor, delta[2] * factor]);
|
|
410
|
+
}
|
|
411
|
+
return offsets;
|
|
412
|
+
}
|
|
413
|
+
// ── display ──────────────────────────────────────────────────────────────────
|
|
414
|
+
/** Human value text ("30°", "12mm", "30° · 5mm") — for tool renders and the panel. */
|
|
415
|
+
export function jointValueText(spec) {
|
|
416
|
+
const v = valueOf(spec);
|
|
417
|
+
const parts = [];
|
|
418
|
+
if (v.angle !== undefined)
|
|
419
|
+
parts.push(`${round(v.angle)}°`);
|
|
420
|
+
if (v.linear !== undefined)
|
|
421
|
+
parts.push(`${round(v.linear)}mm`);
|
|
422
|
+
if (v.radial !== undefined)
|
|
423
|
+
parts.push(`r${round(v.radial)}`);
|
|
424
|
+
if (v.position !== undefined)
|
|
425
|
+
parts.push(`[${round(v.position[0] ?? 0)}, ${round(v.position[1] ?? 0)}]`);
|
|
426
|
+
if (v.angles !== undefined)
|
|
427
|
+
parts.push(v.angles.map((a) => `${round(a)}°`).join(' '));
|
|
428
|
+
return parts.join(' · ');
|
|
429
|
+
}
|
|
430
|
+
function round(value) {
|
|
431
|
+
return Math.round(value * 1000) / 1000;
|
|
432
|
+
}
|
|
433
|
+
/** Chinese labels for the joint kinds (panel display). */
|
|
434
|
+
export const JOINT_TYPE_ZH = {
|
|
435
|
+
rigid: '刚性',
|
|
436
|
+
revolute: '旋转',
|
|
437
|
+
linear: '滑动',
|
|
438
|
+
cylindrical: '圆柱',
|
|
439
|
+
ball: '球面',
|
|
440
|
+
planar: '平面',
|
|
441
|
+
polar: '极坐标',
|
|
442
|
+
};
|
|
@@ -287,6 +287,29 @@ async function applyOp(op) {
|
|
|
287
287
|
case 'assembly_list': {
|
|
288
288
|
return { instances: instancesList() }
|
|
289
289
|
}
|
|
290
|
+
case 'assembly_joint': {
|
|
291
|
+
// Metadata-only op: joint specs live in the document log; the solved
|
|
292
|
+
// placement arrives separately as an assembly_transform op. The worker
|
|
293
|
+
// just guards the instance reference so a bad id fails loudly.
|
|
294
|
+
if (!instances.has(op.instanceId)) throw new Error(`unknown instance: ${op.instanceId}`)
|
|
295
|
+
return { instanceId: op.instanceId, instances: instancesList() }
|
|
296
|
+
}
|
|
297
|
+
case 'assembly_connect': {
|
|
298
|
+
if (!instances.has(op.instanceId)) throw new Error(`unknown instance: ${op.instanceId}`)
|
|
299
|
+
if (!instances.has(op.parent)) throw new Error(`unknown instance: ${op.parent}`)
|
|
300
|
+
return { instanceId: op.instanceId, instances: instancesList() }
|
|
301
|
+
}
|
|
302
|
+
case 'assembly_joint_value': {
|
|
303
|
+
if (!instances.has(op.instanceId)) throw new Error(`unknown instance: ${op.instanceId}`)
|
|
304
|
+
return { instanceId: op.instanceId, instances: instancesList() }
|
|
305
|
+
}
|
|
306
|
+
case 'assembly_explode': {
|
|
307
|
+
if (typeof op.factor !== 'number' || !Number.isFinite(op.factor)) throw new Error('explode factor must be a finite number')
|
|
308
|
+
return { instances: instancesList() }
|
|
309
|
+
}
|
|
310
|
+
case 'import_step': {
|
|
311
|
+
return importStep(op)
|
|
312
|
+
}
|
|
290
313
|
case 'constraints': {
|
|
291
314
|
// Persist-only op: the constraint model lives in the document log and
|
|
292
315
|
// is solved on the main thread (Ansatz wasm); the worker stores nothing.
|
|
@@ -385,6 +408,7 @@ async function applyOp(op) {
|
|
|
385
408
|
bodies.clear()
|
|
386
409
|
instances.clear()
|
|
387
410
|
sketches.clear()
|
|
411
|
+
stepImportCache.clear()
|
|
388
412
|
nextBodyNumber = 1
|
|
389
413
|
return { cleared: true }
|
|
390
414
|
}
|
|
@@ -461,6 +485,70 @@ function requireShape(bodyId, what) {
|
|
|
461
485
|
return body
|
|
462
486
|
}
|
|
463
487
|
|
|
488
|
+
/**
|
|
489
|
+
* Parsed STEP-assembly structures keyed by asset path — the probe pass and
|
|
490
|
+
* the materialize pass (plus every replay) reuse one parse. Cleared on reset
|
|
491
|
+
* with the bodies it keeps alive.
|
|
492
|
+
*/
|
|
493
|
+
const stepImportCache = new Map()
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* import_step: parse a STEP assembly document into unique part bodies +
|
|
497
|
+
* placed instances (cadorange backend). Two phases share one cached parse:
|
|
498
|
+
* - probe (no `bodies`): return the structure (names/keys/placements, no
|
|
499
|
+
* geometry) so the main thread can assign body/instance ids;
|
|
500
|
+
* - materialize (`bodies` + `instances`): create the bodies/instances with
|
|
501
|
+
* the caller-assigned ids — the recorded op replays identically.
|
|
502
|
+
*/
|
|
503
|
+
async function importStep(op) {
|
|
504
|
+
if (typeof adapter.importStepDocument !== 'function') {
|
|
505
|
+
throw new Error('STEP 装配导入需要 cadorange 内核(当前直连后端不支持结构化读取)')
|
|
506
|
+
}
|
|
507
|
+
if (typeof op.path !== 'string' || op.path === '') throw new Error('import_step needs an asset path')
|
|
508
|
+
let cached = stepImportCache.get(op.path)
|
|
509
|
+
if (cached === undefined) {
|
|
510
|
+
const bytes = fs.readFileSync(op.path)
|
|
511
|
+
cached = adapter.importStepDocument(bytes)
|
|
512
|
+
stepImportCache.set(op.path, cached)
|
|
513
|
+
}
|
|
514
|
+
const { parts, leaves } = cached
|
|
515
|
+
if (op.bodies === undefined) {
|
|
516
|
+
return {
|
|
517
|
+
stepStructure: {
|
|
518
|
+
parts: parts.map((part) => ({ name: part.name, key: part.key })),
|
|
519
|
+
leaves: leaves.map((leaf) => ({ part: leaf.part, name: leaf.name, translate: leaf.translate, rotate: leaf.rotate })),
|
|
520
|
+
},
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
// Materialize: the caller's assignments must match this parse exactly — a
|
|
524
|
+
// kernel/file change between record and replay fails loudly here.
|
|
525
|
+
if (!Array.isArray(op.bodies) || op.bodies.length !== parts.length) {
|
|
526
|
+
throw new Error(`import_step: op assigns ${Array.isArray(op.bodies) ? op.bodies.length : 0} bodies but the file parses into ${parts.length} — re-import the file`)
|
|
527
|
+
}
|
|
528
|
+
const meshes = []
|
|
529
|
+
for (let i = 0; i < parts.length; i++) {
|
|
530
|
+
const assigned = op.bodies[i]
|
|
531
|
+
const part = parts[i]
|
|
532
|
+
// Every body gets its own kernel object — disposing one body never
|
|
533
|
+
// dangles a sibling sharing the cached parse.
|
|
534
|
+
const shape = typeof adapter.copyShape === 'function' ? adapter.copyShape(part.shape) : part.shape
|
|
535
|
+
bodies.set(assigned.bodyId, { shape, name: assigned.name ?? part.name })
|
|
536
|
+
meshes.push({ bodyId: assigned.bodyId, name: assigned.name ?? part.name, ...meshOf(shape, assigned.name ?? part.name) })
|
|
537
|
+
}
|
|
538
|
+
for (const instance of op.instances ?? []) {
|
|
539
|
+
if (instances.has(instance.instanceId)) throw new Error(`instance already exists: ${instance.instanceId}`)
|
|
540
|
+
if (!bodies.has(instance.bodyId)) throw new Error(`import_step instance references unknown body: ${instance.bodyId}`)
|
|
541
|
+
instances.set(instance.instanceId, {
|
|
542
|
+
instanceId: instance.instanceId,
|
|
543
|
+
bodyId: instance.bodyId,
|
|
544
|
+
name: instance.name,
|
|
545
|
+
translate: triplet(instance.translate),
|
|
546
|
+
rotate: triplet(instance.rotate),
|
|
547
|
+
})
|
|
548
|
+
}
|
|
549
|
+
return { bodies: op.bodies.map((entry) => entry.bodyId), meshes, instances: instancesList() }
|
|
550
|
+
}
|
|
551
|
+
|
|
464
552
|
async function drawingViews(op) {
|
|
465
553
|
const body = bodies.get(op.target)
|
|
466
554
|
if (body === undefined) throw new Error(`unknown body: ${op.target}`)
|