partforge 0.22.0 → 0.23.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/package.json +1 -1
- package/src/framework/jobs.js +14 -0
- package/src/framework/mount.js +7 -1
- package/src/framework/view-angles.js +33 -0
- package/src/framework/viewer.js +72 -0
package/package.json
CHANGED
package/src/framework/jobs.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { meshTo3MF } from "./geometry/threemf.js";
|
|
2
2
|
import { resolveDerived } from "./derive.js";
|
|
3
3
|
import { resolveFonts } from "./fonts.js";
|
|
4
|
+
import { measure } from "../testing/measure.js";
|
|
5
|
+
import { verify } from "../testing/verify.js";
|
|
4
6
|
|
|
5
7
|
// Names of the sub-parts a view shows: declared in the view and enabled for these
|
|
6
8
|
// params. Order follows Object.keys(part.parts) (definition order).
|
|
@@ -113,6 +115,18 @@ export async function handle(kernel, part, msg, post) {
|
|
|
113
115
|
onProgress("writing 3MF file");
|
|
114
116
|
const data = meshTo3MF(meshes);
|
|
115
117
|
post({ type: "download", data, filename: `${msg.view}.3mf`, mime: "model/3mf" }, [bufferOf(data)]);
|
|
118
|
+
} else if (msg.type === "inspect") {
|
|
119
|
+
// Full geometric oracle for the current view: solid facts (volume/genus/
|
|
120
|
+
// watertight), mesh facts, overlaps, and gap distances, plus the part's
|
|
121
|
+
// structural + declared verify gates. Runs against the worker's live kernel
|
|
122
|
+
// — the main thread only has mesh arrays, so this can only happen here.
|
|
123
|
+
// measure/verify build their own solids via buildView and are cleaned up by
|
|
124
|
+
// the `finally` below.
|
|
125
|
+
const report = {
|
|
126
|
+
measure: measure(kernel, part, msg.view, msg.params ?? {}, { minWall: true }),
|
|
127
|
+
verify: verify(kernel, part, { view: msg.view }),
|
|
128
|
+
};
|
|
129
|
+
post({ type: "report", ...report });
|
|
116
130
|
}
|
|
117
131
|
} catch (err) {
|
|
118
132
|
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
|
package/src/framework/mount.js
CHANGED
|
@@ -19,6 +19,12 @@ import { createViewTabs } from "./view-tabs.js";
|
|
|
19
19
|
import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
|
|
20
20
|
import { createPickRequestClient } from "./pick-request/index.js";
|
|
21
21
|
|
|
22
|
+
// The mount handle, factored out so its shape is unit-testable without booting
|
|
23
|
+
// the full mount() pipeline (WASM + workers + DOM).
|
|
24
|
+
export function makeHandle({ ready, dispose, viewer }) {
|
|
25
|
+
return { ready, dispose, captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames) };
|
|
26
|
+
}
|
|
27
|
+
|
|
22
28
|
function createCleanupStack() {
|
|
23
29
|
const cleanups = [];
|
|
24
30
|
let disposed = false;
|
|
@@ -351,7 +357,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick,
|
|
|
351
357
|
cleanup.dispose();
|
|
352
358
|
}
|
|
353
359
|
|
|
354
|
-
return { ready, dispose };
|
|
360
|
+
return makeHandle({ ready, dispose, viewer });
|
|
355
361
|
} catch (error) {
|
|
356
362
|
try {
|
|
357
363
|
cleanup.dispose();
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Canonical camera angles for headless/offscreen captures, in the viewer's
|
|
2
|
+
// three.js WORLD space (Y-up). The model is authored Z-up; the viewer's pivot
|
|
3
|
+
// rotates it into Y-up, so these directions are expressed Y-up directly. Kept in
|
|
4
|
+
// one place so the cloud render tool and any future headless capture agree.
|
|
5
|
+
export const CANONICAL_VIEWS = ["iso", "front", "back", "top", "bottom", "left", "right"];
|
|
6
|
+
|
|
7
|
+
// Direction FROM the part centre TOWARD the camera (world Y-up), plus the up vector.
|
|
8
|
+
const DIRS = {
|
|
9
|
+
iso: { dir: [1, 1, 1], up: [0, 1, 0] },
|
|
10
|
+
front: { dir: [0, 0, 1], up: [0, 1, 0] },
|
|
11
|
+
back: { dir: [0, 0, -1], up: [0, 1, 0] },
|
|
12
|
+
top: { dir: [0, 1, 0], up: [0, 0, -1] },
|
|
13
|
+
bottom: { dir: [0, -1, 0], up: [0, 0, 1] },
|
|
14
|
+
left: { dir: [-1, 0, 0], up: [0, 1, 0] },
|
|
15
|
+
right: { dir: [1, 0, 0], up: [0, 1, 0] },
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const norm = (v) => {
|
|
19
|
+
const l = Math.hypot(v[0], v[1], v[2]) || 1;
|
|
20
|
+
return [v[0] / l, v[1] / l, v[2] / l];
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export function cameraPoseForView(view, { center, radius }) {
|
|
24
|
+
const a = DIRS[view];
|
|
25
|
+
if (!a) throw new Error(`unknown canonical view "${view}"`);
|
|
26
|
+
const d = norm(a.dir);
|
|
27
|
+
const dist = radius * 2.6 + 6; // matches viewer.frameTo's framing distance
|
|
28
|
+
return {
|
|
29
|
+
position: [center[0] + d[0] * dist, center[1] + d[1] * dist, center[2] + d[2] * dist],
|
|
30
|
+
up: a.up,
|
|
31
|
+
target: [...center],
|
|
32
|
+
};
|
|
33
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -6,6 +6,29 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
|
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
7
|
import { createCutaway } from "./cutaway.js";
|
|
8
8
|
import { addViewerLights } from "./viewer-lighting.js";
|
|
9
|
+
import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
|
|
10
|
+
|
|
11
|
+
// Render a set of canonical views without disturbing the live camera/canvas.
|
|
12
|
+
// `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
|
|
13
|
+
// target → readback → JPEG data URL); injected so this is unit-testable without
|
|
14
|
+
// a GL context. The grid is hidden for the whole synchronous pass and restored.
|
|
15
|
+
export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds }) {
|
|
16
|
+
const views = (viewNames?.length ? viewNames : ["iso", "front", "top"])
|
|
17
|
+
.filter((v) => CANONICAL_VIEWS.includes(v))
|
|
18
|
+
.slice(0, CANONICAL_VIEWS.length);
|
|
19
|
+
const before = liveCamera.position.clone();
|
|
20
|
+
const gridWasVisible = grid?.visible;
|
|
21
|
+
if (grid) grid.visible = false;
|
|
22
|
+
try {
|
|
23
|
+
return views.map((view) => ({
|
|
24
|
+
view,
|
|
25
|
+
dataUrl: renderer.renderOffscreen(cameraPoseForView(view, bounds)),
|
|
26
|
+
}));
|
|
27
|
+
} finally {
|
|
28
|
+
if (grid) grid.visible = gridWasVisible;
|
|
29
|
+
liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
|
|
30
|
+
}
|
|
31
|
+
}
|
|
9
32
|
|
|
10
33
|
export function createViewer(container, part) {
|
|
11
34
|
const names = Object.keys(part.parts);
|
|
@@ -261,6 +284,53 @@ export function createViewer(container, part) {
|
|
|
261
284
|
ro.observe(container);
|
|
262
285
|
resize();
|
|
263
286
|
|
|
287
|
+
// --- offscreen canonical-view capture -------------------------------------
|
|
288
|
+
// Offscreen render of the shared scene from an arbitrary pose → JPEG data URL.
|
|
289
|
+
// A separate WebGLRenderTarget + temp camera means the visible canvas and the
|
|
290
|
+
// live `camera` are never touched. WebGL pixels are bottom-up, so flip on encode.
|
|
291
|
+
const _rtSize = 512;
|
|
292
|
+
let _rt = null;
|
|
293
|
+
function renderOffscreen({ position, up, target }) {
|
|
294
|
+
_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize);
|
|
295
|
+
const cam = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
|
|
296
|
+
cam.position.set(position[0], position[1], position[2]);
|
|
297
|
+
cam.up.set(up[0], up[1], up[2]);
|
|
298
|
+
cam.lookAt(target[0], target[1], target[2]);
|
|
299
|
+
renderer.setRenderTarget(_rt);
|
|
300
|
+
renderer.render(scene, cam);
|
|
301
|
+
const buf = new Uint8Array(_rtSize * _rtSize * 4);
|
|
302
|
+
renderer.readRenderTargetPixels(_rt, 0, 0, _rtSize, _rtSize, buf);
|
|
303
|
+
renderer.setRenderTarget(null);
|
|
304
|
+
const canvas = document.createElement("canvas");
|
|
305
|
+
canvas.width = _rtSize; canvas.height = _rtSize;
|
|
306
|
+
const ctx = canvas.getContext("2d");
|
|
307
|
+
const img = ctx.createImageData(_rtSize, _rtSize);
|
|
308
|
+
// flip rows (GL origin is bottom-left)
|
|
309
|
+
for (let y = 0; y < _rtSize; y++) {
|
|
310
|
+
const src = (_rtSize - 1 - y) * _rtSize * 4;
|
|
311
|
+
img.data.set(buf.subarray(src, src + _rtSize * 4), y * _rtSize * 4);
|
|
312
|
+
}
|
|
313
|
+
ctx.putImageData(img, 0, 0);
|
|
314
|
+
return canvas.toDataURL("image/jpeg", 0.8);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// Render the canonical camera angles offscreen, framed to whatever is visible,
|
|
318
|
+
// without disturbing the user's live view. Returns [{ view, dataUrl }].
|
|
319
|
+
function captureCanonicalViews(viewNames) {
|
|
320
|
+
if (disposed) return [];
|
|
321
|
+
const box = getVisibleWorldBounds();
|
|
322
|
+
if (!box || box.isEmpty()) return [];
|
|
323
|
+
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
324
|
+
const size = box.getSize(new THREE.Vector3());
|
|
325
|
+
const radius = Math.max(size.x, size.y, size.z) / 2 || 10;
|
|
326
|
+
return captureViewsFromScene(viewNames, {
|
|
327
|
+
renderer: { renderOffscreen },
|
|
328
|
+
liveCamera: camera,
|
|
329
|
+
grid,
|
|
330
|
+
bounds: { center, radius },
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
|
|
264
334
|
// --- render loop ----------------------------------------------------------
|
|
265
335
|
renderer.setAnimationLoop(() => {
|
|
266
336
|
controls.update();
|
|
@@ -323,6 +393,7 @@ export function createViewer(container, part) {
|
|
|
323
393
|
lineMaterial.dispose();
|
|
324
394
|
grid.geometry.dispose();
|
|
325
395
|
grid.material.dispose();
|
|
396
|
+
_rt?.dispose();
|
|
326
397
|
renderer.dispose();
|
|
327
398
|
renderer.domElement.remove();
|
|
328
399
|
}
|
|
@@ -334,6 +405,7 @@ export function createViewer(container, part) {
|
|
|
334
405
|
hasSubMesh,
|
|
335
406
|
subTriangles,
|
|
336
407
|
frame,
|
|
408
|
+
captureCanonicalViews,
|
|
337
409
|
setAutoRotate,
|
|
338
410
|
setTheme,
|
|
339
411
|
getCameraState,
|