fix: restore Pascal 3D navigation and walls

This commit is contained in:
134 2026-07-13 10:55:31 +08:00
parent 603163d6ec
commit f832e08293
8 changed files with 174 additions and 29 deletions

View File

@ -1057,17 +1057,20 @@ const useScene: UseSceneStore = create<SceneState>()(
}
}
const dirtyNodes = new Set<AnyNodeId>()
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<AnyNodeId>(),
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<SceneState>()(
}
// 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<AnyNodeId, AnyNode> = {

View File

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

View File

@ -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<string, unknown>): HealSceneResult
}
const dropped = new Set(droppedWallIds)
const inferredParents = new Map<string, string>()
const ambiguousParents = new Set<string>()
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<string, unknown> | 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<string, unknown>): HealSceneResult
nodes[id] = node
}
return { nodes, droppedWallIds, strippedChildRefs, strippedStaleChildRefs }
return {
nodes,
droppedWallIds,
strippedChildRefs,
strippedStaleChildRefs,
repairedParentIds,
}
}

View File

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

View File

@ -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 }) => {
</div>
)}
{!isNavigationReady && (
<div className="pointer-events-none absolute top-4 left-1/2 z-50 -translate-x-1/2">
<div className="rounded-xl border border-border/40 bg-background/90 px-4 py-2 text-center text-foreground text-sm shadow-lg backdrop-blur-xl">
正在准备漫游,可继续旋转和缩放视图
</div>
</div>
)}
{isLocked && isHintVisible && (
<div className="pointer-events-none absolute top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">

View File

@ -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<EditorState>()(
})
},
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<EditorState>()(
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<EditorState>()(
const prevMode = get()._viewModeBeforeFirstPerson
set({
isFirstPersonMode: false,
isFirstPersonNavigationReady: false,
_viewModeBeforeFirstPerson: null,
...(prevMode ? { viewMode: prevMode, isFloorplanOpen: prevMode !== '3d' } : {}),
})

View File

@ -144,9 +144,9 @@ export class SceneBridge {
* 3. Parent has `children: Array<node-object>` (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.
*/

View File

@ -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<string, Set<string>>()
const wallBuildFailures = new Map<string, string>()
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