fix: stabilize 3d workspace and canvas model controls

This commit is contained in:
134 2026-08-10 20:54:23 +08:00
parent 1a9888d825
commit e225e06f0b
7 changed files with 113 additions and 10 deletions

View File

@ -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),
]);

View File

@ -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 [

View File

@ -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"
}

View File

@ -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;
}

View File

@ -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}

View File

@ -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,

View 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)"
);
});
});