fix: 3mf model preview default to proper solid

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-21 20:26:46 +02:00
1 parent 58e3c7296e
commit 46b8687d1d
4 files changed
+51 -26

No files matched your search

+11 -5
View File
@@ -159,10 +159,16 @@ function plateThumbUrl(plateId: string): string | null {
return `/plate/${plateId}/thumb.jpg?v=${mtime}`;
}
function previewGlbUrl(plateId: string): string | null {
if (!modelPreviewGlbExists(plateId)) return null;
const mtime = fs.statSync(modelPreviewGlbPath(plateId)).mtimeMs;
return `/plate/${plateId}/preview.glb?v=${mtime}`;
function previewGlbUrl(plate: Plate): string | null {
if (!modelPreviewGlbExists(plate.id)) {
// Still hand out the URL for a 3MF with no (current-version) cache yet: requesting it is
// what kicks off generation (see routes/plates.ts's self-heal), and the viewer falls back to
// the live parser on the 404. Returning null here would mean nothing ever requests it, so a
// plate that predates the cache (or a cache-format bump) would never get one.
return plate.filename.toLowerCase().endsWith(".3mf") ? `/plate/${plate.id}/preview.glb` : null;
}
const mtime = fs.statSync(modelPreviewGlbPath(plate.id)).mtimeMs;
return `/plate/${plate.id}/preview.glb?v=${mtime}`;
}
export function toPlateOut(printId: string, plate: Plate): PlateOut {
@@ -175,7 +181,7 @@ export function toPlateOut(printId: string, plate: Plate): PlateOut {
size: plate.size,
url: `/print/${printId}/plate/${plate.id}/file/${encodeURIComponent(plate.filename)}`,
thumb_url: plateThumbUrl(plate.id),
preview_glb_url: previewGlbUrl(plate.id),
preview_glb_url: previewGlbUrl(plate),
};
}
+15 -3
View File
@@ -524,13 +524,14 @@ async function buildGlbGroup(parsed: ParsedModel): Promise<import("three").Group
if (!objectData) continue;
for (const mesh of objectData.meshes) {
const positioned = item.transform ? applyAffineToVertices(mesh.vertices, item.transform) : mesh.vertices;
// 3MF: X right, Y back, Z up -> three.js: X right, Y up, Z forward (Y/Z swap, no sign
// flip) -- kept identical to bambuThreeMf.ts's createGeometryFromMesh.
// 3MF Z-up -> three.js Y-up as a rotation, (x, y, z) -> (x, z, -y) -- kept identical to
// bambuThreeMf.ts's createGeometryFromMesh (see there for why it can't be a plain swap).
// Changing this changes every cached GLB: bump PREVIEW_FORMAT_VERSION.
const swapped = new Float32Array(positioned.length);
for (let i = 0; i < positioned.length; i += 3) {
swapped[i] = positioned[i];
swapped[i + 1] = positioned[i + 2];
swapped[i + 2] = positioned[i + 1];
swapped[i + 2] = -positioned[i + 1];
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(swapped, 3));
@@ -587,7 +588,17 @@ async function buildGlbGroup(parsed: ParsedModel): Promise<import("three").Group
// ---- Cache path helpers (same shape as printService.ts's plateThumbPath/plateThumbExists) -----
// Part of the cache filename, so a change to what buildGlbGroup produces (v2: the Z-up -> Y-up
// conversion became a rotation instead of a mirroring swap) makes every older GLB a cache miss
// and gets it regenerated on next view, rather than serving stale geometry forever.
const PREVIEW_FORMAT_VERSION = 2;
export function modelPreviewGlbPath(plateId: string): string {
return path.join(MODEL_PREVIEWS, `${plateId}.v${PREVIEW_FORMAT_VERSION}.glb`);
}
/** Pre-versioning filename (v1) -- removed once its replacement is written. */
function legacyModelPreviewGlbPath(plateId: string): string {
return path.join(MODEL_PREVIEWS, `${plateId}.glb`);
}
@@ -633,6 +644,7 @@ export async function generateModelPreviewGlb(plateId: string, srcPath: string):
await fs.writeFile(tmp, buf);
await fs.rename(tmp, dest);
await fs.rm(modelPreviewErrorPath(plateId), { force: true });
await fs.rm(legacyModelPreviewGlbPath(plateId), { force: true });
} catch (err) {
console.error(`Model preview generation failed for plate ${plateId}:`, err);
await fs.writeFile(modelPreviewErrorPath(plateId), String(err)).catch(() => undefined);
+16 -9
View File
@@ -8,7 +8,7 @@ import { generateModelPreviewGlb, modelPreviewGlbExists, modelPreviewGlbPath } f
// A hand-built 2-object/2-extruder/2-plate Bambu-style .3mf, small enough to commit as test
// data inline rather than shipping a binary fixture file. Exercises the same fast regex-based
// mesh extraction, extruder/plate resolution, and affine-transform + Y/Z-swap math the real
// mesh extraction, extruder/plate resolution, and affine-transform + Z-up->Y-up rotation math the real
// (huge) repro file that motivated this feature goes through.
const MODEL_XML = `<?xml version="1.0" encoding="UTF-8"?>
<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" unit="millimeter">
@@ -149,6 +149,12 @@ async function buildWrapperFixture3mf(destPath: string): Promise<void> {
]);
}
// Vertex positions as plain numbers. `+ 0` turns the -0 the Z-up -> Y-up rotation produces for
// every vertex at 3MF y=0 (z = -y) into 0, which toEqual would otherwise treat as different.
function positionsOf(mesh: any): number[] {
return Array.from(mesh.geometry.attributes.position.array as ArrayLike<number>, v => v + 0);
}
describe("modelPreviewCache", () => {
let fixturePath: string;
const plateId = `test-fixture-${Date.now()}`;
@@ -198,7 +204,7 @@ describe("modelPreviewCache", () => {
if (obj.isMesh) {
meshes[obj.name] = {
color: `#${obj.material.color.getHexString()}`,
positions: Array.from(obj.geometry.attributes.position.array),
positions: positionsOf(obj),
};
}
});
@@ -207,10 +213,11 @@ describe("modelPreviewCache", () => {
expect(meshes["extruder-0"].color.toLowerCase()).toBe("#00b800");
expect(meshes["extruder-1"].color.toLowerCase()).toBe("#ff0000");
// Object 1: identity transform, then the fixed Y/Z swap (3MF X,Y,Z -> three.js X,Z,Y).
expect(meshes["extruder-0"].positions).toEqual([0, 0, 0, 10, 0, 0, 0, 0, 10]);
// Object 2: +50 X translation applied in 3MF space before the same swap.
expect(meshes["extruder-1"].positions).toEqual([50, 0, 0, 70, 0, 0, 50, 0, 20]);
// Object 1: identity transform, then the Z-up -> Y-up rotation (3MF x,y,z -> three.js x,z,-y).
// A plain swap (x,z,y) would mirror the model and flip its triangle winding.
expect(meshes["extruder-0"].positions).toEqual([0, 0, 0, 10, 0, 0, 0, 0, -10]);
// Object 2: +50 X translation applied in 3MF space before the same rotation.
expect(meshes["extruder-1"].positions).toEqual([50, 0, 0, 70, 0, 0, 50, 0, -20]);
void THREE; // imported only to force-load three before GLTFLoader in some module graphs
});
@@ -263,13 +270,13 @@ describe("modelPreviewCache -- internal <component> references", () => {
const meshes: Record<string, { positions: number[] }> = {};
gltf.scene.traverse((obj: any) => {
if (obj.isMesh) {
meshes[obj.name] = { positions: Array.from(obj.geometry.attributes.position.array) };
meshes[obj.name] = { positions: positionsOf(obj) };
}
});
expect(Object.keys(meshes)).toEqual(["extruder-0"]);
// Object 1's vertices, translated by the component's own +5 X transform (build item itself
// is identity), then the fixed Y/Z swap.
expect(meshes["extruder-0"].positions).toEqual([5, 0, 0, 15, 0, 0, 5, 0, 10]);
// is identity), then the Z-up -> Y-up rotation.
expect(meshes["extruder-0"].positions).toEqual([5, 0, 0, 15, 0, 0, 5, 0, -10]);
});
});
+9 -9
View File
@@ -5,9 +5,8 @@
// a single imported/uploaded 3MF here always stays one Plate, its internal plates are a purely
// client-side rendering/browsing concept) and to a filament/extruder index; Metadata/
// project_settings.config carries the filament_colour palette. The coordinate swap in
// createGeometryFromMesh below is kept identical to the original (a Y/Z swap with no sign flip) --
// Thingport's own STL/OBJ/STEP loaders use a rotateX(-90deg) instead, which is not the same
// transform (it negates one axis), so callers must not apply that rotation to this loader's output.
// createGeometryFromMesh below already bakes in the Z-up -> Y-up rotateX(-90deg) that Thingport's
// STL/OBJ/STEP loaders apply as an object rotation, so callers must not rotate this output again.
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
@@ -501,16 +500,17 @@ export async function loadCachedBambuGlb(url: string): Promise<CachedBambuGlb |
function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
const geometry = new THREE.BufferGeometry();
// 3MF: X right, Y back, Z up -> three.js: X right, Y up, Z forward. This is a Y/Z swap with
// no sign flip -- NOT the same transform as the rotateX(-90deg) used to convert STL/OBJ/STEP
// (that maps 3MF-Y to -Z, mirroring a Bambu 3MF's chirality). Kept as the original per-vertex
// swap rather than a whole-object rotation so it matches bambuddy's validated orientation
// exactly; loadObjectFromAsset's centralized rotation is skipped for 3MF for this reason.
// 3MF: X right, Y back, Z up -> three.js: X right, Y up, Z toward the viewer, i.e.
// (x, y, z) -> (x, z, -y): the same rotateX(-90deg) loadObjectFromAsset applies to STL/OBJ/STEP,
// baked per-vertex here (loadObjectFromAsset skips 3MF so it isn't applied twice). It must stay
// a rotation: a plain Y/Z swap (no sign flip) is a mirror, which reverses every triangle's
// winding -- front faces get culled, so models render inside-out/see-through, and mirrored.
// Keep identical to backend/src/services/modelPreviewCache.ts's buildGlbGroup.
const positions = new Float32Array(mesh.vertices.length);
for (let i = 0; i < mesh.vertices.length; i += 3) {
positions[i] = mesh.vertices[i];
positions[i + 1] = mesh.vertices[i + 2];
positions[i + 2] = mesh.vertices[i + 1];
positions[i + 2] = -mesh.vertices[i + 1];
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setIndex(mesh.triangles);