diff --git a/app/api/style-pro/model-options/route.ts b/app/api/style-pro/model-options/route.ts index 4b00ba869..26b5f2b13 100644 --- a/app/api/style-pro/model-options/route.ts +++ b/app/api/style-pro/model-options/route.ts @@ -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), ]); diff --git a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx index 14d633723..a9bbcb039 100644 --- a/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx +++ b/app/canvas/[id]/CanvasEditorLegacyPageClient.tsx @@ -1981,7 +1981,7 @@ export default function CanvasEditorLegacyPage({ ); const [activeWorkflowExecutionTaskKey, setActiveWorkflowExecutionTaskKey] = useState(null); - const [isPanelOpen, setIsPanelOpen] = useState(() => requestedAgentMode); + const [isPanelOpen, setIsPanelOpen] = useState(true); const [floatingPanelWidth, setFloatingPanelWidth] = useState(FLOATING_PANEL_WIDTH); const [ diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 03fe10f43..402af4127 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -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" } diff --git a/components/canvas/model3d/Model3DViewer.tsx b/components/canvas/model3d/Model3DViewer.tsx index bb5465065..a58791365 100644 --- a/components/canvas/model3d/Model3DViewer.tsx +++ b/components/canvas/model3d/Model3DViewer.tsx @@ -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; } diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 062794e6c..a8759fbbc 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -103,6 +103,7 @@ type World3DViewerProps = { resetViewToken?: number; onCaptureReady?: (capture: (() => Promise) | 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} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 6862b52e1..f1999b5a7 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -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, diff --git a/tests/routes/model3d-style-pro-regressions.test.ts b/tests/routes/model3d-style-pro-regressions.test.ts new file mode 100644 index 000000000..3b4bf3d72 --- /dev/null +++ b/tests/routes/model3d-style-pro-regressions.test.ts @@ -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)" + ); + }); +});