feat: various major UI & UX improvements

This commit is contained in:
Tautvydas Derzinskas committed 2026-09-08 09:59:12 +02:00
1 parent c840b62e47
commit 7b1f718c74
25 files changed
+1528 -106

No files matched your search

@@ -0,0 +1,8 @@
-- DropIndex
DROP INDEX "Folder_userId_parentId_idx";
-- AlterTable
ALTER TABLE "Folder" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
-- CreateIndex
CREATE INDEX "Folder_userId_parentId_position_idx" ON "Folder"("userId", "parentId", "position");
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Folder" ADD COLUMN "metaDescription" TEXT,
ADD COLUMN "metaTitle" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Print" ADD COLUMN "printCount" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "Folder" ADD COLUMN "makerworldCatId" INTEGER,
ADD COLUMN "printablesCatId" INTEGER,
ADD COLUMN "thingiverseCatId" INTEGER;
-- CreateIndex
CREATE INDEX "Folder_userId_makerworldCatId_idx" ON "Folder"("userId", "makerworldCatId");
-- CreateIndex
CREATE INDEX "Folder_userId_thingiverseCatId_idx" ON "Folder"("userId", "thingiverseCatId");
-- CreateIndex
CREATE INDEX "Folder_userId_printablesCatId_idx" ON "Folder"("userId", "printablesCatId");
+53 -35
View File
@@ -25,47 +25,65 @@ model User {
}
model Folder {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
tags String[] @default([])
parentId String?
parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: SetNull)
children Folder[] @relation("FolderTree")
prints Print[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
tags String[] @default([])
parentId String?
parent Folder? @relation("FolderTree", fields: [parentId], references: [id], onDelete: SetNull)
children Folder[] @relation("FolderTree")
// Manual sort order within a sibling group (same userId + parentId), set by the "move up/down"
// controls in the category manager. Not DB-uniqueness-constrained (parentId is nullable, and
// Postgres treats NULLs as distinct, so a unique index wouldn't actually cover the root-level
// sibling group) -- ordering is only ever applied via the reorder endpoint, which always
// rewrites every sibling's position together.
position Int @default(0)
metaTitle String?
metaDescription String?
// When set, an import from that site whose own category id matches one of these auto-lands
// in this folder (see importService.ts's resolveFolderIdByCategory), unless the caller already
// picked a folder explicitly. Each site's category ids are independent integer namespaces.
makerworldCatId Int?
thingiverseCatId Int?
printablesCatId Int?
prints Print[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId, parentId])
@@index([userId, parentId, position])
@@index([tags], type: Gin)
@@index([userId, makerworldCatId])
@@index([userId, thingiverseCatId])
@@index([userId, printablesCatId])
}
model Print {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
nameNormalized String
title String?
notes String?
creator String?
collection String?
tags String[] @default([])
viewCount Int @default(0)
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
authorId String?
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
sourcePath String?
plates Plate[]
previewImages PreviewImage[]
files PrintFile[] @relation("PrintFiles")
preparedFileId String? @unique
preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull)
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
name String
nameNormalized String
title String?
notes String?
creator String?
collection String?
tags String[] @default([])
viewCount Int @default(0)
printCount Int @default(0)
folderId String?
folder Folder? @relation(fields: [folderId], references: [id], onDelete: SetNull)
authorId String?
author Author? @relation(fields: [authorId], references: [id], onDelete: SetNull)
sourcePath String?
plates Plate[]
previewImages PreviewImage[]
files PrintFile[] @relation("PrintFiles")
preparedFileId String? @unique
preparedFile PrintFile? @relation("PreparedFile", fields: [preparedFileId], references: [id], onDelete: SetNull)
preparedMetadata Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, folderId, nameNormalized])
@@index([userId, folderId, name])
+14
View File
@@ -102,6 +102,7 @@ export type PrintOut = {
slicer_url: string | null;
slicer_filename: string | null;
view_count: number;
print_count: number;
};
export type FolderOut = {
@@ -109,6 +110,12 @@ export type FolderOut = {
name: string;
tags: string[];
parent_id: string | null;
position: number;
meta_title: string | null;
meta_description: string | null;
makerworld_cat_id: number | null;
thingiverse_cat_id: number | null;
printables_cat_id: number | null;
};
function plateThumbUrl(plateId: string): string | null {
@@ -236,6 +243,7 @@ export function toPrintOut(
slicer_url: slicerUrl,
slicer_filename: slicerFilename,
view_count: print.viewCount,
print_count: print.printCount,
};
}
@@ -245,5 +253,11 @@ export function toFolderOut(folder: Folder): FolderOut {
name: folder.name,
tags: folder.tags,
parent_id: folder.parentId,
position: folder.position,
meta_title: folder.metaTitle,
meta_description: folder.metaDescription,
makerworld_cat_id: folder.makerworldCatId,
thingiverse_cat_id: folder.thingiverseCatId,
printables_cat_id: folder.printablesCatId,
};
}
+61 -1
View File
@@ -23,11 +23,42 @@ const folderSchema = z.object({
router.get(
"/folders",
asyncHandler(async (req, res) => {
const folders = await prisma.folder.findMany({ where: { userId: req.userId } });
const folders = await prisma.folder.findMany({
where: { userId: req.userId },
orderBy: [{ position: "asc" }, { name: "asc" }],
});
res.json(folders.map(toFolderOut));
}),
);
const reorderSchema = z.object({ folder_ids: z.array(z.string()).min(1) });
router.post(
"/folders/reorder",
asyncHandler(async (req, res) => {
const body = parseBody(reorderSchema, req.body);
const folders = await prisma.folder.findMany({
where: { id: { in: body.folder_ids }, userId: req.userId },
});
if (folders.length !== body.folder_ids.length) {
throw new HttpError(400, "folder_ids must reference existing categories");
}
const parentIds = new Set(folders.map((f) => f.parentId ?? null));
if (parentIds.size > 1) {
throw new HttpError(400, "folder_ids must all share the same parent category");
}
const [parentId] = parentIds;
const siblings = await prisma.folder.findMany({ where: { userId: req.userId, parentId } });
if (siblings.length !== body.folder_ids.length) {
throw new HttpError(400, "folder_ids must contain exactly this category's current siblings");
}
await prisma.$transaction(
body.folder_ids.map((id, idx) => prisma.folder.update({ where: { id }, data: { position: idx } })),
);
res.json({ ok: true });
}),
);
router.post(
"/folders",
asyncHandler(async (req, res) => {
@@ -56,6 +87,35 @@ router.patch(
}),
);
const catIdField = z.number().int().positive().nullable().optional();
const folderMetaSchema = z.object({
meta_title: z.string().trim().max(200).nullable().optional(),
meta_description: z.string().trim().max(2000).nullable().optional(),
makerworld_cat_id: catIdField,
thingiverse_cat_id: catIdField,
printables_cat_id: catIdField,
});
router.patch(
"/folder/:id/meta",
asyncHandler(async (req, res) => {
const body = parseBody(folderMetaSchema, req.body);
const folder = await prisma.folder.findFirst({ where: { id: req.params.id, userId: req.userId } });
if (!folder) throw new HttpError(404, "Not found");
const updated = await prisma.folder.update({
where: { id: folder.id },
data: {
metaTitle: body.meta_title || null,
metaDescription: body.meta_description || null,
makerworldCatId: body.makerworld_cat_id ?? null,
thingiverseCatId: body.thingiverse_cat_id ?? null,
printablesCatId: body.printables_cat_id ?? null,
},
});
res.json(toFolderOut(updated));
}),
);
router.delete(
"/folder/:id",
asyncHandler(async (req, res) => {
+17
View File
@@ -180,6 +180,23 @@ router.get(
}),
);
// ---- POST /print/:id/download --------------------------------------------------------------
router.post(
"/print/:id/download",
asyncHandler(async (req, res) => {
// Recorded explicitly from the detail page's download actions (single-file, per-plate, or
// "download all as zip"), rather than inside the plate-file/zip routes themselves -- those
// are shared by the 3D viewer, snapshot generation, and bulk tag/folder zips, none of which
// are a user downloading *this* model.
await prisma.print.updateMany({
where: { id: req.params.id, userId: req.userId },
data: { printCount: { increment: 1 } },
});
res.json({ ok: true });
}),
);
// ---- GET /tags ----------------------------------------------------------------------------
router.get(
+8
View File
@@ -533,6 +533,12 @@ export type ImportedPageMetadata = {
/** The richer, structured creator record (for the Author table) -- distinct from `creator`,
* which stays a plain display string for backward compatibility and non-provider sources. */
author: ImportedAuthorInfo | null;
/** The source site's own category ids for this model (e.g. MakerWorld's `design.categories`),
* paired with which site they belong to -- each site has its own independent id namespace, so
* both are needed to match against the right Folder.*CatId column (see importService.ts's
* resolveFolderIdByCategory). Empty/null when the resolver doesn't expose categories. */
siteCategoryIds: number[];
categorySite: "makerworld" | "thingiverse" | "printables" | null;
};
export type ImportedAuthorInfo = {
@@ -557,6 +563,8 @@ export function emptyImportedPageMetadata(): ImportedPageMetadata {
filename: null,
galleryImages: [],
author: null,
siteCategoryIds: [],
categorySite: null,
};
}
+30 -1
View File
@@ -234,6 +234,8 @@ export async function openImportResponse(
filename: extracted.filename ?? inheritedMeta.filename,
galleryImages: extracted.galleryImages.length ? extracted.galleryImages : inheritedMeta.galleryImages,
author: extracted.author ?? inheritedMeta.author,
siteCategoryIds: extracted.siteCategoryIds.length ? extracted.siteCategoryIds : inheritedMeta.siteCategoryIds,
categorySite: extracted.categorySite ?? inheritedMeta.categorySite,
};
if (pageHost.endsWith("makerworld.com")) {
if (!makerworldCookie) makerworldCookie = resolveMakerworldCookie(body);
@@ -388,6 +390,31 @@ export async function attachImportedPreviewImages(
}
}
const CATEGORY_SITE_FOLDER_FIELD = {
makerworld: "makerworldCatId",
thingiverse: "thingiverseCatId",
printables: "printablesCatId",
} as const;
/** When the caller didn't pick a folder explicitly, checks whether any of the user's folders
* declared a `*CatId` for this source site matching one of the model's own category ids -- if
* so, the import auto-lands there instead of staying uncategorized. Site-scoped (each site's
* category ids are an independent namespace) and best-effort: a lookup failure just leaves the
* print uncategorized rather than failing the import. */
async function resolveFolderIdByCategory(
userId: string,
categorySite: ImportedPageMetadata["categorySite"],
siteCategoryIds: number[],
): Promise<string | null> {
if (!categorySite || !siteCategoryIds.length) return null;
const field = CATEGORY_SITE_FOLDER_FIELD[categorySite];
const folder = await prisma.folder.findFirst({
where: { userId, [field]: { in: siteCategoryIds } },
orderBy: { position: "asc" },
});
return folder?.id ?? null;
}
/** Downloads a URL and creates a single-plate Print from it (POST /import). */
export async function importPrintFromUrl(
userId: string,
@@ -396,11 +423,13 @@ export async function importPrintFromUrl(
): Promise<{ print: Print; plates: Plate[]; author: Author | null; previewImages: PreviewImage[] }> {
const { tempPath, filename, mime, meta } = await downloadImportToTemp(url, body);
const author = await upsertAuthorFromImport(meta.author);
const folderId =
body.folder_id ?? (await resolveFolderIdByCategory(userId, meta.categorySite, meta.siteCategoryIds));
const printMeta: PrintMetaInput = {
title: body.title ?? meta.title ?? null,
notes: body.notes ?? meta.description ?? null,
tags: body.tags && body.tags.length ? body.tags : meta.tags,
folderId: body.folder_id ?? null,
folderId,
creator: meta.creator ?? null,
authorId: author?.id ?? null,
};
@@ -232,6 +232,24 @@ function extractGalleryImages(design: Record<string, unknown>): MakerworldGaller
return images;
}
/** design.categories -- a flat array of `{id, name, ...}` objects, most-specific first (e.g.
* "Cosplay Weapons" then its parent "Props & Cosplays"). Used to auto-land the import into a
* Folder whose makerworldCatId matches one of these (see importService.ts's
* resolveFolderIdByCategory); every id is kept, not just the first, so a folder configured for
* either the specific or the parent category still matches. */
function extractCategoryIds(design: Record<string, unknown>): number[] {
const categories = design.categories;
if (!Array.isArray(categories)) return [];
const ids: number[] = [];
for (const category of categories) {
if (!isRecord(category)) continue;
const id = category.id;
if (typeof id === "number" && Number.isInteger(id)) ids.push(id);
else if (typeof id === "string" && /^\d+$/.test(id)) ids.push(Number(id));
}
return ids;
}
/**
* Resolves a MakerWorld design to a real, directly-downloadable (signed S3) URL entirely
* through api.bambulab.com -- no Cloudflare, no cookie-gated web session, no HTML scraping.
@@ -302,6 +320,7 @@ export async function resolveMakerworldViaCloudApi(
const title = pickString(design, ["title"]);
const galleryImages = extractGalleryImages(design);
const author = creatorUid ? await fetchMakerworldAuthorInfo(creatorUid) : null;
const siteCategoryIds = extractCategoryIds(design);
return {
downloadUrl: body.url,
@@ -314,6 +333,8 @@ export async function resolveMakerworldViaCloudApi(
filename: pickString(body, ["filename"]),
galleryImages,
author,
siteCategoryIds,
categorySite: siteCategoryIds.length ? "makerworld" : null,
},
};
}
+48 -1
View File
@@ -1,7 +1,26 @@
import { authHeaders } from "../utils/auth";
import { apiBase, assertOk } from "./client";
export type Folder = { id: string; name: string; tags: string[]; parent_id?: string | null };
export type Folder = {
id: string;
name: string;
tags: string[];
parent_id?: string | null;
position: number;
meta_title: string | null;
meta_description: string | null;
makerworld_cat_id: number | null;
thingiverse_cat_id: number | null;
printables_cat_id: number | null;
};
export type FolderMetaInput = {
metaTitle: string | null;
metaDescription: string | null;
makerworldCatId: number | null;
thingiverseCatId: number | null;
printablesCatId: number | null;
};
export const foldersApi = {
list: async (): Promise<Folder[]> => {
@@ -36,6 +55,34 @@ export const foldersApi = {
return res.json();
},
updateMeta: async (id: string, meta: FolderMetaInput) => {
const res = await fetch(`${apiBase()}/folder/${id}/meta`, {
method: "PATCH",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({
meta_title: meta.metaTitle,
meta_description: meta.metaDescription,
makerworld_cat_id: meta.makerworldCatId,
thingiverse_cat_id: meta.thingiverseCatId,
printables_cat_id: meta.printablesCatId,
}),
});
assertOk(res, "Update category details failed");
return res.json();
},
/** Persists a new sibling order: `folderIds` must be exactly one category's current children
* (or exactly the current root categories), reordered. */
reorder: async (folderIds: string[]) => {
const res = await fetch(`${apiBase()}/folders/reorder`, {
method: "POST",
headers: authHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ folder_ids: folderIds }),
});
assertOk(res, "Reorder categories failed");
return res.json();
},
downloadZip: async (folder_id: string) => {
const res = await fetch(`${apiBase()}/folder/${folder_id}/download`, { headers: authHeaders() });
assertOk(res, "Folder download failed");
+12
View File
@@ -68,6 +68,7 @@ export type Print = {
slicer_url?: string | null;
slicer_filename?: string | null;
view_count: number;
print_count: number;
};
export type ListPrintsResult = {
@@ -344,4 +345,15 @@ export const printsApi = {
assertOk(res, "Download failed");
return res;
},
/** Records a completed download of this print (called once per explicit download action --
* single-file, per-plate, or "download all as zip" -- from the model detail page). */
recordDownload: async (id: string) => {
const res = await fetch(`${apiBase()}/print/${id}/download`, {
method: "POST",
headers: authHeaders(),
});
assertOk(res, "Failed to record download");
return res.json();
},
};
@@ -1,15 +1,29 @@
import { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import { useTranslation } from "react-i18next";
import { type ResolvedTheme } from "../../../constants/settingsOptions";
import { applyThemeToObject, disposeObject3D, loadObjectFromAsset, paletteForTheme } from "../../../utils/modelLoaders";
import {
applyThemeToObject,
disposeObject3D,
loadBambuThreeMFForViewer,
loadObjectFromAsset,
paletteForTheme,
} from "../../../utils/modelLoaders";
import { buildBambuModelGroup, type Parsed3MFData, type PlateSummary } from "../../../utils/bambuThreeMf";
import { createOrientationGizmo } from "./orientationGizmo";
import Wordmark from "../../Wordmark";
// This viewer's lighting/tone-mapping setup, build-plate + grid + shadow-catcher rendering, and
// fitCameraToBox below are ported from maziggy/bambuddy (https://github.com/maziggy/bambuddy,
// frontend/src/components/ModelViewer.tsx, AGPL-3.0-only). PrintStash uses this file under the
// same license -- see LICENSE at the repo root. Multi-plate parsing itself lives in
// ../../../utils/bambuThreeMf.ts, ported from the same source.
type ModelViewerProps = {
url: string;
ext: string;
@@ -18,13 +32,58 @@ type ModelViewerProps = {
/** Overrides the theme-derived material color (e.g. the fixed "red plate" look used by the
* model detail page's 3D preview modal) while keeping the rest of the palette intact. */
colorOverride?: string;
/** For a multi-plate Bambu Studio 3MF: which internal plate to render (null renders every
* plate's build items together). Ignored for every other format. */
selectedPlateId?: number | null;
/** Fired once after a 3MF's internal plates are known -- empty for a single-plate/non-Bambu
* file. `getThumbnail` is bound to the already-fetched file bytes, so a caller building a
* plate picker doesn't need to refetch the (often tens of MB) file itself. */
onPlatesDetected?: (plates: PlateSummary[], getThumbnail: (index: number) => Promise<string | null>) => void;
};
type ViewErrorKey = "unsupported" | "failed";
export default function ModelViewer({ url, ext, viewKey, theme, colorOverride }: ModelViewerProps) {
const BAMBU_PLATE_COLOR = 0x00ae42;
// Bambu Studio's own three-quarter view (used for multi-plate 3MF); mostly-front-on with a
// slight elevation/side offset for depth for every other format (STL/OBJ/STEP), so a single
// uploaded model loads facing the viewer rather than from a corner.
const BAMBU_VIEW_DIRECTION = new THREE.Vector3(0.7, 0.5, 0.7).normalize();
const FRONT_VIEW_DIRECTION = new THREE.Vector3(0.9, 0.7, 2.1).normalize();
/** Frame the camera on a bounding box, solving for distance against both the vertical and
* (aspect-derived) horizontal field of view so the model fills the frame -- with margin from
* `padding` -- at any viewport shape and from any direction, without ever cropping on orbit
* (the box's circumscribed sphere, not just its "tallest axis", sets the distance). Ported from
* bambuddy's fitCameraToBox, generalized with a `direction` param so it can also replace this
* viewer's old ad-hoc radius*k heuristic for the non-3MF formats. */
function fitCameraToBox(
camera: THREE.PerspectiveCamera,
controls: any,
box: THREE.Box3,
direction: THREE.Vector3 = BAMBU_VIEW_DIRECTION,
padding = 1.15
): void {
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(size.length() / 2, 0.001);
const vFov = THREE.MathUtils.degToRad(camera.fov);
const hFov = 2 * Math.atan(Math.tan(vFov / 2) * camera.aspect);
const distance = padding * Math.max(radius / Math.sin(vFov / 2), radius / Math.sin(hFov / 2));
camera.position.copy(center).addScaledVector(direction, distance);
camera.near = Math.max(distance / 1000, 0.01);
camera.far = distance + radius * 4;
camera.updateProjectionMatrix();
controls.target.copy(center);
controls.update();
}
export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, selectedPlateId = null, onPlatesDetected }: ModelViewerProps) {
const { t } = useTranslation(["library"]);
const mountRef = useRef<HTMLDivElement | null>(null);
// Bridges the setup effect below to the selectedPlateId effect further down, so switching
// plates rebuilds the already-parsed group in place instead of refetching/reparsing the file.
const rebuildBambuPlateRef = useRef<((plateId: number | null) => void) | null>(null);
const [viewError, setViewError] = useState<ViewErrorKey | null>(null);
const [isLoading, setIsLoading] = useState(true);
@@ -45,25 +104,49 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride }:
};
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
const initialWidth = mount.clientWidth || 300;
const initialHeight = mount.clientHeight || 300;
// Real aspect from the start: opening this in a dialog/modal never fires a window "resize"
// event (only the listener registered further down would catch that), so leaving this at the
// constructor's placeholder 1:1 until some future resize left every framing calculation
// (centerSceneOn, fitCameraToBox) solving for the wrong aspect for the entire session.
const camera = new THREE.PerspectiveCamera(45, initialWidth / initialHeight, 0.1, 10000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.setSize(mount.clientWidth || 300, mount.clientHeight || 300);
renderer.setSize(initialWidth, initialHeight);
// Filmic tone mapping + sub-1.0 exposure keeps a saturated filament colour's lit side from
// clipping to white against RoomEnvironment's bright IBL -- ported from bambuddy.
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 0.85;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
mount.appendChild(renderer.domElement);
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 1.6));
scene.add(new THREE.AmbientLight(0xffffff, 0.9));
const dir1 = new THREE.DirectionalLight(0xffffff, 1.4);
dir1.position.set(3, 6, 2);
const dir2 = new THREE.DirectionalLight(0xffffff, 1.2);
dir2.position.set(-4, -2, 3);
scene.add(dir1);
scene.add(dir2);
camera.position.set(1, 1, 3);
// Image-based lighting from a generated room: soft gradients and a hint of reflection across
// curved surfaces, which flat ambient + a couple of directional lights can't produce (every
// same-facing surface got an identical colour, flattening models into silhouettes).
const pmrem = new THREE.PMREMGenerator(renderer);
const environment = pmrem.fromScene(new RoomEnvironment(), 0.04);
scene.environment = environment.texture;
// One mostly-overhead key light for the contact shadow and a highlight direction; the
// environment supplies the fill.
const keyLight = new THREE.DirectionalLight(0xffffff, 0.9);
keyLight.position.set(60, 260, 90);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(2048, 2048);
keyLight.shadow.bias = -0.0005;
keyLight.shadow.normalBias = 0.02;
scene.add(keyLight);
camera.position.set(150, 150, 150);
let controls: any;
let teardown: (() => void) | undefined;
const storageKey = viewKey ? `ps-view-${viewKey}` : null;
// Bumped to v2 when the Z-up -> Y-up axis conversion was added: a view saved under v1 has a
// camera position/target computed for the old (unrotated) layout, so restoring it now would
// orbit around a point nowhere near where the model actually sits.
const storageKey = viewKey ? `ps-view-v2-${viewKey}` : null;
const loadSavedView = () => {
if (!storageKey || typeof window === "undefined") return false;
@@ -85,14 +168,109 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride }:
const saveView = () => {
if (!storageKey || typeof window === "undefined" || !controls) return;
try {
const payload = {
position: camera.position.toArray(),
target: controls.target.toArray(),
};
const payload = { position: camera.position.toArray(), target: controls.target.toArray() };
window.localStorage.setItem(storageKey, JSON.stringify(payload));
} catch {}
};
// Build-plate group: grid + tinted plane + a dedicated shadow-catcher plane just above it
// (the tinted plane is unlit MeshBasicMaterial and can't receive shadows itself). Sized/
// repositioned from the asset's own build volume once known -- only 3MF (Bambu Studio
// project files) carries a real one, so this stays hidden for STL/OBJ/STEP: a fixed 256mm
// bed drawn under, say, a 15mm keychain reads as a camera stuck inside a giant grid, not
// "here's the print bed" (there's no real bed-size data for those formats to size it from).
let buildVolume = { x: 256, y: 256 };
const gridHelper = new THREE.GridHelper(buildVolume.x, Math.ceil(buildVolume.x / 16), 0x444444, 0x333333);
gridHelper.visible = false;
scene.add(gridHelper);
const plateMesh = new THREE.Mesh(
new THREE.PlaneGeometry(buildVolume.x, buildVolume.y),
new THREE.MeshBasicMaterial({ color: BAMBU_PLATE_COLOR, transparent: true, opacity: 0.15, side: THREE.DoubleSide })
);
plateMesh.rotation.x = -Math.PI / 2;
plateMesh.position.y = -0.5;
plateMesh.visible = false;
scene.add(plateMesh);
const shadowCatcher = new THREE.Mesh(
new THREE.PlaneGeometry(buildVolume.x, buildVolume.y),
new THREE.ShadowMaterial({ opacity: 0.22 })
);
shadowCatcher.rotation.x = -Math.PI / 2;
shadowCatcher.position.y = -0.49;
shadowCatcher.receiveShadow = true;
shadowCatcher.visible = false;
scene.add(shadowCatcher);
const layoutBuildPlate = () => {
gridHelper.visible = true;
plateMesh.visible = true;
shadowCatcher.visible = true;
const shadowExtent = Math.max(buildVolume.x, buildVolume.y) * 0.75;
keyLight.shadow.camera.left = -shadowExtent;
keyLight.shadow.camera.right = shadowExtent;
keyLight.shadow.camera.top = shadowExtent;
keyLight.shadow.camera.bottom = -shadowExtent;
keyLight.shadow.camera.near = 1;
keyLight.shadow.camera.far = shadowExtent * 6;
keyLight.shadow.camera.updateProjectionMatrix();
gridHelper.geometry.dispose();
const gridSize = Math.max(buildVolume.x, buildVolume.y);
const replacement = new THREE.GridHelper(gridSize, Math.ceil(gridSize / 16), 0x444444, 0x333333);
gridHelper.geometry = replacement.geometry;
replacement.geometry = new THREE.BufferGeometry();
plateMesh.geometry.dispose();
plateMesh.geometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
shadowCatcher.geometry.dispose();
shadowCatcher.geometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
};
// 3MF-only state: kept around so a selectedPlateId change (switching plates) rebuilds the
// group locally instead of refetching/reparsing the whole file.
let bambuParsed: Parsed3MFData | null = null;
let bambuFilamentColors: string[] = [];
let currentPlateId: number | null = selectedPlateId;
const renderBambuGroup = (centerOnBuildPlate: boolean) => {
if (!bambuParsed) return;
if (activeObject) {
scene.remove(activeObject);
disposeObject3D(activeObject);
}
const group = buildBambuModelGroup(bambuParsed, currentPlateId, bambuFilamentColors);
if (colorOverride) {
group.traverse(child => {
if (child instanceof THREE.Mesh) {
const mat = child.material as THREE.MeshStandardMaterial;
mat.color?.set(colorOverride);
}
});
}
group.traverse(child => {
if (child instanceof THREE.Mesh) child.castShadow = true;
});
activeObject = group;
scene.add(group);
const box = new THREE.Box3().setFromObject(group);
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
group.position.y = -box.min.y;
if (centerOnBuildPlate) {
group.position.x = -center.x + buildVolume.x / 2;
group.position.z = -center.z + buildVolume.y / 2;
}
plateMesh.position.set(buildVolume.x / 2, plateMesh.position.y, buildVolume.y / 2);
shadowCatcher.position.set(buildVolume.x / 2, shadowCatcher.position.y, buildVolume.y / 2);
gridHelper.position.set(buildVolume.x / 2, 0, buildVolume.y / 2);
const finalBox = new THREE.Box3().setFromObject(group);
if (!loadSavedView()) fitCameraToBox(camera, controls, finalBox);
setIsLoading(false);
};
(async () => {
try {
const { OrbitControls } = await import("three/examples/jsm/controls/OrbitControls.js");
@@ -100,43 +278,65 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride }:
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.enablePan = true;
controls.target.set(0, 0, 0);
controls.target.set(0, 50, 0);
controls.addEventListener("change", saveView);
const centerSceneOn = (box: THREE.Box3) => {
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(size.x, size.y, size.z) || 1;
if (!loadSavedView()) {
// Mostly-front-on with a slight elevation/side offset for depth, rather than the
// equal-XZ diagonal this used to be (which reads as "from a corner", not "from the
// front").
camera.position.copy(center).add(new THREE.Vector3(radius * 0.9, radius * 0.7, radius * 2.1));
camera.lookAt(center);
controls?.target.copy(center);
controls?.update();
}
if (!loadSavedView()) fitCameraToBox(camera, controls, box, FRONT_VIEW_DIRECTION);
};
const e = (ext || "").toLowerCase();
try {
const obj = await loadObjectFromAsset(e, url);
if (!obj) {
reportError("unsupported");
return;
}
if (disposed) {
disposeObject3D(obj);
return;
}
applyThemeToObject(obj, palette);
activeObject = obj;
scene.add(obj);
if (!disposed) setIsLoading(false);
const box = new THREE.Box3().setFromObject(obj);
if (!box.isEmpty()) {
centerSceneOn(box);
if (e === "3mf") {
const result = await loadBambuThreeMFForViewer(url);
if (!result) {
// Not a 3MF the Bambu-aware parser could make sense of -- fall back to the
// generic loader chain (simple parse, then three.js's own ThreeMFLoader).
const obj = await loadObjectFromAsset(e, url);
if (!obj) {
reportError("unsupported");
return;
}
if (disposed) {
disposeObject3D(obj);
return;
}
applyThemeToObject(obj, palette);
activeObject = obj;
scene.add(obj);
if (!disposed) setIsLoading(false);
const box = new THREE.Box3().setFromObject(obj);
if (!box.isEmpty()) centerSceneOn(box);
} else {
if (disposed) return;
bambuParsed = result.parsedData;
bambuFilamentColors = result.filamentColors;
buildVolume = result.buildVolume;
layoutBuildPlate();
if (currentPlateId == null && result.plates.length > 0) currentPlateId = result.plates[0].index;
onPlatesDetected?.(result.plates, result.getPlateThumbnail);
renderBambuGroup(true);
}
} else {
const obj = await loadObjectFromAsset(e, url);
if (!obj) {
reportError("unsupported");
return;
}
if (disposed) {
disposeObject3D(obj);
return;
}
applyThemeToObject(obj, palette);
obj.traverse(child => {
if (child instanceof THREE.Mesh) child.castShadow = true;
});
activeObject = obj;
scene.add(obj);
if (!disposed) setIsLoading(false);
const box = new THREE.Box3().setFromObject(obj);
if (!box.isEmpty()) centerSceneOn(box);
}
} catch (err) {
console.error("Viewer asset load failed:", err);
@@ -176,6 +376,11 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride }:
requestAnimationFrame(animate);
};
animate();
rebuildBambuPlateRef.current = plateId => {
currentPlateId = plateId;
renderBambuGroup(true);
};
})();
return () => {
@@ -191,13 +396,31 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride }:
controls?.removeEventListener("change", saveView);
controls?.dispose();
} catch {}
try {
environment.texture.dispose();
pmrem.dispose();
} catch {}
try {
renderer.forceContextLoss?.();
} catch {}
renderer.dispose();
rebuildBambuPlateRef.current = null;
};
// selectedPlateId and onPlatesDetected are deliberately excluded: this effect does the full
// parse/scene setup, reading selectedPlateId only once as the initial plate. Switching plates
// afterward is handled by the separate lightweight effect below via rebuildBambuPlateRef,
// without re-parsing/re-fetching the model -- including either dependency here would re-run
// the full setup on every plate click (or every render, since onPlatesDetected is an
// unmemoized callback prop).
// oxlint-disable-next-line react/exhaustive-effect-dependencies
// oxlint-disable-next-line react-hooks/exhaustive-deps
}, [url, ext, viewKey, theme, colorOverride]);
// Switching the selected plate rebuilds the already-parsed group in place (no refetch).
useEffect(() => {
rebuildBambuPlateRef.current?.(selectedPlateId ?? null);
}, [selectedPlateId]);
return (
<Box
ref={mountRef}
+16 -1
View File
@@ -277,6 +277,8 @@
"remove": "Remove",
"rename": "Rename",
"edit": "Edit",
"moveUp": "Move up",
"moveDown": "Move down",
"add": "Add",
"upload": "Upload",
"download": "Download",
@@ -471,6 +473,9 @@
"description": "Description",
"noDescription": "No description yet.",
"plateLabel": "Plate {{n}}",
"internalPlatesDivider": "{{count}} plates in this file",
"internalPlateObjectCount_one": "{{count}} object",
"internalPlateObjectCount_other": "{{count}} objects",
"downloadFailed": "Download failed. Please try again.",
"deleteFailed": "Failed to delete the model.",
"confirmDelete": "Delete \"{{name}}\"? This cannot be undone.",
@@ -492,6 +497,8 @@
"createCategoryFailed": "Failed to create the category. Please try again.",
"renameCategoryFailed": "Failed to rename the category. Please try again.",
"deleteCategoryFailed": "Failed to delete the category. Please try again.",
"reorderCategoryFailed": "Failed to reorder categories. Please try again.",
"updateCategoryMetaFailed": "Failed to save category details. Please try again.",
"notFound": "Model not found."
},
"categories": {
@@ -508,7 +515,15 @@
"noCategories": "No categories yet. Add one to get started.",
"confirmDeleteCategory": "Delete \"{{name}}\"? Any models in it move to Unassigned.",
"confirmDeleteCategoryWithSub": "Delete \"{{name}}\"? Its subcategories become top-level categories, and any models in them move to Unassigned.",
"confirmDeleteSubcategory": "Delete \"{{name}}\"? Any models in it move to Unassigned."
"confirmDeleteSubcategory": "Delete \"{{name}}\"? Any models in it move to Unassigned.",
"addDetailsTooltip": "Add details",
"editDetailsTooltip": "Edit details",
"metaDialogTitle": "Details — {{name}}",
"metaTitleLabel": "Meta title",
"metaDescriptionLabel": "Meta description",
"makerworldCatIdLabel": "MakerWorld category ID",
"thingiverseCatIdLabel": "Thingiverse category ID",
"printablesCatIdLabel": "Printables category ID"
}
}
}
+17 -1
View File
@@ -277,6 +277,8 @@
"remove": "Pašalinti",
"rename": "Pervadinti",
"edit": "Redaguoti",
"moveUp": "Perkelti aukštyn",
"moveDown": "Perkelti žemyn",
"add": "Pridėti",
"upload": "Įkelti",
"download": "Atsisiųsti",
@@ -471,6 +473,10 @@
"description": "Aprašymas",
"noDescription": "Aprašymo dar nėra.",
"plateLabel": "Plokštė {{n}}",
"internalPlatesDivider": "{{count}} plokščių šiame faile",
"internalPlateObjectCount_one": "{{count}} objektas",
"internalPlateObjectCount_few": "{{count}} objektai",
"internalPlateObjectCount_other": "{{count}} objektų",
"downloadFailed": "Nepavyko atsisiųsti. Bandykite dar kartą.",
"deleteFailed": "Nepavyko ištrinti modelio.",
"confirmDelete": "Ištrinti „{{name}}“? Šio veiksmo nebus galima atšaukti.",
@@ -492,6 +498,8 @@
"createCategoryFailed": "Nepavyko sukurti kategorijos. Bandykite dar kartą.",
"renameCategoryFailed": "Nepavyko pervadinti kategorijos. Bandykite dar kartą.",
"deleteCategoryFailed": "Nepavyko ištrinti kategorijos. Bandykite dar kartą.",
"reorderCategoryFailed": "Nepavyko pakeisti kategorijų tvarkos. Bandykite dar kartą.",
"updateCategoryMetaFailed": "Nepavyko išsaugoti kategorijos informacijos. Bandykite dar kartą.",
"notFound": "Modelis nerastas."
},
"categories": {
@@ -508,7 +516,15 @@
"noCategories": "Kol kas nėra kategorijų. Pridėkite pirmąją.",
"confirmDeleteCategory": "Ištrinti „{{name}}“? Joje esantys modeliai taps nepriskirti.",
"confirmDeleteCategoryWithSub": "Ištrinti „{{name}}“? Jos subkategorijos taps pagrindinėmis kategorijomis, o jose esantys modeliai taps nepriskirti.",
"confirmDeleteSubcategory": "Ištrinti „{{name}}“? Joje esantys modeliai taps nepriskirti."
"confirmDeleteSubcategory": "Ištrinti „{{name}}“? Joje esantys modeliai taps nepriskirti.",
"addDetailsTooltip": "Pridėti informaciją",
"editDetailsTooltip": "Redaguoti informaciją",
"metaDialogTitle": "Informacija — {{name}}",
"metaTitleLabel": "Meta pavadinimas",
"metaDescriptionLabel": "Meta aprašymas",
"makerworldCatIdLabel": "MakerWorld kategorijos ID",
"thingiverseCatIdLabel": "Thingiverse kategorijos ID",
"printablesCatIdLabel": "Printables kategorijos ID"
}
}
}
@@ -3,17 +3,20 @@ import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import Box from "@mui/material/Box";
import Paper from "@mui/material/Paper";
import Divider from "@mui/material/Divider";
import List from "@mui/material/List";
import ListItemButton from "@mui/material/ListItemButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import ListItemText from "@mui/material/ListItemText";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import LayersIcon from "@mui/icons-material/Layers";
import CloseIcon from "@mui/icons-material/Close";
import { type Print, printsApi } from "../../api/prints";
import { MODEL_EXTS } from "../../constants/fileTypes";
import { extOf } from "../../utils/fileExtensions";
import ModelViewer from "../../components/media/ModelViewer";
import type { PlateSummary } from "../../utils/bambuThreeMf";
// A neutral, theme-independent canvas -- this is a fixed "product shot" style preview, not part
// of the app's light/dark chrome, so it stays the same regardless of the viewer's theme.
@@ -38,6 +41,30 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
const ext = activePlate ? extOf(activePlate.filename) : "";
const is3d = Boolean(activePlate) && MODEL_EXTS.has(ext);
// A single uploaded/imported .3mf can itself be a Bambu Studio multi-plate project -- these are
// its *internal* plates (detected client-side by the viewer), distinct from sortedPlates above
// (separate uploaded files). Reset whenever the active file changes; a stale list from the
// previous file would let you pick a plate index that doesn't exist in the new one.
const [internalPlates, setInternalPlates] = useState<PlateSummary[]>([]);
const [internalThumbnails, setInternalThumbnails] = useState<Record<number, string | null>>({});
const [selectedInternalPlateId, setSelectedInternalPlateId] = useState<number | null>(null);
const handlePlatesDetected = (plates: PlateSummary[], getThumbnail: (index: number) => Promise<string | null>) => {
setInternalPlates(plates);
setSelectedInternalPlateId(plates[0]?.index ?? null);
setInternalThumbnails({});
Promise.all(plates.map(async plate => [plate.index, await getThumbnail(plate.index)] as const)).then(pairs => {
setInternalThumbnails(Object.fromEntries(pairs));
});
};
const selectPlate = (plateId: string) => {
setActivePlateId(plateId);
setInternalPlates([]);
setInternalThumbnails({});
setSelectedInternalPlateId(null);
};
return (
<Dialog
open
@@ -82,7 +109,7 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
<ListItemButton
key={plate.id}
selected={plate.id === activePlate?.id}
onClick={() => setActivePlateId(plate.id)}
onClick={() => selectPlate(plate.id)}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 40 }}>
@@ -106,6 +133,61 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
</ListItemButton>
))}
</List>
{internalPlates.length > 0 && (
<>
<Divider sx={{ my: 1 }}>
<Typography variant="caption" color="text.secondary">
{t("models:detail.internalPlatesDivider", { count: internalPlates.length })}
</Typography>
</Divider>
<List disablePadding>
{internalPlates.map(plate => (
<ListItemButton
key={plate.index}
selected={plate.index === selectedInternalPlateId}
onClick={() => setSelectedInternalPlateId(plate.index)}
sx={{ borderRadius: 1, mb: 0.5 }}
>
<ListItemIcon sx={{ minWidth: 40 }}>
{internalThumbnails[plate.index] ? (
<Box
component="img"
src={internalThumbnails[plate.index] ?? undefined}
alt=""
sx={{ width: 32, height: 32, borderRadius: 0.75, objectFit: "cover" }}
/>
) : (
<Box
sx={{
width: 32,
height: 32,
borderRadius: 0.75,
bgcolor: "action.hover",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<LayersIcon fontSize="small" color="disabled" />
</Box>
)}
</ListItemIcon>
<ListItemText
primary={
plate.name
? t("models:detail.plateLabel", { n: plate.index }) + ` — ${plate.name}`
: t("models:detail.plateLabel", { n: plate.index })
}
secondary={t("models:detail.internalPlateObjectCount", { count: plate.objectCount })}
primaryTypographyProps={{ variant: "body2", noWrap: true }}
secondaryTypographyProps={{ variant: "caption" }}
/>
</ListItemButton>
))}
</List>
</>
)}
</Paper>
<Box sx={{ width: "100%", height: "100%" }}>
@@ -117,6 +199,8 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
viewKey={`preview-${print.id}-${activePlate.id}`}
theme="light"
colorOverride={PREVIEW_MODEL_COLOR}
selectedPlateId={selectedInternalPlateId}
onPlatesDetected={handlePlatesDetected}
/>
) : (
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "center", height: "100%" }}>
@@ -60,6 +60,7 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted }: P
if (!res.ok) throw new Error("Download failed");
await saveResponseToDisk(res, plate.filename || "download");
setPickerOpen(false);
printsApi.recordDownload(print.id).catch(() => {});
} catch (err) {
handleDownloadError(err);
} finally {
@@ -73,6 +74,7 @@ export default function ModelActionsMenu({ print, onUnauthorized, onDeleted }: P
const res = await printsApi.downloadZip({ print_ids: [print.id] });
await saveResponseToDisk(res, `${print.name || "model"}.zip`);
setPickerOpen(false);
printsApi.recordDownload(print.id).catch(() => {});
} catch (err) {
handleDownloadError(err);
} finally {
@@ -12,7 +12,7 @@ import Collapse from "@mui/material/Collapse";
import CircularProgress from "@mui/material/CircularProgress";
import SettingsIcon from "@mui/icons-material/Settings";
import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import type { Folder } from "../../api/folders";
import type { Folder, FolderMetaInput } from "../../api/folders";
import CategoryManagerModal from "./CategoryManagerModal";
type Props = {
@@ -23,6 +23,8 @@ type Props = {
onCreate: (name: string, parentId: string | null) => Promise<void>;
onRename: (id: string, name: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onReorder: (folderIds: string[]) => Promise<void>;
onUpdateMeta: (id: string, meta: FolderMetaInput) => Promise<void>;
};
/** The Models page's own category browser: a pinned "All" row, then a strictly two-level tree --
@@ -31,7 +33,7 @@ type Props = {
* just that one. Only one top-level category can be expanded at a time, and it only collapses
* when another one is clicked. Creating, renaming, and deleting categories all happen in the
* cog-triggered CategoryManagerModal, not inline here. */
export default function CategoriesPanel({ folders, loading, selectedId, onSelect, onCreate, onRename, onDelete }: Props) {
export default function CategoriesPanel({ folders, loading, selectedId, onSelect, onCreate, onRename, onDelete, onReorder, onUpdateMeta }: Props) {
const { t } = useTranslation(["models", "common"]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [managerOpen, setManagerOpen] = useState(false);
@@ -49,11 +51,12 @@ export default function CategoriesPanel({ folders, loading, selectedId, onSelect
rootList.push(f);
}
});
const byPosition = (a: Folder, b: Folder) => a.position - b.position || a.name.localeCompare(b.name);
Object.keys(childrenMap).forEach(key => {
childrenMap[key] = childrenMap[key].toSorted((a, b) => a.name.localeCompare(b.name));
childrenMap[key] = childrenMap[key].toSorted(byPosition);
});
return {
roots: rootList.toSorted((a, b) => a.name.localeCompare(b.name)),
roots: rootList.toSorted(byPosition),
childrenByParent: childrenMap,
};
}, [folders]);
@@ -192,6 +195,8 @@ export default function CategoriesPanel({ folders, loading, selectedId, onSelect
onCreate={onCreate}
onRename={onRename}
onDelete={onDelete}
onReorder={onReorder}
onUpdateMeta={onUpdateMeta}
/>
)}
</>
@@ -14,13 +14,18 @@ import List from "@mui/material/List";
import ListItem from "@mui/material/ListItem";
import CircularProgress from "@mui/material/CircularProgress";
import Divider from "@mui/material/Divider";
import Tooltip from "@mui/material/Tooltip";
import CloseIcon from "@mui/icons-material/Close";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import CheckIcon from "@mui/icons-material/Check";
import type { Folder } from "../../api/folders";
import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward";
import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward";
import InfoOutlinedIcon from "@mui/icons-material/InfoOutlined";
import type { Folder, FolderMetaInput } from "../../api/folders";
import { useConfirm } from "../../components/ConfirmProvider";
import CategoryMetaDialog from "./CategoryMetaDialog";
type Props = {
folders: Folder[];
@@ -28,15 +33,35 @@ type Props = {
onCreate: (name: string, parentId: string | null) => Promise<void>;
onRename: (id: string, name: string) => Promise<void>;
onDelete: (id: string) => Promise<void>;
onReorder: (folderIds: string[]) => Promise<void>;
onUpdateMeta: (id: string, meta: FolderMetaInput) => Promise<void>;
};
/** One editable row shared by both category levels: plain text + edit/delete icons, or (while
* editing) a text field + save/cancel. */
function hasMeta(folder: Folder): boolean {
return Boolean(
folder.meta_title ||
folder.meta_description ||
folder.makerworld_cat_id ||
folder.thingiverse_cat_id ||
folder.printables_cat_id,
);
}
/** One editable row shared by both category levels: plain text + move-up/move-down/details/edit/
* delete icons, or (while editing) a text field + save/cancel. */
function CategoryRow({
name,
indent,
bold,
busy,
canMoveUp,
canMoveDown,
onMoveUp,
onMoveDown,
metaTitle,
metaDescription,
hasDetails,
onOpenMeta,
onRename,
onDelete,
}: {
@@ -44,10 +69,18 @@ function CategoryRow({
indent: number;
bold?: boolean;
busy: boolean;
canMoveUp: boolean;
canMoveDown: boolean;
onMoveUp: () => void;
onMoveDown: () => void;
metaTitle: string | null;
metaDescription: string | null;
hasDetails: boolean;
onOpenMeta: () => void;
onRename: (name: string) => Promise<void>;
onDelete: () => void;
}) {
const { t } = useTranslation(["common"]);
const { t } = useTranslation(["models", "common"]);
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(name);
const [saving, setSaving] = useState(false);
@@ -111,6 +144,45 @@ function CategoryRow({
sx={{ pl: indent, py: 0.5 }}
secondaryAction={
<Stack direction="row" spacing={0.25}>
<IconButton size="small" onClick={onMoveUp} disabled={busy || !canMoveUp} aria-label={t("common:moveUp") ?? undefined}>
<ArrowUpwardIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={onMoveDown} disabled={busy || !canMoveDown} aria-label={t("common:moveDown") ?? undefined}>
<ArrowDownwardIcon fontSize="small" />
</IconButton>
<Tooltip
title={
hasDetails ? (
<Stack spacing={0.25} sx={{ py: 0.25 }}>
{metaTitle && <Typography variant="caption" fontWeight={700} sx={{ display: "block" }}>{metaTitle}</Typography>}
{metaDescription && <Typography variant="caption" sx={{ display: "block" }}>{metaDescription}</Typography>}
</Stack>
) : (
t("models:categories.manager.addDetailsTooltip") ?? ""
)
}
>
<span>
<IconButton
size="small"
onClick={onOpenMeta}
disabled={busy}
aria-label={
(hasDetails
? t("models:categories.manager.editDetailsTooltip")
: t("models:categories.manager.addDetailsTooltip")) ?? undefined
}
>
<InfoOutlinedIcon
fontSize="small"
sx={{
opacity: hasDetails ? 1 : 0.35,
color: hasDetails ? "primary.main" : "action.active",
}}
/>
</IconButton>
</span>
</Tooltip>
<IconButton size="small" onClick={startEdit} disabled={busy} aria-label={t("common:rename") ?? undefined}>
<EditIcon fontSize="small" />
</IconButton>
@@ -120,7 +192,7 @@ function CategoryRow({
</Stack>
}
>
<Typography variant="body2" fontWeight={bold ? 600 : 400} noWrap sx={{ pr: 8 }}>
<Typography variant="body2" fontWeight={bold ? 600 : 400} noWrap sx={{ pr: 19 }}>
{name}
</Typography>
</ListItem>
@@ -184,10 +256,11 @@ function AddRow({ indent, placeholder, busy, onAdd }: {
);
}
export default function CategoryManagerModal({ folders, onClose, onCreate, onRename, onDelete }: Props) {
export default function CategoryManagerModal({ folders, onClose, onCreate, onRename, onDelete, onReorder, onUpdateMeta }: Props) {
const { t } = useTranslation(["models", "common"]);
const confirmDialog = useConfirm();
const [busyId, setBusyId] = useState<string | null>(null);
const [metaFolder, setMetaFolder] = useState<Folder | null>(null);
const { roots, childrenByParent } = useMemo(() => {
const childrenMap: Record<string, Folder[]> = {};
@@ -200,11 +273,12 @@ export default function CategoryManagerModal({ folders, onClose, onCreate, onRen
rootList.push(f);
}
});
const byPosition = (a: Folder, b: Folder) => a.position - b.position || a.name.localeCompare(b.name);
Object.keys(childrenMap).forEach(key => {
childrenMap[key] = childrenMap[key].toSorted((a, b) => a.name.localeCompare(b.name));
childrenMap[key] = childrenMap[key].toSorted(byPosition);
});
return {
roots: rootList.toSorted((a, b) => a.name.localeCompare(b.name)),
roots: rootList.toSorted(byPosition),
childrenByParent: childrenMap,
};
}, [folders]);
@@ -236,7 +310,29 @@ export default function CategoryManagerModal({ folders, onClose, onCreate, onRen
}
};
const swapAndReorder = async (siblingIds: string[], index: number, direction: -1 | 1, busyKey: string) => {
const swapIndex = index + direction;
if (swapIndex < 0 || swapIndex >= siblingIds.length) return;
const reordered = siblingIds.slice();
[reordered[index], reordered[swapIndex]] = [reordered[swapIndex], reordered[index]];
setBusyId(busyKey);
try {
await onReorder(reordered);
} finally {
setBusyId(null);
}
};
const moveRoot = (index: number, direction: -1 | 1) =>
swapAndReorder(roots.map(r => r.id), index, direction, roots[index].id);
const moveChild = (parentId: string, index: number, direction: -1 | 1) => {
const children = childrenByParent[parentId] || [];
return swapAndReorder(children.map(c => c.id), index, direction, children[index].id);
};
return (
<>
<Dialog open onClose={onClose} fullWidth maxWidth="sm">
<DialogTitle sx={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
{t("models:categories.manager.title")}
@@ -246,7 +342,7 @@ export default function CategoryManagerModal({ folders, onClose, onCreate, onRen
</DialogTitle>
<DialogContent dividers>
<List disablePadding>
{roots.map(root => {
{roots.map((root, rootIndex) => {
const children = childrenByParent[root.id] || [];
return (
<Box key={root.id} sx={{ mb: 1.5 }}>
@@ -255,16 +351,32 @@ export default function CategoryManagerModal({ folders, onClose, onCreate, onRen
indent={0}
bold
busy={busyId === root.id}
canMoveUp={rootIndex > 0}
canMoveDown={rootIndex < roots.length - 1}
onMoveUp={() => moveRoot(rootIndex, -1)}
onMoveDown={() => moveRoot(rootIndex, 1)}
metaTitle={root.meta_title}
metaDescription={root.meta_description}
hasDetails={hasMeta(root)}
onOpenMeta={() => setMetaFolder(root)}
onRename={name => onRename(root.id, name)}
onDelete={() => handleDeleteCategory(root)}
/>
<List disablePadding>
{children.map(child => (
{children.map((child, childIndex) => (
<CategoryRow
key={child.id}
name={child.name || untitledLabel}
indent={3}
busy={busyId === child.id}
canMoveUp={childIndex > 0}
canMoveDown={childIndex < children.length - 1}
onMoveUp={() => moveChild(root.id, childIndex, -1)}
onMoveDown={() => moveChild(root.id, childIndex, 1)}
metaTitle={child.meta_title}
metaDescription={child.meta_description}
hasDetails={hasMeta(child)}
onOpenMeta={() => setMetaFolder(child)}
onRename={name => onRename(child.id, name)}
onDelete={() => handleDeleteSubcategory(child)}
/>
@@ -316,5 +428,13 @@ export default function CategoryManagerModal({ folders, onClose, onCreate, onRen
<Button onClick={onClose}>{t("common:close")}</Button>
</DialogActions>
</Dialog>
{metaFolder && (
<CategoryMetaDialog
folder={metaFolder}
onClose={() => setMetaFolder(null)}
onSave={meta => onUpdateMeta(metaFolder.id, meta)}
/>
)}
</>
);
}
@@ -0,0 +1,126 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import Dialog from "@mui/material/Dialog";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Stack from "@mui/material/Stack";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import type { Folder, FolderMetaInput } from "../../api/folders";
type Props = {
folder: Folder;
onClose: () => void;
onSave: (meta: FolderMetaInput) => Promise<void>;
};
/** Parses a category-id field: blank stays null, anything else must be a positive integer (kept
* as free text while typing so "12" isn't clobbered mid-edit; validated only on save). */
function parseCatId(raw: string): number | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const value = Number(trimmed);
return Number.isInteger(value) && value > 0 ? value : null;
}
/** Meta title/description/site-category-id editor for one category, opened from its "details"
* icon in the manager. Saving with all fields blank clears the category's metadata entirely.
* The three `*CatId` fields drive auto-categorization on import (see importService.ts's
* resolveFolderIdByCategory on the backend): when an imported model's own site category id
* matches one set here, it lands in this category automatically. */
export default function CategoryMetaDialog({ folder, onClose, onSave }: Props) {
const { t } = useTranslation(["models", "common"]);
const untitledLabel = t("models:categories.untitled");
const [title, setTitle] = useState(folder.meta_title ?? "");
const [description, setDescription] = useState(folder.meta_description ?? "");
const [makerworldCatId, setMakerworldCatId] = useState(folder.makerworld_cat_id?.toString() ?? "");
const [thingiverseCatId, setThingiverseCatId] = useState(folder.thingiverse_cat_id?.toString() ?? "");
const [printablesCatId, setPrintablesCatId] = useState(folder.printables_cat_id?.toString() ?? "");
const [saving, setSaving] = useState(false);
const handleSave = async () => {
setSaving(true);
try {
await onSave({
metaTitle: title.trim() || null,
metaDescription: description.trim() || null,
makerworldCatId: parseCatId(makerworldCatId),
thingiverseCatId: parseCatId(thingiverseCatId),
printablesCatId: parseCatId(printablesCatId),
});
onClose();
} finally {
setSaving(false);
}
};
return (
<Dialog open onClose={() => !saving && onClose()} fullWidth maxWidth="sm">
<DialogTitle>
{t("models:categories.manager.metaDialogTitle", { name: folder.name || untitledLabel })}
</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 0.5 }}>
<TextField
label={t("models:categories.manager.metaTitleLabel")}
fullWidth
value={title}
onChange={e => setTitle(e.target.value)}
disabled={saving}
// Deliberate: focus the field the moment the dialog opens.
// oxlint-disable-next-line jsx-a11y/no-autofocus
autoFocus
/>
<TextField
label={t("models:categories.manager.metaDescriptionLabel")}
fullWidth
multiline
minRows={3}
value={description}
onChange={e => setDescription(e.target.value)}
disabled={saving}
/>
<Stack direction="row" spacing={2}>
<TextField
label={t("models:categories.manager.makerworldCatIdLabel")}
type="number"
fullWidth
value={makerworldCatId}
onChange={e => setMakerworldCatId(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.thingiverseCatIdLabel")}
type="number"
fullWidth
value={thingiverseCatId}
onChange={e => setThingiverseCatId(e.target.value)}
disabled={saving}
/>
<TextField
label={t("models:categories.manager.printablesCatIdLabel")}
type="number"
fullWidth
value={printablesCatId}
onChange={e => setPrintablesCatId(e.target.value)}
disabled={saving}
/>
</Stack>
</Stack>
</DialogContent>
<DialogActions>
<Button onClick={onClose} disabled={saving}>{t("common:cancel")}</Button>
<Button
variant="contained"
onClick={handleSave}
disabled={saving}
startIcon={saving ? <CircularProgress size={14} /> : undefined}
>
{t("common:save")}
</Button>
</DialogActions>
</Dialog>
);
}
+1 -1
View File
@@ -86,7 +86,7 @@ export default function ModelCard({ item, theme, previewMode }: Props) {
</Stack>
<Stack direction="row" alignItems="center" spacing={0.4}>
<PrintIcon sx={{ fontSize: 14 }} />
<Typography variant="caption">0</Typography>
<Typography variant="caption">{item.print_count}</Typography>
</Stack>
</Stack>
</Stack>
+21 -1
View File
@@ -7,7 +7,7 @@ import Button from "@mui/material/Button";
import CircularProgress from "@mui/material/CircularProgress";
import { UnauthorizedError } from "../../api/client";
import { type Print, printsApi } from "../../api/prints";
import { type Folder, foldersApi } from "../../api/folders";
import { type Folder, type FolderMetaInput, foldersApi } from "../../api/folders";
import { type PreviewMode } from "../../api/settings";
import { type ResolvedTheme } from "../../constants/settingsOptions";
import CategoriesPanel from "./CategoriesPanel";
@@ -135,6 +135,24 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o
}
};
const reorderCategories = async (folderIds: string[]) => {
try {
await foldersApi.reorder(folderIds);
onFoldersChanged();
} catch (err) {
handleError(err, t("models:errors.reorderCategoryFailed"));
}
};
const updateCategoryMeta = async (id: string, meta: FolderMetaInput) => {
try {
await foldersApi.updateMeta(id, meta);
onFoldersChanged();
} catch (err) {
handleError(err, t("models:errors.updateCategoryMetaFailed"));
}
};
return (
<Stack direction="row" spacing={2} alignItems="flex-start">
<CategoriesPanel
@@ -145,6 +163,8 @@ export default function ModelsPage({ folderId, onSelectFolder, foldersVersion, o
onCreate={createCategory}
onRename={renameCategory}
onDelete={deleteCategory}
onReorder={reorderCategories}
onUpdateMeta={updateCategoryMeta}
/>
<Box sx={{ flex: 1, minWidth: 0 }}>
{loading ? (
+520
View File
@@ -0,0 +1,520 @@
// A Bambu Studio project .3mf is a zip: 3D/3dmodel.model holds <object> elements (either an
// inline <mesh> or <component p:path="..."> references to per-object files under 3D/Objects/)
// and a <build> section placing objects on the bed; Metadata/model_settings.config assigns each
// object to a "plate" (one page of the Bambu Studio build-plate UI, not a PrintStash Plate row --
// 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) --
// PrintStash'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.
import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
type MeshData = {
vertices: number[];
triangles: number[];
extruder: number;
};
type ObjectData = {
id: string;
meshes: MeshData[];
defaultExtruder: number;
plateId: number | null;
};
type BuildItem = {
objectId: string;
transform: THREE.Matrix4;
plateId: number | null;
};
export type Parsed3MFData = {
objects: Map<string, ObjectData>;
buildItems: BuildItem[];
plateBounds: Map<number, { minX: number; minY: number; maxX: number; maxY: number }>;
plateOffsets: Map<number, { offsetX: number; offsetY: number }>;
};
export type PlateSummary = {
index: number;
name: string | null;
objectCount: number;
};
export type ParsedBambuThreeMF = {
parsed: Parsed3MFData;
plates: PlateSummary[];
filamentColors: string[];
buildVolume: { x: number; y: number };
/** Looks up one plate's thumbnail (the PNG Bambu Studio renders for its build-plate UI)
* straight out of the already-unzipped archive -- no re-unzipping per call, unlike fetching
* the whole model file again for each plate a picker UI wants a thumbnail for. */
getPlateThumbnail: (plateIndex: number) => Promise<string | null>;
};
function bytesToDataUrl(bytes: Uint8Array, mime: string): Promise<string> {
const blob = new Blob([bytes as unknown as BlobPart], { type: mime });
return new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(reader.result as string));
reader.addEventListener("error", () => reject(reader.error));
reader.readAsDataURL(blob);
});
}
function createThumbnailGetter(zipEntries: Record<string, Uint8Array>): (plateIndex: number) => Promise<string | null> {
return async plateIndex => {
for (const path of [`Metadata/plate_${plateIndex}.png`, `Metadata/top_${plateIndex}.png`]) {
const bytes = zipEntries[path];
if (bytes) return bytesToDataUrl(bytes, "image/png");
}
return null;
};
}
function nextTick(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}
const YIELD_EVERY_N_VERTICES = 20000;
const YIELD_EVERY_N_TRIANGLES = 20000;
function parseTransform3MF(transformStr: string | null): THREE.Matrix4 {
const matrix = new THREE.Matrix4();
if (!transformStr) return matrix;
// 3MF transform is a 3x4 affine matrix in row-major order:
// "m00 m01 m02 m10 m11 m12 m20 m21 m22 m30 m31 m32" -- (m30,m31,m32) is the translation.
const values = transformStr.trim().split(/\s+/).map(parseFloat);
if (values.length >= 12) {
matrix.set(
values[0], values[1], values[2], values[9],
values[3], values[4], values[5], values[10],
values[6], values[7], values[8], values[11],
0, 0, 0, 1
);
}
return matrix;
}
/** Document and Element both expose getElementsByTagName; this runs on either -- the whole
* 3dmodel.model document for top-level objects, or a single <object> element for meshes nested
* directly under it (as opposed to referenced via a <component> in another file). */
type ElementSource = { getElementsByTagName(tag: string): HTMLCollectionOf<Element> };
async function parseMeshFromDoc(doc: ElementSource, defaultExtruder = 0): Promise<MeshData[]> {
const meshes: MeshData[] = [];
const meshElements = doc.getElementsByTagName("mesh");
for (let j = 0; j < meshElements.length; j++) {
const meshEl = meshElements[j];
const vertices: number[] = [];
const triangles: number[] = [];
const vertexElements = meshEl.getElementsByTagName("vertex");
for (let k = 0; k < vertexElements.length; k++) {
const v = vertexElements[k];
vertices.push(
parseFloat(v.getAttribute("x") || "0"),
parseFloat(v.getAttribute("y") || "0"),
parseFloat(v.getAttribute("z") || "0")
);
if (k > 0 && k % YIELD_EVERY_N_VERTICES === 0) await nextTick();
}
const triangleElements = meshEl.getElementsByTagName("triangle");
for (let k = 0; k < triangleElements.length; k++) {
const t = triangleElements[k];
triangles.push(
parseInt(t.getAttribute("v1") || "0", 10),
parseInt(t.getAttribute("v2") || "0", 10),
parseInt(t.getAttribute("v3") || "0", 10)
);
if (k > 0 && k % YIELD_EVERY_N_TRIANGLES === 0) await nextTick();
}
if (vertices.length > 0 && triangles.length > 0) {
meshes.push({ vertices, triangles, extruder: defaultExtruder });
}
}
return meshes;
}
function parsePlateIdFromAttributes(element: Element): number | null {
const plateAttribute = Array.from(element.attributes).find(attr => {
const name = attr.name.toLowerCase();
return (
name === "plate_id" || name === "plater_id" || name === "plateid" || name === "platerid" ||
name.endsWith(":plate_id") || name.endsWith(":plater_id")
);
});
if (!plateAttribute?.value) return null;
const parsed = Number.parseInt(plateAttribute.value, 10);
return Number.isFinite(parsed) ? parsed : null;
}
/** Parses `Metadata/project_settings.config` for the filament color palette and bed size, both
* best-effort -- absent/malformed data just falls back to defaults at the call site. */
function parseProjectSettings(text: string): { filamentColors: string[]; buildVolume: { x: number; y: number } | null } {
let filamentColors: string[] = [];
let buildVolume: { x: number; y: number } | null = null;
try {
const json = JSON.parse(text) as Record<string, unknown>;
if (Array.isArray(json.filament_colour)) {
filamentColors = json.filament_colour.filter((c): c is string => typeof c === "string");
}
const area = json.printable_area;
if (Array.isArray(area) && area.length >= 3 && typeof area[2] === "string") {
// "0x0 256x0 256x256 0x256" -- the third corner is the bed's (width, depth).
const match = area[2].match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/);
if (match) buildVolume = { x: parseFloat(match[1]), y: parseFloat(match[2]) };
}
} catch {
// Ignore malformed project_settings.config.
}
return { filamentColors, buildVolume };
}
async function parse3MF(zipEntries: Record<string, Uint8Array>): Promise<ParsedBambuThreeMF> {
const getPlateThumbnail = createThumbnailGetter(zipEntries);
const objects = new Map<string, ObjectData>();
const buildItems: BuildItem[] = [];
const plateBounds = new Map<number, { minX: number; minY: number; maxX: number; maxY: number }>();
const plateOffsets = new Map<number, { offsetX: number; offsetY: number }>();
const plateNames = new Map<number, string>();
const decoder = new TextDecoder();
const parser = new DOMParser();
const findEntry = (path: string): Uint8Array | undefined => {
const normalized = path.startsWith("/") ? path.slice(1) : path;
return zipEntries[normalized];
};
const loadModelFile = (path: string): Document | null => {
const bytes = findEntry(path);
if (!bytes) return null;
return parser.parseFromString(decoder.decode(bytes), "application/xml");
};
const extruderMapById = new Map<string, number>();
const partExtruderMap = new Map<string, number>();
const objectNameById = new Map<string, string>();
const plateAssignmentsByObjectId = new Map<string, number>();
const modelSettingsBytes = findEntry("Metadata/model_settings.config");
if (modelSettingsBytes) {
try {
const doc = parser.parseFromString(decoder.decode(modelSettingsBytes), "application/xml");
const objectElements = doc.getElementsByTagName("object");
for (let i = 0; i < objectElements.length; i++) {
const objEl = objectElements[i];
const objectId = objEl.getAttribute("id");
if (!objectId) continue;
const directMetadata = Array.from(objEl.children).filter(
el => el.tagName === "metadata" && el.getAttribute("key") === "extruder"
);
if (directMetadata.length > 0) {
const extruderVal = directMetadata[0].getAttribute("value");
if (extruderVal) extruderMapById.set(objectId, Math.max(0, parseInt(extruderVal, 10) - 1));
}
const nameMetadata = Array.from(objEl.children).find(
el => el.tagName === "metadata" && el.getAttribute("key") === "name"
);
const objectName = nameMetadata?.getAttribute("value");
if (objectName) objectNameById.set(objectId, objectName);
const partElements = objEl.getElementsByTagName("part");
for (let j = 0; j < partElements.length; j++) {
const partEl = partElements[j];
const partId = partEl.getAttribute("id");
if (!partId) continue;
const partMetadata = Array.from(partEl.children).filter(
el => el.tagName === "metadata" && el.getAttribute("key") === "extruder"
);
if (partMetadata.length > 0) {
const extruderVal = partMetadata[0].getAttribute("value");
if (extruderVal) partExtruderMap.set(`${objectId}:${partId}`, Math.max(0, parseInt(extruderVal, 10) - 1));
}
}
}
const plateElements = doc.getElementsByTagName("plate");
for (let i = 0; i < plateElements.length; i++) {
const plateEl = plateElements[i];
let plateId: number | null = null;
let plateOffsetX = 0;
let plateOffsetY = 0;
const metadataElements = plateEl.getElementsByTagName("metadata");
for (let j = 0; j < metadataElements.length; j++) {
const metaEl = metadataElements[j];
const key = metaEl.getAttribute("key");
const value = metaEl.getAttribute("value");
if ((key === "plater_id" || key === "plate_id") && value) {
const parsedId = Number.parseInt(value, 10);
if (Number.isFinite(parsedId)) plateId = parsedId;
} else if (key === "pos_x" && value) {
const parsedVal = Number.parseFloat(value);
if (Number.isFinite(parsedVal)) plateOffsetX = parsedVal;
} else if (key === "pos_y" && value) {
const parsedVal = Number.parseFloat(value);
if (Number.isFinite(parsedVal)) plateOffsetY = parsedVal;
}
}
if (plateId == null) continue;
if (plateOffsetX !== 0 || plateOffsetY !== 0) plateOffsets.set(plateId, { offsetX: plateOffsetX, offsetY: plateOffsetY });
const nameMeta = Array.from(metadataElements).find(m => m.getAttribute("key") === "plater_name");
const nameVal = nameMeta?.getAttribute("value")?.trim();
if (nameVal) plateNames.set(plateId, nameVal);
const modelInstances = plateEl.getElementsByTagName("model_instance");
for (let j = 0; j < modelInstances.length; j++) {
const instanceMetadata = modelInstances[j].getElementsByTagName("metadata");
for (let k = 0; k < instanceMetadata.length; k++) {
if (instanceMetadata[k].getAttribute("key") === "object_id") {
const value = instanceMetadata[k].getAttribute("value");
if (value) plateAssignmentsByObjectId.set(value, plateId);
}
}
}
}
} catch {
// Ignore malformed model_settings.config -- falls back to no plate/extruder awareness.
}
}
// plate_*.json fallback for source-only (unsliced-by-MakerWorld) files: bbox_objects names +
// bbox_all bounds, keyed by object *name* rather than id.
const plateAssignmentsByName = new Map<string, number>();
for (const name of Object.keys(zipEntries)) {
const match = name.match(/^Metadata\/plate_(\d+)\.json$/);
if (!match) continue;
const plateIndex = Number.parseInt(match[1], 10);
if (!Number.isFinite(plateIndex)) continue;
try {
const json = JSON.parse(decoder.decode(zipEntries[name])) as {
bbox_objects?: { name?: string }[];
bbox_all?: number[];
};
for (const entry of json.bbox_objects ?? []) {
if (entry?.name) plateAssignmentsByName.set(entry.name, plateIndex);
}
if (Array.isArray(json.bbox_all) && json.bbox_all.length >= 4) {
const [minX, minY, maxX, maxY] = json.bbox_all;
if ([minX, minY, maxX, maxY].every(v => Number.isFinite(v))) {
plateBounds.set(plateIndex, { minX, minY, maxX, maxY });
}
}
} catch {
// Ignore malformed plate_N.json.
}
}
const mainModelPath = Object.keys(zipEntries).find(name => name === "3D/3dmodel.model" || name.endsWith("/3dmodel.model"));
let filamentColors: string[] = [];
let buildVolume: { x: number; y: number } | null = null;
const projectSettingsBytes = findEntry("Metadata/project_settings.config");
if (projectSettingsBytes) {
const result = parseProjectSettings(decoder.decode(projectSettingsBytes));
filamentColors = result.filamentColors;
buildVolume = result.buildVolume;
}
if (!mainModelPath) {
const anyModelPath = Object.keys(zipEntries).find(name => name.endsWith(".model"));
if (anyModelPath) {
const doc = loadModelFile(anyModelPath);
if (doc) {
const meshes = await parseMeshFromDoc(doc, 0);
if (meshes.length > 0) objects.set("1", { id: "1", meshes, defaultExtruder: 0, plateId: null });
}
}
return {
parsed: { objects, buildItems, plateBounds, plateOffsets },
plates: [],
filamentColors,
buildVolume: buildVolume ?? { x: 256, y: 256 },
getPlateThumbnail,
};
}
const mainDoc = loadModelFile(mainModelPath);
if (!mainDoc) {
return {
parsed: { objects, buildItems, plateBounds, plateOffsets },
plates: [],
filamentColors,
buildVolume: buildVolume ?? { x: 256, y: 256 },
getPlateThumbnail,
};
}
const objectElements = mainDoc.getElementsByTagName("object");
for (let i = 0; i < objectElements.length; i++) {
if (i > 0) await nextTick();
const objEl = objectElements[i];
const objectId = objEl.getAttribute("id");
if (!objectId) continue;
const objectPlateId = parsePlateIdFromAttributes(objEl) ?? plateAssignmentsByObjectId.get(objectId) ?? null;
let defaultExtruder = extruderMapById.get(objectId) ?? -1;
if (defaultExtruder < 0) {
const extruderAttr =
objEl.getAttribute("p:extruder") ||
objEl.getAttributeNS("http://schemas.microsoft.com/3dmanufacturing/production/2015/06", "extruder") ||
"1";
defaultExtruder = Math.max(0, parseInt(extruderAttr, 10) - 1);
}
const meshes: MeshData[] = await parseMeshFromDoc(objEl, defaultExtruder);
const componentElements = objEl.getElementsByTagName("component");
for (let j = 0; j < componentElements.length; j++) {
await nextTick();
const compEl = componentElements[j];
const extPath =
compEl.getAttribute("p:path") ||
compEl.getAttributeNS("http://schemas.microsoft.com/3dmanufacturing/production/2015/06", "path");
const compObjectId = compEl.getAttribute("objectid");
if (!extPath) continue;
const extDoc = loadModelFile(extPath);
if (!extDoc) continue;
const partKey = compObjectId ? `${objectId}:${compObjectId}` : null;
const compExtruder = partKey ? partExtruderMap.get(partKey) ?? defaultExtruder : defaultExtruder;
const extMeshes = await parseMeshFromDoc(extDoc, compExtruder);
const compTransformStr = compEl.getAttribute("transform");
const compTransform = parseTransform3MF(compTransformStr);
for (const mesh of extMeshes) {
if (compTransformStr) {
const transformedVertices: number[] = [];
for (let k = 0; k < mesh.vertices.length; k += 3) {
const v = new THREE.Vector3(mesh.vertices[k], mesh.vertices[k + 1], mesh.vertices[k + 2]);
v.applyMatrix4(compTransform);
transformedVertices.push(v.x, v.y, v.z);
}
meshes.push({ vertices: transformedVertices, triangles: mesh.triangles, extruder: mesh.extruder });
} else {
meshes.push(mesh);
}
}
}
if (meshes.length > 0) objects.set(objectId, { id: objectId, meshes, defaultExtruder, plateId: objectPlateId });
}
const buildElements = mainDoc.getElementsByTagName("build");
if (buildElements.length > 0) {
const itemElements = buildElements[0].getElementsByTagName("item");
for (let i = 0; i < itemElements.length; i++) {
const itemEl = itemElements[i];
const objectId = itemEl.getAttribute("objectid");
if (!objectId) continue;
const transform = parseTransform3MF(itemEl.getAttribute("transform"));
const itemPlateId = parsePlateIdFromAttributes(itemEl);
const objectPlateId = objects.get(objectId)?.plateId ?? null;
const objectName = objectNameById.get(objectId);
const namePlateId = objectName ? plateAssignmentsByName.get(objectName) ?? null : null;
buildItems.push({ objectId, transform, plateId: itemPlateId ?? objectPlateId ?? namePlateId ?? null });
}
}
const objectCountByPlate = new Map<number, number>();
for (const item of buildItems) {
if (item.plateId == null) continue;
objectCountByPlate.set(item.plateId, (objectCountByPlate.get(item.plateId) ?? 0) + 1);
}
const plates: PlateSummary[] = Array.from(objectCountByPlate.keys())
.toSorted((a, b) => a - b)
.map(index => ({ index, name: plateNames.get(index) ?? null, objectCount: objectCountByPlate.get(index) ?? 0 }));
return {
parsed: { objects, buildItems, plateBounds, plateOffsets },
plates,
filamentColors,
buildVolume: buildVolume ?? { x: 256, y: 256 },
getPlateThumbnail,
};
}
/** Unzips and parses a Bambu Studio project 3MF (or a plain/generic 3MF -- model_settings.config
* is optional, everything degrades to "no plate/extruder info" without it). */
export async function parseBambuThreeMF(buffer: ArrayBuffer): Promise<ParsedBambuThreeMF> {
const { unzipSync } = await import("fflate");
const zipEntries = unzipSync(new Uint8Array(buffer));
return parse3MF(zipEntries);
}
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.
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];
}
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setIndex(mesh.triangles);
geometry.computeVertexNormals();
return geometry;
}
/** Builds the renderable group for one selection: `selectedPlateId` filters to just that plate's
* build items (null renders every plate's items together, e.g. for a single-object 3MF with no
* plate assignments at all). Each extruder gets its own merged mesh colored from `filamentColors`. */
export function buildBambuModelGroup(
parsedData: Parsed3MFData,
selectedPlateId: number | null,
filamentColors: string[]
): THREE.Group {
const { objects, buildItems } = parsedData;
const group = new THREE.Group();
const getMaterial = (extruder: number, fallbackColor: THREE.Color): THREE.MeshStandardMaterial => {
const colorStr = filamentColors[extruder];
const color = colorStr ? new THREE.Color(colorStr) : fallbackColor;
return new THREE.MeshStandardMaterial({ color, roughness: 0.62, metalness: 0.0, envMapIntensity: 0.55 });
};
const geometriesByExtruder = new Map<number, THREE.BufferGeometry[]>();
const hasPlateAssignments = buildItems.some(item => item.plateId != null);
const plateFilteredItems =
selectedPlateId == null || !hasPlateAssignments ? buildItems : buildItems.filter(item => item.plateId === selectedPlateId);
const activeBuildItems = plateFilteredItems.length > 0 ? plateFilteredItems : buildItems;
for (const item of activeBuildItems) {
const objectData = objects.get(item.objectId);
if (!objectData) continue;
for (const meshData of objectData.meshes) {
const transformedVertices: number[] = [];
for (let k = 0; k < meshData.vertices.length; k += 3) {
const v = new THREE.Vector3(meshData.vertices[k], meshData.vertices[k + 1], meshData.vertices[k + 2]);
v.applyMatrix4(item.transform);
transformedVertices.push(v.x, v.y, v.z);
}
const geometry = createGeometryFromMesh({ vertices: transformedVertices, triangles: meshData.triangles, extruder: meshData.extruder });
if (!geometriesByExtruder.has(meshData.extruder)) geometriesByExtruder.set(meshData.extruder, []);
geometriesByExtruder.get(meshData.extruder)!.push(geometry);
}
}
const fallbackColor = new THREE.Color(0xdddddd);
for (const [extruder, geometries] of geometriesByExtruder) {
if (geometries.length === 0) continue;
const mergedGeometry = geometries.length === 1 ? geometries[0] : mergeGeometries(geometries, false);
if (mergedGeometry) {
const mesh = new THREE.Mesh(mergedGeometry, getMaterial(extruder, fallbackColor));
mesh.castShadow = true;
group.add(mesh);
}
if (geometries.length > 1) geometries.forEach(g => g.dispose());
}
return group;
}
+41 -2
View File
@@ -7,6 +7,7 @@ import occtWasmUrl from "occt-import-js/dist/occt-import-js.wasm?url";
import occtWorkerUrl from "occt-import-js/dist/occt-import-js-worker.js?url";
import type { ResolvedTheme } from "../constants/settingsOptions";
import { buildTheme } from "../theme";
import { buildBambuModelGroup, parseBambuThreeMF, type Parsed3MFData, type PlateSummary } from "./bambuThreeMf";
export type ModelPalette = {
color: THREE.Color;
@@ -57,8 +58,10 @@ export function applyThemeToObject(obj: THREE.Object3D, palette: ModelPalette) {
export async function loadObjectFromAsset(ext: string, url: string): Promise<THREE.Object3D | null> {
const obj = await loadRawObjectFromAsset(ext, url);
if (obj) {
// Print files (STL/3MF/OBJ/STEP) are authored Z-up (Z = the model's height off the bed), but
// 3MF already comes out Y-up from bambuThreeMf.ts's own (unrelated) coordinate swap -- see the
// comment at the top of that file for why it must not also get this rotation.
if (obj && ext.toLowerCase() !== "3mf") {
// Print files (STL/OBJ/STEP) are authored Z-up (Z = the model's height off the bed), but
// three.js's world/camera "up" is Y. Without this, a tall model ends up lying on its side.
obj.rotateX(-Math.PI / 2);
}
@@ -513,6 +516,22 @@ async function loadStepGroup(url: string) {
}
async function load3MFObject(url: string) {
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`3MF fetch failed with status ${res.status}`);
const buffer = await res.arrayBuffer();
const { parsed, filamentColors } = await parseBambuThreeMF(buffer);
if (parsed.objects.size > 0) {
// No plate selected: renders every plate's build items together. Good enough for the
// generic/single-object case (and for ModelSnapshot's card thumbnails); the interactive
// ModelViewer uses loadBambuThreeMFForViewer directly so it can offer a plate picker.
const group = buildBambuModelGroup(parsed, null, filamentColors);
if (group.children.length > 0) return group;
}
throw new Error("Bambu 3MF parse produced no meshes");
} catch (err) {
console.warn("Bambu 3MF parse failed, falling back to simple 3MF parse", err);
}
try {
return await loadSimple3MFGroup(url);
} catch (err) {
@@ -521,6 +540,26 @@ async function load3MFObject(url: string) {
}
}
/** Richer 3MF load for the interactive viewer: keeps the parsed data around so switching the
* selected plate rebuilds the THREE.Group locally (no refetch), and exposes the detected plate
* list + filament palette + a thumbnail lookup for a plate-picker UI. Returns null for anything
* that isn't a 3MF the Bambu-aware parser can make sense of (caller falls back to
* loadObjectFromAsset). */
export async function loadBambuThreeMFForViewer(url: string): Promise<{
parsedData: Parsed3MFData;
plates: PlateSummary[];
filamentColors: string[];
buildVolume: { x: number; y: number };
getPlateThumbnail: (plateIndex: number) => Promise<string | null>;
} | null> {
const res = await fetch(url);
if (!res.ok) throw new Error(`3MF fetch failed with status ${res.status}`);
const rawBuffer = await res.arrayBuffer();
const { parsed, plates, filamentColors, buildVolume, getPlateThumbnail } = await parseBambuThreeMF(rawBuffer);
if (parsed.objects.size === 0) return null;
return { parsedData: parsed, plates, filamentColors, buildVolume, getPlateThumbnail };
}
async function loadViaThreeMFLoader(url: string) {
const { ThreeMFLoader } = await import("three/examples/jsm/loaders/3MFLoader.js");
return await new Promise<THREE.Group>((resolve, reject) => {