mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
fix: 3mf model preview default to proper solid
This commit is contained in:
1 parent
58e3c7296e
commit
46b8687d1d
4 files changed
+51
-26
No files matched your search
+11
-5
@@ -159,10 +159,16 @@ function plateThumbUrl(plateId: string): string | null {
|
|||||||
return `/plate/${plateId}/thumb.jpg?v=${mtime}`;
|
return `/plate/${plateId}/thumb.jpg?v=${mtime}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function previewGlbUrl(plateId: string): string | null {
|
function previewGlbUrl(plate: Plate): string | null {
|
||||||
if (!modelPreviewGlbExists(plateId)) return null;
|
if (!modelPreviewGlbExists(plate.id)) {
|
||||||
const mtime = fs.statSync(modelPreviewGlbPath(plateId)).mtimeMs;
|
// Still hand out the URL for a 3MF with no (current-version) cache yet: requesting it is
|
||||||
return `/plate/${plateId}/preview.glb?v=${mtime}`;
|
// 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 {
|
export function toPlateOut(printId: string, plate: Plate): PlateOut {
|
||||||
@@ -175,7 +181,7 @@ export function toPlateOut(printId: string, plate: Plate): PlateOut {
|
|||||||
size: plate.size,
|
size: plate.size,
|
||||||
url: `/print/${printId}/plate/${plate.id}/file/${encodeURIComponent(plate.filename)}`,
|
url: `/print/${printId}/plate/${plate.id}/file/${encodeURIComponent(plate.filename)}`,
|
||||||
thumb_url: plateThumbUrl(plate.id),
|
thumb_url: plateThumbUrl(plate.id),
|
||||||
preview_glb_url: previewGlbUrl(plate.id),
|
preview_glb_url: previewGlbUrl(plate),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -524,13 +524,14 @@ async function buildGlbGroup(parsed: ParsedModel): Promise<import("three").Group
|
|||||||
if (!objectData) continue;
|
if (!objectData) continue;
|
||||||
for (const mesh of objectData.meshes) {
|
for (const mesh of objectData.meshes) {
|
||||||
const positioned = item.transform ? applyAffineToVertices(mesh.vertices, item.transform) : mesh.vertices;
|
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
|
// 3MF Z-up -> three.js Y-up as a rotation, (x, y, z) -> (x, z, -y) -- kept identical to
|
||||||
// flip) -- kept identical to bambuThreeMf.ts's createGeometryFromMesh.
|
// 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);
|
const swapped = new Float32Array(positioned.length);
|
||||||
for (let i = 0; i < positioned.length; i += 3) {
|
for (let i = 0; i < positioned.length; i += 3) {
|
||||||
swapped[i] = positioned[i];
|
swapped[i] = positioned[i];
|
||||||
swapped[i + 1] = positioned[i + 2];
|
swapped[i + 1] = positioned[i + 2];
|
||||||
swapped[i + 2] = positioned[i + 1];
|
swapped[i + 2] = -positioned[i + 1];
|
||||||
}
|
}
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
geometry.setAttribute("position", new THREE.BufferAttribute(swapped, 3));
|
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) -----
|
// ---- 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 {
|
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`);
|
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.writeFile(tmp, buf);
|
||||||
await fs.rename(tmp, dest);
|
await fs.rename(tmp, dest);
|
||||||
await fs.rm(modelPreviewErrorPath(plateId), { force: true });
|
await fs.rm(modelPreviewErrorPath(plateId), { force: true });
|
||||||
|
await fs.rm(legacyModelPreviewGlbPath(plateId), { force: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`Model preview generation failed for plate ${plateId}:`, err);
|
console.error(`Model preview generation failed for plate ${plateId}:`, err);
|
||||||
await fs.writeFile(modelPreviewErrorPath(plateId), String(err)).catch(() => undefined);
|
await fs.writeFile(modelPreviewErrorPath(plateId), String(err)).catch(() => undefined);
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// (huge) repro file that motivated this feature goes through.
|
||||||
const MODEL_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
const MODEL_XML = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" unit="millimeter">
|
<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", () => {
|
describe("modelPreviewCache", () => {
|
||||||
let fixturePath: string;
|
let fixturePath: string;
|
||||||
const plateId = `test-fixture-${Date.now()}`;
|
const plateId = `test-fixture-${Date.now()}`;
|
||||||
@@ -198,7 +204,7 @@ describe("modelPreviewCache", () => {
|
|||||||
if (obj.isMesh) {
|
if (obj.isMesh) {
|
||||||
meshes[obj.name] = {
|
meshes[obj.name] = {
|
||||||
color: `#${obj.material.color.getHexString()}`,
|
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-0"].color.toLowerCase()).toBe("#00b800");
|
||||||
expect(meshes["extruder-1"].color.toLowerCase()).toBe("#ff0000");
|
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).
|
// Object 1: identity transform, then the Z-up -> Y-up rotation (3MF x,y,z -> three.js x,z,-y).
|
||||||
expect(meshes["extruder-0"].positions).toEqual([0, 0, 0, 10, 0, 0, 0, 0, 10]);
|
// A plain swap (x,z,y) would mirror the model and flip its triangle winding.
|
||||||
// Object 2: +50 X translation applied in 3MF space before the same swap.
|
expect(meshes["extruder-0"].positions).toEqual([0, 0, 0, 10, 0, 0, 0, 0, -10]);
|
||||||
expect(meshes["extruder-1"].positions).toEqual([50, 0, 0, 70, 0, 0, 50, 0, 20]);
|
// 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
|
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[] }> = {};
|
const meshes: Record<string, { positions: number[] }> = {};
|
||||||
gltf.scene.traverse((obj: any) => {
|
gltf.scene.traverse((obj: any) => {
|
||||||
if (obj.isMesh) {
|
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"]);
|
expect(Object.keys(meshes)).toEqual(["extruder-0"]);
|
||||||
// Object 1's vertices, translated by the component's own +5 X transform (build item itself
|
// Object 1's vertices, translated by the component's own +5 X transform (build item itself
|
||||||
// is identity), then the fixed Y/Z swap.
|
// is identity), then the Z-up -> Y-up rotation.
|
||||||
expect(meshes["extruder-0"].positions).toEqual([5, 0, 0, 15, 0, 0, 5, 0, 10]);
|
expect(meshes["extruder-0"].positions).toEqual([5, 0, 0, 15, 0, 0, 5, 0, -10]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,9 +5,8 @@
|
|||||||
// a single imported/uploaded 3MF here always stays one Plate, its internal plates are a purely
|
// 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/
|
// client-side rendering/browsing concept) and to a filament/extruder index; Metadata/
|
||||||
// project_settings.config carries the filament_colour palette. The coordinate swap in
|
// 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) --
|
// createGeometryFromMesh below already bakes in the Z-up -> Y-up rotateX(-90deg) that Thingport's
|
||||||
// Thingport's own STL/OBJ/STEP loaders use a rotateX(-90deg) instead, which is not the same
|
// STL/OBJ/STEP loaders apply as an object rotation, so callers must not rotate this output again.
|
||||||
// transform (it negates one axis), so callers must not apply that rotation to this loader's output.
|
|
||||||
import * as THREE from "three";
|
import * as THREE from "three";
|
||||||
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
|
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 {
|
function createGeometryFromMesh(mesh: MeshData): THREE.BufferGeometry {
|
||||||
const geometry = new 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
|
// 3MF: X right, Y back, Z up -> three.js: X right, Y up, Z toward the viewer, i.e.
|
||||||
// no sign flip -- NOT the same transform as the rotateX(-90deg) used to convert STL/OBJ/STEP
|
// (x, y, z) -> (x, z, -y): the same rotateX(-90deg) loadObjectFromAsset applies to STL/OBJ/STEP,
|
||||||
// (that maps 3MF-Y to -Z, mirroring a Bambu 3MF's chirality). Kept as the original per-vertex
|
// baked per-vertex here (loadObjectFromAsset skips 3MF so it isn't applied twice). It must stay
|
||||||
// swap rather than a whole-object rotation so it matches bambuddy's validated orientation
|
// a rotation: a plain Y/Z swap (no sign flip) is a mirror, which reverses every triangle's
|
||||||
// exactly; loadObjectFromAsset's centralized rotation is skipped for 3MF for this reason.
|
// 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);
|
const positions = new Float32Array(mesh.vertices.length);
|
||||||
for (let i = 0; i < mesh.vertices.length; i += 3) {
|
for (let i = 0; i < mesh.vertices.length; i += 3) {
|
||||||
positions[i] = mesh.vertices[i];
|
positions[i] = mesh.vertices[i];
|
||||||
positions[i + 1] = mesh.vertices[i + 2];
|
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.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
geometry.setIndex(mesh.triangles);
|
geometry.setIndex(mesh.triangles);
|
||||||
|
|||||||
Reference in new issue
Block a user