fix: restore Pascal 3D navigation and walls
This commit is contained in:
parent
603163d6ec
commit
f832e08293
@ -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> = {
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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' } : {}),
|
||||
})
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user