fix: stabilize 3d workspace and canvas model controls
This commit is contained in:
parent
1a9888d825
commit
e225e06f0b
@ -1,7 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getModelGenerationCreditCost } from "@/lib/ai/image-model-pricing";
|
||||
import { getSystemSetting } from "@/lib/db/queries";
|
||||
import { getSystemSetting, getUserById } from "@/lib/db/queries";
|
||||
import {
|
||||
findStyleProImageModelByRef,
|
||||
getStyleProImageModelDisplayEntries,
|
||||
@ -26,10 +26,16 @@ export async function GET() {
|
||||
);
|
||||
}
|
||||
|
||||
const currentUser = await getUserById({ id: session.user.id });
|
||||
const allowHiddenModels =
|
||||
session.user.type === "admin" || Boolean(currentUser?.isHighVip);
|
||||
|
||||
const [displayEntries, entries, realisticModelEntries, setting] =
|
||||
await Promise.all([
|
||||
getStyleProImageModelDisplayEntries({ includeHidden: false }),
|
||||
getStyleProImageModelEntries({ includeHidden: false }),
|
||||
getStyleProImageModelDisplayEntries({
|
||||
includeHidden: allowHiddenModels,
|
||||
}),
|
||||
getStyleProImageModelEntries({ includeHidden: allowHiddenModels }),
|
||||
getStyleProImageModelEntries({ includeHidden: true }),
|
||||
getSystemSetting({ key: STYLE_PRO_CONFIG_KEY }).catch(() => null),
|
||||
]);
|
||||
|
||||
@ -1981,7 +1981,7 @@ export default function CanvasEditorLegacyPage({
|
||||
);
|
||||
const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] =
|
||||
useState<string | null>(null);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(() => requestedAgentMode);
|
||||
const [isPanelOpen, setIsPanelOpen] = useState(true);
|
||||
const [floatingPanelWidth, setFloatingPanelWidth] =
|
||||
useState(FLOATING_PANEL_WIDTH);
|
||||
const [
|
||||
|
||||
@ -2790,9 +2790,9 @@ export default function CanvasModel3DPage() {
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspaceMode !== "world" || worldViewportMode !== "splat") {
|
||||
setWorldFirstPerson(false);
|
||||
}
|
||||
setWorldFirstPerson(
|
||||
workspaceMode === "world" && worldViewportMode === "splat"
|
||||
);
|
||||
}, [workspaceMode, worldViewportMode]);
|
||||
|
||||
return (
|
||||
@ -4000,6 +4000,7 @@ export default function CanvasModel3DPage() {
|
||||
previewImageUrl={currentPreviewImage}
|
||||
resetViewToken={viewerResetToken}
|
||||
semanticsMetadata={currentWorldAsset?.semanticsMetadata}
|
||||
showInfoOverlay={false}
|
||||
showMovementControls={
|
||||
worldViewportMode === "splat" || worldViewportMode === "mesh"
|
||||
}
|
||||
|
||||
@ -298,6 +298,29 @@ function configureMaterialTextures(
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
|
||||
function cloneSceneWithIndependentMaterials(scene: Object3D) {
|
||||
const clonedScene = scene.clone(true);
|
||||
|
||||
clonedScene.traverse((node) => {
|
||||
if (!isMesh(node)) return;
|
||||
|
||||
const sourceMaterials = Array.isArray(node.material)
|
||||
? node.material
|
||||
: [node.material];
|
||||
const clonedMaterials = sourceMaterials.map((material) => {
|
||||
const clonedMaterial = material.clone();
|
||||
configureMaterialTextures(clonedMaterial, node.geometry);
|
||||
return clonedMaterial;
|
||||
});
|
||||
|
||||
node.material = Array.isArray(node.material)
|
||||
? clonedMaterials
|
||||
: clonedMaterials[0] || node.material;
|
||||
});
|
||||
|
||||
return clonedScene;
|
||||
}
|
||||
|
||||
function resolveSceneGroundY(
|
||||
scene: Object3D,
|
||||
modelRotation?: [number, number, number]
|
||||
@ -354,7 +377,10 @@ function ModelScene({
|
||||
loader.setKTX2Loader(ktx2Loader);
|
||||
},
|
||||
);
|
||||
const scene = useMemo(() => model.scene.clone(), [model.scene]);
|
||||
const scene = useMemo(
|
||||
() => cloneSceneWithIndependentMaterials(model.scene),
|
||||
[model.scene]
|
||||
);
|
||||
const meta = useMemo(() => collectSceneMeta(scene), [scene]);
|
||||
const modelRotationKey = modelRotation?.join("|") || "";
|
||||
const groundY = useMemo(
|
||||
@ -379,6 +405,20 @@ function ModelScene({
|
||||
}
|
||||
}, [layerVisibility, meta.meshRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
scene.traverse((node) => {
|
||||
if (!isMesh(node)) return;
|
||||
const materials = Array.isArray(node.material)
|
||||
? node.material
|
||||
: [node.material];
|
||||
for (const material of materials) {
|
||||
material.dispose();
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [scene]);
|
||||
|
||||
useEffect(() => {
|
||||
for (const { mesh } of meta.meshRefs) {
|
||||
mesh.castShadow = true;
|
||||
@ -388,7 +428,6 @@ function ModelScene({
|
||||
: [mesh.material];
|
||||
for (const material of materials) {
|
||||
if (!material) continue;
|
||||
configureMaterialTextures(material, mesh.geometry);
|
||||
if ("wireframe" in material) {
|
||||
(material as any).wireframe = settings.wireframe;
|
||||
}
|
||||
|
||||
@ -103,6 +103,7 @@ type World3DViewerProps = {
|
||||
resetViewToken?: number;
|
||||
onCaptureReady?: (capture: (() => Promise<Blob | null>) | null) => void;
|
||||
showMovementControls?: boolean;
|
||||
showInfoOverlay?: boolean;
|
||||
};
|
||||
|
||||
const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = {
|
||||
@ -2281,6 +2282,7 @@ export function World3DViewer({
|
||||
resetViewToken,
|
||||
onCaptureReady,
|
||||
showMovementControls = false,
|
||||
showInfoOverlay = !minimalFrame,
|
||||
}: World3DViewerProps) {
|
||||
const resolvedImageUrl = String(imageUrl || previewImageUrl || "").trim() || null;
|
||||
|
||||
@ -2387,7 +2389,7 @@ export function World3DViewer({
|
||||
minimalFrame={minimalFrame}
|
||||
resetViewToken={resetViewToken}
|
||||
semanticsMetadata={semanticsMetadata}
|
||||
showInfoOverlay={!minimalFrame}
|
||||
showInfoOverlay={showInfoOverlay}
|
||||
showMovementControls={showMovementControls}
|
||||
splatUrl={splatUrl}
|
||||
splatUrls={splatUrls}
|
||||
|
||||
@ -13,6 +13,7 @@ export default defineConfig({
|
||||
/routes\/canvas-scale-adjust-intent\.test\.ts/,
|
||||
/routes\/canvas-runtime-internal-endpoint\.test\.ts/,
|
||||
/routes\/model-classification\.test\.ts/,
|
||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||
],
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
|
||||
54
tests/routes/model3d-style-pro-regressions.test.ts
Normal file
54
tests/routes/model3d-style-pro-regressions.test.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
function readSource(path: string) {
|
||||
return readFileSync(resolve(process.cwd(), path), "utf8");
|
||||
}
|
||||
|
||||
test.describe("3D workspace and Style Pro regressions", () => {
|
||||
test("opens World splats in first-person mode without the debug HUD", () => {
|
||||
const page = readSource("app/canvas/[id]/model3d/page.tsx");
|
||||
const viewer = readSource(
|
||||
"components/canvas/world3d/World3DViewer.tsx"
|
||||
);
|
||||
|
||||
expect(page).toContain(
|
||||
'workspaceMode === "world" && worldViewportMode === "splat"'
|
||||
);
|
||||
expect(page).toContain("showInfoOverlay={false}");
|
||||
expect(viewer).toContain("showInfoOverlay = !minimalFrame");
|
||||
expect(viewer).toContain("showInfoOverlay={showInfoOverlay}");
|
||||
});
|
||||
|
||||
test("keeps each GLB viewer material instance isolated and texture-aware", () => {
|
||||
const viewer = readSource(
|
||||
"components/canvas/model3d/Model3DViewer.tsx"
|
||||
);
|
||||
|
||||
expect(viewer).toContain("cloneSceneWithIndependentMaterials");
|
||||
expect(viewer).toContain("const clonedMaterial = material.clone()");
|
||||
expect(viewer).toContain(
|
||||
"configureMaterialTextures(clonedMaterial, node.geometry)"
|
||||
);
|
||||
});
|
||||
|
||||
test("uses the same hidden-model access rule in Style Pro as canvas", () => {
|
||||
const route = readSource("app/api/style-pro/model-options/route.ts");
|
||||
|
||||
expect(route).toContain("getUserById");
|
||||
expect(route).toContain('session.user.type === "admin"');
|
||||
expect(route).toContain("Boolean(currentUser?.isHighVip)");
|
||||
expect(route).toContain("includeHidden: allowHiddenModels");
|
||||
});
|
||||
|
||||
test("opens the canvas conversation panel by default", () => {
|
||||
const page = readSource(
|
||||
"app/canvas/[id]/CanvasEditorLegacyPageClient.tsx"
|
||||
);
|
||||
|
||||
expect(page).toContain(
|
||||
"const [isPanelOpen, setIsPanelOpen] = useState(true)"
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user