partforge 0.40.0 → 0.44.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 +46 -6
- package/bin/cli.js +103 -27
- package/docs/AUTHORING-PARTS.md +143 -15
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +48 -7
- package/skills/partforge/SKILL.md +17 -3
- package/src/app-embed-test.js +1 -1
- package/src/app-hinged-box.js +12 -0
- package/src/framework/animation-controls.js +243 -0
- package/src/framework/animation.js +217 -0
- package/src/framework/app.css +32 -0
- package/src/framework/assembly.js +1 -1
- package/src/framework/backend-select.js +25 -0
- package/src/framework/camera-tween.js +58 -0
- package/src/framework/chrome.css +16 -0
- package/src/framework/controls.js +13 -3
- package/src/framework/cutaway-gizmo-scene.js +244 -0
- package/src/framework/cutaway-gizmo.js +80 -243
- package/src/framework/default-view.js +46 -0
- package/src/framework/download.js +7 -2
- package/src/framework/export-controller.js +13 -2
- package/src/framework/geometry/probe.js +3 -22
- package/src/framework/jobs.js +23 -42
- package/src/framework/lint/finding.js +4 -0
- package/src/framework/lint/index.js +7 -3
- package/src/framework/lint/rules-animations.js +404 -0
- package/src/framework/lint/rules-place.js +76 -0
- package/src/framework/lint/rules-shape.js +12 -0
- package/src/framework/lint/rules-verify.js +2 -2
- package/src/framework/mount.js +113 -18
- package/src/{testing → framework/oracle}/build.js +1 -1
- package/src/framework/oracle/bvh.js +463 -0
- package/src/{testing → framework/oracle}/gaps.js +6 -3
- package/src/{testing → framework/oracle}/measure.js +34 -4
- package/src/framework/oracle/min-wall.js +98 -0
- package/src/{testing → framework/oracle}/verify.js +61 -5
- package/src/framework/param-deps.js +1 -1
- package/src/framework/part-model.js +48 -0
- package/src/framework/pick-request/client.js +11 -3
- package/src/framework/pick-request/endpoint.js +60 -0
- package/src/framework/pick-request/index.js +6 -0
- package/src/framework/pick-request/server.js +222 -34
- package/src/framework/pick-request/token-store.js +31 -0
- package/src/framework/pose-fast-path.js +12 -1
- package/src/framework/pose-probe-core.js +129 -0
- package/src/framework/pose-probe.js +7 -123
- package/src/framework/regen-loop.js +10 -3
- package/src/framework/safe-name.js +26 -0
- package/src/framework/verify-metrics.js +19 -6
- package/src/framework/view-state.js +25 -21
- package/src/framework/view-tabs.js +22 -7
- package/src/framework/viewer-controls.js +5 -26
- package/src/framework/viewer.js +126 -16
- package/src/hinged-box-worker.js +3 -0
- package/src/index.js +1 -1
- package/src/parts/hinged-box.js +94 -0
- package/src/testing/render.js +19 -8
- package/src/testing.js +15 -8
- package/types/derive.d.ts +14 -0
- package/types/geometry.d.ts +117 -0
- package/types/index.d.ts +240 -0
- package/types/kernel.d.ts +409 -0
- package/types/lint.d.ts +85 -0
- package/types/part.d.ts +381 -0
- package/types/testing.d.ts +362 -0
- package/types/worker.d.ts +21 -0
- package/src/testing/bvh.js +0 -273
- package/src/testing/min-wall.js +0 -38
- /package/src/{testing → framework/oracle}/assert-dsl.js +0 -0
- /package/src/{testing → framework/oracle}/cases.js +0 -0
- /package/src/{testing → framework/oracle}/dfm-profiles.js +0 -0
- /package/src/{testing → framework/oracle}/mesh.js +0 -0
|
@@ -4,8 +4,11 @@ import {
|
|
|
4
4
|
signedAngleAroundAxis,
|
|
5
5
|
snapQuaternionToAxis,
|
|
6
6
|
} from "./cutaway-math.js";
|
|
7
|
-
import {
|
|
7
|
+
import { buildGizmoScene } from "./cutaway-gizmo-scene.js";
|
|
8
8
|
|
|
9
|
+
// The single source of truth for gizmo colors: the scene is constructed from
|
|
10
|
+
// `dark` and `updateAppearance` re-derives from whichever mode is active, so a
|
|
11
|
+
// palette edit here reaches the first frame as well as every later one.
|
|
9
12
|
const THEMES = {
|
|
10
13
|
dark: {
|
|
11
14
|
fill: 0x65bff5,
|
|
@@ -23,6 +26,8 @@ const THEMES = {
|
|
|
23
26
|
},
|
|
24
27
|
};
|
|
25
28
|
|
|
29
|
+
export { THEMES as CUTAWAY_GIZMO_THEMES };
|
|
30
|
+
|
|
26
31
|
const TRANSLATION_SCREEN_ALIGNMENT = 0.9;
|
|
27
32
|
const ROTATION_SCREEN_ALIGNMENT = 0.15;
|
|
28
33
|
// A 120 px perpendicular drag rotates the plane by 90 degrees.
|
|
@@ -30,11 +35,9 @@ const SCREEN_ROTATION_RADIANS_PER_PIXEL = Math.PI / 240;
|
|
|
30
35
|
const SCREEN_AXIS_EPSILON_SQ = 1e-8;
|
|
31
36
|
// Reserve the visually shared center for the end-on translation handle.
|
|
32
37
|
const TRANSLATE_CENTER_RADIUS_PX = 22;
|
|
33
|
-
const GIZMO_RENDER_ORDER = CUTAWAY_OVERLAY_RENDER_ORDER + 1;
|
|
34
38
|
const GHOST_OFFSET_FACTOR = 0.001;
|
|
35
39
|
const MIN_GHOST_OFFSET = 0.01;
|
|
36
40
|
const MAX_GHOST_OFFSET = 0.25;
|
|
37
|
-
const HANDLE_HOVER_THICKNESS = 1.6;
|
|
38
41
|
const HANDLE_HOVER_WHITE_MIX = 0.28;
|
|
39
42
|
const WHITE = new THREE.Color(0xffffff);
|
|
40
43
|
|
|
@@ -50,211 +53,20 @@ export function createCutawayGizmo({
|
|
|
50
53
|
onDragChange = () => {},
|
|
51
54
|
pickHandle,
|
|
52
55
|
}) {
|
|
53
|
-
const
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
depthWrite: false,
|
|
65
|
-
side: THREE.DoubleSide,
|
|
66
|
-
}),
|
|
67
|
-
);
|
|
68
|
-
geometries.add(fill.geometry);
|
|
69
|
-
materials.add(fill.material);
|
|
70
|
-
|
|
71
|
-
const borderGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
72
|
-
new THREE.Vector3(-0.5, -0.5, 0),
|
|
73
|
-
new THREE.Vector3(0.5, -0.5, 0),
|
|
74
|
-
new THREE.Vector3(0.5, 0.5, 0),
|
|
75
|
-
new THREE.Vector3(-0.5, 0.5, 0),
|
|
76
|
-
]);
|
|
77
|
-
const borderMaterial = new THREE.LineBasicMaterial({
|
|
78
|
-
color: 0xa8dcff,
|
|
79
|
-
opacity: 1,
|
|
80
|
-
transparent: true,
|
|
81
|
-
depthTest: false,
|
|
82
|
-
depthWrite: false,
|
|
83
|
-
});
|
|
84
|
-
const border = new THREE.LineLoop(borderGeometry, borderMaterial);
|
|
85
|
-
border.renderOrder = GIZMO_RENDER_ORDER;
|
|
86
|
-
geometries.add(borderGeometry);
|
|
87
|
-
materials.add(borderMaterial);
|
|
88
|
-
|
|
89
|
-
const handleRoot = new THREE.Group();
|
|
90
|
-
const translateVisualRoot = new THREE.Group();
|
|
91
|
-
const arcRoot = new THREE.Group();
|
|
92
|
-
const translateMaterial = new THREE.MeshBasicMaterial({
|
|
93
|
-
color: 0x36d399,
|
|
94
|
-
transparent: true,
|
|
95
|
-
depthTest: true,
|
|
96
|
-
depthWrite: true,
|
|
97
|
-
});
|
|
98
|
-
const rotateXMaterial = new THREE.MeshBasicMaterial({
|
|
99
|
-
color: 0xff6b7a,
|
|
100
|
-
transparent: true,
|
|
101
|
-
depthTest: true,
|
|
102
|
-
depthWrite: true,
|
|
103
|
-
});
|
|
104
|
-
const rotateYMaterial = new THREE.MeshBasicMaterial({
|
|
105
|
-
color: 0x5aa9ff,
|
|
106
|
-
transparent: true,
|
|
107
|
-
depthTest: true,
|
|
108
|
-
depthWrite: true,
|
|
109
|
-
});
|
|
110
|
-
materials.add(translateMaterial);
|
|
111
|
-
materials.add(rotateXMaterial);
|
|
112
|
-
materials.add(rotateYMaterial);
|
|
113
|
-
|
|
114
|
-
const shaftGeometry = new THREE.CylinderGeometry(0.025, 0.025, 0.58, 12);
|
|
115
|
-
const shaftHoverGeometry = new THREE.CylinderGeometry(
|
|
116
|
-
0.025 * HANDLE_HOVER_THICKNESS,
|
|
117
|
-
0.025 * HANDLE_HOVER_THICKNESS,
|
|
118
|
-
0.58,
|
|
119
|
-
12,
|
|
120
|
-
);
|
|
121
|
-
const shaft = new THREE.Mesh(shaftGeometry, translateMaterial);
|
|
122
|
-
shaft.rotation.x = Math.PI / 2;
|
|
123
|
-
shaft.position.z = 0.29;
|
|
124
|
-
const coneGeometry = new THREE.ConeGeometry(0.075, 0.2, 16);
|
|
125
|
-
const coneHoverGeometry = new THREE.ConeGeometry(
|
|
126
|
-
0.075 * HANDLE_HOVER_THICKNESS,
|
|
127
|
-
0.2,
|
|
128
|
-
16,
|
|
129
|
-
);
|
|
130
|
-
const cone = new THREE.Mesh(coneGeometry, translateMaterial);
|
|
131
|
-
cone.rotation.x = Math.PI / 2;
|
|
132
|
-
cone.position.z = 0.68;
|
|
133
|
-
geometries.add(shaftGeometry);
|
|
134
|
-
geometries.add(shaftHoverGeometry);
|
|
135
|
-
geometries.add(coneGeometry);
|
|
136
|
-
geometries.add(coneHoverGeometry);
|
|
137
|
-
|
|
138
|
-
const ringXGeometry = new THREE.TorusGeometry(
|
|
139
|
-
0.42,
|
|
140
|
-
0.015,
|
|
141
|
-
8,
|
|
142
|
-
64,
|
|
143
|
-
Math.PI,
|
|
144
|
-
);
|
|
145
|
-
const ringX = new THREE.Mesh(ringXGeometry, rotateXMaterial);
|
|
146
|
-
const ringXHoverGeometry = new THREE.TorusGeometry(
|
|
147
|
-
0.42,
|
|
148
|
-
0.015 * HANDLE_HOVER_THICKNESS,
|
|
149
|
-
8,
|
|
150
|
-
64,
|
|
151
|
-
Math.PI,
|
|
152
|
-
);
|
|
153
|
-
ringX.quaternion
|
|
154
|
-
.setFromAxisAngle(new THREE.Vector3(1, 0, 0), -Math.PI / 2)
|
|
155
|
-
.multiply(new THREE.Quaternion().setFromAxisAngle(
|
|
156
|
-
new THREE.Vector3(0, 1, 0),
|
|
157
|
-
Math.PI / 2,
|
|
158
|
-
));
|
|
159
|
-
const ringYGeometry = new THREE.TorusGeometry(
|
|
160
|
-
0.42,
|
|
161
|
-
0.015,
|
|
162
|
-
8,
|
|
163
|
-
64,
|
|
164
|
-
Math.PI,
|
|
165
|
-
);
|
|
166
|
-
const ringY = new THREE.Mesh(ringYGeometry, rotateYMaterial);
|
|
167
|
-
const ringYHoverGeometry = new THREE.TorusGeometry(
|
|
168
|
-
0.42,
|
|
169
|
-
0.015 * HANDLE_HOVER_THICKNESS,
|
|
170
|
-
8,
|
|
171
|
-
64,
|
|
172
|
-
Math.PI,
|
|
173
|
-
);
|
|
174
|
-
ringY.rotation.x = -Math.PI / 2;
|
|
175
|
-
geometries.add(ringXGeometry);
|
|
176
|
-
geometries.add(ringXHoverGeometry);
|
|
177
|
-
geometries.add(ringYGeometry);
|
|
178
|
-
geometries.add(ringYHoverGeometry);
|
|
179
|
-
|
|
180
|
-
const hitMaterial = new THREE.MeshBasicMaterial({
|
|
181
|
-
color: 0xffffff,
|
|
182
|
-
opacity: 0,
|
|
183
|
-
transparent: true,
|
|
184
|
-
depthWrite: false,
|
|
185
|
-
});
|
|
186
|
-
materials.add(hitMaterial);
|
|
187
|
-
const translateHitGeometry = new THREE.CylinderGeometry(0.1, 0.1, 0.95, 10);
|
|
188
|
-
const translateHit = new THREE.Mesh(translateHitGeometry, hitMaterial);
|
|
189
|
-
translateHit.rotation.x = Math.PI / 2;
|
|
190
|
-
translateHit.position.z = 0.38;
|
|
191
|
-
translateHit.userData.cutawayHandle = "translate";
|
|
192
|
-
const rotateXHitGeometry = new THREE.TorusGeometry(
|
|
193
|
-
0.42,
|
|
194
|
-
0.12,
|
|
195
|
-
8,
|
|
196
|
-
48,
|
|
197
|
-
Math.PI,
|
|
198
|
-
);
|
|
199
|
-
const rotateXHit = new THREE.Mesh(rotateXHitGeometry, hitMaterial);
|
|
200
|
-
rotateXHit.quaternion.copy(ringX.quaternion);
|
|
201
|
-
rotateXHit.userData.cutawayHandle = "rotate-x";
|
|
202
|
-
const rotateYHitGeometry = new THREE.TorusGeometry(
|
|
203
|
-
0.42,
|
|
204
|
-
0.12,
|
|
205
|
-
8,
|
|
206
|
-
48,
|
|
207
|
-
Math.PI,
|
|
208
|
-
);
|
|
209
|
-
const rotateYHit = new THREE.Mesh(rotateYHitGeometry, hitMaterial);
|
|
210
|
-
rotateYHit.quaternion.copy(ringY.quaternion);
|
|
211
|
-
rotateYHit.userData.cutawayHandle = "rotate-y";
|
|
212
|
-
geometries.add(translateHitGeometry);
|
|
213
|
-
geometries.add(rotateXHitGeometry);
|
|
214
|
-
geometries.add(rotateYHitGeometry);
|
|
215
|
-
|
|
216
|
-
translateVisualRoot.add(shaft, cone);
|
|
217
|
-
arcRoot.add(ringX, ringY, rotateXHit, rotateYHit);
|
|
218
|
-
handleRoot.add(translateVisualRoot, translateHit, arcRoot);
|
|
219
|
-
group.add(fill, border);
|
|
56
|
+
const sceneGraph = buildGizmoScene(THEMES.dark);
|
|
57
|
+
const {
|
|
58
|
+
group,
|
|
59
|
+
fill,
|
|
60
|
+
border,
|
|
61
|
+
handleRoot,
|
|
62
|
+
arcRoot,
|
|
63
|
+
handles,
|
|
64
|
+
handleVisuals,
|
|
65
|
+
handleAppearance,
|
|
66
|
+
} = sceneGraph;
|
|
220
67
|
scene.add(group);
|
|
221
68
|
overlayScene.add(handleRoot);
|
|
222
69
|
|
|
223
|
-
const handles = {
|
|
224
|
-
translate: translateHit,
|
|
225
|
-
rotateX: rotateXHit,
|
|
226
|
-
rotateY: rotateYHit,
|
|
227
|
-
};
|
|
228
|
-
const handleVisuals = {
|
|
229
|
-
translate: translateVisualRoot,
|
|
230
|
-
rotateX: ringX,
|
|
231
|
-
rotateY: ringY,
|
|
232
|
-
};
|
|
233
|
-
const handleAppearance = {
|
|
234
|
-
translate: {
|
|
235
|
-
visual: translateVisualRoot,
|
|
236
|
-
material: translateMaterial,
|
|
237
|
-
geometryPairs: [
|
|
238
|
-
{ mesh: shaft, normal: shaftGeometry, hovered: shaftHoverGeometry },
|
|
239
|
-
{ mesh: cone, normal: coneGeometry, hovered: coneHoverGeometry },
|
|
240
|
-
],
|
|
241
|
-
},
|
|
242
|
-
"rotate-x": {
|
|
243
|
-
visual: ringX,
|
|
244
|
-
material: rotateXMaterial,
|
|
245
|
-
geometryPairs: [
|
|
246
|
-
{ mesh: ringX, normal: ringXGeometry, hovered: ringXHoverGeometry },
|
|
247
|
-
],
|
|
248
|
-
},
|
|
249
|
-
"rotate-y": {
|
|
250
|
-
visual: ringY,
|
|
251
|
-
material: rotateYMaterial,
|
|
252
|
-
geometryPairs: [
|
|
253
|
-
{ mesh: ringY, normal: ringYGeometry, hovered: ringYHoverGeometry },
|
|
254
|
-
],
|
|
255
|
-
},
|
|
256
|
-
};
|
|
257
|
-
|
|
258
70
|
let disposed = false;
|
|
259
71
|
let poseSize = 1;
|
|
260
72
|
let flipped = false;
|
|
@@ -336,8 +148,8 @@ export function createCutawayGizmo({
|
|
|
336
148
|
const theme = THEMES[themeMode] ?? THEMES.dark;
|
|
337
149
|
fill.material.color.set(theme.fill);
|
|
338
150
|
fill.material.opacity = activeAppearance ? 0.18 : 0.055;
|
|
339
|
-
|
|
340
|
-
|
|
151
|
+
border.material.color.set(theme.border);
|
|
152
|
+
border.material.opacity = activeAppearance ? 1 : 0.72;
|
|
341
153
|
|
|
342
154
|
for (const [handle, { visual, material, geometryPairs }] of Object.entries(handleAppearance)) {
|
|
343
155
|
const hovered = handle === hoveredHandle;
|
|
@@ -396,6 +208,12 @@ export function createCutawayGizmo({
|
|
|
396
208
|
return camera.getWorldDirection(new THREE.Vector3()).normalize();
|
|
397
209
|
}
|
|
398
210
|
|
|
211
|
+
// |axis . view|: 1 when we are staring straight down the axis, 0 when the
|
|
212
|
+
// axis lies flat in the screen plane. Both drag families branch on it.
|
|
213
|
+
function axisViewAlignment(position, axis) {
|
|
214
|
+
return Math.abs(axis.dot(viewDirectionAt(position)));
|
|
215
|
+
}
|
|
216
|
+
|
|
399
217
|
function projectToClient(point) {
|
|
400
218
|
const rect = domElement.getBoundingClientRect();
|
|
401
219
|
if (rect.width <= 0 || rect.height <= 0) return null;
|
|
@@ -419,13 +237,9 @@ export function createCutawayGizmo({
|
|
|
419
237
|
return new THREE.Vector2(-screenAxis.y, screenAxis.x);
|
|
420
238
|
}
|
|
421
239
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
if (!ray) return;
|
|
426
|
-
const handle = pick(event, ray);
|
|
427
|
-
if (!handle) return;
|
|
428
|
-
|
|
240
|
+
// The fields every drag carries, before the mode-specific ones are filled in
|
|
241
|
+
// by beginTranslateDrag / beginRotateDrag.
|
|
242
|
+
function baseDrag(event, handle) {
|
|
429
243
|
const startPosition = group.position.clone();
|
|
430
244
|
const startQuaternion = group.quaternion.clone();
|
|
431
245
|
const localAxis = handle === "rotate-x"
|
|
@@ -433,10 +247,7 @@ export function createCutawayGizmo({
|
|
|
433
247
|
: handle === "rotate-y"
|
|
434
248
|
? new THREE.Vector3(0, 1, 0)
|
|
435
249
|
: new THREE.Vector3(0, 0, 1);
|
|
436
|
-
|
|
437
|
-
const viewDirection = viewDirectionAt(startPosition);
|
|
438
|
-
const alignment = Math.abs(axis.dot(viewDirection));
|
|
439
|
-
const nextDrag = {
|
|
250
|
+
return {
|
|
440
251
|
pointerId: event.pointerId,
|
|
441
252
|
handle,
|
|
442
253
|
orbitEnabled: orbitControls?.enabled,
|
|
@@ -445,38 +256,65 @@ export function createCutawayGizmo({
|
|
|
445
256
|
startClientX: event.clientX,
|
|
446
257
|
startClientY: event.clientY,
|
|
447
258
|
unitsPerPixel: worldUnitsPerPixelAt(startPosition),
|
|
448
|
-
axis,
|
|
259
|
+
axis: localAxis.applyQuaternion(startQuaternion).normalize(),
|
|
449
260
|
mode: null,
|
|
450
261
|
startParameter: null,
|
|
451
262
|
rotationPlane: null,
|
|
452
263
|
startRadial: null,
|
|
453
264
|
screenRotationDirection: null,
|
|
454
265
|
};
|
|
266
|
+
}
|
|
455
267
|
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
268
|
+
// Sliding along the plane normal. Pointing the normal at the camera leaves
|
|
269
|
+
// the axis almost no screen travel to slide along - and the ray may miss it
|
|
270
|
+
// outright - so vertical pointer motion drives the offset instead.
|
|
271
|
+
function beginTranslateDrag(record, ray) {
|
|
272
|
+
const alignment = axisViewAlignment(record.startPosition, record.axis);
|
|
273
|
+
record.startParameter = axisParameterFromRay(ray, record.startPosition, record.axis);
|
|
274
|
+
record.mode = alignment > TRANSLATION_SCREEN_ALIGNMENT
|
|
275
|
+
|| record.startParameter == null
|
|
276
|
+
? "screen-translate"
|
|
277
|
+
: "axis-translate";
|
|
278
|
+
return record;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Spinning about a ring axis. Resolving the drag geometrically against the
|
|
282
|
+
// rotation plane only works when we are looking down that axis; edge-on, the
|
|
283
|
+
// plane is nearly parallel to the view and the intersection is useless, so
|
|
284
|
+
// the angle comes from pointer travel perpendicular to the on-screen axis.
|
|
285
|
+
// Returns null when neither route is usable, which aborts the press.
|
|
286
|
+
function beginRotateDrag(record, ray) {
|
|
287
|
+
const alignment = axisViewAlignment(record.startPosition, record.axis);
|
|
288
|
+
if (alignment >= ROTATION_SCREEN_ALIGNMENT) {
|
|
289
|
+
const plane = new THREE.Plane()
|
|
290
|
+
.setFromNormalAndCoplanarPoint(record.axis, record.startPosition);
|
|
291
|
+
const point = ray.intersectPlane(plane, new THREE.Vector3());
|
|
292
|
+
const radial = point?.sub(record.startPosition);
|
|
293
|
+
if (radial && radial.lengthSq() >= 1e-12) {
|
|
294
|
+
record.mode = "plane-rotate";
|
|
295
|
+
record.rotationPlane = plane;
|
|
296
|
+
record.startRadial = radial.normalize();
|
|
297
|
+
return record;
|
|
478
298
|
}
|
|
479
299
|
}
|
|
300
|
+
record.screenRotationDirection = screenRotationDirection(record.startPosition, record.axis);
|
|
301
|
+
if (!record.screenRotationDirection) return null;
|
|
302
|
+
record.mode = "screen-rotate";
|
|
303
|
+
return record;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function onPointerDown(event) {
|
|
307
|
+
if (disposed || drag || !group.visible || (event.button != null && event.button !== 0)) return;
|
|
308
|
+
const ray = rayFromEvent(event);
|
|
309
|
+
if (!ray) return;
|
|
310
|
+
const handle = pick(event, ray);
|
|
311
|
+
if (!handle) return;
|
|
312
|
+
|
|
313
|
+
const record = baseDrag(event, handle);
|
|
314
|
+
const nextDrag = handle === "translate"
|
|
315
|
+
? beginTranslateDrag(record, ray)
|
|
316
|
+
: beginRotateDrag(record, ray);
|
|
317
|
+
if (!nextDrag) return;
|
|
480
318
|
|
|
481
319
|
setHoveredHandle(handle);
|
|
482
320
|
onActivity();
|
|
@@ -680,8 +518,7 @@ export function createCutawayGizmo({
|
|
|
680
518
|
}
|
|
681
519
|
scene.remove(group);
|
|
682
520
|
overlayScene.remove(handleRoot);
|
|
683
|
-
|
|
684
|
-
for (const material of materials) material.dispose();
|
|
521
|
+
sceneGraph.dispose();
|
|
685
522
|
}
|
|
686
523
|
}
|
|
687
524
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Which view a part opens on. The rule is independent of display order: an author
|
|
2
|
+
// can flag a view `default: true` wherever it sits in the tab bar, and when nobody
|
|
3
|
+
// flags one, the biggest view wins — the one placing the most sub-parts at the
|
|
4
|
+
// part's defaults, which for a multi-view part is the assembly. Ties fall back to
|
|
5
|
+
// declaration order, so every part written before this existed resolves to the view
|
|
6
|
+
// it has always opened on.
|
|
7
|
+
//
|
|
8
|
+
// Pure: no DOM, no storage, no kernel, no build. The headless surfaces (measure /
|
|
9
|
+
// verify / renderViews) deliberately do NOT use this — they stay on
|
|
10
|
+
// Object.keys(part.views)[0], where a mechanically obvious rule matters more than a
|
|
11
|
+
// convenient one.
|
|
12
|
+
|
|
13
|
+
const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
|
|
14
|
+
|
|
15
|
+
// A sub-part counts toward a view when it lists that view and is enabled at the
|
|
16
|
+
// part's defaults. A throwing `enabled` counts as present: the same instinct as
|
|
17
|
+
// mount's throwing-`derive` handling, and an unrelated predicate bug shouldn't
|
|
18
|
+
// silently demote a whole view.
|
|
19
|
+
function placedCount(part, view, defaults) {
|
|
20
|
+
const parts = isPlainObject(part?.parts) ? part.parts : {};
|
|
21
|
+
let n = 0;
|
|
22
|
+
for (const sp of Object.values(parts)) {
|
|
23
|
+
if (!Array.isArray(sp?.views) || !sp.views.includes(view)) continue;
|
|
24
|
+
if (typeof sp.enabled !== "function") { n++; continue; }
|
|
25
|
+
try { if (sp.enabled(defaults)) n++; } catch { n++; }
|
|
26
|
+
}
|
|
27
|
+
return n;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resolveDefaultView(part) {
|
|
31
|
+
if (!isPlainObject(part?.views)) return null;
|
|
32
|
+
const keys = Object.keys(part.views);
|
|
33
|
+
if (keys.length === 0) return null;
|
|
34
|
+
|
|
35
|
+
const flagged = keys.find((k) => part.views[k]?.default === true);
|
|
36
|
+
if (flagged) return flagged;
|
|
37
|
+
|
|
38
|
+
const defaults = isPlainObject(part?.defaults) ? part.defaults : {};
|
|
39
|
+
let best = keys[0];
|
|
40
|
+
let bestCount = placedCount(part, best, defaults);
|
|
41
|
+
for (const k of keys.slice(1)) {
|
|
42
|
+
const n = placedCount(part, k, defaults);
|
|
43
|
+
if (n > bestCount) { best = k; bestCount = n; } // strict > keeps a tie on the earlier key
|
|
44
|
+
}
|
|
45
|
+
return best;
|
|
46
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { zipSync } from "fflate";
|
|
2
|
+
import { safeName } from "./safe-name.js";
|
|
2
3
|
|
|
3
4
|
// Browser file-download helpers. Pure DOM/Blob utilities with no app state — the
|
|
4
5
|
// worker produces the bytes; these just hand them to the browser as a download.
|
|
@@ -23,9 +24,13 @@ export function triggerDownload(data, filename, mime, sink) {
|
|
|
23
24
|
// Download a set of built parts: a single part downloads directly; multiple parts
|
|
24
25
|
// are bundled into one flat, store-only (level 0) zip named `zipName`. `sink`, if
|
|
25
26
|
// given, is forwarded to triggerDownload so it receives the final bytes.
|
|
27
|
+
//
|
|
28
|
+
// Sub-part names come from the part's `export.name` — untrusted data — and become
|
|
29
|
+
// zip entry names, so they go through safeName(): the zip must stay flat, and an
|
|
30
|
+
// entry like "../evil.stl" would escape the target directory in naive extractors.
|
|
26
31
|
export function downloadParts({ parts, ext, mime }, zipName, sink) {
|
|
27
|
-
if (parts.length === 1) return triggerDownload(parts[0].data, `${parts[0].name}.${ext}`, mime, sink);
|
|
32
|
+
if (parts.length === 1) return triggerDownload(parts[0].data, `${safeName(parts[0].name)}.${ext}`, mime, sink);
|
|
28
33
|
const entries = {};
|
|
29
|
-
for (const p of parts) entries[`${p.name}.${ext}`] = new Uint8Array(p.data);
|
|
34
|
+
for (const p of parts) entries[`${safeName(p.name)}.${ext}`] = new Uint8Array(p.data);
|
|
30
35
|
triggerDownload(zipSync(entries, { level: 0 }), zipName, "application/zip", sink);
|
|
31
36
|
}
|
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
// replies (progress/download/error) back to the Promise that started them.
|
|
4
4
|
// Pure — no DOM, no worker; `send` and the sink are injected.
|
|
5
5
|
import { triggerDownload, downloadParts } from "./download.js";
|
|
6
|
+
import { safeName } from "./safe-name.js";
|
|
7
|
+
|
|
8
|
+
// The one place the "which backend does this export format need" policy lives.
|
|
9
|
+
// STEP is always OCCT — only OCCT (OpenCASCADE) emits exact B-rep; Manifold's mesh
|
|
10
|
+
// CSG has no STEP writer. Every other format is free to use whichever backend the
|
|
11
|
+
// caller is already using for preview. Both the UI export buttons (mount.js) and
|
|
12
|
+
// the headless exportParts() API route through this so the rule is never encoded
|
|
13
|
+
// twice.
|
|
14
|
+
export function backendForFormat(format, defaultBackend) {
|
|
15
|
+
return format === "step" ? "occt" : defaultBackend();
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
export function createExportController({ send, currentView, title, defaultBackend = () => "manifold", currentParams = () => ({}) }) {
|
|
8
19
|
const pending = new Map(); // jobId -> { resolve, reject, onProgress }
|
|
@@ -11,7 +22,7 @@ export function createExportController({ send, currentView, title, defaultBacken
|
|
|
11
22
|
function exportParts({ parts, format, quality = "print", onProgress } = {}) {
|
|
12
23
|
const jobId = nextId++;
|
|
13
24
|
const type = `export-${format}`;
|
|
14
|
-
const backend = format
|
|
25
|
+
const backend = backendForFormat(format, defaultBackend);
|
|
15
26
|
return new Promise((resolve, reject) => {
|
|
16
27
|
pending.set(jobId, { resolve, reject, onProgress });
|
|
17
28
|
send({ type, jobId, parts, view: currentView(), params: currentParams(), name: title(), quality }, backend);
|
|
@@ -32,7 +43,7 @@ export function createExportController({ send, currentView, title, defaultBacken
|
|
|
32
43
|
}
|
|
33
44
|
if (m.type === "download-parts") {
|
|
34
45
|
pending.delete(m.jobId);
|
|
35
|
-
const zipName = `${title()
|
|
46
|
+
const zipName = `${safeName(title(), "parts")}.zip`; // title() is the part's meta.title — untrusted
|
|
36
47
|
downloadParts(m, zipName, sink);
|
|
37
48
|
entry.resolve();
|
|
38
49
|
return true;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Geometry-free build execution. Two consumers share one Proxy implementation:
|
|
2
2
|
//
|
|
3
|
-
// • createProbeKernel() — records op NAMES so
|
|
4
|
-
// OCCT when it uses fillet/chamfer/shell.
|
|
3
|
+
// • createProbeKernel() — records op NAMES so ../backend-select.js's
|
|
4
|
+
// detectBackend() can route a part to OCCT when it uses fillet/chamfer/shell.
|
|
5
5
|
// • createValidatingProbe() — additionally checks op names against the kernel
|
|
6
6
|
// contract's op lists and routes options-form calls through the same op-options
|
|
7
7
|
// normalizers the real backends use, so partforge/lint can catch a bad call in
|
|
@@ -14,13 +14,10 @@
|
|
|
14
14
|
// validating probe DOES need an allowlist, so it takes one from kernel.js's op
|
|
15
15
|
// lists, which test/kernel-contract.test.js pins to both backend implementations.
|
|
16
16
|
import {
|
|
17
|
-
|
|
17
|
+
KERNEL_OPS, KERNEL_OPTIONAL_OPS,
|
|
18
18
|
SOLID_OPS, SOLID_OPTIONAL_OPS, SHAPE2D_OPS,
|
|
19
19
|
} from "./kernel.js";
|
|
20
20
|
import { KERNEL_OP_SPECS, SOLID_OP_SPECS, isPlainOptions } from "./op-options.js";
|
|
21
|
-
import { resolveDerived } from "../derive.js";
|
|
22
|
-
|
|
23
|
-
const OCCT_ONLY = new Set(OCCT_ONLY_OPS);
|
|
24
21
|
|
|
25
22
|
// The probe returns ONE chainable handle for every non-query op, so it cannot tell a
|
|
26
23
|
// Solid from a Shape2D — k.box() and k.shape2d() yield the same object. The solid-scope
|
|
@@ -145,19 +142,3 @@ export function runValidatingProbe(part, p, d, { maxOps = MAX_PROBE_OPS } = {})
|
|
|
145
142
|
}
|
|
146
143
|
return { calls: probe.calls, issues: probe.issues, used: probe.used, throws, runaway };
|
|
147
144
|
}
|
|
148
|
-
|
|
149
|
-
export function detectBackend(part, params = {}) {
|
|
150
|
-
if (part.meta?.backend) return part.meta.backend;
|
|
151
|
-
const p = { ...part.defaults, ...params };
|
|
152
|
-
let d = {};
|
|
153
|
-
// A throwing derive must not escape here — this runs on the main thread mid
|
|
154
|
-
// regen (after the busy spinner goes up). Probe with an empty `d`; the worker
|
|
155
|
-
// build hits the same throw and posts a proper error for the UI.
|
|
156
|
-
try { d = resolveDerived(part, p); } catch { /* fall through with d = {} */ }
|
|
157
|
-
const { kernel, used } = createProbeKernel();
|
|
158
|
-
for (const name of Object.keys(part.parts)) {
|
|
159
|
-
try { part.parts[name].build(kernel, p, d); } catch { /* probe miss → capability backstop covers it */ }
|
|
160
|
-
}
|
|
161
|
-
for (const op of used) if (OCCT_ONLY.has(op)) return "occt";
|
|
162
|
-
return "manifold";
|
|
163
|
-
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -1,45 +1,14 @@
|
|
|
1
|
+
// The worker's job protocol. The pure part model it runs over — viewSubParts /
|
|
2
|
+
// exportSubParts / resolveParams / buildPosed — lives in part-model.js, a leaf, so
|
|
3
|
+
// the oracle and the collision check can share it without importing this async,
|
|
4
|
+
// kernel-bound module back.
|
|
1
5
|
import { meshTo3MF } from "./geometry/threemf.js";
|
|
2
6
|
import { exportablePartNames } from "./export-select.js";
|
|
3
|
-
import { resolveDerived } from "./derive.js";
|
|
4
7
|
import { resolveFonts } from "./fonts.js";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
// params. Order follows Object.keys(part.parts) (definition order).
|
|
10
|
-
export function viewSubParts(part, view, params) {
|
|
11
|
-
return Object.keys(part.parts).filter((name) => {
|
|
12
|
-
const sp = part.parts[name];
|
|
13
|
-
const inView = sp.views.includes(view);
|
|
14
|
-
const on = sp.enabled ? !!sp.enabled(params) : true;
|
|
15
|
-
return inView && on;
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
// Sub-parts to include in an EXPORT of this view: the visible sub-parts, minus any
|
|
20
|
-
// flagged `exportable: false` (reference/preview-only parts — motor ghosts, bearing
|
|
21
|
-
// placeholders, etc.). They still show in the viewer; they're just never written to
|
|
22
|
-
// an STL/STEP/3MF file, so the user never has to toggle them off before exporting.
|
|
23
|
-
export function exportSubParts(part, view, params) {
|
|
24
|
-
return viewSubParts(part, view, params).filter((name) => part.parts[name].exportable !== false);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
// Resolve a part's effective params + derived values for a build: the user's params
|
|
28
|
-
// layered over the part defaults, and derive() run once over the result.
|
|
29
|
-
export function resolveParams(part, params) {
|
|
30
|
-
const p = { ...part.defaults, ...params };
|
|
31
|
-
return { p, d: resolveDerived(part, p) };
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Build one sub-part and apply its optional place() for the given purpose/view.
|
|
35
|
-
// `p`/`d` come from resolveParams(). This is the SINGLE definition of "a posed
|
|
36
|
-
// sub-part solid" — the worker, the collision check, and the test harness all call
|
|
37
|
-
// it, so display/export poses can never drift between the app and its tests.
|
|
38
|
-
export function buildPosed(kernel, part, name, { purpose, view, p, d, onProgress } = {}) {
|
|
39
|
-
const sp = part.parts[name];
|
|
40
|
-
const solid = sp.build(kernel, p, d, onProgress);
|
|
41
|
-
return sp.place ? sp.place(solid, { view, purpose, p, d }) : solid;
|
|
42
|
-
}
|
|
8
|
+
import { safeName } from "./safe-name.js";
|
|
9
|
+
import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
|
|
10
|
+
import { measure } from "./oracle/measure.js";
|
|
11
|
+
import { verify } from "./oracle/verify.js";
|
|
43
12
|
|
|
44
13
|
// Handle one geometry job, posting results/progress via `post(msg, transfer?)`.
|
|
45
14
|
// Backend-agnostic and part-agnostic: every part specific comes through `part`.
|
|
@@ -82,7 +51,7 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
82
51
|
msg.parts
|
|
83
52
|
? exportablePartNames(part, p).filter((name) => msg.parts.includes(name))
|
|
84
53
|
: exportSubParts(part, msg.view, p);
|
|
85
|
-
const fileBase = msg.name ?? msg.view; // STEP/3MF single-file name base
|
|
54
|
+
const fileBase = safeName(msg.name ?? msg.view); // STEP/3MF single-file name base (part-derived → untrusted)
|
|
86
55
|
|
|
87
56
|
if (msg.type === "generate") {
|
|
88
57
|
const t0 = Date.now();
|
|
@@ -149,9 +118,21 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
149
118
|
// — the main thread only has mesh arrays, so this can only happen here.
|
|
150
119
|
// measure/verify build their own solids via buildView and are cleaned up by
|
|
151
120
|
// the `finally` below.
|
|
121
|
+
// The two halves overlap: verify always expands a "defaults" case, and for
|
|
122
|
+
// an unparameterized inspect that case IS this measurement. Seeding it in
|
|
123
|
+
// (see verify.js's seeding block for the min-wall superset rule that makes
|
|
124
|
+
// the reuse sound) stops the oracle from rebuilding the same geometry and
|
|
125
|
+
// re-casting the same min-wall rays a second time. Measuring `{ minWall:
|
|
126
|
+
// true }` here is what makes the seed usable by any verify run, min-wall
|
|
127
|
+
// gated or not — the result says so itself (`measuredMinWall`), so this
|
|
128
|
+
// call and the seed cannot drift apart.
|
|
129
|
+
const measured = measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true });
|
|
152
130
|
const report = {
|
|
153
|
-
measure:
|
|
154
|
-
verify: verify(kernel, part, {
|
|
131
|
+
measure: measured,
|
|
132
|
+
verify: verify(kernel, part, {
|
|
133
|
+
view: msg.view,
|
|
134
|
+
seed: { params: msg.params ?? {}, result: measured },
|
|
135
|
+
}),
|
|
155
136
|
};
|
|
156
137
|
post({ type: "report", ...report });
|
|
157
138
|
}
|
|
@@ -18,3 +18,7 @@ const make = (severity) => (rule, message, hint, path = "", pattern) => ({
|
|
|
18
18
|
export const err = make("error");
|
|
19
19
|
// warning → suspicious or lossy, but the part behaves as authored.
|
|
20
20
|
export const warn = make("warning");
|
|
21
|
+
// note → neither broken nor suspicious; informational context an authoring
|
|
22
|
+
// agent should see (e.g. "this animated track rebuilds geometry"). Notes never
|
|
23
|
+
// gate measure or --strict.
|
|
24
|
+
export const note = make("note");
|