mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-19 05:10:01 -05:00
feat: add additional 3D preview toggable options
This commit is contained in:
1 parent
ca81dbbb2b
commit
58e3c7296e
5 files changed
+442
-67
No files matched your search
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import Box from "@mui/material/Box";
|
||||
@@ -24,6 +24,15 @@ import BrandMark from "../../BrandMark";
|
||||
// same license -- see LICENSE at the repo root. Multi-plate parsing itself lives in
|
||||
// ../../../utils/bambuThreeMf.ts, ported from the same source.
|
||||
|
||||
export type RenderStyle = "solid" | "wire" | "xray";
|
||||
export type CameraView = "top" | "front" | "side";
|
||||
|
||||
/** Imperative controls for a mounted viewer -- a camera preset is a one-off action (clicking
|
||||
* "Top" twice should re-frame twice), not state, so it's a method rather than a prop. */
|
||||
export type ModelViewerHandle = {
|
||||
setCameraView: (view: CameraView) => void;
|
||||
};
|
||||
|
||||
type ModelViewerProps = {
|
||||
url: string;
|
||||
ext: string;
|
||||
@@ -43,8 +52,31 @@ type ModelViewerProps = {
|
||||
* 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;
|
||||
/** Solid (default), wireframe, or see-through "X-ray" materials. */
|
||||
renderStyle?: RenderStyle;
|
||||
/** Whether the build plate + grid is drawn, for formats that have one (see buildPlateForMeshes). */
|
||||
showBuildPlate?: boolean;
|
||||
/** STL/OBJ/STEP (and a non-Bambu 3MF) carry no bed size, so by default they get no plate.
|
||||
* Set to draw a standard DEFAULT_BED_SIZE bed under them anyway (grown to fit a bigger model). */
|
||||
buildPlateForMeshes?: boolean;
|
||||
/** Slowly orbits the camera around the model. */
|
||||
autoRotate?: boolean;
|
||||
/** Camera preset to frame the model from on load and on plate switches (until setCameraView
|
||||
* picks another). Unset keeps the per-format defaults and restores a saved view if any. */
|
||||
initialCameraView?: CameraView;
|
||||
};
|
||||
|
||||
// Appearance props that change without reloading the model: read through a ref by the setup
|
||||
// effect (for whatever it builds next) and pushed onto the live scene by their own effect.
|
||||
type Appearance = Required<Pick<ModelViewerProps, "renderStyle" | "showBuildPlate" | "autoRotate">> & {
|
||||
colorOverride?: string;
|
||||
};
|
||||
|
||||
const XRAY_OPACITY = 0.3;
|
||||
|
||||
// Bambu X1/P1/A1's 256x256mm bed -- the stand-in for formats with no bed size of their own.
|
||||
const DEFAULT_BED_SIZE = 256;
|
||||
|
||||
type ViewErrorKey = "unsupported" | "failed";
|
||||
|
||||
const BAMBU_PLATE_COLOR = 0x00ae42;
|
||||
@@ -55,6 +87,14 @@ const BAMBU_PLATE_COLOR = 0x00ae42;
|
||||
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();
|
||||
|
||||
// Axis-aligned presets for the preview toolbar. +Z is "front", matching the default views above.
|
||||
// Top keeps a hair of +Z so OrbitControls' up vector isn't parallel to the view direction.
|
||||
const CAMERA_VIEW_DIRECTIONS: Record<CameraView, THREE.Vector3> = {
|
||||
top: new THREE.Vector3(0, 1, 0.0001).normalize(),
|
||||
front: new THREE.Vector3(0, 0, 1),
|
||||
side: new THREE.Vector3(1, 0, 0),
|
||||
};
|
||||
|
||||
/** 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
|
||||
@@ -66,7 +106,11 @@ function fitCameraToBox(
|
||||
controls: any,
|
||||
box: THREE.Box3,
|
||||
direction: THREE.Vector3 = BAMBU_VIEW_DIRECTION,
|
||||
padding = 1.15
|
||||
padding = 1.15,
|
||||
/** Size of scenery around the model (the build plate's diagonal) that must not be cut off by
|
||||
* the far plane -- the camera frames the model, so a bed much larger than it would otherwise
|
||||
* end in a hard clipped edge a few model-lengths behind it. */
|
||||
sceneryExtent = 0
|
||||
): void {
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
@@ -76,15 +120,39 @@ function fitCameraToBox(
|
||||
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.far = distance + Math.max(radius * 4, sceneryExtent);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.target.copy(center);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, selectedPlateId = null, previewGlbUrl, onPlatesDetected }: ModelViewerProps) {
|
||||
const ModelViewer = forwardRef<ModelViewerHandle, ModelViewerProps>(function ModelViewer(
|
||||
{
|
||||
url,
|
||||
ext,
|
||||
viewKey,
|
||||
theme,
|
||||
colorOverride,
|
||||
selectedPlateId = null,
|
||||
previewGlbUrl,
|
||||
onPlatesDetected,
|
||||
renderStyle = "solid",
|
||||
showBuildPlate = true,
|
||||
autoRotate = false,
|
||||
buildPlateForMeshes = false,
|
||||
initialCameraView,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const { t } = useTranslation(["library"]);
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const appearanceRef = useRef<Appearance>({ colorOverride, renderStyle, showBuildPlate, autoRotate });
|
||||
appearanceRef.current = { colorOverride, renderStyle, showBuildPlate, autoRotate };
|
||||
// Set by the setup effect once its scene exists; cleared on teardown.
|
||||
const sceneApiRef = useRef<{
|
||||
applyAppearance: () => void;
|
||||
setCameraView: (view: CameraView) => void;
|
||||
} | 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);
|
||||
@@ -97,7 +165,6 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
const mount = mountRef.current;
|
||||
if (!mount) return;
|
||||
const palette = paletteForTheme(theme);
|
||||
if (colorOverride) palette.color = new THREE.Color(colorOverride);
|
||||
setViewError(null);
|
||||
setIsLoading(true);
|
||||
const reportError = (key: ViewErrorKey) => {
|
||||
@@ -179,11 +246,9 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
|
||||
// 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 };
|
||||
// repositioned from the asset's own build volume once known -- only a Bambu 3MF carries a
|
||||
// real one; STL/OBJ/STEP get a DEFAULT_BED_SIZE stand-in only with buildPlateForMeshes.
|
||||
let buildVolume = { x: DEFAULT_BED_SIZE, y: DEFAULT_BED_SIZE };
|
||||
const gridHelper = new THREE.GridHelper(buildVolume.x, Math.ceil(buildVolume.x / 16), 0x444444, 0x333333);
|
||||
gridHelper.visible = false;
|
||||
scene.add(gridHelper);
|
||||
@@ -205,10 +270,17 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
shadowCatcher.visible = false;
|
||||
scene.add(shadowCatcher);
|
||||
|
||||
let buildPlateLaidOut = false;
|
||||
const syncBuildPlateVisibility = () => {
|
||||
const visible = buildPlateLaidOut && appearanceRef.current.showBuildPlate;
|
||||
gridHelper.visible = visible;
|
||||
plateMesh.visible = visible;
|
||||
shadowCatcher.visible = visible;
|
||||
};
|
||||
|
||||
const layoutBuildPlate = () => {
|
||||
gridHelper.visible = true;
|
||||
plateMesh.visible = true;
|
||||
shadowCatcher.visible = true;
|
||||
buildPlateLaidOut = true;
|
||||
syncBuildPlateVisibility();
|
||||
|
||||
const shadowExtent = Math.max(buildVolume.x, buildVolume.y) * 0.75;
|
||||
keyLight.shadow.camera.left = -shadowExtent;
|
||||
@@ -231,6 +303,37 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
shadowCatcher.geometry = new THREE.PlaneGeometry(buildVolume.x, buildVolume.y);
|
||||
};
|
||||
|
||||
// Color / render style for everything under `obj`. Colors only change with a colorOverride
|
||||
// (otherwise the theme palette or per-filament colors stay); X-ray also turns off depth
|
||||
// writes and backface culling so inner walls show through. Shadows are for solid only.
|
||||
const applyAppearance = (obj: THREE.Object3D | null) => {
|
||||
if (!obj) return;
|
||||
const { colorOverride: color, renderStyle: style } = appearanceRef.current;
|
||||
const xray = style === "xray";
|
||||
obj.traverse(child => {
|
||||
if (!(child instanceof THREE.Mesh)) return;
|
||||
child.castShadow = style === "solid";
|
||||
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||
materials.forEach(mat => {
|
||||
const typed = mat as THREE.MeshStandardMaterial;
|
||||
if (color) typed.color?.set(color);
|
||||
if (typed.userData.baseSide === undefined) typed.userData.baseSide = typed.side;
|
||||
typed.wireframe = style === "wire";
|
||||
typed.transparent = xray;
|
||||
typed.opacity = xray ? XRAY_OPACITY : 1;
|
||||
typed.depthWrite = !xray;
|
||||
typed.side = xray ? THREE.DoubleSide : typed.userData.baseSide;
|
||||
typed.needsUpdate = true;
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Last framed bounds, so camera presets can re-frame without re-measuring.
|
||||
let lastFitBox: THREE.Box3 | null = null;
|
||||
// Active camera preset direction; null keeps the per-format default (3/4 view for Bambu 3MF,
|
||||
// raised front for everything else).
|
||||
let presetDirection: THREE.Vector3 | null = initialCameraView ? CAMERA_VIEW_DIRECTIONS[initialCameraView] : null;
|
||||
|
||||
// 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;
|
||||
@@ -260,7 +363,16 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
// plate with no visible geometry) must still clear loading rather than leaving the spinner
|
||||
// stuck forever, regardless of which path produced it. `refitCamera` is set on a plate
|
||||
// switch so the newly shown plate is framed, rather than restoring the previous saved view.
|
||||
const finalizeGroupPlacement = (group: THREE.Object3D, centerOnBuildPlate: boolean, refitCamera = false) => {
|
||||
const bedExtent = () => (buildPlateLaidOut ? Math.hypot(buildVolume.x, buildVolume.y) : 0);
|
||||
const fitCamera = (box: THREE.Box3, defaultDirection: THREE.Vector3) =>
|
||||
fitCameraToBox(camera, controls, box, presetDirection ?? defaultDirection, undefined, bedExtent());
|
||||
|
||||
const finalizeGroupPlacement = (
|
||||
group: THREE.Object3D,
|
||||
centerOnBuildPlate: boolean,
|
||||
refitCamera = false,
|
||||
defaultDirection = BAMBU_VIEW_DIRECTION
|
||||
) => {
|
||||
const box = visibleBox(group);
|
||||
if (box.isEmpty()) {
|
||||
setIsLoading(false);
|
||||
@@ -279,7 +391,8 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
gridHelper.position.set(buildVolume.x / 2, 0, buildVolume.y / 2);
|
||||
|
||||
const finalBox = visibleBox(group);
|
||||
if (refitCamera || !loadSavedView()) fitCameraToBox(camera, controls, finalBox);
|
||||
lastFitBox = finalBox;
|
||||
if (refitCamera || !loadSavedView()) fitCamera(finalBox, defaultDirection);
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
@@ -290,17 +403,7 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
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;
|
||||
});
|
||||
applyAppearance(group);
|
||||
activeObject = group;
|
||||
scene.add(group);
|
||||
finalizeGroupPlacement(group, centerOnBuildPlate, refitCamera);
|
||||
@@ -332,10 +435,33 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.enablePan = true;
|
||||
controls.target.set(0, 50, 0);
|
||||
controls.addEventListener("change", saveView);
|
||||
// "end" (a finished drag/zoom), not "change": auto-rotate and programmatic framing move
|
||||
// the camera every frame, and only the user's own view is worth restoring next time.
|
||||
controls.addEventListener("end", saveView);
|
||||
|
||||
const centerSceneOn = (box: THREE.Box3) => {
|
||||
if (!loadSavedView()) fitCameraToBox(camera, controls, box, FRONT_VIEW_DIRECTION);
|
||||
// Shared by every non-Bambu path (STL/OBJ/STEP, plain 3MF): theme it, add it, and either
|
||||
// set it on a stand-in bed or just frame it as-is.
|
||||
const showMeshObject = (obj: THREE.Object3D) => {
|
||||
applyThemeToObject(obj, palette);
|
||||
applyAppearance(obj);
|
||||
activeObject = obj;
|
||||
scene.add(obj);
|
||||
const box = visibleBox(obj);
|
||||
if (buildPlateForMeshes && !box.isEmpty()) {
|
||||
// Grow past the default for a model that wouldn't fit on it (10% margin).
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
buildVolume = {
|
||||
x: Math.max(DEFAULT_BED_SIZE, Math.ceil(size.x * 1.1)),
|
||||
y: Math.max(DEFAULT_BED_SIZE, Math.ceil(size.z * 1.1)),
|
||||
};
|
||||
layoutBuildPlate();
|
||||
finalizeGroupPlacement(obj, true, false, FRONT_VIEW_DIRECTION);
|
||||
return;
|
||||
}
|
||||
setIsLoading(false);
|
||||
if (box.isEmpty()) return;
|
||||
lastFitBox = box;
|
||||
if (!loadSavedView()) fitCamera(box, FRONT_VIEW_DIRECTION);
|
||||
};
|
||||
|
||||
const e = (ext || "").toLowerCase();
|
||||
@@ -350,17 +476,7 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
cachedGlbRoot = cached.rootGroup;
|
||||
buildVolume = cached.buildVolume;
|
||||
layoutBuildPlate();
|
||||
if (colorOverride) {
|
||||
cachedGlbRoot.traverse(child => {
|
||||
if (child instanceof THREE.Mesh) {
|
||||
const mat = child.material as THREE.MeshStandardMaterial;
|
||||
mat.color?.set(colorOverride);
|
||||
}
|
||||
});
|
||||
}
|
||||
cachedGlbRoot.traverse(child => {
|
||||
if (child instanceof THREE.Mesh) child.castShadow = true;
|
||||
});
|
||||
applyAppearance(cachedGlbRoot);
|
||||
activeObject = cachedGlbRoot;
|
||||
scene.add(cachedGlbRoot);
|
||||
if (currentPlateId == null && cached.plates.length > 0) currentPlateId = cached.plates[0].index;
|
||||
@@ -387,12 +503,7 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
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);
|
||||
showMeshObject(obj);
|
||||
} else if (!disposed) {
|
||||
bambuParsed = result.parsedData;
|
||||
bambuFilamentColors = result.filamentColors;
|
||||
@@ -413,15 +524,7 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
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);
|
||||
showMeshObject(obj);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Viewer asset load failed:", err);
|
||||
@@ -455,6 +558,8 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
|
||||
const animate = () => {
|
||||
if (disposed) return;
|
||||
// Read per frame so a toggle made while the model was still loading isn't lost.
|
||||
if (controls) controls.autoRotate = appearanceRef.current.autoRotate;
|
||||
controls?.update();
|
||||
renderer.render(scene, camera);
|
||||
gizmo.render(camera, controls?.target ?? new THREE.Vector3(), width, height);
|
||||
@@ -462,6 +567,19 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
};
|
||||
animate();
|
||||
|
||||
sceneApiRef.current = {
|
||||
applyAppearance: () => {
|
||||
applyAppearance(activeObject);
|
||||
syncBuildPlateVisibility();
|
||||
},
|
||||
setCameraView: view => {
|
||||
presetDirection = CAMERA_VIEW_DIRECTIONS[view];
|
||||
if (lastFitBox && controls) {
|
||||
fitCameraToBox(camera, controls, lastFitBox, CAMERA_VIEW_DIRECTIONS[view], undefined, bedExtent());
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
rebuildBambuPlateRef.current = plateId => {
|
||||
if (plateId === currentPlateId) return;
|
||||
currentPlateId = plateId;
|
||||
@@ -480,7 +598,7 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
disposeObject3D(activeObject);
|
||||
}
|
||||
try {
|
||||
controls?.removeEventListener("change", saveView);
|
||||
controls?.removeEventListener("end", saveView);
|
||||
controls?.dispose();
|
||||
} catch {}
|
||||
try {
|
||||
@@ -492,7 +610,12 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
} catch {}
|
||||
renderer.dispose();
|
||||
rebuildBambuPlateRef.current = null;
|
||||
sceneApiRef.current = null;
|
||||
};
|
||||
// initialCameraView is read once, like selectedPlateId: later presets go through
|
||||
// setCameraView, and a parent tracking the active preset in state must not trigger a reload.
|
||||
// colorOverride and the other appearance props are read via appearanceRef and applied live by
|
||||
// the appearance effect below, so changing them never reloads the model.
|
||||
// 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,
|
||||
@@ -501,13 +624,21 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
// unmemoized callback prop).
|
||||
// oxlint-disable-next-line react/exhaustive-effect-dependencies
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [url, ext, viewKey, theme, colorOverride, previewGlbUrl]);
|
||||
}, [url, ext, viewKey, theme, previewGlbUrl]);
|
||||
|
||||
// Switching the selected plate rebuilds the already-parsed group in place (no refetch).
|
||||
useEffect(() => {
|
||||
rebuildBambuPlateRef.current?.(selectedPlateId ?? null);
|
||||
}, [selectedPlateId]);
|
||||
|
||||
useEffect(() => {
|
||||
sceneApiRef.current?.applyAppearance();
|
||||
}, [colorOverride, renderStyle, showBuildPlate, autoRotate]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
setCameraView: view => sceneApiRef.current?.setCameraView(view),
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<Box
|
||||
ref={mountRef}
|
||||
@@ -559,4 +690,6 @@ export default function ModelViewer({ url, ext, viewKey, theme, colorOverride, s
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export default ModelViewer;
|
||||
@@ -1105,6 +1105,27 @@
|
||||
"internalPlatesDivider": "{{count}} plates in this file",
|
||||
"internalPlateObjectCount_one": "{{count}} object",
|
||||
"internalPlateObjectCount_other": "{{count}} objects",
|
||||
"previewToolbar": {
|
||||
"label": "Preview options",
|
||||
"top": "Top",
|
||||
"front": "Front",
|
||||
"side": "Side",
|
||||
"solid": "Solid",
|
||||
"wire": "Wire",
|
||||
"xray": "X-Ray",
|
||||
"grid": "Grid",
|
||||
"spin": "Spin",
|
||||
"colorLabel": "Model color: {{name}}",
|
||||
"colors": {
|
||||
"red": "Red",
|
||||
"orange": "Orange",
|
||||
"yellow": "Yellow",
|
||||
"green": "Green",
|
||||
"blue": "Blue",
|
||||
"purple": "Purple",
|
||||
"grey": "Grey"
|
||||
}
|
||||
},
|
||||
"downloadFailed": "Download failed. Please try again.",
|
||||
"deleteFailed": "Failed to delete the model.",
|
||||
"confirmDelete": "Delete \"{{name}}\"? This cannot be undone.",
|
||||
|
||||
@@ -1106,6 +1106,27 @@
|
||||
"internalPlateObjectCount_one": "{{count}} objektas",
|
||||
"internalPlateObjectCount_few": "{{count}} objektai",
|
||||
"internalPlateObjectCount_other": "{{count}} objektų",
|
||||
"previewToolbar": {
|
||||
"label": "Peržiūros parinktys",
|
||||
"top": "Viršus",
|
||||
"front": "Priekis",
|
||||
"side": "Šonas",
|
||||
"solid": "Vientisas",
|
||||
"wire": "Tinklelis",
|
||||
"xray": "Rentgenas",
|
||||
"grid": "Plokštė",
|
||||
"spin": "Sukimas",
|
||||
"colorLabel": "Modelio spalva: {{name}}",
|
||||
"colors": {
|
||||
"red": "Raudona",
|
||||
"orange": "Oranžinė",
|
||||
"yellow": "Geltona",
|
||||
"green": "Žalia",
|
||||
"blue": "Mėlyna",
|
||||
"purple": "Violetinė",
|
||||
"grey": "Pilka"
|
||||
}
|
||||
},
|
||||
"downloadFailed": "Nepavyko atsisiųsti. Bandykite dar kartą.",
|
||||
"deleteFailed": "Nepavyko ištrinti modelio.",
|
||||
"confirmDelete": "Ištrinti „{{name}}“? Šio veiksmo nebus galima atšaukti.",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import Box from "@mui/material/Box";
|
||||
@@ -15,14 +15,13 @@ 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 ModelViewer, { type CameraView, type ModelViewerHandle, type RenderStyle } from "../../components/media/ModelViewer";
|
||||
import type { PlateSummary } from "../../utils/bambuThreeMf";
|
||||
import PreviewToolbar, { DEFAULT_PREVIEW_COLOR } from "./PreviewToolbar";
|
||||
|
||||
// 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.
|
||||
const PREVIEW_BG = "#e7e7ea";
|
||||
// Matches the reference preview's solid red plate color; unrelated to theme.thingport.modelColor.
|
||||
const PREVIEW_MODEL_COLOR = "#d32f2f";
|
||||
|
||||
type Props = {
|
||||
print: Print;
|
||||
@@ -30,9 +29,10 @@ type Props = {
|
||||
};
|
||||
|
||||
/** The "3D Preview" modal opened from the model detail page's main image: a fixed neutral canvas
|
||||
* rendering the active plate in a fixed red so it reads as a print preview rather than a themed
|
||||
* UI element, with a plate-switcher overlay always shown on the left (even for a single plate,
|
||||
* so the panel doesn't jump in/out of existence as plates are added/removed). */
|
||||
* rendering the active plate in a picked flat color (green by default) so it reads as a print
|
||||
* preview rather than a themed UI element, with a plate-switcher overlay always shown on the left
|
||||
* (even for a single plate, so the panel doesn't jump in/out of existence as plates are
|
||||
* added/removed) and the camera/render-style/color/grid/spin toolbar along the bottom. */
|
||||
export default function Model3DPreviewModal({ print, onClose }: Props) {
|
||||
const { t } = useTranslation(["models", "library", "common"]);
|
||||
const sortedPlates = useMemo(() => print.plates.toSorted((a, b) => a.position - b.position), [print.plates]);
|
||||
@@ -49,6 +49,22 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
|
||||
const [internalThumbnails, setInternalThumbnails] = useState<Record<number, string | null>>({});
|
||||
const [selectedInternalPlateId, setSelectedInternalPlateId] = useState<number | null>(null);
|
||||
|
||||
// Toolbar state lives here rather than in the viewer so it survives switching files (the
|
||||
// viewer remounts per file).
|
||||
const viewerRef = useRef<ModelViewerHandle>(null);
|
||||
const [renderStyle, setRenderStyle] = useState<RenderStyle>("solid");
|
||||
const [modelColor, setModelColor] = useState<string>(DEFAULT_PREVIEW_COLOR);
|
||||
const [cameraView, setCameraView] = useState<CameraView>("front");
|
||||
const [showGrid, setShowGrid] = useState(true);
|
||||
const [spin, setSpin] = useState(true);
|
||||
|
||||
// A preset is a fixed viewpoint -- spinning would immediately carry the camera away from it.
|
||||
const handleCameraView = (view: CameraView) => {
|
||||
setSpin(false);
|
||||
setCameraView(view);
|
||||
viewerRef.current?.setCameraView(view);
|
||||
};
|
||||
|
||||
// A lone file that is itself a multi-plate project would otherwise show one "Plate 1" file row
|
||||
// above its real plates -- redundant, and reads as if it were one of them.
|
||||
const showFileRows = sortedPlates.length > 1 || internalPlates.length === 0;
|
||||
@@ -204,12 +220,17 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
|
||||
<Box sx={{ width: "100%", height: "100%" }}>
|
||||
{is3d && activePlate ? (
|
||||
<ModelViewer
|
||||
ref={viewerRef}
|
||||
key={activePlate.id}
|
||||
url={printsApi.fileUrl(activePlate.url)}
|
||||
ext={ext}
|
||||
viewKey={`preview-${print.id}-${activePlate.id}`}
|
||||
initialCameraView={cameraView}
|
||||
theme="light"
|
||||
colorOverride={PREVIEW_MODEL_COLOR}
|
||||
colorOverride={modelColor}
|
||||
renderStyle={renderStyle}
|
||||
showBuildPlate={showGrid}
|
||||
buildPlateForMeshes
|
||||
autoRotate={spin}
|
||||
selectedPlateId={selectedInternalPlateId}
|
||||
previewGlbUrl={activePlate.preview_glb_url ? printsApi.fileUrl(activePlate.preview_glb_url) : null}
|
||||
onPlatesDetected={handlePlatesDetected}
|
||||
@@ -220,6 +241,21 @@ export default function Model3DPreviewModal({ print, onClose }: Props) {
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{is3d && (
|
||||
<PreviewToolbar
|
||||
cameraView={cameraView}
|
||||
onCameraView={handleCameraView}
|
||||
renderStyle={renderStyle}
|
||||
onRenderStyleChange={setRenderStyle}
|
||||
color={modelColor}
|
||||
onColorChange={setModelColor}
|
||||
showGrid={showGrid}
|
||||
onShowGridChange={setShowGrid}
|
||||
spin={spin}
|
||||
onSpinChange={setSpin}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ButtonBase from "@mui/material/ButtonBase";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import Paper from "@mui/material/Paper";
|
||||
import Stack from "@mui/material/Stack";
|
||||
import ToggleButton from "@mui/material/ToggleButton";
|
||||
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
|
||||
import Tooltip from "@mui/material/Tooltip";
|
||||
import GridOnIcon from "@mui/icons-material/GridOn";
|
||||
import ThreeSixtyIcon from "@mui/icons-material/ThreeSixty";
|
||||
import type { CameraView, RenderStyle } from "../../components/media/ModelViewer";
|
||||
|
||||
export const PREVIEW_COLORS = [
|
||||
{ key: "red", value: "#d32f2f" },
|
||||
{ key: "orange", value: "#f57c00" },
|
||||
{ key: "yellow", value: "#fbc02d" },
|
||||
{ key: "green", value: "#00b800" },
|
||||
{ key: "blue", value: "#1976d2" },
|
||||
{ key: "purple", value: "#7b1fa2" },
|
||||
{ key: "grey", value: "#78909c" },
|
||||
] as const;
|
||||
|
||||
export const DEFAULT_PREVIEW_COLOR = "#00b800";
|
||||
|
||||
const CAMERA_VIEWS: CameraView[] = ["top", "front", "side"];
|
||||
const RENDER_STYLES: RenderStyle[] = ["solid", "wire", "xray"];
|
||||
|
||||
type Props = {
|
||||
cameraView: CameraView;
|
||||
onCameraView: (view: CameraView) => void;
|
||||
renderStyle: RenderStyle;
|
||||
onRenderStyleChange: (style: RenderStyle) => void;
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
showGrid: boolean;
|
||||
onShowGridChange: (show: boolean) => void;
|
||||
spin: boolean;
|
||||
onSpinChange: (spin: boolean) => void;
|
||||
};
|
||||
|
||||
const sectionDivider = <Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />;
|
||||
|
||||
/** Bottom overlay of the 3D preview modal: camera presets, render style, model color, and the
|
||||
* grid/spin toggles. Stateless -- the modal owns every value and passes it to ModelViewer. */
|
||||
export default function PreviewToolbar({
|
||||
cameraView,
|
||||
onCameraView,
|
||||
renderStyle,
|
||||
onRenderStyleChange,
|
||||
color,
|
||||
onColorChange,
|
||||
showGrid,
|
||||
onShowGridChange,
|
||||
spin,
|
||||
onSpinChange,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["models"]);
|
||||
|
||||
return (
|
||||
<Paper
|
||||
elevation={3}
|
||||
role="toolbar"
|
||||
aria-label={t("models:detail.previewToolbar.label") ?? undefined}
|
||||
sx={{
|
||||
position: "absolute",
|
||||
bottom: 16,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 2,
|
||||
maxWidth: "calc(100% - 32px)",
|
||||
overflowX: "auto",
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
borderRadius: "12px",
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" alignItems="center" spacing={0.5} sx={{ width: "max-content" }}>
|
||||
{/* Re-clicking the selected preset yields null here -- still re-frame to it, since the
|
||||
camera may have been orbited away since. */}
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={cameraView}
|
||||
onChange={(_, value: CameraView | null) => onCameraView(value ?? cameraView)}
|
||||
>
|
||||
{CAMERA_VIEWS.map(view => (
|
||||
<ToggleButton key={view} value={view} sx={{ px: 1.25, py: 0.5, textTransform: "none" }}>
|
||||
{t(`models:detail.previewToolbar.${view}`)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{sectionDivider}
|
||||
|
||||
<ToggleButtonGroup
|
||||
size="small"
|
||||
exclusive
|
||||
value={renderStyle}
|
||||
onChange={(_, value: RenderStyle | null) => value && onRenderStyleChange(value)}
|
||||
>
|
||||
{RENDER_STYLES.map(style => (
|
||||
<ToggleButton key={style} value={style} sx={{ px: 1.25, py: 0.5, textTransform: "none" }}>
|
||||
{t(`models:detail.previewToolbar.${style}`)}
|
||||
</ToggleButton>
|
||||
))}
|
||||
</ToggleButtonGroup>
|
||||
|
||||
{sectionDivider}
|
||||
|
||||
<Stack direction="row" spacing={0.75} sx={{ px: 0.5 }}>
|
||||
{PREVIEW_COLORS.map(({ key, value }) => {
|
||||
const name = t(`models:detail.previewToolbar.colors.${key}`);
|
||||
const selected = value === color;
|
||||
return (
|
||||
<Tooltip key={key} title={name}>
|
||||
<ButtonBase
|
||||
aria-label={t("models:detail.previewToolbar.colorLabel", { name }) ?? undefined}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onColorChange(value)}
|
||||
sx={{
|
||||
width: 22,
|
||||
height: 22,
|
||||
borderRadius: "50%",
|
||||
bgcolor: value,
|
||||
boxShadow: theme =>
|
||||
selected
|
||||
? `0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette.text.primary}`
|
||||
: `inset 0 0 0 1px rgba(0, 0, 0, 0.2)`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
{sectionDivider}
|
||||
|
||||
<ToggleButton
|
||||
size="small"
|
||||
value="grid"
|
||||
selected={showGrid}
|
||||
onChange={() => onShowGridChange(!showGrid)}
|
||||
sx={{ px: 1.25, py: 0.5, textTransform: "none", gap: 0.5 }}
|
||||
>
|
||||
<GridOnIcon fontSize="small" />
|
||||
{t("models:detail.previewToolbar.grid")}
|
||||
</ToggleButton>
|
||||
|
||||
{sectionDivider}
|
||||
|
||||
<ToggleButton
|
||||
size="small"
|
||||
value="spin"
|
||||
selected={spin}
|
||||
onChange={() => onSpinChange(!spin)}
|
||||
sx={{ px: 1.25, py: 0.5, textTransform: "none", gap: 0.5 }}
|
||||
>
|
||||
<ThreeSixtyIcon fontSize="small" />
|
||||
{t("models:detail.previewToolbar.spin")}
|
||||
</ToggleButton>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user