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}`;
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user