From f832e08293094a40de9f866b51d49af4ec786bfc Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 13 Jul 2026 10:55:31 +0800 Subject: [PATCH] fix: restore Pascal 3D navigation and walls --- .../packages/core/src/store/use-scene.ts | 23 +++++--- .../core/src/utils/heal-scene-graph.test.ts | 23 +++++++- .../core/src/utils/heal-scene-graph.ts | 42 ++++++++++++- .../editor/custom-camera-controls.tsx | 3 +- .../editor/first-person-controls.tsx | 24 +++++++- .../packages/editor/src/store/use-editor.tsx | 23 +++++++- .../packages/mcp/src/bridge/scene-bridge.ts | 6 +- .../viewer/src/systems/wall/wall-system.tsx | 59 ++++++++++++++++--- 8 files changed, 174 insertions(+), 29 deletions(-) diff --git a/services/pascal-editor/packages/core/src/store/use-scene.ts b/services/pascal-editor/packages/core/src/store/use-scene.ts index 643065edc..637ff80d3 100644 --- a/services/pascal-editor/packages/core/src/store/use-scene.ts +++ b/services/pascal-editor/packages/core/src/store/use-scene.ts @@ -1057,17 +1057,20 @@ const useScene: UseSceneStore = create()( } } + const dirtyNodes = new Set() + for (const node of Object.values(cleanedNodes)) { + if (nodeRegistry.get(node.type)?.dirtyTracking !== false) { + dirtyNodes.add(node.id) + } + } + set({ nodes: cleanedNodes, rootNodeIds: normalizedRootNodeIds, - dirtyNodes: new Set(), + dirtyNodes, collections: extra?.collections ?? {}, materials, }) - // Mark all nodes as dirty to trigger re-validation - Object.values(cleanedNodes).forEach((node) => { - get().markDirty(node.id) - }) }, loadScene: () => { @@ -1080,18 +1083,20 @@ const useScene: UseSceneStore = create()( } // Create hierarchy: Site → Building → Level - const level0 = LevelNode.parse({ + const parsedLevel0 = LevelNode.parse({ level: 0, children: [], }) - const building = BuildingNode.parse({ - children: [level0.id], + const parsedBuilding = BuildingNode.parse({ + children: [parsedLevel0.id], }) const site = SiteNode.parse({ - children: [building.id], + children: [parsedBuilding.id], }) + const building = { ...parsedBuilding, parentId: site.id } as BuildingNode + const level0 = { ...parsedLevel0, parentId: building.id } as LevelNode // Define all nodes flat const nodes: Record = { diff --git a/services/pascal-editor/packages/core/src/utils/heal-scene-graph.test.ts b/services/pascal-editor/packages/core/src/utils/heal-scene-graph.test.ts index 699d9ce1b..3697c1a5a 100644 --- a/services/pascal-editor/packages/core/src/utils/heal-scene-graph.test.ts +++ b/services/pascal-editor/packages/core/src/utils/heal-scene-graph.test.ts @@ -76,7 +76,7 @@ describe('healSceneNodes', () => { expect((nodes.wall_a as { children: string[] }).children).toEqual(['door_1']) }) - test('keeps children whose parentId matches or is absent', () => { + test('keeps matching parents and repairs an absent parentId', () => { const input = { wall_a: { id: 'wall_a', @@ -88,8 +88,27 @@ describe('healSceneNodes', () => { door_1: { id: 'door_1', type: 'door', parentId: 'wall_a' }, item_legacy: { id: 'item_legacy', type: 'item' }, } - const { nodes, strippedStaleChildRefs } = healSceneNodes(input) + const { nodes, repairedParentIds, strippedStaleChildRefs } = healSceneNodes(input) expect(strippedStaleChildRefs).toBe(0) expect(nodes.wall_a).toBe(input.wall_a) + expect(repairedParentIds).toEqual(['item_legacy']) + expect((nodes.item_legacy as { parentId: string }).parentId).toBe('wall_a') + }) + + test('repairs the site-building-level chain emitted by older MCP tools', () => { + const { nodes, repairedParentIds } = healSceneNodes({ + site_1: { id: 'site_1', type: 'site', parentId: null, children: ['building_1'] }, + building_1: { + id: 'building_1', + type: 'building', + parentId: null, + children: ['level_1'], + }, + level_1: { id: 'level_1', type: 'level', parentId: null, children: [] }, + }) + + expect(repairedParentIds).toEqual(['building_1', 'level_1']) + expect((nodes.building_1 as { parentId: string }).parentId).toBe('site_1') + expect((nodes.level_1 as { parentId: string }).parentId).toBe('building_1') }) }) diff --git a/services/pascal-editor/packages/core/src/utils/heal-scene-graph.ts b/services/pascal-editor/packages/core/src/utils/heal-scene-graph.ts index 80780b2b2..8c95efb54 100644 --- a/services/pascal-editor/packages/core/src/utils/heal-scene-graph.ts +++ b/services/pascal-editor/packages/core/src/utils/heal-scene-graph.ts @@ -13,6 +13,10 @@ // duplicate reference renders the child twice (duplicate React keys in the // 2D plan, doubled hosted geometry in 3D). Same-array duplicates are // collapsed too. +// 4. A child listed by exactly one parent while its own `parentId` is absent. +// Older MCP scene writers updated the parent's `children` array but left +// building/level `parentId` null, which breaks hierarchy-dependent 3D +// systems even though recursive rendering still appears to work. // // All are also prevented at the source now; this is the load-time safety net // for already-saved scenes. @@ -30,6 +34,8 @@ export interface HealSceneResult { * a different node (stale reparent leftovers), plus same-array duplicates. */ strippedStaleChildRefs: number + /** Ids whose missing `parentId` was recovered from a parent's children. */ + repairedParentIds: string[] } function isWallLike(node: unknown): node is { start: [number, number]; end: [number, number] } { @@ -71,6 +77,34 @@ export function healSceneNodes(input: Record): HealSceneResult } const dropped = new Set(droppedWallIds) + const inferredParents = new Map() + const ambiguousParents = new Set() + + for (const [parentId, parent] of Object.entries(kept)) { + const children = (parent as { children?: unknown })?.children + if (!Array.isArray(children)) continue + for (const childId of children) { + if (typeof childId !== 'string' || dropped.has(childId)) continue + const child = kept[childId] as { parentId?: unknown } | undefined + if (!(child && (child.parentId === null || child.parentId === undefined))) continue + const previousParentId = inferredParents.get(childId) + if (previousParentId && previousParentId !== parentId) { + ambiguousParents.add(childId) + } else { + inferredParents.set(childId, parentId) + } + } + } + + const repairedParentIds: string[] = [] + for (const [childId, parentId] of inferredParents) { + if (ambiguousParents.has(childId)) continue + const child = kept[childId] as Record | undefined + if (!child) continue + kept[childId] = { ...child, parentId } + repairedParentIds.push(childId) + } + let strippedChildRefs = 0 let strippedStaleChildRefs = 0 @@ -107,5 +141,11 @@ export function healSceneNodes(input: Record): HealSceneResult nodes[id] = node } - return { nodes, droppedWallIds, strippedChildRefs, strippedStaleChildRefs } + return { + nodes, + droppedWallIds, + strippedChildRefs, + strippedStaleChildRefs, + repairedParentIds, + } } diff --git a/services/pascal-editor/packages/editor/src/components/editor/custom-camera-controls.tsx b/services/pascal-editor/packages/editor/src/components/editor/custom-camera-controls.tsx index bc435956a..93bd81dc1 100644 --- a/services/pascal-editor/packages/editor/src/components/editor/custom-camera-controls.tsx +++ b/services/pascal-editor/packages/editor/src/components/editor/custom-camera-controls.tsx @@ -355,6 +355,7 @@ export const CustomCameraControls = () => { }) const isPreviewMode = useEditor((s) => s.isPreviewMode) const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode) + const isFirstPersonNavigationReady = useEditor((s) => s.isFirstPersonNavigationReady) const allowUndergroundCamera = useEditor((s) => s.allowUndergroundCamera) const isFloorplanOpen = useEditor((s) => s.isFloorplanOpen) const selection = useViewer((s) => s.selection) @@ -1182,7 +1183,7 @@ export const CustomCameraControls = () => { const isPresetCapture = captureMode.mode === 'preset' const minDistance = isPresetCapture ? 0.5 : 2 - if (isFirstPersonMode) { + if (isFirstPersonMode && isFirstPersonNavigationReady) { return null } diff --git a/services/pascal-editor/packages/editor/src/components/editor/first-person-controls.tsx b/services/pascal-editor/packages/editor/src/components/editor/first-person-controls.tsx index ce3ec96b9..08c475ef1 100644 --- a/services/pascal-editor/packages/editor/src/components/editor/first-person-controls.tsx +++ b/services/pascal-editor/packages/editor/src/components/editor/first-person-controls.tsx @@ -569,6 +569,12 @@ export const FirstPersonControls = () => { position: [number, number, number] yaw: number } | null>(null) + const navigationReady = Boolean(world && controllerStart) + + useEffect(() => { + useEditor.getState().setFirstPersonNavigationReady(navigationReady) + return () => useEditor.getState().setFirstPersonNavigationReady(false) + }, [navigationReady]) const replaceColliderWorld = useCallback((nextWorld: FirstPersonColliderWorld | null) => { worldRef.current?.dispose() @@ -962,14 +968,16 @@ export const FirstPersonControls = () => { }, [camera, controllerStart, placedSpawn, world]) useEffect(() => { + if (!navigationReady) return const canvas = gl.domElement focusFirstPersonCanvas(canvas) const frame = window.requestAnimationFrame(() => focusFirstPersonCanvas(canvas)) return () => window.cancelAnimationFrame(frame) - }, [gl]) + }, [gl, navigationReady]) useEffect(() => { + if (!navigationReady) return const canvas = gl.domElement const handleMouseMove = (e: MouseEvent) => { if (document.pointerLockElement !== canvas) return @@ -1026,9 +1034,10 @@ export const FirstPersonControls = () => { document.exitPointerLock() } } - }, [gl, toggleInteractableTarget]) + }, [gl, navigationReady, toggleInteractableTarget]) useEffect(() => { + if (!navigationReady) return const canvas = gl.domElement const applyMovementKey = (event: KeyboardEvent, active: boolean) => { @@ -1096,7 +1105,7 @@ export const FirstPersonControls = () => { window.removeEventListener('blur', resetMovement) resetMovement() } - }, [closeInteractableTarget, gl, toggleInteractableTarget]) + }, [closeInteractableTarget, gl, navigationReady, toggleInteractableTarget]) const syncElevatorColliderMeshes = useCallback(() => { const nodes = useScene.getState().nodes @@ -1429,6 +1438,7 @@ export const FirstPersonControls = () => { export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { const [isLocked, setIsLocked] = useState(false) const [isHintVisible, setIsHintVisible] = useState(true) + const isNavigationReady = useEditor((state) => state.isFirstPersonNavigationReady) const hasPlacedSpawn = useScene((state) => Object.values(state.nodes).some((node) => node.type === 'spawn'), ) @@ -1506,6 +1516,14 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => { )} + {!isNavigationReady && ( +
+
+ 正在准备漫游,可继续旋转和缩放视图 +
+
+ )} + {isLocked && isHintVisible && (
diff --git a/services/pascal-editor/packages/editor/src/store/use-editor.tsx b/services/pascal-editor/packages/editor/src/store/use-editor.tsx index 9891a6f99..572af39fb 100644 --- a/services/pascal-editor/packages/editor/src/store/use-editor.tsx +++ b/services/pascal-editor/packages/editor/src/store/use-editor.tsx @@ -13,6 +13,7 @@ import { type DormerNode, type DormerSurfaceMaterialRole, type ElevatorNode, + emitter, type FenceNode, type ItemNode, type LevelNode, @@ -427,6 +428,8 @@ type EditorState = { setShow2dVoronoi: (enabled: boolean) => void // First-person walkthrough mode (street view) isFirstPersonMode: boolean + isFirstPersonNavigationReady: boolean + setFirstPersonNavigationReady: (ready: boolean) => void _viewModeBeforeFirstPerson: ViewMode | null setFirstPersonMode: (enabled: boolean) => void // Workspace mode: 'edit' is the full editing surface; 'studio' is the @@ -1080,7 +1083,21 @@ const useEditor = create()( }) }, viewMode: DEFAULT_PERSISTED_EDITOR_UI_STATE.viewMode, - setViewMode: (mode) => set({ viewMode: mode, isFloorplanOpen: mode !== '3d' }), + setViewMode: (mode) => { + if (mode === '3d' && get().viewMode !== '3d') { + emitter.emit('tool:cancel') + useInteractionScope.getState().end() + set({ + viewMode: mode, + isFloorplanOpen: false, + floorplanSelectionTool: 'click', + mode: 'select', + tool: null, + }) + return + } + set({ viewMode: mode, isFloorplanOpen: true }) + }, splitOrientation: DEFAULT_PERSISTED_EDITOR_LAYOUT_STATE.splitOrientation, setSplitOrientation: (orientation) => set({ splitOrientation: orientation }), isFloorplanOpen: DEFAULT_PERSISTED_EDITOR_UI_STATE.isFloorplanOpen, @@ -1164,12 +1181,15 @@ const useEditor = create()( show2dVoronoi: false, setShow2dVoronoi: (enabled) => set({ show2dVoronoi: enabled }), isFirstPersonMode: false, + isFirstPersonNavigationReady: false, + setFirstPersonNavigationReady: (ready) => set({ isFirstPersonNavigationReady: ready }), _viewModeBeforeFirstPerson: null as ViewMode | null, setFirstPersonMode: (enabled) => { if (enabled) { const currentViewMode = get().viewMode set({ isFirstPersonMode: true, + isFirstPersonNavigationReady: false, _viewModeBeforeFirstPerson: currentViewMode, viewMode: '3d', isFloorplanOpen: false, @@ -1181,6 +1201,7 @@ const useEditor = create()( const prevMode = get()._viewModeBeforeFirstPerson set({ isFirstPersonMode: false, + isFirstPersonNavigationReady: false, _viewModeBeforeFirstPerson: null, ...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}), }) diff --git a/services/pascal-editor/packages/mcp/src/bridge/scene-bridge.ts b/services/pascal-editor/packages/mcp/src/bridge/scene-bridge.ts index 9598f9979..3127b6e8d 100644 --- a/services/pascal-editor/packages/mcp/src/bridge/scene-bridge.ts +++ b/services/pascal-editor/packages/mcp/src/bridge/scene-bridge.ts @@ -144,9 +144,9 @@ export class SceneBridge { * 3. Parent has `children: Array` (the SiteNode quirk — see * PLAN §0.7). We resolve each object to its flat-dict entry by `id`. * - * The `loadScene()` default assembler skips the store mutation paths so the - * default site/building/level tree has `parentId === null` on every node — - * only the `children` arrays reflect the hierarchy. + * Older scenes may still contain a default site/building/level tree whose + * hierarchy exists only in `children`; the load-time graph healer repairs + * those parent IDs, while this fallback keeps pre-repair callers readable. * * Results are de-duplicated by id, in flat-dict iteration order. */ diff --git a/services/pascal-editor/packages/viewer/src/systems/wall/wall-system.tsx b/services/pascal-editor/packages/viewer/src/systems/wall/wall-system.tsx index 824dc21b5..4d204be35 100644 --- a/services/pascal-editor/packages/viewer/src/systems/wall/wall-system.tsx +++ b/services/pascal-editor/packages/viewer/src/systems/wall/wall-system.tsx @@ -330,6 +330,7 @@ const WALL_PROGRESSIVE_DIRTY_THRESHOLD = MAX_WALL_REBUILDS_PER_FRAME const WALL_PROGRESSIVE_TIME_BUDGET_MS = 8 let lastWallDirtyAtMs = 0 const pendingAdjacentByLevel = new Map>() +const wallBuildFailures = new Map() function getPendingAdjacentCount() { let count = 0 @@ -416,10 +417,11 @@ export const WallSystem = () => { const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh if (mesh) { - updateWallGeometry(wallId, miterData) - clearDirty(wallId as AnyNodeId) - rebuiltWallIds.add(wallId) - rebuiltWallsThisFrame += 1 + if (rebuildWallGeometrySafely(wallId, miterData)) { + clearDirty(wallId as AnyNodeId) + rebuiltWallIds.add(wallId) + rebuiltWallsThisFrame += 1 + } } // If mesh not found, keep it dirty for next frame } @@ -471,9 +473,10 @@ export const WallSystem = () => { } const mesh = sceneRegistry.nodes.get(wallId) as THREE.Mesh - if (mesh) updateWallGeometry(wallId, miterData) - pendingIds.delete(wallId) - rebuiltAdjacentThisFrame += 1 + if (mesh && rebuildWallGeometrySafely(wallId, miterData)) { + pendingIds.delete(wallId) + rebuiltAdjacentThisFrame += 1 + } } if (pendingIds.size === 0) { @@ -528,12 +531,45 @@ function getLevelWalls(levelId: string): WallNode[] { return walls } +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : String(error) +} + +function logWallBuildFailure(wallId: string, primaryError: unknown, fallbackError?: unknown) { + const message = fallbackError + ? `${errorMessage(primaryError)}; fallback: ${errorMessage(fallbackError)}` + : errorMessage(primaryError) + if (wallBuildFailures.get(wallId) === message) return + wallBuildFailures.set(wallId, message) + console.error(`[WallSystem] Failed to rebuild ${wallId}: ${message}`) +} + +function rebuildWallGeometrySafely(wallId: string, miterData: WallMiterData): boolean { + try { + updateWallGeometry(wallId, miterData, true) + wallBuildFailures.delete(wallId) + return true + } catch (primaryError) { + try { + // A malformed or not-yet-mounted opening must not make the wall itself + // disappear or block every later wall in this frame. Render the shell + // now; door/window systems will dirty it again once their geometry is ready. + updateWallGeometry(wallId, miterData, false) + logWallBuildFailure(wallId, primaryError) + return true + } catch (fallbackError) { + logWallBuildFailure(wallId, primaryError, fallbackError) + return false + } + } +} + /** * Updates the geometry for a single wall. Reads the effective node * (override-merged) so a 2D drag visibly moves the 3D mesh without * having touched `useScene` mid-drag. */ -function updateWallGeometry(wallId: string, miterData: WallMiterData) { +function updateWallGeometry(wallId: string, miterData: WallMiterData, includeOpenings: boolean) { const nodes = useScene.getState().nodes const sceneNode = nodes[wallId as WallNode['id']] if (sceneNode?.type !== 'wall') return @@ -551,7 +587,7 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { node.thickness, ) - const childrenIds = node.children || [] + const childrenIds = includeOpenings ? node.children || [] : [] // Merge live overrides into door / window children so cutouts track an // in-flight resize drag (door width arrow, window height arrow, etc.) // without waiting on the scene store. Non-cutout children pass through @@ -573,6 +609,11 @@ function updateWallGeometry(wallId: string, miterData: WallMiterData) { }) const builtGeo = generateExtrudedWall(node, childrenNodes, miterData, slabElevation) + const builtPositions = builtGeo.getAttribute('position') + if (!builtPositions || builtPositions.count < 3) { + builtGeo.dispose() + throw new Error('generated wall geometry is empty') + } const wallAngle = Math.atan2(node.end[1] - node.start[1], node.end[0] - node.start[0]) // World transform the render mesh will apply (position + Y-rotation below). // Reproduce it here so the UVs can be projected in WORLD space — see