sync: comprehensive monorepo → editor parity (2D/3D decoupling, UX polish, crash fixes)
Squash merge of 3 commits: 1. useLiveTransforms store for 2D/3D decoupling + floorplan overhaul + Sentry crash fixes 2. Comprehensive 59-file sync bringing editor to full monorepo parity (selection highlights, delete tool, furnish/zone modes, keyboard shortcuts, all panels) 3. Missing files fix (materials.ts, merged-outline-node.ts, type fix) 75 files changed, ~6K additions.
This commit is contained in:
@@ -14,7 +14,7 @@ const lineY = smoothstep(lineWidth, 0, gridY).add(smoothstep(1.0 - lineWidth, 1.
|
||||
const gridPattern = lineX.max(lineY)
|
||||
const gridOpacity = mix(float(0.2), float(0.6), gridPattern)
|
||||
|
||||
function createCeilingMaterials(color: string = '#999999') {
|
||||
function createCeilingMaterials(color = '#999999') {
|
||||
const topMaterial = new MeshBasicNodeMaterial({
|
||||
color,
|
||||
transparent: true,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
type AnimationEffect,
|
||||
type AnyNodeId,
|
||||
baseMaterial,
|
||||
glassMaterial,
|
||||
type Interactive,
|
||||
type ItemNode,
|
||||
type LightEffect,
|
||||
@@ -16,36 +18,18 @@ import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
import type { AnimationAction, Group, Material, Mesh } from 'three'
|
||||
import { MathUtils } from 'three'
|
||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { resolveCdnUrl } from '../../../lib/asset-url'
|
||||
import { useItemLightPool } from '../../../store/use-item-light-pool'
|
||||
import { ErrorBoundary } from '../../error-boundary'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
// Shared materials to avoid creating new instances for every mesh
|
||||
const defaultMaterial = new MeshStandardNodeMaterial({
|
||||
color: 0xff_ff_ff,
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
})
|
||||
|
||||
const glassMaterial = new MeshStandardNodeMaterial({
|
||||
name: 'glass',
|
||||
color: 'lightgray',
|
||||
roughness: 0.8,
|
||||
metalness: 0,
|
||||
transparent: true,
|
||||
opacity: 0.35,
|
||||
side: DoubleSide,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
const getMaterialForOriginal = (original: Material): MeshStandardNodeMaterial => {
|
||||
if (original.name.toLowerCase() === 'glass') {
|
||||
return glassMaterial
|
||||
}
|
||||
return defaultMaterial
|
||||
return baseMaterial
|
||||
}
|
||||
|
||||
const BrokenItemFallback = ({ node }: { node: ItemNode }) => {
|
||||
@@ -145,6 +129,18 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material = mesh.material.map((mat) => getMaterialForOriginal(mat))
|
||||
hasGlass = mesh.material.some((mat) => mat.name === 'glass')
|
||||
|
||||
// Fix geometry groups that reference materialIndex beyond the material
|
||||
// array length — this causes three-mesh-bvh to crash with
|
||||
// "Cannot read properties of undefined (reading 'side')"
|
||||
const matCount = mesh.material.length
|
||||
if (mesh.geometry.groups.length > 0) {
|
||||
for (const group of mesh.geometry.groups) {
|
||||
if (group.materialIndex !== undefined && group.materialIndex >= matCount) {
|
||||
group.materialIndex = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
mesh.material = getMaterialForOriginal(mesh.material)
|
||||
hasGlass = mesh.material.name === 'glass'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { type SiteNode, useRegistry } from '@pascal-app/core'
|
||||
import { type SiteNode, type SlabNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import polygonClipping from 'polygon-clipping'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Shape } from 'three'
|
||||
import { BufferGeometry, Float32BufferAttribute, type Group, Path, Shape } from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import useViewer from '../../../store/use-viewer'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
|
||||
const Y_OFFSET = 0.01
|
||||
@@ -29,29 +31,76 @@ const createBoundaryLineGeometry = (points: Array<[number, number]>): BufferGeom
|
||||
return geometry
|
||||
}
|
||||
|
||||
type S = ReturnType<typeof useScene.getState>
|
||||
|
||||
export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
const ref = useRef<Group>(null!)
|
||||
|
||||
useRegistry(node.id, 'site', ref)
|
||||
|
||||
// Create floor shape from polygon points
|
||||
const floorShape = useMemo(() => {
|
||||
const theme = useViewer((state) => state.theme)
|
||||
const bgColor = theme === 'dark' ? '#1f2433' : '#fafafa'
|
||||
|
||||
// Cache slab polygon references to keep the selector stable across unrelated store updates
|
||||
const slabPolygonsCache = useRef<[number, number][][]>([])
|
||||
const slabPolygons = useScene((state: S) => {
|
||||
const nodeList = Object.values(state.nodes)
|
||||
|
||||
const levelIndexById = new Map<string, number>()
|
||||
let lowestLevelIndex = Number.POSITIVE_INFINITY
|
||||
nodeList.forEach((n) => {
|
||||
if (n.type !== 'level') return
|
||||
levelIndexById.set(n.id, n.level)
|
||||
lowestLevelIndex = Math.min(lowestLevelIndex, n.level)
|
||||
})
|
||||
|
||||
const next = nodeList
|
||||
.filter((n): n is SlabNode => n.type === 'slab' && n.visible && n.polygon.length >= 3)
|
||||
.filter((n) => {
|
||||
if (!Number.isFinite(lowestLevelIndex)) return true
|
||||
const parentLevel = n.parentId ? levelIndexById.get(n.parentId as string) : undefined
|
||||
return parentLevel === lowestLevelIndex
|
||||
})
|
||||
.map((n) => n.polygon as [number, number][])
|
||||
|
||||
const prev = slabPolygonsCache.current
|
||||
if (next.length === prev.length && next.every((p, i) => p === prev[i])) return prev
|
||||
slabPolygonsCache.current = next
|
||||
return next
|
||||
})
|
||||
|
||||
// Ground shape: site polygon with slab footprints punched as holes
|
||||
const groundShape = useMemo(() => {
|
||||
if (!node?.polygon?.points || node.polygon.points.length < 3) return null
|
||||
|
||||
const pts = node.polygon.points
|
||||
const shape = new Shape()
|
||||
const firstPt = node.polygon.points[0]!
|
||||
|
||||
// Shape is in X-Y plane, we rotate it to X-Z plane
|
||||
// Negate Y (which becomes Z) to get correct orientation
|
||||
shape.moveTo(firstPt[0]!, -firstPt[1]!)
|
||||
|
||||
for (let i = 1; i < node.polygon.points.length; i++) {
|
||||
const pt = node.polygon.points[i]!
|
||||
shape.lineTo(pt[0]!, -pt[1]!)
|
||||
}
|
||||
shape.moveTo(pts[0]![0], -pts[0]![1])
|
||||
for (let i = 1; i < pts.length; i++) shape.lineTo(pts[i]![0], -pts[i]![1])
|
||||
shape.closePath()
|
||||
|
||||
if (slabPolygons.length > 0) {
|
||||
const multiPolygons = slabPolygons.map((p) => [
|
||||
p.map((pt) => [pt[0], -pt[1]] as [number, number]),
|
||||
])
|
||||
const unioned = polygonClipping.union(
|
||||
multiPolygons[0] as polygonClipping.Polygon,
|
||||
...(multiPolygons.slice(1) as polygonClipping.Polygon[]),
|
||||
)
|
||||
for (const geom of unioned) {
|
||||
const ring = geom[0]
|
||||
if (ring && ring.length > 0) {
|
||||
const hole = new Path()
|
||||
hole.moveTo(ring[0]![0], ring[0]![1])
|
||||
for (let i = 1; i < ring.length; i++) hole.lineTo(ring[i]![0], ring[i]![1])
|
||||
hole.closePath()
|
||||
shape.holes.push(hole)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return shape
|
||||
}, [node?.polygon?.points])
|
||||
}, [node?.polygon?.points, slabPolygons])
|
||||
|
||||
// Create boundary line geometry
|
||||
const lineGeometry = useMemo(() => {
|
||||
@@ -61,7 +110,7 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
|
||||
const handlers = useNodeEvents(node, 'site')
|
||||
|
||||
if (!(node && floorShape && lineGeometry)) {
|
||||
if (!(node && lineGeometry)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -75,11 +124,19 @@ export const SiteRenderer = ({ node }: { node: SiteNode }) => {
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Transparent floor fill */}
|
||||
<mesh position={[0, Y_OFFSET - 0.005, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<shapeGeometry args={[floorShape]} />
|
||||
<shadowMaterial opacity={0.75} transparent />
|
||||
</mesh>
|
||||
{/* Ground fill: site polygon with slab holes, occludes below-grade geometry */}
|
||||
{groundShape && (
|
||||
<mesh position={[0, -0.05, 0]} receiveShadow rotation={[-Math.PI / 2, 0, 0]}>
|
||||
<shapeGeometry args={[groundShape]} />
|
||||
<meshStandardMaterial
|
||||
color={bgColor}
|
||||
depthWrite={true}
|
||||
polygonOffset={true}
|
||||
polygonOffsetFactor={1}
|
||||
polygonOffsetUnits={1}
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Simple boundary line */}
|
||||
{/* @ts-ignore */}
|
||||
|
||||
@@ -23,8 +23,8 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
{...handlers}
|
||||
visible={node.visible}
|
||||
material={material}
|
||||
visible={node.visible}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const WallRenderer = ({ node }: { node: WallNode }) => {
|
||||
}, [node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
|
||||
return (
|
||||
<mesh castShadow receiveShadow ref={ref} visible={node.visible} material={material}>
|
||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
<mesh name="collision-mesh" visible={false} {...handlers}>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
'use client'
|
||||
|
||||
// Must run before @react-three/fiber's Canvas instantiates new THREE.Clock().
|
||||
// See lib/suppress-three-clock-warning.ts for rationale and removal condition.
|
||||
import '../../lib/suppress-three-clock-warning'
|
||||
|
||||
import {
|
||||
CeilingSystem,
|
||||
DoorSystem,
|
||||
@@ -19,7 +15,6 @@ import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@re
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three/webgpu'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { ExportSystem } from '../../systems/export/export-system'
|
||||
import { GuideSystem } from '../../systems/guide/guide-system'
|
||||
import { ItemLightSystem } from '../../systems/item-light/item-light-system'
|
||||
import { LevelSystem } from '../../systems/level/level-system'
|
||||
@@ -151,7 +146,6 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
<WallSystem />
|
||||
<WindowSystem />
|
||||
<ZoneSystem />
|
||||
<ExportSystem />
|
||||
<PostProcessing />
|
||||
{/* <DebugRenderer /> */}
|
||||
<GPUDeviceWatcher />
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Color, Layers, UnsignedByteType } from 'three'
|
||||
import { outline } from 'three/addons/tsl/display/OutlineNode.js'
|
||||
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
||||
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
||||
import {
|
||||
@@ -23,6 +22,7 @@ import {
|
||||
} from 'three/tsl'
|
||||
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
|
||||
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
|
||||
import { mergedOutline } from '../../lib/merged-outline-node'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
// SSGI Parameters - adjust these to fine-tune global illumination and ambient occlusion
|
||||
@@ -67,6 +67,7 @@ const PostProcessingPasses = () => {
|
||||
l.disable(SCENE_LAYER)
|
||||
return l
|
||||
}, [])
|
||||
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
||||
|
||||
// Subscribe to projectId so the pipeline rebuilds on project switch
|
||||
const projectId = useViewer((s) => s.projectId)
|
||||
@@ -197,60 +198,45 @@ const PostProcessingPasses = () => {
|
||||
)
|
||||
}
|
||||
|
||||
function generateSelectedOutlinePass() {
|
||||
const edgeStrength = uniform(3)
|
||||
const edgeGlow = uniform(0)
|
||||
const edgeThickness = uniform(1)
|
||||
const visibleEdgeColor = uniform(new Color(0xff_ff_ff))
|
||||
const hiddenEdgeColor = uniform(new Color(0xf3_ff_47))
|
||||
// Single merged outline node: one shared depth pass for both selected + hovered groups.
|
||||
const outliner = useViewer.getState().outliner
|
||||
const outlineNode = mergedOutline(scene, camera, {
|
||||
primaryObjects: outliner.selectedObjects,
|
||||
secondaryObjects: outliner.hoveredObjects,
|
||||
primaryEdgeThickness: uniform(1),
|
||||
secondaryEdgeThickness: uniform(1.5),
|
||||
})
|
||||
|
||||
const outlinePass = outline(scene, camera, {
|
||||
selectedObjects: useViewer.getState().outliner.selectedObjects,
|
||||
edgeGlow,
|
||||
edgeThickness,
|
||||
})
|
||||
const { visibleEdge, hiddenEdge } = outlinePass
|
||||
// Selected: white visible, yellow hidden
|
||||
const selectedVisibleColor = uniform(new Color(0xff_ff_ff))
|
||||
const selectedHiddenColor = uniform(new Color(0xf3_ff_47))
|
||||
const selectedStrength = uniform(3)
|
||||
const selectedOutline = outlineNode.primaryVisibleEdge
|
||||
.mul(selectedVisibleColor)
|
||||
.add(outlineNode.primaryHiddenEdge.mul(selectedHiddenColor))
|
||||
.mul(selectedStrength)
|
||||
|
||||
const outlineColor = visibleEdge
|
||||
.mul(visibleEdgeColor)
|
||||
.add(hiddenEdge.mul(hiddenEdgeColor))
|
||||
.mul(edgeStrength)
|
||||
|
||||
return outlineColor
|
||||
}
|
||||
|
||||
function generateHoverOutlinePass() {
|
||||
const edgeStrength = uniform(5)
|
||||
const edgeGlow = uniform(0.5)
|
||||
const edgeThickness = uniform(1.5)
|
||||
const pulsePeriod = uniform(3)
|
||||
const visibleEdgeColor = uniform(new Color(0x00_aa_ff))
|
||||
const hiddenEdgeColor = uniform(new Color(0xf3_ff_47))
|
||||
|
||||
const outlinePass = outline(scene, camera, {
|
||||
selectedObjects: useViewer.getState().outliner.hoveredObjects,
|
||||
edgeGlow,
|
||||
edgeThickness,
|
||||
})
|
||||
const { visibleEdge, hiddenEdge } = outlinePass
|
||||
|
||||
const period = time.div(pulsePeriod).mul(2)
|
||||
const osc = oscSine(period).mul(0.5).add(0.5) // osc [ 0.5, 1.0 ]
|
||||
|
||||
const outlineColor = visibleEdge
|
||||
.mul(visibleEdgeColor)
|
||||
.add(hiddenEdge.mul(hiddenEdgeColor))
|
||||
.mul(edgeStrength)
|
||||
const outlinePulse = pulsePeriod.greaterThan(0).select(outlineColor.mul(osc), outlineColor)
|
||||
|
||||
return outlinePulse
|
||||
}
|
||||
|
||||
const selectedOutlinePass = generateSelectedOutlinePass()
|
||||
const hoverOutlinePass = generateHoverOutlinePass()
|
||||
// Hovered: blue visible, yellow hidden, pulsing
|
||||
const hoverVisibleColor = uniform(
|
||||
new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff),
|
||||
)
|
||||
const hoverHiddenColor = uniform(
|
||||
new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47),
|
||||
)
|
||||
const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5)
|
||||
const pulsePeriod = uniform(3)
|
||||
const osc =
|
||||
hoverHighlightMode === 'delete'
|
||||
? float(1)
|
||||
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
|
||||
const hoverOutline = outlineNode.secondaryVisibleEdge
|
||||
.mul(hoverVisibleColor)
|
||||
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
||||
.mul(hoverStrength)
|
||||
.mul(osc)
|
||||
|
||||
const compositeWithOutlines = vec4(
|
||||
add(sceneColor.rgb, selectedOutlinePass.add(hoverOutlinePass)),
|
||||
add(sceneColor.rgb, selectedOutline.add(hoverOutline)),
|
||||
sceneColor.a,
|
||||
)
|
||||
|
||||
@@ -280,7 +266,7 @@ const PostProcessingPasses = () => {
|
||||
}
|
||||
renderPipelineRef.current = null
|
||||
}
|
||||
}, [renderer, scene, camera, isInitialized, zoneLayers])
|
||||
}, [renderer, scene, camera, hoverHighlightMode, isInitialized, zoneLayers])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
'use client'
|
||||
|
||||
import { PointerLockControls } from '@react-three/drei'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { Vector3 } from 'three'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
const MOVE_SPEED = 5
|
||||
const EYE_HEIGHT = 1.6
|
||||
|
||||
const _direction = new Vector3()
|
||||
const _forward = new Vector3()
|
||||
const _right = new Vector3()
|
||||
|
||||
export const WalkthroughControls = () => {
|
||||
const controlsRef = useRef<any>(null!)
|
||||
const walkthroughMode = useViewer((s: any) => s.walkthroughMode)
|
||||
const keys = useRef({ w: false, a: false, s: false, d: false })
|
||||
const camera = useThree((s) => s.camera)
|
||||
|
||||
// Set initial eye height
|
||||
useEffect(() => {
|
||||
if (walkthroughMode) {
|
||||
camera.position.y = EYE_HEIGHT
|
||||
}
|
||||
}, [walkthroughMode, camera])
|
||||
|
||||
// Keyboard handlers
|
||||
useEffect(() => {
|
||||
if (!walkthroughMode) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return
|
||||
const key = e.key.toLowerCase()
|
||||
|
||||
// ESC exits walkthrough mode completely
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
useViewer.getState().setWalkthroughMode(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (key === 'w' || key === 'arrowup') keys.current.w = true
|
||||
if (key === 'a' || key === 'arrowleft') keys.current.a = true
|
||||
if (key === 's' || key === 'arrowdown') keys.current.s = true
|
||||
if (key === 'd' || key === 'arrowright') keys.current.d = true
|
||||
}
|
||||
|
||||
const onKeyUp = (e: KeyboardEvent) => {
|
||||
const key = e.key.toLowerCase()
|
||||
if (key === 'w' || key === 'arrowup') keys.current.w = false
|
||||
if (key === 'a' || key === 'arrowleft') keys.current.a = false
|
||||
if (key === 's' || key === 'arrowdown') keys.current.s = false
|
||||
if (key === 'd' || key === 'arrowright') keys.current.d = false
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
window.addEventListener('keyup', onKeyUp)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKeyDown)
|
||||
window.removeEventListener('keyup', onKeyUp)
|
||||
// Reset keys on cleanup
|
||||
keys.current = { w: false, a: false, s: false, d: false }
|
||||
}
|
||||
}, [walkthroughMode])
|
||||
|
||||
// Release pointer lock when walkthrough mode is turned off
|
||||
useEffect(() => {
|
||||
if (!walkthroughMode && document.pointerLockElement) {
|
||||
document.exitPointerLock()
|
||||
}
|
||||
}, [walkthroughMode])
|
||||
|
||||
// Movement loop
|
||||
useFrame((_, delta) => {
|
||||
if (!(walkthroughMode && controlsRef.current)) return
|
||||
|
||||
_direction.set(0, 0, 0)
|
||||
|
||||
// Get camera forward and right vectors (XZ plane only)
|
||||
camera.getWorldDirection(_forward)
|
||||
_forward.y = 0
|
||||
_forward.normalize()
|
||||
|
||||
_right.crossVectors(_forward, camera.up).normalize()
|
||||
|
||||
if (keys.current.w) _direction.add(_forward)
|
||||
if (keys.current.s) _direction.sub(_forward)
|
||||
if (keys.current.d) _direction.add(_right)
|
||||
if (keys.current.a) _direction.sub(_right)
|
||||
|
||||
if (_direction.lengthSq() > 0) {
|
||||
_direction.normalize().multiplyScalar(MOVE_SPEED * delta)
|
||||
camera.position.add(_direction)
|
||||
// Keep eye height constant
|
||||
camera.position.y = EYE_HEIGHT
|
||||
}
|
||||
})
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (walkthroughMode && controlsRef.current) {
|
||||
// Feature detection: some browsers (Facebook/Instagram in-app, older Safari)
|
||||
// don't support pointer lock on the canvas element
|
||||
if (typeof controlsRef.current.lock === 'function') {
|
||||
try {
|
||||
controlsRef.current.lock()
|
||||
} catch {
|
||||
// Silently ignore — pointer lock unavailable in this browser context
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [walkthroughMode])
|
||||
|
||||
// Click to lock
|
||||
useEffect(() => {
|
||||
if (!walkthroughMode) return
|
||||
const canvas = document.querySelector('canvas')
|
||||
if (!canvas) return
|
||||
|
||||
canvas.addEventListener('click', handleClick)
|
||||
return () => canvas.removeEventListener('click', handleClick)
|
||||
}, [walkthroughMode, handleClick])
|
||||
|
||||
if (!walkthroughMode) return null
|
||||
|
||||
// Skip PointerLockControls on browsers that don't support pointer lock
|
||||
// (Facebook/Instagram in-app browsers, some iOS WebViews)
|
||||
if (typeof document !== 'undefined' && !('requestPointerLock' in HTMLElement.prototype)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <PointerLockControls ref={controlsRef} />
|
||||
}
|
||||
Reference in New Issue
Block a user