mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: various major UI & UX improvements
This commit is contained in:
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");
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
Reference in new issue
Block a user