diff --git a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx index cffff1672..c6b27768f 100644 --- a/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx +++ b/app/(admin)/settings/_components/local-ai-sections/image-pipeline-sections.tsx @@ -81,6 +81,8 @@ export function LocalAiImagePipelineSections({ setWatermarkRemoveConfig, angleEditConfig, setAngleEditConfig, + pointsSystemConfig, + updatePointsCostField, layerSplitConfig, setLayerSplitConfig, isLocalAiSectionExpanded, @@ -1447,6 +1449,12 @@ export function LocalAiImagePipelineSections({ label: aiWashConfig.modeLabel || "AI洗图", tone: "neutral", }, + { + label: aiWashConfig.hideModelSelector + ? "前台隐藏模型" + : "前台可选模型", + tone: aiWashConfig.hideModelSelector ? "enabled" : "neutral", + }, ]} /> } @@ -1633,6 +1641,23 @@ export function LocalAiImagePipelineSections({ } /> +
+
+
隐藏前台生图模型选择
+
+ 开启后,AI洗图页面不再显示生图模型下拉框,直接使用后台预设或默认模型。 +
+
+ + setAiWashConfig((prev) => ({ + ...prev, + hideModelSelector: checked, + })) + } + /> +
@@ -2383,6 +2408,13 @@ export function LocalAiImagePipelineSections({ label: `${angleEditConfig.inferenceSteps} steps`, tone: "neutral", }, + { + label: `${pointsSystemConfig.creditCostPerImageAngleEdit || 0} 积分/次`, + tone: + pointsSystemConfig.creditCostPerImageAngleEdit > 0 + ? "neutral" + : "disabled", + }, ]} /> } @@ -2453,6 +2485,28 @@ export function LocalAiImagePipelineSections({ + {/* 积分消耗 */} +
+ + + updatePointsCostField( + "creditCostPerImageAngleEdit", + e.target.value + ) + } + type="number" + value={pointsSystemConfig.creditCostPerImageAngleEdit} + /> +
+ 每次成功生成多角度图片后扣除的积分;填写 0 表示免费。保存本配置时会同步保存到全站积分设置。 +
+
+ + + {/* LoRA 强度 */}
diff --git a/app/(admin)/settings/_lib/types.ts b/app/(admin)/settings/_lib/types.ts index 20e8cebab..5ba9d781e 100644 --- a/app/(admin)/settings/_lib/types.ts +++ b/app/(admin)/settings/_lib/types.ts @@ -8,6 +8,7 @@ import type { EnhanceProPreset, } from "@/lib/enhance-pro/config"; import type { ThreeDModelAliasConfig } from "@/lib/model3d/model-alias-config"; +import type { PointsSystemConfig } from "@/lib/points/config"; import type { ProductPlanningApiConfig } from "@/lib/product-planning/config"; import type { ProductPlanningAiModelConfig } from "@/lib/product-planning/model-config"; import type { DEFAULT_LOCAL_AI_EXPANDED_SECTIONS } from "./local-ai-sections"; @@ -331,6 +332,11 @@ export type LocalAiImagePipelineSectionsProps = LocalAiSectionControlProps & { setWatermarkRemoveConfig: Dispatch>; angleEditConfig: AngleEditConfig; setAngleEditConfig: Dispatch>; + pointsSystemConfig: PointsSystemConfig; + updatePointsCostField: ( + key: keyof PointsSystemConfig, + value: unknown + ) => void; layerSplitConfig: LayerSplitConfig; setLayerSplitConfig: Dispatch>; renderEnhanceProPresetEditor: ( diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index f897f6c03..29066c50e 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -262,6 +262,7 @@ import { type EnhanceProPointsResolutionBucket, normalizePointsPackages, normalizePointsSystemConfig, + POINTS_SYSTEM_CONFIG_KEY, type PointsPackage, type PointsSystemConfig, } from "@/lib/points/config"; @@ -799,7 +800,8 @@ type PointsCostField = | "creditCostPerImageBoxCutout" | "creditCostPerImageWatermarkRemove" | "creditCostPerImageExtend" - | "creditCostPerImageRepaint"; + | "creditCostPerImageRepaint" + | "creditCostPerImageAngleEdit"; const THREE_D_GENERATION_POINT_FIELDS: Array<{ key: PointsCostField; @@ -885,6 +887,11 @@ const IMAGE_TOOL_POINT_FIELDS: Array<{ label: "重绘", description: "调用图片提示词重绘功能时扣除。", }, + { + key: "creditCostPerImageAngleEdit", + label: "多角度", + description: "调用 fal.ai 多角度 / 角度编辑功能时扣除。", + }, ]; type AiModel = { @@ -4484,7 +4491,7 @@ export function AdminSettingsClient() { }); }; - const updatePointsCostField = (key: PointsCostField, value: unknown) => { + const updatePointsCostField = (key: keyof PointsSystemConfig, value: unknown) => { setSiteSettings((prev) => { const config = normalizePointsSystemConfig(prev.pointsSystemConfig); return { @@ -5298,7 +5305,11 @@ export function AdminSettingsClient() { return; } - const response = await fetch("/api/admin/settings", { + const normalizedPointsSystemConfig = normalizePointsSystemConfig( + siteSettings.pointsSystemConfig + ); + const [angleResponse, pointsResponse] = await Promise.all([ + fetch("/api/admin/settings", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -5313,9 +5324,18 @@ export function AdminSettingsClient() { timeout: angleEditConfig.timeout, }, }), - }); + }), + fetch("/api/admin/settings", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + key: POINTS_SYSTEM_CONFIG_KEY, + value: normalizedPointsSystemConfig, + }), + }), + ]); - if (!response.ok) throw new Error("保存失败"); + if (!angleResponse.ok || !pointsResponse.ok) throw new Error("保存失败"); toast.success("角度编辑API配置保存成功"); } catch (error) { toast.error("保存角度编辑API配置失败"); @@ -17738,6 +17758,7 @@ export function AdminSettingsClient() { isLocalAiSectionExpanded={isLocalAiSectionExpanded} layerSplitConfig={layerSplitConfig} modelsData={modelsData} + pointsSystemConfig={pointsSystemConfig} renderEnhanceProPresetEditor={renderEnhanceProPresetEditor} rmbgConfig={rmbgConfig} savedEnhanceProKeyRows={savedEnhanceProKeyRows} @@ -17756,6 +17777,7 @@ export function AdminSettingsClient() { setSelectedEnhanceProPresetId={setSelectedEnhanceProPresetId} setWatermarkRemoveConfig={setWatermarkRemoveConfig} toggleLocalAiSection={toggleLocalAiSection} + updatePointsCostField={updatePointsCostField} watermarkRemoveConfig={watermarkRemoveConfig} /> diff --git a/app/api/angle-edit/route.ts b/app/api/angle-edit/route.ts index 92901de6f..403c2a204 100644 --- a/app/api/angle-edit/route.ts +++ b/app/api/angle-edit/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { createMediaAssetRelation, getMediaAssetsByUserAndUrls, + getPointsSystemConfig, getSystemSetting, upsertMediaAsset, } from "@/lib/db/queries"; @@ -10,6 +11,8 @@ import { uploadFile } from "@/lib/storage/service"; import { db } from "@/lib/db"; import { generatedImage } from "@/lib/db/schema"; import { createLogger } from "@/lib/observability/logger"; +import { getImageToolCreditCost } from "@/lib/points/config"; +import { prepareFeaturePointsCharge } from "@/lib/points/feature-charge"; const logger = createLogger("angle-edit-api"); @@ -171,6 +174,19 @@ export async function POST(request: NextRequest) { zoom: finalZoom, }); + const pointsConfig = await getPointsSystemConfig(); + const pointsCharge = await prepareFeaturePointsCharge({ + userId: session.user.id, + unitCost: getImageToolCreditCost(pointsConfig, "angle-edit"), + reason: "image_angle_edit", + modelId: "qwen-image-edit-2511-multiple-angles", + note: "多角度编辑消耗积分", + pointsConfig, + }); + if (!pointsCharge.ok) { + return NextResponse.json(pointsCharge.payload, { status: pointsCharge.status }); + } + // 4. 准备图片输入 let imageInput: string; if (image_url) { @@ -256,6 +272,19 @@ export async function POST(request: NextRequest) { }) .returning(); + if (pointsCharge.chargeable) { + try { + await pointsCharge.consume({ + note: `多角度编辑消耗积分(${savedImage.id})`, + }); + } catch (pointsError) { + logger.error("points_consume_failed", pointsError, { + imageId: savedImage.id, + userId: session.user.id, + }); + } + } + try { const sourceAssets = typeof image_url === "string" && image_url.trim() diff --git a/app/api/style-pro/model-options/route.ts b/app/api/style-pro/model-options/route.ts index a1af3f604..4b00ba869 100644 --- a/app/api/style-pro/model-options/route.ts +++ b/app/api/style-pro/model-options/route.ts @@ -6,6 +6,7 @@ import { findStyleProImageModelByRef, getStyleProImageModelDisplayEntries, getStyleProImageModelEntries, + sortStyleProImageModelEntries, toPublicStyleProModelOption, } from "@/lib/style-pro/model-options"; import { @@ -64,7 +65,9 @@ export async function GET() { success: true, defaultModelId: defaultEntry?.model.id || null, realisticUnitCost, - models: displayEntries.map(toPublicStyleProModelOption), + models: sortStyleProImageModelEntries(displayEntries).map( + toPublicStyleProModelOption + ), }); } catch (error) { console.error("[Style Pro Model Options] Failed:", error); diff --git a/app/api/user/account-center/route.ts b/app/api/user/account-center/route.ts index 9de450fd7..10e9ccb35 100644 --- a/app/api/user/account-center/route.ts +++ b/app/api/user/account-center/route.ts @@ -81,6 +81,9 @@ function formatLedgerDetail(params: { if (reason === "realtime_canvas_generation") { return "实时绘画生成消耗积分"; } + if (reason === "image_angle_edit") { + return "多角度编辑消耗积分"; + } if (reason === "model3d_task") { const taskMatch = trimmedNote.match(MODEL3D_LEDGER_NOTE_WITH_TASK_PATTERN); diff --git a/app/canvas/[id]/model3d/page.tsx b/app/canvas/[id]/model3d/page.tsx index 85c0a73aa..a0898230d 100644 --- a/app/canvas/[id]/model3d/page.tsx +++ b/app/canvas/[id]/model3d/page.tsx @@ -339,6 +339,71 @@ function resolveTaskModelUrl(task?: TaskRecord | null): string | null { }); } +function resolveWorldTaskSplatUrl(task?: TaskRecord | null) { + if (!task || task.provider !== "worldlabs-3d") return null; + return pickBestArchivedWorldLabsSplatUrl(task.assetPayload?.spzUrls); +} + +function resolveWorldTaskImageUrl(task?: TaskRecord | null) { + if (!task || task.provider !== "worldlabs-3d") return null; + return ( + task.assetPayload?.panoUrl || + task.assetPayload?.thumbnailUrl || + task.previewImageUrl || + null + ); +} + +function resolveWorldTaskPreviewUrl(task?: TaskRecord | null) { + if (!task) return null; + return ( + task.previewImageUrl || + task.assetPayload?.thumbnailUrl || + task.assetPayload?.panoUrl || + null + ); +} + +function canImportTaskToCanvas(task?: TaskRecord | null) { + if (!task || task.status !== "success") return false; + if (task.provider !== "worldlabs-3d") return Boolean(resolveTaskModelUrl(task)); + return Boolean( + resolveTaskModelUrl(task) || + task.assetPayload?.worldUrl || + resolveWorldTaskImageUrl(task) || + resolveWorldTaskSplatUrl(task) + ); +} + +function resolveCanvasImportPosition(params: { + index: number; + totalCount: number; + viewportX: number; + viewportY: number; + viewportZoom: number; + stageWidth: number; + stageHeight: number; + targetWidth: number; + targetHeight: number; +}) { + const column = params.index % 3; + const row = Math.floor(params.index / 3); + const gap = 36; + const centerX = + (params.stageWidth * 0.48 - params.viewportX) / params.viewportZoom; + const centerY = + (params.stageHeight * 0.45 - params.viewportY) / params.viewportZoom; + const totalColumns = Math.min(3, Math.max(1, params.totalCount)); + const xOffset = + (column - (totalColumns - 1) / 2) * (params.targetWidth + gap); + const yOffset = row * (params.targetHeight + gap); + + return { + x: centerX - params.targetWidth / 2 + xOffset, + y: centerY - params.targetHeight / 2 + yOffset, + }; +} + function createMultiViewSlots(count = 4): MultiViewSlot[] { return Array.from({ length: count }, (_, index) => ({ id: `slot_${index + 1}`, @@ -651,7 +716,6 @@ export default function CanvasModel3DPage() { const viewport = useCanvasStore(canvasSelectors.viewport); const addNode = useCanvasStore((state) => state.addNode); const nodes = useCanvasStore(canvasSelectors.nodes); - const deleteNode = useCanvasStore((state) => state.deleteNode); const getCanvasState = useCanvasStore((state) => state.getCanvasState); const [workspaceMode, setWorkspaceMode] = useState("model"); @@ -722,12 +786,13 @@ export default function CanvasModel3DPage() { ); const [worldViewportMode, setWorldViewportMode] = useState("world"); - const [worldFirstPerson, setWorldFirstPerson] = useState(false); + const [worldFirstPerson, setWorldFirstPerson] = useState(true); const [viewerResetToken, setViewerResetToken] = useState(0); const [taskHistory, setTaskHistory] = useState([]); const [currentTask, setCurrentTask] = useState(null); const [selectedWorldShareTaskIds, setSelectedWorldShareTaskIds] = useState([]); + const [selectedWorldCanvasTaskIds, setSelectedWorldCanvasTaskIds] = useState([]); const [autoDownloadTaskId, setAutoDownloadTaskId] = useState( null ); @@ -1274,10 +1339,21 @@ export default function CanvasModel3DPage() { ), [visibleTaskHistory] ); + const importableWorldTasks = useMemo( + () => + visibleTaskHistory.filter( + (task) => task.provider === "worldlabs-3d" && canImportTaskToCanvas(task) + ), + [visibleTaskHistory] + ); const selectedWorldShareTasks = useMemo(() => { const selectedIds = new Set(selectedWorldShareTaskIds); return shareableWorldTasks.filter((task) => selectedIds.has(task.taskId)); }, [selectedWorldShareTaskIds, shareableWorldTasks]); + const selectedWorldCanvasTasks = useMemo(() => { + const selectedIds = new Set(selectedWorldCanvasTaskIds); + return importableWorldTasks.filter((task) => selectedIds.has(task.taskId)); + }, [selectedWorldCanvasTaskIds, importableWorldTasks]); useEffect(() => { const shareableIds = new Set(shareableWorldTasks.map((task) => task.taskId)); @@ -1287,6 +1363,14 @@ export default function CanvasModel3DPage() { }); }, [shareableWorldTasks]); + useEffect(() => { + const importableIds = new Set(importableWorldTasks.map((task) => task.taskId)); + setSelectedWorldCanvasTaskIds((prev) => { + const next = prev.filter((taskId) => importableIds.has(taskId)); + return next.length === prev.length ? prev : next; + }); + }, [importableWorldTasks]); + const currentModelUrl = resolveTaskModelUrl(currentTask); const currentPreviewImage = currentTask?.previewImageUrl || @@ -1993,35 +2077,12 @@ export default function CanvasModel3DPage() { } }; - const handleImportToCanvas = async () => { - if (!currentTask || currentTask.status !== "success") { + const handleImportTasksToCanvas = async (tasks: TaskRecord[]) => { + const importableTasks = tasks.filter(canImportTaskToCanvas); + if (importableTasks.length === 0) { toast.error("暂无可添加的 3D 资产"); return; } - - const isWorldTask = currentTask.provider === "worldlabs-3d"; - const worldAssetPayload = currentTask.assetPayload ?? null; - const primarySplatUrl = isWorldTask - ? pickBestArchivedWorldLabsSplatUrl(worldAssetPayload?.spzUrls) - : null; - const worldImageUrl = - worldAssetPayload?.panoUrl || - worldAssetPayload?.thumbnailUrl || - currentTask.previewImageUrl || - null; - - const canImportWorld = Boolean( - currentModelUrl || - worldAssetPayload?.worldUrl || - worldImageUrl || - primarySplatUrl - ); - - if ((!isWorldTask && !currentModelUrl) || (isWorldTask && !canImportWorld)) { - toast.error(isWorldTask ? "暂无可添加的 3D 世界" : "暂无可添加的 3D 模型"); - return; - } - const viewportX = viewport.x || 0; const viewportY = viewport.y || 0; const viewportZoom = viewport.zoom || 1; @@ -2030,92 +2091,109 @@ export default function CanvasModel3DPage() { typeof window !== "undefined" ? window.innerHeight : 720; const targetWidth = 380; const targetHeight = 300; - const x = (stageWidth * 0.48 - viewportX) / viewportZoom - targetWidth / 2; - const y = - (stageHeight * 0.45 - viewportY) / viewportZoom - targetHeight / 2; + const existing3dNodeCount = nodes.filter( + (node) => node.type === "world3d" || node.type === "model3d" + ).length; - if (isWorldTask) { - const worldNodeIds = nodes - .filter((node) => node.type === "world3d") - .map((node) => node.id); - for (const nodeId of worldNodeIds) { - deleteNode(nodeId); - } - } + importableTasks.forEach((task, index) => { + const isWorldTask = task.provider === "worldlabs-3d"; + const modelUrl = resolveTaskModelUrl(task); + const worldAssetPayload = task.assetPayload ?? null; + const primarySplatUrl = resolveWorldTaskSplatUrl(task); + const worldImageUrl = resolveWorldTaskImageUrl(task); + const position = resolveCanvasImportPosition({ + index: existing3dNodeCount + index, + totalCount: existing3dNodeCount + importableTasks.length, + viewportX, + viewportY, + viewportZoom, + stageWidth, + stageHeight, + targetWidth, + targetHeight, + }); - addNode( - isWorldTask - ? { - type: "world3d", - position: { x, y }, - size: { width: targetWidth, height: targetHeight }, - data: { - worldUrl: worldAssetPayload?.worldUrl || undefined, - imageUrl: worldImageUrl || undefined, - meshUrl: currentModelUrl || worldAssetPayload?.meshUrl || undefined, - splatUrl: primarySplatUrl || undefined, - previewImage: - currentTask.previewImageUrl || - worldAssetPayload?.thumbnailUrl || - undefined, - prompt: currentTask.prompt, - modelVersion: currentTask.modelVersion, - status: "completed", - activeView: primarySplatUrl - ? "splat" - : worldImageUrl - ? "image" - : "mesh", - firstPerson: false, - hotspots: [], - semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined, - metadata: { - taskId: currentTask.taskId, - provider: currentTask.provider, - mode: currentTask.mode, - sourceTaskId: currentTask.sourceTaskId, - progress: currentTask.progress, - triangles: viewerStats.triangles, - vertices: viewerStats.vertices, - meshCount: viewerStats.meshCount, - assetPayload: currentTask.assetPayload ?? null, + addNode( + isWorldTask + ? { + type: "world3d", + position, + size: { width: targetWidth, height: targetHeight }, + data: { + worldUrl: worldAssetPayload?.worldUrl || undefined, + imageUrl: worldImageUrl || undefined, + meshUrl: modelUrl || worldAssetPayload?.meshUrl || undefined, + splatUrl: primarySplatUrl || undefined, + previewImage: resolveWorldTaskPreviewUrl(task) || undefined, + prompt: task.prompt, + modelVersion: task.modelVersion, + status: "completed", + activeView: primarySplatUrl ? "splat" : worldImageUrl ? "image" : "mesh", + firstPerson: false, + hotspots: [], + semanticsMetadata: worldAssetPayload?.semanticsMetadata || undefined, + metadata: { + taskId: task.taskId, + provider: task.provider, + mode: task.mode, + sourceTaskId: task.sourceTaskId, + progress: task.progress, + triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined, + vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined, + meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined, + assetPayload: task.assetPayload ?? null, + }, }, - }, - connections: [], - } - : { - type: "model3d", - position: { x, y }, - size: { width: targetWidth, height: targetHeight }, - data: { - src: currentModelUrl!, - previewImage: currentTask.previewImageUrl || undefined, - prompt: currentTask.prompt, - modelVersion: currentTask.modelVersion, - format: extractModelFormat(currentModelUrl!), - status: "completed", - metadata: { - taskId: currentTask.taskId, - provider: currentTask.provider, - mode: currentTask.mode, - sourceTaskId: currentTask.sourceTaskId, - progress: currentTask.progress, - triangles: viewerStats.triangles, - vertices: viewerStats.vertices, - meshCount: viewerStats.meshCount, - assetPayload: currentTask.assetPayload ?? null, + connections: [], + } + : { + type: "model3d", + position, + size: { width: targetWidth, height: targetHeight }, + data: { + src: modelUrl!, + previewImage: task.previewImageUrl || undefined, + prompt: task.prompt, + modelVersion: task.modelVersion, + format: extractModelFormat(modelUrl!), + status: "completed", + metadata: { + taskId: task.taskId, + provider: task.provider, + mode: task.mode, + sourceTaskId: task.sourceTaskId, + progress: task.progress, + triangles: currentTask?.taskId === task.taskId ? viewerStats.triangles : undefined, + vertices: currentTask?.taskId === task.taskId ? viewerStats.vertices : undefined, + meshCount: currentTask?.taskId === task.taskId ? viewerStats.meshCount : undefined, + assetPayload: task.assetPayload ?? null, + }, }, - }, - connections: [], - } - ); + connections: [], + } + ); + }); try { await canvasService.saveCanvasState(id, getCanvasState()); } catch (error) { console.error("[Model3DPage] 保存画布失败:", error); } - toast.success(isWorldTask ? "3D 世界已添加到画布" : "3D 模型已添加到画布"); + toast.success( + importableTasks.length > 1 + ? `已添加 ${importableTasks.length} 个 3D 世界到画布` + : importableTasks[0]?.provider === "worldlabs-3d" + ? "3D 世界已添加到画布" + : "3D 模型已添加到画布" + ); + }; + + const handleImportToCanvas = async () => { + if (!currentTask || currentTask.status !== "success") { + toast.error("暂无可添加的 3D 资产"); + return; + } + await handleImportTasksToCanvas([currentTask]); }; const handleDownloadCurrent = () => { @@ -2229,6 +2307,37 @@ export default function CanvasModel3DPage() { ); }, [shareableWorldTasks]); + const handleToggleWorldCanvasSelection = useCallback( + (task: TaskRecord, event?: MouseEvent) => { + event?.stopPropagation(); + if (task.provider !== "worldlabs-3d" || !canImportTaskToCanvas(task)) { + return; + } + setSelectedWorldCanvasTaskIds((prev) => + prev.includes(task.taskId) + ? prev.filter((taskId) => taskId !== task.taskId) + : [...prev, task.taskId] + ); + }, + [] + ); + + const handleSelectAllImportableWorlds = useCallback(() => { + setSelectedWorldCanvasTaskIds((prev) => + prev.length === importableWorldTasks.length + ? [] + : importableWorldTasks.map((task) => task.taskId) + ); + }, [importableWorldTasks]); + + const handleImportSelectedWorldsToCanvas = useCallback(async () => { + if (selectedWorldCanvasTasks.length === 0) { + toast.error("请选择要添加到画布的世界"); + return; + } + await handleImportTasksToCanvas(selectedWorldCanvasTasks); + }, [handleImportTasksToCanvas, selectedWorldCanvasTasks]); + const handleShareSelectedWorlds = useCallback(async () => { if (selectedWorldShareTasks.length < 2) { toast.error("请至少选择 2 个成功生成的 3D 世界"); @@ -3892,31 +4001,27 @@ export default function CanvasModel3DPage() { {worldViewportMode === "splat" ? ( <>
- {worldFirstPerson ? "WASD / 方向键可六向移动" : "鼠标拖拽会围绕场景主体中心旋转"} + {worldFirstPerson ? "漫游:WASD / 方向键" : "观察:拖拽旋转"}
-
+
{worldFirstPerson - ? "按住左键拖动画面转向,WASD 或方向键移动,Q/R 上升,E/F 下降。" - : "左键拖拽旋转,滚轮缩放;开启“漫游”后可用 WASD / 方向键移动。需要切换模型时,直接点击右侧资产卡片。"} + ? "左键转向 · WASD 移动 · Q/R 上升 · E/F 下降" + : "左键旋转 · 右键平移 · 滚轮缩放"}
) : worldViewportMode === "mesh" ? ( <>
- Mesh 视图已恢复 -
-
- 当前世界关联的 Mesh 会在这里直接显示,也可以从右侧资产库点击缩略图切换当前模型。 + Mesh 视图
+
检查几何结构与模型资产。
) : ( <>
- 当前为图像预览 -
-
- 可以切回 World 查看空间结果,或切到 Mesh 继续检查几何结构。 + 图像预览
+
查看全景/缩略图结果。
)}
@@ -3937,6 +4042,9 @@ export default function CanvasModel3DPage() { previewImageUrl={currentPreviewImage} resetViewToken={viewerResetToken} semanticsMetadata={currentWorldAsset?.semanticsMetadata} + showMovementControls={ + worldViewportMode === "splat" || worldViewportMode === "mesh" + } splatUrl={currentPrimarySpzUrl} worldUrl={currentWorldAsset?.worldUrl} /> @@ -4023,7 +4131,7 @@ export default function CanvasModel3DPage() { {workspaceMode === "world" ? "世界资产库" : "模型资产库"}

- 点击缩略图切换当前{workspaceMode === "world" ? "世界 / 模型" : "模型"} + 点击切换{workspaceMode === "world" ? "世界" : "模型"}

+
+ 添加到画布 + 已选 {selectedWorldCanvasTasks.length} +
+
+ + +
) : null} @@ -4072,6 +4207,10 @@ export default function CanvasModel3DPage() { const canShareWorld = task.provider === "worldlabs-3d" && task.status === "success"; const isSelectedForShare = selectedWorldShareTaskIds.includes(task.taskId); + const canImportWorld = + task.provider === "worldlabs-3d" && canImportTaskToCanvas(task); + const isSelectedForCanvas = selectedWorldCanvasTaskIds.includes(task.taskId); + const previewUrl = resolveWorldTaskPreviewUrl(task) || task.previewImageUrl; return ( ) : null} + {canImportWorld ? ( + + ) : null} {canShareWorld ? ( ) : null} - {task.previewImageUrl ? ( + {previewUrl ? ( preview ) : (
)} -
+
+
-
- {modeLabel(task.mode)} +
+ + {modeLabel(task.mode)} + + + {formatDistanceToNow(new Date(task.createdAt), { + addSuffix: true, + locale: zhCN, + })} +
-
- {formatDistanceToNow(new Date(task.createdAt), { - addSuffix: true, - locale: zhCN, - })} -
- {task.status === "success" && resolveTaskModelUrl(task) ? ( -
- 点击右上角可下载模型 -
- ) : null} {task.provider === "worldlabs-3d" && task.shareEnabled ? (
- 已开启分享 + 已分享
) : null}
diff --git a/components/canvas/AiWashWorkspace.tsx b/components/canvas/AiWashWorkspace.tsx index 4af410e5f..965ed33db 100644 --- a/components/canvas/AiWashWorkspace.tsx +++ b/components/canvas/AiWashWorkspace.tsx @@ -47,6 +47,7 @@ import { } from "@/lib/ai-wash/config"; import { buildImageProxyUrl } from "@/lib/image-delivery"; import { cn } from "@/lib/utils"; +import { imageGenerationService } from "@/services/canvas"; const fetcher = (url: string) => fetch(url).then((res) => res.json()); @@ -181,6 +182,44 @@ function hashValue(value: string) { return (hash >>> 0).toString(36); } +function shouldUploadAiWashReferenceImage(value: string) { + return /^data:image\/[a-z0-9.+-]+;base64,/i.test(value) || value.startsWith("blob:"); +} + +async function uploadAiWashReferenceImage(imageUrl: string) { + const normalized = imageUrl.trim(); + if (!shouldUploadAiWashReferenceImage(normalized)) { + return normalized; + } + + const response = await fetch(normalized); + if (!response.ok) { + throw new Error("读取洗图底图失败,请重新上传图片"); + } + + const blob = await response.blob(); + if (!blob.type.startsWith("image/")) { + throw new Error("洗图底图格式无效,请上传图片文件"); + } + + const extension = blob.type.includes("jpeg") + ? "jpg" + : blob.type.includes("webp") + ? "webp" + : blob.type.includes("png") + ? "png" + : "jpg"; + const file = new File([blob], `ai-wash-reference-${Date.now()}.${extension}`, { + type: blob.type || "image/jpeg", + }); + const uploadResult = await imageGenerationService.uploadImage(file); + const uploadedUrl = uploadResult.data?.url || ""; + if (!uploadResult.success || !uploadedUrl) { + throw new Error(uploadResult.error || "洗图底图上传失败,请稍后重试"); + } + return uploadedUrl; +} + function createHistoryScopeKey(value?: string | null) { const raw = String(value || "").trim(); return raw ? `ai-wash-scope-${hashValue(raw)}` : "ai-wash-scope-global"; @@ -534,7 +573,15 @@ export function AiWashWorkspace({ [enabledPresets, modelOptionByRef, selectedLevelIndexByControl, selectedUserModel, selectedUserModelId] ); const shouldShowUserModelSelector = useMemo( - () => presetExecutionStates.some((item) => item.usesUserSelectableModel), + () => + !config.hideModelSelector && + presetExecutionStates.some((item) => item.usesUserSelectableModel), + [config.hideModelSelector, presetExecutionStates] + ); + const hasExecutableModelRefs = useMemo( + () => + presetExecutionStates.length > 0 && + presetExecutionStates.every((item) => Boolean(item.effectiveModelRef)), [presetExecutionStates] ); @@ -680,6 +727,10 @@ export function AiWashWorkspace({ toast.error("请先选择一个可用的生图模型"); return; } + if (!hasExecutableModelRefs) { + toast.error("当前没有可用的 AI洗图 生图模型,请先在系统设置中配置模型"); + return; + } const currentRunId = runIdRef.current + 1; runIdRef.current = currentRunId; @@ -714,6 +765,20 @@ export function AiWashWorkspace({ ); const startedAt = Date.now(); + let requestReferenceImage = compareSourceUrl; + try { + if (shouldUploadAiWashReferenceImage(compareSourceUrl)) { + toast.info("正在准备洗图底图..."); + } + requestReferenceImage = await uploadAiWashReferenceImage(compareSourceUrl); + } catch (error) { + const message = + error instanceof Error ? error.message : "洗图底图上传失败,请稍后重试"; + setLastErrorMessage(message); + toast.error(message); + setIsSubmitting(false); + return; + } const taskResults = await Promise.all( presetExecutionStates.map(async (executionState, index) => { @@ -728,7 +793,7 @@ export function AiWashWorkspace({ headers: { "Content-Type": "application/json" }, body: JSON.stringify({ prompt: executionState.resolvedPrompt, - referenceImage: compareSourceUrl, + referenceImage: requestReferenceImage, selectedModelId: executionState.effectiveModelRef, imageSize: config.imageSize, promptExtend: config.promptExtend, @@ -844,7 +909,7 @@ export function AiWashWorkspace({ id: `ai-wash-history-${startedAt.toString(36)}`, scopeKey: resolvedHistoryScopeKey, createdAt: startedAt, - sourceImageUrl: getPersistableSourceImageUrl(compareSourceUrl) || undefined, + sourceImageUrl: getPersistableSourceImageUrl(requestReferenceImage) || undefined, items: successfulItems, }, ...currentEntries, @@ -867,6 +932,7 @@ export function AiWashWorkspace({ config.imageSize, config.promptExtend, enabledPresets, + hasExecutableModelRefs, presetExecutionStates, persistHistoryBatches, resolvedHistoryScopeKey, @@ -1312,6 +1378,7 @@ export function AiWashWorkspace({ isSubmitting || !compareSourceUrl || enabledPresets.length === 0 || + !hasExecutableModelRefs || (shouldShowUserModelSelector && !selectedUserModel) } onClick={handleSubmit} diff --git a/components/canvas/RealtimeCanvasDialog.tsx b/components/canvas/RealtimeCanvasDialog.tsx index b71ef1ad0..81d7c3061 100644 --- a/components/canvas/RealtimeCanvasDialog.tsx +++ b/components/canvas/RealtimeCanvasDialog.tsx @@ -214,6 +214,59 @@ const REALTIME_ASPECT_RATIO_OPTIONS: RealtimeAspectRatio[] = [ "2:3", ]; +function parseRealtimeAspectRatio(ratio: RealtimeAspectRatio) { + const [width, height] = ratio.split(":").map((part) => Number(part)); + if (!Number.isFinite(width) || !Number.isFinite(height) || height <= 0) { + return { width: 1, height: 1, value: 1 }; + } + return { width, height, value: width / height }; +} + +function fitRealtimeFrameSize(params: { + containerWidth: number; + containerHeight: number; + ratio: RealtimeAspectRatio; +}) { + const ratio = parseRealtimeAspectRatio(params.ratio).value; + const availableWidth = Math.max(1, Math.floor(params.containerWidth)); + const availableHeight = Math.max(1, Math.floor(params.containerHeight)); + let width = availableWidth; + let height = width / ratio; + + if (height > availableHeight) { + height = availableHeight; + width = height * ratio; + } + + return { + width: Math.max(1, Math.floor(width)), + height: Math.max(1, Math.floor(height)), + }; +} + +function scaleImageLayersToFrame(params: { + layers: ImageLayer[]; + fromWidth: number; + fromHeight: number; + toWidth: number; + toHeight: number; +}) { + if (params.fromWidth <= 0 || params.fromHeight <= 0) { + return cloneImageLayers(params.layers); + } + const scaleX = params.toWidth / params.fromWidth; + const scaleY = params.toHeight / params.fromHeight; + return params.layers.map((layer) => ({ + ...layer, + x: layer.x * scaleX, + y: layer.y * scaleY, + width: layer.width * scaleX, + height: layer.height * scaleY, + baseWidth: layer.baseWidth * scaleX, + baseHeight: layer.baseHeight * scaleY, + })); +} + function cloneImageLayers(layers: ImageLayer[]) { return layers.map((layer) => ({ ...layer })); } @@ -849,6 +902,7 @@ export function RealtimeCanvasDialog({ const sessionIdRef = useRef( `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` ); + const hasInitializedRealtimeCanvasRef = useRef(false); const seedRef = useRef(createSessionSeed()); const hasLoadedInitialImageRef = useRef(false); const canvasBackgroundRef = useRef(CANVAS_BACKGROUND); @@ -874,6 +928,14 @@ export function RealtimeCanvasDialog({ const [, setStatusMessage] = useState("等待连接"); const [prompt, setPrompt] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); + const [previewImageSize, setPreviewImageSize] = useState<{ + width: number; + height: number; + } | null>(null); + const [realtimeFrameSize, setRealtimeFrameSize] = useState({ + width: 0, + height: 0, + }); const [workspaceMode, setWorkspaceMode] = useState(initialMode); const [realtimeGenerationMode, setRealtimeGenerationMode] = @@ -1763,6 +1825,8 @@ export function RealtimeCanvasDialog({ const payload = (await response.json().catch(() => ({}))) as { imageUrl?: string; imageDataURI?: string; + width?: number; + height?: number; cost?: number; transport?: string; warning?: string; @@ -1792,6 +1856,16 @@ export function RealtimeCanvasDialog({ lastRenderedSeqRef.current = Math.max(lastRenderedSeqRef.current, seq); setPreviewUrl(nextPreviewUrl); + const nextPreviewWidth = Number(payload.width); + const nextPreviewHeight = Number(payload.height); + setPreviewImageSize( + nextPreviewWidth > 0 && nextPreviewHeight > 0 + ? { + width: nextPreviewWidth, + height: nextPreviewHeight, + } + : null + ); setStatus("live"); setStatusMessage( payload.transport === "ws" ? "实时渲染中(WS)" : "实时渲染中(HTTP)" @@ -1964,6 +2038,7 @@ export function RealtimeCanvasDialog({ if (nextRatio === realtimeAspectRatioRef.current) return; realtimeAspectRatioRef.current = nextRatio; setRealtimeAspectRatio(nextRatio); + setPreviewImageSize(null); if (realtimeGenerationModeRef.current === "auto") { scheduleFlush(true, false, "refine"); @@ -2172,8 +2247,13 @@ export function RealtimeCanvasDialog({ } const rect = wrap.getBoundingClientRect(); - const cssWidth = Math.max(1, Math.floor(rect.width)); - const cssHeight = Math.max(1, Math.floor(rect.height)); + const frameSize = fitRealtimeFrameSize({ + containerWidth: rect.width, + containerHeight: rect.height, + ratio: realtimeAspectRatioRef.current, + }); + const cssWidth = frameSize.width; + const cssHeight = frameSize.height; if (cssWidth <= 1 || cssHeight <= 1) { return false; } @@ -2199,6 +2279,7 @@ export function RealtimeCanvasDialog({ configureCanvas(imageLayerCanvas); configureCanvas(canvas); configureCanvas(previewCanvas); + setRealtimeFrameSize(frameSize); if (drawingSnapshot) { await loadDrawingLayerFromDataUrl(drawingSnapshot); @@ -2470,18 +2551,40 @@ export function RealtimeCanvasDialog({ return true; }, [createPlacedLayer, initialImageUrl, markCanvasDirty, open, saveHistory]); - const initializeCanvas = useCallback(() => { + const initializeCanvas = useCallback(async (options?: { preserveContent?: boolean }) => { const canvas = canvasRef.current; const imageLayerCanvas = imageLayerCanvasRef.current; const previewCanvas = previewCanvasRef.current; const wrap = canvasWrapRef.current; if (!canvas || !imageLayerCanvas || !previewCanvas || !wrap) return; const rect = wrap.getBoundingClientRect(); + const frameSize = fitRealtimeFrameSize({ + containerWidth: rect.width, + containerHeight: rect.height, + ratio: realtimeAspectRatioRef.current, + }); const dpr = Math.max(1, window.devicePixelRatio || 1); - canvas.width = Math.max(640, Math.floor(rect.width * dpr)); - canvas.height = Math.max(480, Math.floor(rect.height * dpr)); - canvas.style.width = `${Math.floor(rect.width)}px`; - canvas.style.height = `${Math.floor(rect.height)}px`; + const previousCssWidth = Math.max( + 1, + Number.parseFloat(canvas.style.width) || frameSize.width + ); + const previousCssHeight = Math.max( + 1, + Number.parseFloat(canvas.style.height) || frameSize.height + ); + const preserveContent = options?.preserveContent === true; + const drawingSnapshot = + preserveContent && drawingCanvasHasContent(true) + ? exportDrawingLayerDataUrl() + : ""; + const layersSnapshot = preserveContent + ? cloneImageLayers(imageLayersRef.current) + : []; + + canvas.width = Math.max(1, Math.floor(frameSize.width * dpr)); + canvas.height = Math.max(1, Math.floor(frameSize.height * dpr)); + canvas.style.width = `${frameSize.width}px`; + canvas.style.height = `${frameSize.height}px`; imageLayerCanvas.width = canvas.width; imageLayerCanvas.height = canvas.height; imageLayerCanvas.style.width = canvas.style.width; @@ -2510,9 +2613,39 @@ export function RealtimeCanvasDialog({ imageLayerCanvas.height ); previewCtx.clearRect(0, 0, previewCanvas.width, previewCanvas.height); - resetCanvas(); - hasLoadedInitialImageRef.current = false; - }, [resetCanvas]); + setRealtimeFrameSize(frameSize); + + if (preserveContent) { + const nextLayers = scaleImageLayersToFrame({ + layers: layersSnapshot, + fromWidth: previousCssWidth, + fromHeight: previousCssHeight, + toWidth: frameSize.width, + toHeight: frameSize.height, + }); + imageLayersRef.current = nextLayers; + setImageLayers(nextLayers); + if (drawingSnapshot) { + await loadDrawingLayerFromDataUrl(drawingSnapshot); + } else { + clearDrawingCanvas(); + } + await renderImageLayers(); + markCanvasDirty(null); + } else { + resetCanvas(); + hasLoadedInitialImageRef.current = false; + } + hasInitializedRealtimeCanvasRef.current = true; + }, [ + clearDrawingCanvas, + drawingCanvasHasContent, + exportDrawingLayerDataUrl, + loadDrawingLayerFromDataUrl, + markCanvasDirty, + renderImageLayers, + resetCanvas, + ]); const getCanvasPoint = useCallback( (event: ReactPointerEvent) => { @@ -2949,6 +3082,7 @@ export function RealtimeCanvasDialog({ if (!open) return; seedRef.current = createSessionSeed(); sessionIdRef.current = `rt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + hasInitializedRealtimeCanvasRef.current = false; seqRef.current = 0; lastRenderedSeqRef.current = 0; requestStartMapRef.current.clear(); @@ -2964,19 +3098,37 @@ export function RealtimeCanvasDialog({ useEffect(() => { if (!open) return; - initializeCanvas(); - void drawInitialImage().then((hasInitialImage) => { - if (hasInitialImage) { - scheduleFlushRef.current(true, false); - } + void initializeCanvas().then(() => { + void drawInitialImage().then((hasInitialImage) => { + if (hasInitialImage) { + scheduleFlushRef.current(true, false); + } + }); }); - const onResize = () => initializeCanvas(); + const onResize = () => { + void initializeCanvas({ preserveContent: true }); + }; window.addEventListener("resize", onResize); return () => { window.removeEventListener("resize", onResize); }; }, [drawInitialImage, initializeCanvas, open]); + useEffect(() => { + if ( + !open || + workspaceMode !== "realtime" || + !hasInitializedRealtimeCanvasRef.current + ) { + return; + } + void initializeCanvas({ preserveContent: true }).then(() => { + if (realtimeGenerationModeRef.current === "auto") { + scheduleFlushRef.current(true, false, "refine"); + } + }); + }, [initializeCanvas, open, realtimeAspectRatio, workspaceMode]); + useEffect(() => { if (!open) { return; @@ -3281,9 +3433,20 @@ export function RealtimeCanvasDialog({ prompt: prompt.trim() || FALLBACK_PROMPT, title: "实时画布结果", model: "bfl/flux-2", + width: previewImageSize?.width || realtimeFrameSize.width || undefined, + height: previewImageSize?.height || realtimeFrameSize.height || undefined, }); onOpenChange(false); - }, [onApplyResult, onOpenChange, previewUrl, prompt]); + }, [ + onApplyResult, + onOpenChange, + previewImageSize?.height, + previewImageSize?.width, + previewUrl, + prompt, + realtimeFrameSize.height, + realtimeFrameSize.width, + ]); const handleApplyEnhanceResult = useCallback( (payload: { @@ -4000,14 +4163,25 @@ export function RealtimeCanvasDialog({ {/* Right: Output Preview */}
- {previewUrl ? ( - 实时预览 - ) : ( -
+
+
+ {previewUrl ? ( + 实时预览 + ) : ( +
{status === "connecting" ? ( <> @@ -4019,7 +4193,8 @@ export function RealtimeCanvasDialog({ )}
- )} + )} +
{previewUrl ? (
) : null} @@ -9088,6 +9254,7 @@ export function ImageNode({ item={imageBoxCutoutItem} /> {imageBoxCutoutItem.label} + ) : null} @@ -9122,6 +9289,10 @@ export function ImageNode({ item={imageRemoveWatermarkItem} /> {imageRemoveWatermarkItem.label} + ) : null} @@ -9156,6 +9327,10 @@ export function ImageNode({ item={imageMultiAngleItem} /> {imageMultiAngleItem.label} + ) : null} @@ -9173,6 +9348,10 @@ export function ImageNode({ item={imageGenerate3dModelItem} /> {imageGenerate3dModelItem.label} + ) : null} @@ -9190,6 +9369,10 @@ export function ImageNode({ item={imageGenerate3dWorldItem} /> {imageGenerate3dWorldItem.label} + ) : null} @@ -9224,6 +9407,7 @@ export function ImageNode({ item={imageExtendItem} /> {imageExtendItem.label} + ) : null} @@ -9292,6 +9476,10 @@ export function ImageNode({ item={imagePromptRegenerateItem} /> {imagePromptRegenerateItem.label} + ) : null} @@ -9346,7 +9534,8 @@ export function ImageNode({ fallback={Scan} item={imageBoxCutoutItem} /> - {imageBoxCutoutItem.label} + {imageBoxCutoutItem.label} + ) : null} {isSelectedImageActionInMore("localEdit") ? ( @@ -9366,7 +9555,10 @@ export function ImageNode({ fallback={Eraser} item={imageRemoveWatermarkItem} /> - {imageRemoveWatermarkItem.label} + {imageRemoveWatermarkItem.label} + ) : null} {isSelectedImageActionInMore("resize") ? ( @@ -9386,7 +9578,10 @@ export function ImageNode({ fallback={Box} item={imageMultiAngleItem} /> - {imageMultiAngleItem.label} + {imageMultiAngleItem.label} + ) : null} {isSelectedImageActionInMore("generate3dModel") ? ( @@ -9398,7 +9593,10 @@ export function ImageNode({ fallback={Box} item={imageGenerate3dModelItem} /> - {imageGenerate3dModelItem.label} + {imageGenerate3dModelItem.label} + ) : null} {isSelectedImageActionInMore("generate3dWorld") ? ( @@ -9410,7 +9608,8 @@ export function ImageNode({ fallback={Globe2} item={imageGenerate3dWorldItem} /> - {imageGenerate3dWorldItem.label} + {imageGenerate3dWorldItem.label} + ) : null} {isSelectedImageActionInMore("layerSplit") ? ( @@ -9430,7 +9629,8 @@ export function ImageNode({ fallback={Expand} item={imageExtendItem} /> - {imageExtendItem.label} + {imageExtendItem.label} + ) : null} {isSelectedImageActionInMore("crop") ? ( @@ -9472,7 +9672,10 @@ export function ImageNode({ fallback={RefreshCw} item={imagePromptRegenerateItem} /> - {imagePromptRegenerateItem.label} + {imagePromptRegenerateItem.label} + ) : null} {isSelectedImageActionInMore("note") ? ( @@ -10336,6 +10539,11 @@ export function ImageNode({ ) : ( <> 立即使用 + )} diff --git a/components/canvas/nodes/image-node/image-node-dialogs.tsx b/components/canvas/nodes/image-node/image-node-dialogs.tsx index 180fb57bd..c1f558860 100644 --- a/components/canvas/nodes/image-node/image-node-dialogs.tsx +++ b/components/canvas/nodes/image-node/image-node-dialogs.tsx @@ -1,6 +1,6 @@ "use client"; -import { Box, Globe2, Loader2, RefreshCw } from "lucide-react"; +import { Box, Globe2, Loader2, RefreshCw, Zap } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -38,6 +38,8 @@ interface PromptRegenerateDialogProps { } interface ThreeDGenerationDialogProps { + costLabel?: string; + costShortLabel?: string | null; isProcessing: boolean; mode: ThreeDGenerationDialogMode; onConfirm: () => void; @@ -108,6 +110,8 @@ export function PromptRegenerateDialog({ } export function ThreeDGenerationDialog({ + costLabel, + costShortLabel, isProcessing, mode, onConfirm, @@ -138,7 +142,7 @@ export function ThreeDGenerationDialog({ > @@ -189,6 +193,12 @@ export function ThreeDGenerationDialog({
{selectedEntry?.description || "当前模型暂无说明。"}
+ {costLabel ? ( +
+ + {costLabel} +
+ ) : null}
@@ -206,7 +216,13 @@ export function ThreeDGenerationDialog({ ) : ( )} - 开始生成 + 开始生成 + {costShortLabel ? ( + + + {costShortLabel} + + ) : null} diff --git a/components/canvas/world3d/World3DViewer.tsx b/components/canvas/world3d/World3DViewer.tsx index 706399b0b..5e4ff661a 100644 --- a/components/canvas/world3d/World3DViewer.tsx +++ b/components/canvas/world3d/World3DViewer.tsx @@ -109,6 +109,8 @@ const DEFAULT_MESH_SETTINGS: Model3DViewerSettings = { const WORLDLABS_ASSET_ROTATION: [number, number, number] = [Math.PI, 0, 0]; const WORLDLABS_REFERENCE_VIEW_AXIS = new THREE.Vector3(0, 0, 1); +const WORLDLABS_DISPLAY_GROUND_BIAS_RATIO = 0.08; +const WORLDLABS_DISPLAY_GROUND_HELPER_BIAS = 0.22; const EMPTY_MOVEMENT_STATE: MovementState = { forward: false, @@ -340,6 +342,10 @@ function getWorldLabsDisplayGroundPlaneOffset(value: unknown) { return -value; } +function getWorldLabsDisplayGroundHelperOffset(value: number | null) { + return value === null ? null : value - WORLDLABS_DISPLAY_GROUND_HELPER_BIAS; +} + function getLoadedSplatCount(splatMesh: SparkSplatMesh) { try { return Math.max( @@ -469,8 +475,16 @@ function positionCameraInsideWorld({ groundPlaneOffset <= worldBounds.max.y + size.y * 0.25 ? THREE.MathUtils.clamp(groundPlaneOffset, worldBounds.min.y, worldBounds.max.y) : null; - const estimatedFloorY = semanticGroundY ?? worldBounds.min.y + size.y * 0.06; - const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.18, 0.9, 1.48); + const displayFloorY = + semanticGroundY !== null + ? THREE.MathUtils.clamp( + semanticGroundY - size.y * WORLDLABS_DISPLAY_GROUND_BIAS_RATIO, + worldBounds.min.y, + worldBounds.max.y + ) + : worldBounds.min.y + size.y * 0.04; + const estimatedFloorY = displayFloorY; + const humanEyeOffset = THREE.MathUtils.clamp(size.y * 0.24, 1.05, 1.7); const rawEyeY = estimatedFloorY + humanEyeOffset; const minEyeY = worldBounds.min.y + Math.max(size.y * 0.12, 0.42); const maxEyeY = Math.min( @@ -483,9 +497,9 @@ function positionCameraInsideWorld({ : center.y - size.y * 0.08; const anchor = new THREE.Vector3(center.x, eyeY, center.z); const cameraDistance = THREE.MathUtils.clamp(majorSpan * 0.16, 0.7, 1.9); - const cameraPosition = anchor - .clone() - .addScaledVector(horizontalAxis, cameraDistance); + const cameraPosition = firstPerson + ? anchor.clone() + : anchor.clone().addScaledVector(horizontalAxis, cameraDistance); cameraPosition.x = clampInsideAxis( cameraPosition.x, @@ -502,7 +516,9 @@ function positionCameraInsideWorld({ center.z ); - const lookDirection = anchor.clone().sub(cameraPosition); + const lookDirection = firstPerson + ? horizontalAxis.clone() + : anchor.clone().sub(cameraPosition); if (lookDirection.lengthSq() < 0.0001) { lookDirection.copy(horizontalAxis).multiplyScalar(-1); } @@ -511,7 +527,7 @@ function positionCameraInsideWorld({ .clone() .addScaledVector(lookDirection, THREE.MathUtils.clamp(majorSpan * 0.24, 0.75, 2.4)); const targetDrop = firstPerson - ? THREE.MathUtils.clamp(humanEyeOffset * 0.46 + size.y * 0.035, 0.38, 0.95) + ? THREE.MathUtils.clamp(humanEyeOffset * 0.18 + size.y * 0.018, 0.16, 0.46) : THREE.MathUtils.clamp(size.y * 0.025, 0.04, 0.18); const minLookTargetY = Math.max( worldBounds.min.y + Math.max(size.y * 0.04, 0.08), @@ -830,6 +846,34 @@ function WorldSplatLoadingOverlay({ ); } +function stopViewerSurfaceWheel(event: React.SyntheticEvent) { + event.stopPropagation(); +} + +function stopViewerSurfaceContextMenu(event: React.SyntheticEvent) { + event.preventDefault(); + event.stopPropagation(); +} + +function WorldViewerInteractionSurface({ + className, + children, +}: { + className?: string; + children: React.ReactNode; +}) { + return ( +
+ {children} +
+ ); +} + async function readSplatFetchError(response: Response) { const fallback = `SPZ 请求失败(HTTP ${response.status})`; const contentType = response.headers.get("content-type") || ""; @@ -1337,6 +1381,7 @@ function WorldSplatScene({ const groundPlaneOffset = getWorldLabsDisplayGroundPlaneOffset( semanticsMetadata?.ground_plane_offset ); + const groundHelperOffset = getWorldLabsDisplayGroundHelperOffset(groundPlaneOffset); const handleOrbitControlsRef = React.useCallback((controls: any | null) => { orbitControlsRef.current = controls; setControlsMountVersion((version) => version + 1); @@ -1622,10 +1667,10 @@ function WorldSplatScene({ <> - {groundPlaneOffset !== null ? ( + {groundHelperOffset !== null ? ( ) : null} @@ -1661,7 +1706,7 @@ function WorldSplatScene({ autoRotate={false} dampingFactor={0.08} enableDamping - enablePan={false} + enablePan enableRotate enableZoom maxDistance={16} @@ -1670,10 +1715,16 @@ function WorldSplatScene({ mouseButtons={{ LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, - RIGHT: THREE.MOUSE.ROTATE, + RIGHT: THREE.MOUSE.PAN, }} + panSpeed={0.8} + screenSpacePanning minDistance={0.5} ref={handleOrbitControlsRef} + touches={{ + ONE: THREE.TOUCH.ROTATE, + TWO: THREE.TOUCH.DOLLY_PAN, + }} /> ) : null} @@ -2127,7 +2178,7 @@ export function World3DViewer({ if (mode === "mesh") { return ( -
+ -
+ ); } if (mode === "world") { return ( -
+ {worldUrl ? (