Merge origin/main into feat/placement-interaction-overhaul
Resolve 7 conflicts keeping our snapping migration + floorplan perf work as source of truth, combined with main's MEP run-continuation / Alt-detach / latch handles. Rebuilt two import blocks the auto-merge silently truncated (node-arrow-handles.tsx, duct-fitting/move-tool.tsx). Verified: tsc clean across core/viewer/editor/nodes/mcp, 451 tests pass, biome clean. Floorplan view-transform re-render storm confirmed pre-existing (not introduced by this merge). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,7 +8,7 @@ import { ParametricNodeRenderer } from './parametric-node-renderer'
|
||||
// on every render — that would create a new Suspense boundary each time.
|
||||
const lazyCache = new WeakMap<RendererSource<AnyNode>, ComponentType<{ node: AnyNode }>>()
|
||||
|
||||
function getRegistryRenderer(
|
||||
export function getRegistryRenderer(
|
||||
source: RendererSource<AnyNode>,
|
||||
): ComponentType<{ node: AnyNode }> | null {
|
||||
const cached = lazyCache.get(source)
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
// R3F JSX type augmentations (mesh, group, box3Helper, …) for the debug overlay.
|
||||
import '@react-three/fiber'
|
||||
import { TransformControls, useKeyboardControls } from '@react-three/drei'
|
||||
import { type ThreeElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import type { ReactNode } from 'react'
|
||||
import { forwardRef, Suspense, useCallback, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { clamp } from 'three/src/math/MathUtils.js'
|
||||
|
||||
export type MovementInput = {
|
||||
forward?: boolean
|
||||
backward?: boolean
|
||||
leftward?: boolean
|
||||
rightward?: boolean
|
||||
joystick?: { x: number; y: number }
|
||||
run?: boolean
|
||||
jump?: boolean
|
||||
}
|
||||
|
||||
export type CharacterAnimationStatus =
|
||||
| 'IDLE'
|
||||
| 'WALK'
|
||||
| 'RUN'
|
||||
| 'JUMP_START'
|
||||
| 'JUMP_IDLE'
|
||||
| 'JUMP_FALL'
|
||||
| 'JUMP_LAND'
|
||||
|
||||
export type FloatCheckType = 'RAYCAST' | 'SHAPECAST' | 'BOTH'
|
||||
|
||||
export interface BVHEcctrlApi {
|
||||
group: THREE.Group | null
|
||||
model: THREE.Group | null
|
||||
resetLinVel: () => void
|
||||
addLinVel: (v: THREE.Vector3) => void
|
||||
setLinVel: (v: THREE.Vector3) => void
|
||||
setMovement: (input: MovementInput) => void
|
||||
}
|
||||
|
||||
export interface EcctrlProps extends Omit<ThreeElements['group'], 'ref'> {
|
||||
children?: ReactNode
|
||||
debug?: boolean
|
||||
colliderMeshes?: THREE.Mesh[]
|
||||
colliderCapsuleArgs?: [
|
||||
radius: number,
|
||||
length: number,
|
||||
capSegments: number,
|
||||
radialSegments: number,
|
||||
]
|
||||
paused?: boolean
|
||||
delay?: number
|
||||
gravity?: number
|
||||
fallGravityFactor?: number
|
||||
maxFallSpeed?: number
|
||||
mass?: number
|
||||
sleepTimeout?: number
|
||||
slowMotionFactor?: number
|
||||
turnSpeed?: number
|
||||
maxWalkSpeed?: number
|
||||
maxRunSpeed?: number
|
||||
acceleration?: number
|
||||
deceleration?: number
|
||||
counterAccFactor?: number
|
||||
airDragFactor?: number
|
||||
jumpVel?: number
|
||||
floatCheckType?: FloatCheckType
|
||||
maxSlope?: number
|
||||
floatHeight?: number
|
||||
floatPullBackHeight?: number
|
||||
floatSensorRadius?: number
|
||||
floatSpringK?: number
|
||||
floatDampingC?: number
|
||||
collisionCheckIteration?: number
|
||||
collisionPushBackDamping?: number
|
||||
collisionPushBackThreshold?: number
|
||||
}
|
||||
|
||||
type CharacterStatus = {
|
||||
position: THREE.Vector3
|
||||
linvel: THREE.Vector3
|
||||
quaternion: THREE.Quaternion
|
||||
inputDir: THREE.Vector3
|
||||
movingDir: THREE.Vector3
|
||||
isOnGround: boolean
|
||||
isOnMovingPlatform: boolean
|
||||
animationStatus: CharacterAnimationStatus
|
||||
}
|
||||
|
||||
export const characterStatus: CharacterStatus = {
|
||||
position: new THREE.Vector3(),
|
||||
linvel: new THREE.Vector3(),
|
||||
quaternion: new THREE.Quaternion(),
|
||||
inputDir: new THREE.Vector3(),
|
||||
movingDir: new THREE.Vector3(),
|
||||
isOnGround: false,
|
||||
isOnMovingPlatform: false,
|
||||
animationStatus: 'IDLE',
|
||||
}
|
||||
|
||||
const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
debug = false,
|
||||
colliderMeshes = [],
|
||||
colliderCapsuleArgs = [0.3, 0.6, 4, 8],
|
||||
paused = false,
|
||||
delay = 1.5,
|
||||
gravity = 9.81,
|
||||
fallGravityFactor = 4,
|
||||
maxFallSpeed = 50,
|
||||
mass = 1,
|
||||
sleepTimeout = 10,
|
||||
slowMotionFactor = 1,
|
||||
turnSpeed = 15,
|
||||
maxWalkSpeed = 3,
|
||||
maxRunSpeed = 5,
|
||||
acceleration = 30,
|
||||
deceleration = 20,
|
||||
counterAccFactor = 0.5,
|
||||
airDragFactor = 0.3,
|
||||
jumpVel = 5,
|
||||
floatCheckType = 'BOTH',
|
||||
maxSlope = 1,
|
||||
floatHeight = 0.2,
|
||||
floatPullBackHeight = 0.25,
|
||||
floatSensorRadius = 0.12,
|
||||
floatSpringK = 600,
|
||||
floatDampingC = 28,
|
||||
collisionCheckIteration = 3,
|
||||
collisionPushBackDamping = 0.1,
|
||||
collisionPushBackThreshold = 0.05,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const { camera } = useThree()
|
||||
const capsuleRadius = useMemo(() => colliderCapsuleArgs[0], [colliderCapsuleArgs])
|
||||
const capsuleLength = useMemo(() => colliderCapsuleArgs[1], [colliderCapsuleArgs])
|
||||
const characterGroupRef = useRef<THREE.Group | null>(null)
|
||||
const characterColliderRef = useRef<THREE.Mesh | null>(null)
|
||||
const characterModelRef = useRef<THREE.Group | null>(null)
|
||||
const debugLineStart = useRef<THREE.Mesh | null>(null)
|
||||
const debugLineEnd = useRef<THREE.Mesh | null>(null)
|
||||
const debugRaySensorStart = useRef<THREE.Mesh | null>(null)
|
||||
const debugRaySensorEnd = useRef<THREE.Mesh | null>(null)
|
||||
const standPointRef = useRef<THREE.Mesh | null>(null)
|
||||
const lookDirRef = useRef<THREE.Mesh | null>(null)
|
||||
const inputDirRef = useRef<THREE.ArrowHelper | null>(null)
|
||||
const moveDirRef = useRef<THREE.ArrowHelper | null>(null)
|
||||
const elapsedRef = useRef(0)
|
||||
|
||||
const [, getKeys] = useKeyboardControls()
|
||||
const presetKeys = {
|
||||
forward: false,
|
||||
backward: false,
|
||||
leftward: false,
|
||||
rightward: false,
|
||||
jump: false,
|
||||
run: false,
|
||||
}
|
||||
|
||||
const upAxis = useRef(new THREE.Vector3(0, 1, 0))
|
||||
const localUpAxis = useRef(new THREE.Vector3())
|
||||
const gravityDir = useRef(new THREE.Vector3(0, -1, 0))
|
||||
const currentLinVel = useRef(new THREE.Vector3())
|
||||
const currentLinVelOnPlane = useRef(new THREE.Vector3())
|
||||
const isFalling = useRef(false)
|
||||
const idleTime = useRef(0)
|
||||
const isSleeping = useRef(false)
|
||||
const camProjDir = useRef(new THREE.Vector3())
|
||||
const camRightDir = useRef(new THREE.Vector3())
|
||||
const inputDir = useRef(new THREE.Vector3())
|
||||
const inputDirOnPlane = useRef(new THREE.Vector3())
|
||||
const movingDir = useRef(new THREE.Vector3())
|
||||
const deltaLinVel = useRef(new THREE.Vector3())
|
||||
const wantToMoveVel = useRef(new THREE.Vector3())
|
||||
const forwardState = useRef(false)
|
||||
const backwardState = useRef(false)
|
||||
const leftwardState = useRef(false)
|
||||
const rightwardState = useRef(false)
|
||||
const joystickState = useRef(new THREE.Vector2())
|
||||
const runState = useRef(false)
|
||||
const jumpState = useRef(false)
|
||||
const isOnGround = useRef(false)
|
||||
const prevIsOnGround = useRef(false)
|
||||
const prevAnimation = useRef<CharacterAnimationStatus>('IDLE')
|
||||
const characterModelTargetQuat = useRef(new THREE.Quaternion())
|
||||
const characterModelLookMatrix = useRef(new THREE.Matrix4())
|
||||
const characterOrigin = useMemo(() => new THREE.Vector3(0, 0, 0), [])
|
||||
const contactDepth = useRef(0)
|
||||
const contactNormal = useRef(new THREE.Vector3())
|
||||
const triContactPoint = useRef(new THREE.Vector3())
|
||||
const capsuleContactPoint = useRef(new THREE.Vector3())
|
||||
const totalDepth = useRef(0)
|
||||
const triangleCount = useRef(0)
|
||||
const accumulatedContactNormal = useRef(new THREE.Vector3())
|
||||
const accumulatedContactPoint = useRef(new THREE.Vector3())
|
||||
const absorbVel = useRef(new THREE.Vector3())
|
||||
const pushBackVel = useRef(new THREE.Vector3())
|
||||
const characterBbox = useRef(new THREE.Box3())
|
||||
const characterSegment = useRef(new THREE.Line3())
|
||||
const localCharacterBbox = useRef(new THREE.Box3())
|
||||
const localCharacterSegment = useRef(new THREE.Line3())
|
||||
const collideInvertMatrix = useRef(new THREE.Matrix4())
|
||||
const relativeCollideVel = useRef(new THREE.Vector3())
|
||||
const scaledContactRadiusVec = useRef(new THREE.Vector3())
|
||||
const deltaDist = useRef(new THREE.Vector3())
|
||||
const currSlopeAngle = useRef(0)
|
||||
const localMinDistance = useRef(Number.POSITIVE_INFINITY)
|
||||
const localClosestPoint = useRef(new THREE.Vector3())
|
||||
const localHitNormal = useRef(new THREE.Vector3())
|
||||
const triNormal = useRef(new THREE.Vector3())
|
||||
const globalMinDistance = useRef(Number.POSITIVE_INFINITY)
|
||||
const globalClosestPoint = useRef(new THREE.Vector3())
|
||||
const triHitPoint = useRef(new THREE.Vector3())
|
||||
const segHitPoint = useRef(new THREE.Vector3())
|
||||
const floatHitNormal = useRef(new THREE.Vector3())
|
||||
const groundFriction = useRef(0.8)
|
||||
const floatSensorBbox = useRef(new THREE.Box3())
|
||||
const floatSensorBboxExpendPoint = useRef(new THREE.Vector3())
|
||||
const floatSensorSegment = useRef(new THREE.Line3())
|
||||
const localFloatSensorBbox = useRef(new THREE.Box3())
|
||||
const localFloatSensorBboxExpendPoint = useRef(new THREE.Vector3())
|
||||
const localFloatSensorSegment = useRef(new THREE.Line3())
|
||||
const floatInvertMatrix = useRef(new THREE.Matrix4())
|
||||
const floatNormalInverseMatrix = useRef(new THREE.Matrix3())
|
||||
const floatNormalMatrix = useRef(new THREE.Matrix3())
|
||||
const floatRaycaster = useRef(new THREE.Raycaster())
|
||||
const relativeHitPoint = useRef(new THREE.Vector3())
|
||||
const totalPlatformDeltaPos = useRef(new THREE.Vector3())
|
||||
const isOnMovingPlatform = useRef(false)
|
||||
const floatTempPos = useRef(new THREE.Vector3())
|
||||
const floatTempQuat = useRef(new THREE.Quaternion())
|
||||
const floatTempScale = useRef(new THREE.Vector3())
|
||||
const scaledFloatRadiusVec = useRef(new THREE.Vector3())
|
||||
const deltaHit = useRef(new THREE.Vector3())
|
||||
const rotationDeltaPos = useRef(new THREE.Vector3())
|
||||
const yawQuaternion = useRef(new THREE.Quaternion())
|
||||
const contactTempPos = useRef(new THREE.Vector3())
|
||||
const contactTempQuat = useRef(new THREE.Quaternion())
|
||||
const contactTempScale = useRef(new THREE.Vector3())
|
||||
|
||||
floatRaycaster.current.far = capsuleRadius + floatHeight + floatPullBackHeight
|
||||
|
||||
const floatRaycastCandidates = useMemo(
|
||||
() =>
|
||||
colliderMeshes.filter(
|
||||
(mesh) => mesh.geometry.boundsTree && !(mesh instanceof THREE.InstancedMesh),
|
||||
),
|
||||
[colliderMeshes],
|
||||
)
|
||||
|
||||
const applyGravity = useCallback(
|
||||
(delta: number) => {
|
||||
gravityDir.current.copy(upAxis.current).negate()
|
||||
const fallingSpeed = currentLinVel.current.dot(gravityDir.current)
|
||||
isFalling.current = fallingSpeed > 0
|
||||
if (fallingSpeed < maxFallSpeed) {
|
||||
currentLinVel.current.addScaledVector(
|
||||
gravityDir.current,
|
||||
gravity * (isFalling.current ? fallGravityFactor : 1) * delta,
|
||||
)
|
||||
}
|
||||
},
|
||||
[fallGravityFactor, gravity, maxFallSpeed],
|
||||
)
|
||||
|
||||
const checkCharacterSleep = useCallback(
|
||||
(jump: boolean, delta: number) => {
|
||||
const moving = currentLinVel.current.lengthSq() > 1e-6
|
||||
const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6
|
||||
|
||||
if (
|
||||
!moving &&
|
||||
isOnGround.current &&
|
||||
!jump &&
|
||||
!isOnMovingPlatform.current &&
|
||||
!platformIsMoving
|
||||
) {
|
||||
idleTime.current += delta
|
||||
if (idleTime.current > sleepTimeout) isSleeping.current = true
|
||||
} else {
|
||||
idleTime.current = 0
|
||||
isSleeping.current = false
|
||||
}
|
||||
},
|
||||
[sleepTimeout],
|
||||
)
|
||||
|
||||
const setInputDirection = useCallback(
|
||||
(dir: {
|
||||
forward?: boolean
|
||||
backward?: boolean
|
||||
leftward?: boolean
|
||||
rightward?: boolean
|
||||
joystick?: THREE.Vector2
|
||||
}) => {
|
||||
inputDir.current.set(0, 0, 0)
|
||||
|
||||
camera.getWorldDirection(camProjDir.current)
|
||||
camProjDir.current.projectOnPlane(upAxis.current).normalize()
|
||||
camRightDir.current.crossVectors(camProjDir.current, upAxis.current).normalize()
|
||||
|
||||
if (dir.joystick && dir.joystick.lengthSq() > 0) {
|
||||
inputDir.current
|
||||
.addScaledVector(camProjDir.current, dir.joystick.y)
|
||||
.addScaledVector(camRightDir.current, dir.joystick.x)
|
||||
} else {
|
||||
if (dir.forward) inputDir.current.add(camProjDir.current)
|
||||
if (dir.backward) inputDir.current.sub(camProjDir.current)
|
||||
if (dir.leftward) inputDir.current.sub(camRightDir.current)
|
||||
if (dir.rightward) inputDir.current.add(camRightDir.current)
|
||||
}
|
||||
|
||||
inputDir.current.normalize()
|
||||
},
|
||||
[camera],
|
||||
)
|
||||
|
||||
const handleCharacterMovement = useCallback(
|
||||
(run: boolean, delta: number) => {
|
||||
const friction = clamp(groundFriction.current, 0, 1)
|
||||
|
||||
if (inputDir.current.lengthSq() > 0) {
|
||||
if (characterModelRef.current) {
|
||||
inputDirOnPlane.current.copy(inputDir.current).projectOnPlane(upAxis.current)
|
||||
characterModelLookMatrix.current.lookAt(
|
||||
inputDirOnPlane.current,
|
||||
characterOrigin,
|
||||
upAxis.current,
|
||||
)
|
||||
characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current)
|
||||
characterModelRef.current.quaternion.slerp(
|
||||
characterModelTargetQuat.current,
|
||||
delta * turnSpeed,
|
||||
)
|
||||
}
|
||||
|
||||
const maxSpeed = run ? maxRunSpeed : maxWalkSpeed
|
||||
wantToMoveVel.current.copy(inputDir.current).multiplyScalar(maxSpeed)
|
||||
const dot = movingDir.current.dot(inputDir.current)
|
||||
|
||||
deltaLinVel.current.subVectors(wantToMoveVel.current, currentLinVelOnPlane.current)
|
||||
deltaLinVel.current.clampLength(
|
||||
0,
|
||||
(dot <= 0 ? 1 + counterAccFactor : 1) *
|
||||
acceleration *
|
||||
friction *
|
||||
delta *
|
||||
(isOnGround.current ? 1 : airDragFactor),
|
||||
)
|
||||
currentLinVel.current.add(deltaLinVel.current)
|
||||
} else if (isOnGround.current) {
|
||||
deltaLinVel.current
|
||||
.copy(currentLinVelOnPlane.current)
|
||||
.clampLength(0, deceleration * friction * delta)
|
||||
currentLinVel.current.sub(deltaLinVel.current)
|
||||
}
|
||||
},
|
||||
[
|
||||
acceleration,
|
||||
airDragFactor,
|
||||
counterAccFactor,
|
||||
deceleration,
|
||||
maxRunSpeed,
|
||||
maxWalkSpeed,
|
||||
turnSpeed,
|
||||
characterOrigin,
|
||||
],
|
||||
)
|
||||
|
||||
const updateSegmentBBox = useCallback(() => {
|
||||
if (!characterGroupRef.current) return
|
||||
|
||||
characterSegment.current.start
|
||||
.set(0, capsuleLength / 2, 0)
|
||||
.add(characterGroupRef.current.position)
|
||||
characterSegment.current.end
|
||||
.set(0, -capsuleLength / 2, 0)
|
||||
.add(characterGroupRef.current.position)
|
||||
|
||||
characterBbox.current
|
||||
.makeEmpty()
|
||||
.expandByPoint(characterSegment.current.start)
|
||||
.expandByPoint(characterSegment.current.end)
|
||||
.expandByScalar(capsuleRadius)
|
||||
|
||||
floatSensorSegment.current.start.copy(characterSegment.current.end)
|
||||
floatSensorSegment.current.end
|
||||
.copy(floatSensorSegment.current.start)
|
||||
.addScaledVector(gravityDir.current, floatHeight + capsuleRadius)
|
||||
floatSensorBboxExpendPoint.current
|
||||
.copy(floatSensorSegment.current.end)
|
||||
.addScaledVector(gravityDir.current, floatPullBackHeight)
|
||||
|
||||
floatSensorBbox.current
|
||||
.makeEmpty()
|
||||
.expandByPoint(floatSensorSegment.current.start)
|
||||
.expandByPoint(floatSensorBboxExpendPoint.current)
|
||||
.expandByScalar(floatSensorRadius)
|
||||
}, [capsuleLength, capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius])
|
||||
|
||||
const collisionCheck = useCallback(
|
||||
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => {
|
||||
if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeCollisionCheck)
|
||||
return
|
||||
|
||||
originMatrix.decompose(
|
||||
contactTempPos.current,
|
||||
contactTempQuat.current,
|
||||
contactTempScale.current,
|
||||
)
|
||||
collideInvertMatrix.current.copy(originMatrix).invert()
|
||||
localCharacterSegment.current
|
||||
.copy(characterSegment.current)
|
||||
.applyMatrix4(collideInvertMatrix.current)
|
||||
|
||||
scaledContactRadiusVec.current.set(
|
||||
capsuleRadius / contactTempScale.current.x,
|
||||
capsuleRadius / contactTempScale.current.y,
|
||||
capsuleRadius / contactTempScale.current.z,
|
||||
)
|
||||
|
||||
localCharacterBbox.current
|
||||
.makeEmpty()
|
||||
.expandByPoint(localCharacterSegment.current.start)
|
||||
.expandByPoint(localCharacterSegment.current.end)
|
||||
localCharacterBbox.current.min.addScaledVector(scaledContactRadiusVec.current, -1)
|
||||
localCharacterBbox.current.max.add(scaledContactRadiusVec.current)
|
||||
|
||||
contactDepth.current = 0
|
||||
contactNormal.current.set(0, 0, 0)
|
||||
absorbVel.current.set(0, 0, 0)
|
||||
pushBackVel.current.set(0, 0, 0)
|
||||
totalDepth.current = 0
|
||||
triangleCount.current = 0
|
||||
accumulatedContactNormal.current.set(0, 0, 0)
|
||||
accumulatedContactPoint.current.set(0, 0, 0)
|
||||
|
||||
mesh.geometry.boundsTree.shapecast({
|
||||
intersectsBounds: (box) => box.intersectsBox(localCharacterBbox.current),
|
||||
intersectsTriangle: (tri) => {
|
||||
tri.closestPointToSegment(
|
||||
localCharacterSegment.current,
|
||||
triContactPoint.current,
|
||||
capsuleContactPoint.current,
|
||||
)
|
||||
|
||||
deltaDist.current.copy(triContactPoint.current).sub(capsuleContactPoint.current)
|
||||
deltaDist.current.divide(scaledContactRadiusVec.current)
|
||||
|
||||
if (deltaDist.current.lengthSq() < 1) {
|
||||
triContactPoint.current.applyMatrix4(originMatrix)
|
||||
capsuleContactPoint.current.applyMatrix4(originMatrix)
|
||||
|
||||
contactNormal.current
|
||||
.copy(capsuleContactPoint.current)
|
||||
.sub(triContactPoint.current)
|
||||
.normalize()
|
||||
contactDepth.current =
|
||||
capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current)
|
||||
|
||||
accumulatedContactNormal.current.addScaledVector(
|
||||
contactNormal.current,
|
||||
contactDepth.current,
|
||||
)
|
||||
accumulatedContactPoint.current.add(triContactPoint.current)
|
||||
totalDepth.current += contactDepth.current
|
||||
triangleCount.current += 1
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (triangleCount.current > 0) {
|
||||
accumulatedContactNormal.current.normalize()
|
||||
accumulatedContactPoint.current.divideScalar(triangleCount.current)
|
||||
const avgDepth = totalDepth.current / triangleCount.current
|
||||
relativeCollideVel.current.copy(currentLinVel.current)
|
||||
const intoSurfaceVel = relativeCollideVel.current.dot(accumulatedContactNormal.current)
|
||||
|
||||
if (intoSurfaceVel < 0) {
|
||||
absorbVel.current
|
||||
.copy(accumulatedContactNormal.current)
|
||||
.multiplyScalar(-intoSurfaceVel * (1 + (mesh.userData.restitution ?? 0.05)))
|
||||
currentLinVel.current.add(absorbVel.current)
|
||||
}
|
||||
|
||||
if (avgDepth > collisionPushBackThreshold) {
|
||||
const correction = (collisionPushBackDamping / delta) * avgDepth
|
||||
pushBackVel.current.copy(accumulatedContactNormal.current).multiplyScalar(correction)
|
||||
currentLinVel.current.add(pushBackVel.current)
|
||||
}
|
||||
}
|
||||
},
|
||||
[capsuleRadius, collisionPushBackDamping, collisionPushBackThreshold],
|
||||
)
|
||||
|
||||
const handleCollisionResponse = useCallback(
|
||||
(meshes: THREE.Mesh[], delta: number) => {
|
||||
if (meshes.length === 0) return
|
||||
|
||||
for (let iteration = 0; iteration < collisionCheckIteration; iteration += 1) {
|
||||
for (const mesh of meshes) {
|
||||
collisionCheck(mesh, mesh.matrixWorld, delta)
|
||||
}
|
||||
}
|
||||
},
|
||||
[collisionCheck, collisionCheckIteration],
|
||||
)
|
||||
|
||||
const floatingCheck = useCallback(
|
||||
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => {
|
||||
if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeFloatHit) return
|
||||
|
||||
originMatrix.decompose(floatTempPos.current, floatTempQuat.current, floatTempScale.current)
|
||||
floatInvertMatrix.current.copy(originMatrix).invert()
|
||||
floatNormalInverseMatrix.current.getNormalMatrix(floatInvertMatrix.current)
|
||||
floatNormalMatrix.current.getNormalMatrix(originMatrix)
|
||||
|
||||
localFloatSensorSegment.current
|
||||
.copy(floatSensorSegment.current)
|
||||
.applyMatrix4(floatInvertMatrix.current)
|
||||
localFloatSensorBboxExpendPoint.current
|
||||
.copy(floatSensorBboxExpendPoint.current)
|
||||
.applyMatrix4(floatInvertMatrix.current)
|
||||
|
||||
scaledFloatRadiusVec.current.set(
|
||||
floatSensorRadius / floatTempScale.current.x,
|
||||
floatSensorRadius / floatTempScale.current.y,
|
||||
floatSensorRadius / floatTempScale.current.z,
|
||||
)
|
||||
|
||||
localFloatSensorBbox.current
|
||||
.makeEmpty()
|
||||
.expandByPoint(localFloatSensorSegment.current.start)
|
||||
.expandByPoint(localFloatSensorBboxExpendPoint.current)
|
||||
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
|
||||
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
|
||||
|
||||
localMinDistance.current = Number.POSITIVE_INFINITY
|
||||
localClosestPoint.current.set(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
|
||||
mesh.geometry.boundsTree.shapecast({
|
||||
intersectsBounds: (box) => box.intersectsBox(localFloatSensorBbox.current),
|
||||
intersectsTriangle: (tri) => {
|
||||
tri.closestPointToSegment(
|
||||
localFloatSensorSegment.current,
|
||||
triHitPoint.current,
|
||||
segHitPoint.current,
|
||||
)
|
||||
localUpAxis.current
|
||||
.copy(upAxis.current)
|
||||
.applyMatrix3(floatNormalInverseMatrix.current)
|
||||
.normalize()
|
||||
deltaHit.current.subVectors(triHitPoint.current, localFloatSensorSegment.current.start)
|
||||
deltaHit.current.divide(scaledFloatRadiusVec.current)
|
||||
|
||||
const totalLengthSq = deltaHit.current.lengthSq()
|
||||
const dot = deltaHit.current.dot(localUpAxis.current)
|
||||
const verticalLength =
|
||||
Math.abs(dot) /
|
||||
((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius)
|
||||
const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot))
|
||||
|
||||
if (horizontalLength < 1 && verticalLength < 1) {
|
||||
tri.getNormal(triNormal.current)
|
||||
triNormal.current.applyMatrix3(floatNormalMatrix.current).normalize()
|
||||
triHitPoint.current.applyMatrix4(originMatrix)
|
||||
|
||||
const slopeAngle = triNormal.current.angleTo(upAxis.current)
|
||||
if (verticalLength < localMinDistance.current && slopeAngle < maxSlope) {
|
||||
localMinDistance.current = verticalLength
|
||||
localClosestPoint.current.copy(triHitPoint.current)
|
||||
localHitNormal.current.copy(triNormal.current)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
if (localMinDistance.current < globalMinDistance.current) {
|
||||
globalMinDistance.current = localMinDistance.current
|
||||
globalClosestPoint.current.copy(localClosestPoint.current)
|
||||
floatHitNormal.current.copy(localHitNormal.current)
|
||||
}
|
||||
},
|
||||
[capsuleRadius, floatHeight, floatPullBackHeight, floatSensorRadius, maxSlope],
|
||||
)
|
||||
|
||||
const handleFloatingResponse = useCallback(
|
||||
(meshes: THREE.Mesh[], jump: boolean, delta: number) => {
|
||||
if (meshes.length === 0) return
|
||||
let shouldJump = jump
|
||||
|
||||
globalMinDistance.current = Number.POSITIVE_INFINITY
|
||||
globalClosestPoint.current.set(
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
floatHitNormal.current.set(0, 1, 0)
|
||||
isOnGround.current = false
|
||||
totalPlatformDeltaPos.current.set(0, 0, 0)
|
||||
isOnMovingPlatform.current = false
|
||||
|
||||
if (floatCheckType !== 'RAYCAST') {
|
||||
for (const mesh of meshes) {
|
||||
floatingCheck(mesh, mesh.matrixWorld)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
floatCheckType !== 'SHAPECAST' &&
|
||||
floatRaycastCandidates.length > 0 &&
|
||||
globalMinDistance.current === Number.POSITIVE_INFINITY
|
||||
) {
|
||||
floatRaycaster.current.ray.origin.copy(floatSensorSegment.current.start)
|
||||
floatRaycaster.current.ray.direction.copy(gravityDir.current)
|
||||
const hits = floatRaycaster.current.intersectObjects(floatRaycastCandidates, false)
|
||||
const hit = hits[0]
|
||||
if (hit?.point) {
|
||||
globalClosestPoint.current.copy(hit.point)
|
||||
if (hit.face) {
|
||||
floatHitNormal.current
|
||||
.copy(hit.face.normal)
|
||||
.transformDirection(hit.object.matrixWorld)
|
||||
.normalize()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (globalClosestPoint.current.x === Number.POSITIVE_INFINITY) return
|
||||
|
||||
relativeHitPoint.current
|
||||
.copy(globalClosestPoint.current)
|
||||
.sub(floatSensorSegment.current.start)
|
||||
const currentDistance = relativeHitPoint.current.length()
|
||||
currSlopeAngle.current = floatHitNormal.current.angleTo(upAxis.current)
|
||||
|
||||
if (currentDistance < floatHeight + capsuleRadius) {
|
||||
isOnGround.current = true
|
||||
shouldJump = false
|
||||
}
|
||||
|
||||
if (!shouldJump) {
|
||||
const displacement = floatHeight + capsuleRadius - currentDistance
|
||||
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
|
||||
const springForce = displacement * floatSpringK
|
||||
const dampingForce = -velocityOnHitNormal * floatDampingC
|
||||
const totalForce = springForce + dampingForce - mass * gravity
|
||||
|
||||
currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta)
|
||||
}
|
||||
},
|
||||
[
|
||||
capsuleRadius,
|
||||
floatCheckType,
|
||||
floatDampingC,
|
||||
floatHeight,
|
||||
floatRaycastCandidates,
|
||||
floatSpringK,
|
||||
floatingCheck,
|
||||
gravity,
|
||||
mass,
|
||||
],
|
||||
)
|
||||
|
||||
const updateCharacterWithPlatform = useCallback(() => {
|
||||
if (!characterGroupRef.current) return
|
||||
rotationDeltaPos.current.copy(totalPlatformDeltaPos.current)
|
||||
characterGroupRef.current.position.add(rotationDeltaPos.current)
|
||||
yawQuaternion.current.setFromUnitVectors(upAxis.current, floatHitNormal.current)
|
||||
}, [])
|
||||
|
||||
const updateCharacterAnimation = useCallback(
|
||||
(run: boolean, jump: boolean): CharacterAnimationStatus => {
|
||||
if (prevIsOnGround.current && jump) return 'JUMP_START'
|
||||
if (!isOnGround.current && currentLinVel.current.y > 0) return 'JUMP_IDLE'
|
||||
if (!isOnGround.current && currentLinVel.current.y <= 0) return 'JUMP_FALL'
|
||||
if (!prevIsOnGround.current && isOnGround.current) return 'JUMP_LAND'
|
||||
if (inputDir.current.lengthSq() > 0) return run ? 'RUN' : 'WALK'
|
||||
return 'IDLE'
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
const updateCharacterStatus = useCallback(
|
||||
(run: boolean, jump: boolean) => {
|
||||
characterModelRef.current?.getWorldPosition(characterStatus.position)
|
||||
characterModelRef.current?.getWorldQuaternion(characterStatus.quaternion)
|
||||
characterStatus.linvel.copy(currentLinVel.current)
|
||||
characterStatus.inputDir.copy(inputDir.current)
|
||||
characterStatus.movingDir.copy(movingDir.current)
|
||||
characterStatus.isOnGround = isOnGround.current
|
||||
characterStatus.isOnMovingPlatform = isOnMovingPlatform.current
|
||||
characterStatus.animationStatus = updateCharacterAnimation(run, jump)
|
||||
prevAnimation.current = characterStatus.animationStatus
|
||||
},
|
||||
[updateCharacterAnimation],
|
||||
)
|
||||
|
||||
const resetLinVel = useCallback(() => currentLinVel.current.set(0, 0, 0), [])
|
||||
const addLinVel = useCallback(
|
||||
(velocity: THREE.Vector3) => currentLinVel.current.add(velocity),
|
||||
[],
|
||||
)
|
||||
const setLinVel = useCallback(
|
||||
(velocity: THREE.Vector3) => currentLinVel.current.copy(velocity),
|
||||
[],
|
||||
)
|
||||
const setMovement = useCallback((movement: MovementInput) => {
|
||||
if (movement.forward !== undefined) forwardState.current = movement.forward
|
||||
if (movement.backward !== undefined) backwardState.current = movement.backward
|
||||
if (movement.leftward !== undefined) leftwardState.current = movement.leftward
|
||||
if (movement.rightward !== undefined) rightwardState.current = movement.rightward
|
||||
if (movement.joystick) joystickState.current.set(movement.joystick.x, movement.joystick.y)
|
||||
if (movement.run !== undefined) runState.current = movement.run
|
||||
if (movement.jump !== undefined) jumpState.current = movement.jump
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
get group() {
|
||||
return characterGroupRef.current
|
||||
},
|
||||
get model() {
|
||||
return characterModelRef.current
|
||||
},
|
||||
resetLinVel,
|
||||
addLinVel,
|
||||
setLinVel,
|
||||
setMovement,
|
||||
}),
|
||||
[addLinVel, resetLinVel, setLinVel, setMovement],
|
||||
)
|
||||
|
||||
const updateDebugger = useCallback(() => {
|
||||
debugLineStart.current?.position.copy(characterSegment.current.start)
|
||||
debugLineEnd.current?.position.copy(characterSegment.current.end)
|
||||
debugRaySensorStart.current?.position.copy(floatSensorSegment.current.start)
|
||||
debugRaySensorEnd.current?.position.copy(floatSensorSegment.current.end)
|
||||
standPointRef.current?.position.copy(globalClosestPoint.current)
|
||||
if (characterGroupRef.current) {
|
||||
lookDirRef.current?.position
|
||||
.copy(characterGroupRef.current.position)
|
||||
.addScaledVector(upAxis.current, 0.7)
|
||||
}
|
||||
lookDirRef.current?.lookAt(lookDirRef.current.position.clone().add(camProjDir.current))
|
||||
inputDirRef.current?.position.copy(characterSegment.current.end)
|
||||
inputDirRef.current?.setDirection(inputDir.current)
|
||||
inputDirRef.current?.setLength(inputDir.current.lengthSq())
|
||||
moveDirRef.current?.position.copy(characterSegment.current.end)
|
||||
moveDirRef.current?.setDirection(currentLinVel.current)
|
||||
moveDirRef.current?.setLength(currentLinVel.current.length() / maxWalkSpeed)
|
||||
}, [maxWalkSpeed])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
elapsedRef.current += delta
|
||||
if (paused || elapsedRef.current < delay) return
|
||||
|
||||
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
|
||||
const keys = getKeys() ?? presetKeys
|
||||
const forward = forwardState.current || (keys.forward ?? false)
|
||||
const backward = backwardState.current || (keys.backward ?? false)
|
||||
const leftward = leftwardState.current || (keys.leftward ?? false)
|
||||
const rightward = rightwardState.current || (keys.rightward ?? false)
|
||||
const run = runState.current || (keys.run ?? false)
|
||||
const jump = jumpState.current || (keys.jump ?? false)
|
||||
|
||||
setInputDirection({
|
||||
forward,
|
||||
backward,
|
||||
leftward,
|
||||
rightward,
|
||||
joystick: joystickState.current,
|
||||
})
|
||||
handleCharacterMovement(run, deltaTime)
|
||||
if (jump && isOnGround.current) currentLinVel.current.y = jumpVel
|
||||
movingDir.current.copy(currentLinVel.current).normalize()
|
||||
currentLinVelOnPlane.current.copy(currentLinVel.current).projectOnPlane(upAxis.current)
|
||||
|
||||
checkCharacterSleep(jump, deltaTime)
|
||||
if (!isSleeping.current) {
|
||||
if (!isOnGround.current) applyGravity(deltaTime)
|
||||
|
||||
updateSegmentBBox()
|
||||
handleCollisionResponse(colliderMeshes, deltaTime)
|
||||
handleFloatingResponse(colliderMeshes, jump, deltaTime)
|
||||
updateCharacterWithPlatform()
|
||||
|
||||
if (characterGroupRef.current) {
|
||||
characterGroupRef.current.position.addScaledVector(currentLinVel.current, deltaTime)
|
||||
}
|
||||
|
||||
updateCharacterStatus(run, jump)
|
||||
prevIsOnGround.current = isOnGround.current
|
||||
}
|
||||
|
||||
if (debug) updateDebugger()
|
||||
})
|
||||
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<group {...props} dispose={null} ref={characterGroupRef}>
|
||||
{debug && (
|
||||
<mesh ref={characterColliderRef}>
|
||||
<capsuleGeometry args={colliderCapsuleArgs} />
|
||||
<meshNormalMaterial wireframe />
|
||||
</mesh>
|
||||
)}
|
||||
<group name="BVHEcctrl-Model" ref={characterModelRef}>
|
||||
{children}
|
||||
</group>
|
||||
</group>
|
||||
|
||||
{debug && (
|
||||
<group>
|
||||
<TransformControls object={characterGroupRef.current!} />
|
||||
<box3Helper args={[characterBbox.current]} />
|
||||
<mesh ref={debugLineStart}>
|
||||
<octahedronGeometry args={[0.05, 0]} />
|
||||
<meshNormalMaterial />
|
||||
</mesh>
|
||||
<mesh ref={debugLineEnd}>
|
||||
<octahedronGeometry args={[0.05, 0]} />
|
||||
<meshNormalMaterial />
|
||||
</mesh>
|
||||
<box3Helper args={[floatSensorBbox.current]} />
|
||||
<mesh ref={debugRaySensorStart}>
|
||||
<octahedronGeometry args={[0.1, 0]} />
|
||||
<meshBasicMaterial color="yellow" wireframe />
|
||||
</mesh>
|
||||
<mesh ref={debugRaySensorEnd}>
|
||||
<octahedronGeometry args={[0.1, 0]} />
|
||||
<meshBasicMaterial color="yellow" wireframe />
|
||||
</mesh>
|
||||
<mesh ref={lookDirRef} scale={[1, 0.5, 4]}>
|
||||
<octahedronGeometry args={[0.1, 0]} />
|
||||
<meshNormalMaterial />
|
||||
</mesh>
|
||||
<arrowHelper args={[undefined, undefined, undefined, '#00f']} ref={inputDirRef} />
|
||||
<arrowHelper args={[undefined, undefined, undefined, '#f00']} ref={moveDirRef} />
|
||||
<mesh ref={standPointRef}>
|
||||
<octahedronGeometry args={[0.12, 0]} />
|
||||
<meshBasicMaterial color="red" opacity={0.2} transparent />
|
||||
</mesh>
|
||||
</group>
|
||||
)}
|
||||
</Suspense>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
BVHEcctrl.displayName = 'BVHEcctrl'
|
||||
|
||||
export default BVHEcctrl
|
||||
@@ -0,0 +1,573 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type Interactive,
|
||||
type LightEffect,
|
||||
pointInPolygon,
|
||||
type SceneGraph,
|
||||
type SliderControl,
|
||||
useInteractive,
|
||||
} from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
type AnimationAction,
|
||||
LoopRepeat,
|
||||
MathUtils,
|
||||
type Object3D,
|
||||
type PointLight,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { ControlWidget } from '../../systems/interactive/control-widget'
|
||||
|
||||
/** An interactive item recovered from the scene graph so the baked GLB can be
|
||||
* re-lit / re-animated by joining on `pascalId`. The GLB carries the geometry
|
||||
* + identity; the effects + controls live in the DB scene graph (no sidecar). */
|
||||
export type GlbInteractiveItem = {
|
||||
pascalId: AnyNodeId
|
||||
label: string
|
||||
/** Item height (world units) for placing the controls overlay above it. */
|
||||
height: number
|
||||
interactive: Interactive
|
||||
}
|
||||
|
||||
/** A baked zone's identity node + its local floor polygon (from `extras`). */
|
||||
export type GlbZoneRef = {
|
||||
id: string
|
||||
node: Object3D
|
||||
polygon: [number, number][]
|
||||
}
|
||||
|
||||
/** Pull the interactive items out of a scene graph. Only items that actually
|
||||
* carry effects (light / animation) are returned — everything else baked
|
||||
* faithfully and needs no runtime help. */
|
||||
export function buildGlbInteractiveItems(
|
||||
sceneGraph: SceneGraph | null | undefined,
|
||||
): GlbInteractiveItem[] {
|
||||
const nodes = sceneGraph?.nodes
|
||||
if (!nodes) return []
|
||||
const items: GlbInteractiveItem[] = []
|
||||
for (const [id, raw] of Object.entries(nodes)) {
|
||||
const node = raw as {
|
||||
type?: string
|
||||
scale?: [number, number, number]
|
||||
asset?: { name?: string; dimensions?: [number, number, number]; interactive?: Interactive }
|
||||
}
|
||||
if (node?.type !== 'item') continue
|
||||
const interactive = node.asset?.interactive
|
||||
if (!interactive?.effects?.length) continue
|
||||
const dims = node.asset?.dimensions ?? [1, 1, 1]
|
||||
const scaleY = node.scale?.[1] ?? 1
|
||||
items.push({
|
||||
pascalId: id as AnyNodeId,
|
||||
label: node.asset?.name ?? id,
|
||||
height: (dims[1] ?? 1) * scaleY,
|
||||
interactive,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
const _itemPos = new Vector3()
|
||||
|
||||
/**
|
||||
* Re-creates the item-driven interactivity the parametric viewer has — pooled
|
||||
* lights, ambient animation, and the controls overlay — on top of a baked GLB.
|
||||
* Effects come from the DB scene graph (`items`); world transforms come from the
|
||||
* baked Object3Ds (`identity`), joined on `pascalId`. Nothing is stamped into
|
||||
* the GLB itself, so the artifact stays integrator-clean.
|
||||
*/
|
||||
export function GlbInteractive({
|
||||
items,
|
||||
identity,
|
||||
zones,
|
||||
actions,
|
||||
levelOrder,
|
||||
}: {
|
||||
items: GlbInteractiveItem[]
|
||||
identity: Map<string, Object3D>
|
||||
zones: GlbZoneRef[]
|
||||
/** Baked animation actions keyed by clip name — ambient item loops play from
|
||||
* `<pascalId>: loop`. */
|
||||
actions: Record<string, AnimationAction | null>
|
||||
/** Level pascalIds bottom-to-top, so the light pool can prefer ground-floor
|
||||
* lights when nothing is focused (mirrors the parametric level factor). */
|
||||
levelOrder: string[]
|
||||
}) {
|
||||
// Seed control state for every interactive item. The viewer shows a baked
|
||||
// scene "lit": toggles default ON (the editor defaults them off) and sliders
|
||||
// to their authored default, so lamps glow and fans spin on load. Explicit
|
||||
// overlay toggles then win. Cleared on unmount so the global store never
|
||||
// carries state across scenes.
|
||||
useEffect(() => {
|
||||
const store = useInteractive.getState()
|
||||
for (const item of items) {
|
||||
store.initItem(item.pascalId, item.interactive)
|
||||
item.interactive.controls.forEach((control, i) => {
|
||||
if (control.kind === 'toggle') store.setControlValue(item.pascalId, i, true)
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
const store = useInteractive.getState()
|
||||
for (const item of items) store.removeItem(item.pascalId)
|
||||
}
|
||||
}, [items])
|
||||
|
||||
const animationItems = useMemo(
|
||||
() => items.filter((item) => item.interactive.effects.some((e) => e.kind === 'animation')),
|
||||
[items],
|
||||
)
|
||||
|
||||
// Light registrations: one per item with a light effect, joined to its baked
|
||||
// node. Fed to a fixed pool (below) rather than mounting a light per item.
|
||||
const lightRegs = useMemo<GlbLightReg[]>(() => {
|
||||
const regs: GlbLightReg[] = []
|
||||
for (const item of items) {
|
||||
const effect = item.interactive.effects.find((e) => e.kind === 'light') as
|
||||
| LightEffect
|
||||
| undefined
|
||||
if (!effect) continue
|
||||
const object = identity.get(item.pascalId)
|
||||
if (!object) continue
|
||||
const controls = item.interactive.controls
|
||||
const toggleIndex = controls.findIndex((c) => c.kind === 'toggle')
|
||||
const sliderIndex = controls.findIndex((c) => c.kind === 'slider')
|
||||
const slider = sliderIndex >= 0 ? (controls[sliderIndex] as SliderControl) : null
|
||||
regs.push({
|
||||
key: item.pascalId,
|
||||
object,
|
||||
effect,
|
||||
toggleIndex,
|
||||
sliderIndex,
|
||||
hasSlider: !!slider,
|
||||
sliderMin: slider?.min ?? 0,
|
||||
sliderMax: slider?.max ?? 1,
|
||||
levelId: findLevelId(object),
|
||||
})
|
||||
}
|
||||
return regs
|
||||
}, [items, identity])
|
||||
const levelIndexById = useMemo(
|
||||
() => new Map(levelOrder.map((id, i) => [id, i] as const)),
|
||||
[levelOrder],
|
||||
)
|
||||
|
||||
// Controls overlay is scoped to the focused zone (matches the parametric
|
||||
// viewer). Project the zone's baked-local polygon into world space once so an
|
||||
// item's world position can be point-tested regardless of level stacking.
|
||||
const focusedZoneId = useViewer((s) => s.selection.zoneId)
|
||||
const worldPolygon = useMemo<[number, number][] | null>(() => {
|
||||
if (!focusedZoneId) return null
|
||||
const zone = zones.find((z) => z.id === focusedZoneId)
|
||||
if (!zone) return null
|
||||
zone.node.updateWorldMatrix(true, false)
|
||||
return zone.polygon.map(([x, z]) => {
|
||||
const v = new Vector3(x, 0, z).applyMatrix4(zone.node.matrixWorld)
|
||||
return [v.x, v.z]
|
||||
})
|
||||
}, [focusedZoneId, zones])
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlbItemLights levelIndexById={levelIndexById} regs={lightRegs} />
|
||||
{animationItems.map((item) => (
|
||||
<GlbItemAnimation actions={actions} item={item} key={item.pascalId} />
|
||||
))}
|
||||
{items.map((item) => {
|
||||
const object = identity.get(item.pascalId)
|
||||
return object ? (
|
||||
<GlbItemControls
|
||||
item={item}
|
||||
key={item.pascalId}
|
||||
object={object}
|
||||
worldPolygon={worldPolygon}
|
||||
/>
|
||||
) : null
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Pooled item lights ──────────────────────────────────────────────────────
|
||||
//
|
||||
// Mirrors the parametric `ItemLightSystem`: a fixed pool of point lights is
|
||||
// assigned to the nearest/most-visible lit items each tick (camera-proximity
|
||||
// scored, with hysteresis), snapped to the item's world position + offset, and
|
||||
// faded in/out on reassignment. Mounting a light per item instead would blow
|
||||
// the renderer's light budget on a large house.
|
||||
|
||||
const POOL_SIZE = 12
|
||||
const REASSIGN_INTERVAL = 0.2
|
||||
const HYSTERESIS = 0.15
|
||||
const CAM_MOVE_DIST = 0.5
|
||||
const CAM_ROT_DOT = 0.995
|
||||
|
||||
type GlbLightReg = {
|
||||
key: AnyNodeId
|
||||
object: Object3D
|
||||
effect: LightEffect
|
||||
toggleIndex: number
|
||||
sliderIndex: number
|
||||
hasSlider: boolean
|
||||
sliderMin: number
|
||||
sliderMax: number
|
||||
levelId: string | null
|
||||
}
|
||||
|
||||
type SlotRuntime = { key: string | null; pendingKey: string | null; isFadingOut: boolean }
|
||||
|
||||
const _camPos = new Vector3()
|
||||
const _camFwd = new Vector3()
|
||||
const _dir = new Vector3()
|
||||
const _lightWorld = new Vector3()
|
||||
|
||||
/** The nearest level-identity ancestor's pascalId, for the level factor. */
|
||||
function findLevelId(object: Object3D): string | null {
|
||||
let cur: Object3D | null = object
|
||||
while (cur) {
|
||||
const ud = cur.userData as { kind?: string; pascalId?: string }
|
||||
if (ud.kind === 'level' && ud.pascalId) return ud.pascalId
|
||||
cur = cur.parent
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function scoreReg(
|
||||
reg: GlbLightReg,
|
||||
selectedLevelId: string | null,
|
||||
levelMode: string,
|
||||
levelIndexById: Map<string, number>,
|
||||
interactiveState: ReturnType<typeof useInteractive.getState>,
|
||||
): number {
|
||||
// Toggled-off lights contribute no illumination — drop them from the pool.
|
||||
if (reg.toggleIndex >= 0 && !interactiveState.items[reg.key]?.controlValues?.[reg.toggleIndex]) {
|
||||
return Number.POSITIVE_INFINITY
|
||||
}
|
||||
reg.object.getWorldPosition(_lightWorld)
|
||||
_lightWorld.x += reg.effect.offset[0]
|
||||
_lightWorld.y += reg.effect.offset[1]
|
||||
_lightWorld.z += reg.effect.offset[2]
|
||||
_dir.copy(_lightWorld).sub(_camPos).normalize()
|
||||
const angular = 1 - _camFwd.dot(_dir)
|
||||
const dist = _camPos.distanceTo(_lightWorld) / 200
|
||||
let levelPenalty = 0
|
||||
if (selectedLevelId) {
|
||||
if (reg.levelId !== selectedLevelId) levelPenalty = levelMode === 'solo' ? 100 : 0.8
|
||||
} else if (reg.levelId && (levelIndexById.get(reg.levelId) ?? 0) !== 0) {
|
||||
levelPenalty = 0.3
|
||||
}
|
||||
return angular * 0.7 + dist * 0.3 + levelPenalty
|
||||
}
|
||||
|
||||
function GlbItemLights({
|
||||
regs,
|
||||
levelIndexById,
|
||||
}: {
|
||||
regs: GlbLightReg[]
|
||||
levelIndexById: Map<string, number>
|
||||
}) {
|
||||
const lightRefs = useRef<Array<PointLight | null>>(Array.from({ length: POOL_SIZE }, () => null))
|
||||
const slots = useRef<SlotRuntime[]>(
|
||||
Array.from({ length: POOL_SIZE }, () => ({ key: null, pendingKey: null, isFadingOut: false })),
|
||||
)
|
||||
const reassignTimer = useRef(0)
|
||||
const prevCamPos = useRef(new Vector3())
|
||||
const prevCamFwd = useRef(new Vector3(0, 0, -1))
|
||||
const regByKey = useMemo(() => new Map(regs.map((r) => [r.key as string, r])), [regs])
|
||||
|
||||
useFrame(({ camera }, delta) => {
|
||||
const dt = Math.min(delta, 0.1)
|
||||
const interactiveState = useInteractive.getState()
|
||||
camera.getWorldPosition(_camPos)
|
||||
camera.getWorldDirection(_camFwd)
|
||||
|
||||
const camMoved =
|
||||
_camPos.distanceTo(prevCamPos.current) > CAM_MOVE_DIST ||
|
||||
_camFwd.dot(prevCamFwd.current) < CAM_ROT_DOT
|
||||
reassignTimer.current -= delta
|
||||
|
||||
if (reassignTimer.current <= 0 || camMoved) {
|
||||
reassignTimer.current = REASSIGN_INTERVAL
|
||||
prevCamPos.current.copy(_camPos)
|
||||
prevCamFwd.current.copy(_camFwd)
|
||||
const viewer = useViewer.getState()
|
||||
const selectedLevelId = viewer.selection.levelId
|
||||
const levelMode = viewer.levelMode
|
||||
|
||||
const scored = regs.map((reg) => ({
|
||||
key: reg.key as string,
|
||||
score: scoreReg(reg, selectedLevelId, levelMode, levelIndexById, interactiveState),
|
||||
}))
|
||||
scored.sort((a, b) => a.score - b.score)
|
||||
const scoreByKey = new Map(scored.map((s) => [s.key, s.score] as const))
|
||||
const desired = scored
|
||||
.filter((s) => Number.isFinite(s.score))
|
||||
.slice(0, POOL_SIZE)
|
||||
.map((s) => s.key)
|
||||
|
||||
const currentlyAssigned = new Map<string, number>()
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const s = slots.current[i]
|
||||
const k = s?.key ?? s?.pendingKey
|
||||
if (k) currentlyAssigned.set(k, i)
|
||||
}
|
||||
|
||||
const usedSlots = new Set<number>()
|
||||
const assignedKeys = new Set<string>()
|
||||
// Pass 1: keep existing slots whose key is still wanted.
|
||||
for (const key of desired) {
|
||||
const existingSlot = currentlyAssigned.get(key)
|
||||
if (existingSlot !== undefined && !usedSlots.has(existingSlot)) {
|
||||
usedSlots.add(existingSlot)
|
||||
assignedKeys.add(key)
|
||||
}
|
||||
}
|
||||
// Pass 2: assign the rest to free slots, evicting only on a clear win.
|
||||
let freeSlot = 0
|
||||
for (const key of desired) {
|
||||
if (assignedKeys.has(key)) continue
|
||||
while (freeSlot < POOL_SIZE && usedSlots.has(freeSlot)) freeSlot++
|
||||
if (freeSlot >= POOL_SIZE) break
|
||||
|
||||
const freeSlotData = slots.current[freeSlot]
|
||||
const currentKey = freeSlotData ? (freeSlotData.key ?? freeSlotData.pendingKey) : null
|
||||
if (currentKey && !desired.includes(currentKey)) {
|
||||
const currentScore = scoreByKey.get(currentKey) ?? Number.POSITIVE_INFINITY
|
||||
const newScore = scoreByKey.get(key) ?? 0
|
||||
if (currentScore - newScore < HYSTERESIS) {
|
||||
freeSlot++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
usedSlots.add(freeSlot)
|
||||
assignedKeys.add(key)
|
||||
const slot = slots.current[freeSlot]
|
||||
if (slot && slot.key !== key) {
|
||||
slot.pendingKey = key
|
||||
slot.isFadingOut = slot.key !== null
|
||||
if (!slot.isFadingOut) {
|
||||
slot.key = key
|
||||
slot.pendingKey = null
|
||||
const light = lightRefs.current[freeSlot]
|
||||
const reg = regByKey.get(key)
|
||||
if (light && reg) {
|
||||
light.color.set(reg.effect.color)
|
||||
light.distance = reg.effect.distance ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
freeSlot++
|
||||
}
|
||||
|
||||
// Retire slots whose key is no longer wanted.
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
if (!usedSlots.has(i)) {
|
||||
const slot = slots.current[i]
|
||||
if (slot?.key && !desired.includes(slot.key)) {
|
||||
slot.pendingKey = null
|
||||
slot.isFadingOut = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Per-frame: fade, snap position, and track intensity from control state.
|
||||
// The pool lights stay permanently `visible` — only `intensity` is animated
|
||||
// (an idle light just lerps to 0). Toggling `visible` would change the
|
||||
// active-light count, which forces the WebGPU renderer to recompile every
|
||||
// material's lighting node — a hard frame-time spike on every reassignment
|
||||
// (i.e. on every camera move). Keeping the count fixed avoids that entirely.
|
||||
for (let i = 0; i < POOL_SIZE; i++) {
|
||||
const light = lightRefs.current[i]
|
||||
const slot = slots.current[i]
|
||||
if (!(light && slot)) continue
|
||||
|
||||
if (slot.isFadingOut) {
|
||||
light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12)
|
||||
if (light.intensity < 0.01) {
|
||||
light.intensity = 0
|
||||
slot.isFadingOut = false
|
||||
slot.key = slot.pendingKey
|
||||
slot.pendingKey = null
|
||||
if (slot.key) {
|
||||
const reg = regByKey.get(slot.key)
|
||||
if (reg) {
|
||||
light.color.set(reg.effect.color)
|
||||
light.distance = reg.effect.distance ?? 0
|
||||
}
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (!slot.key) {
|
||||
light.intensity = MathUtils.lerp(light.intensity, 0, dt * 12)
|
||||
continue
|
||||
}
|
||||
const reg = regByKey.get(slot.key)
|
||||
if (!reg) {
|
||||
slot.key = null
|
||||
continue
|
||||
}
|
||||
|
||||
reg.object.getWorldPosition(_lightWorld)
|
||||
light.position.set(
|
||||
_lightWorld.x + reg.effect.offset[0],
|
||||
_lightWorld.y + reg.effect.offset[1],
|
||||
_lightWorld.z + reg.effect.offset[2],
|
||||
)
|
||||
|
||||
const values = interactiveState.items[reg.key]?.controlValues
|
||||
const isOn = reg.toggleIndex >= 0 ? Boolean(values?.[reg.toggleIndex]) : true
|
||||
let t = 1
|
||||
if (reg.hasSlider) {
|
||||
const raw = (values?.[reg.sliderIndex] as number) ?? reg.sliderMin
|
||||
t =
|
||||
reg.sliderMax > reg.sliderMin
|
||||
? (raw - reg.sliderMin) / (reg.sliderMax - reg.sliderMin)
|
||||
: 1
|
||||
}
|
||||
const targetIntensity = isOn
|
||||
? MathUtils.lerp(reg.effect.intensityRange[0], reg.effect.intensityRange[1], t)
|
||||
: reg.effect.intensityRange[0]
|
||||
light.intensity = MathUtils.lerp(light.intensity, targetIntensity, dt * 12)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{Array.from({ length: POOL_SIZE }, (_, i) => (
|
||||
<pointLight
|
||||
castShadow={false}
|
||||
intensity={0}
|
||||
key={i}
|
||||
ref={(el) => {
|
||||
lightRefs.current[i] = el
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Plays an item's baked ambient loop (a fan's spin), gated on its toggle.
|
||||
* The clip and its targets are already in the GLB; we only start/stop it. */
|
||||
function GlbItemAnimation({
|
||||
item,
|
||||
actions,
|
||||
}: {
|
||||
item: GlbInteractiveItem
|
||||
actions: Record<string, AnimationAction | null>
|
||||
}) {
|
||||
const values = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues))
|
||||
const toggleIndex = item.interactive.controls.findIndex((c) => c.kind === 'toggle')
|
||||
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex] ?? true) : true
|
||||
|
||||
useEffect(() => {
|
||||
const action = actions[`${item.pascalId}: loop`]
|
||||
if (!action) return
|
||||
action.loop = LoopRepeat
|
||||
action.clampWhenFinished = false
|
||||
if (isOn) {
|
||||
action.enabled = true
|
||||
action.paused = false
|
||||
if (!action.isRunning()) action.play()
|
||||
} else {
|
||||
action.stop()
|
||||
}
|
||||
}, [actions, item.pascalId, isOn])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const FADE_MS = 300
|
||||
|
||||
/** Controls overlay for one item — fades in while the item sits inside the
|
||||
* focused zone, portaled above the baked node. */
|
||||
function GlbItemControls({
|
||||
item,
|
||||
object,
|
||||
worldPolygon,
|
||||
}: {
|
||||
item: GlbInteractiveItem
|
||||
object: Object3D
|
||||
worldPolygon: [number, number][] | null
|
||||
}) {
|
||||
const controlValues = useInteractive(useShallow((s) => s.items[item.pascalId]?.controlValues))
|
||||
const setControlValue = useInteractive((s) => s.setControlValue)
|
||||
|
||||
let visible = false
|
||||
if (worldPolygon?.length) {
|
||||
object.getWorldPosition(_itemPos)
|
||||
visible = pointInPolygon(_itemPos.x, _itemPos.z, worldPolygon)
|
||||
}
|
||||
|
||||
// Fade in on mount and fade out before unmounting the <Html>.
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [shown, setShown] = useState(false)
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setMounted(true)
|
||||
let raf2 = 0
|
||||
const raf1 = requestAnimationFrame(() => {
|
||||
raf2 = requestAnimationFrame(() => setShown(true))
|
||||
})
|
||||
return () => {
|
||||
cancelAnimationFrame(raf1)
|
||||
cancelAnimationFrame(raf2)
|
||||
}
|
||||
}
|
||||
setShown(false)
|
||||
const timeout = setTimeout(() => setMounted(false), FADE_MS)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [visible])
|
||||
|
||||
if (!(mounted && controlValues)) return null
|
||||
|
||||
return createPortal(
|
||||
<Html
|
||||
center
|
||||
distanceFactor={8}
|
||||
eps={-1}
|
||||
position={[0, item.height + 0.3, 0]}
|
||||
zIndexRange={[20, 0]}
|
||||
>
|
||||
{/* Stop pointer/click events from reaching the canvas — otherwise R3F's
|
||||
pointer-missed fires and deselects the zone the moment you toggle. */}
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
minWidth: 120,
|
||||
pointerEvents: visible ? 'auto' : 'none',
|
||||
userSelect: 'none',
|
||||
opacity: shown ? 1 : 0,
|
||||
transition: `opacity ${FADE_MS}ms ease`,
|
||||
}}
|
||||
>
|
||||
{item.interactive.controls.map((control, i) => (
|
||||
<ControlWidget
|
||||
control={control}
|
||||
key={i}
|
||||
onChange={(v) => setControlValue(item.pascalId, i, v)}
|
||||
value={controlValues[i] ?? false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Html>,
|
||||
object,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, nodeRegistry, type RendererSource, type SceneGraph } from '@pascal-app/core'
|
||||
import { createPortal } from '@react-three/fiber'
|
||||
import { Suspense } from 'react'
|
||||
import type { Object3D } from 'three'
|
||||
import { getRegistryRenderer } from '../renderers/node-renderer'
|
||||
|
||||
/**
|
||||
* Scans (LiDAR meshes) and guides (floorplan images) are stripped from the
|
||||
* baked GLB — they're heavy reference assets stored elsewhere. The GLB viewer
|
||||
* re-adds them at runtime from the scene graph, portaled into their parent
|
||||
* level's baked node so they ride level stacking, using the same registry
|
||||
* renderers as the parametric viewer. Privacy is enforced upstream: the page
|
||||
* only includes nodes whose `show_*_public` flag (or owner/admin) allows it, so
|
||||
* a disallowed asset is never even fetched.
|
||||
*/
|
||||
export function buildGlbReferenceNodes(
|
||||
sceneGraph: SceneGraph | null | undefined,
|
||||
allow: { scans: boolean; guides: boolean },
|
||||
): AnyNode[] {
|
||||
const nodes = sceneGraph?.nodes
|
||||
if (!nodes) return []
|
||||
const out: AnyNode[] = []
|
||||
for (const raw of Object.values(nodes)) {
|
||||
const node = raw as AnyNode
|
||||
if (node.type === 'scan' && allow.scans) out.push(node)
|
||||
else if (node.type === 'guide' && allow.guides) out.push(node)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function GlbReferenceNodes({
|
||||
nodes,
|
||||
identity,
|
||||
}: {
|
||||
nodes: AnyNode[]
|
||||
identity: Map<string, Object3D>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{nodes.map((node) => {
|
||||
const anchor = node.parentId ? identity.get(node.parentId) : undefined
|
||||
return anchor ? <GlbReferenceNode anchor={anchor} key={node.id} node={node} /> : null
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** Render one scan/guide via its registry renderer, portaled into its parent
|
||||
* level's baked Object3D (so the node's level-local transform resolves to the
|
||||
* same world pose as the parametric scene). */
|
||||
function GlbReferenceNode({ node, anchor }: { node: AnyNode; anchor: Object3D }) {
|
||||
const source = nodeRegistry.get(node.type)?.renderer
|
||||
const Renderer = source ? getRegistryRenderer(source as RendererSource<AnyNode>) : null
|
||||
if (!Renderer) return null
|
||||
return createPortal(
|
||||
<Suspense fallback={null}>
|
||||
<Renderer node={node} />
|
||||
</Suspense>,
|
||||
anchor,
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,404 @@
|
||||
'use client'
|
||||
|
||||
import { KeyboardControls } from '@react-three/drei'
|
||||
import { useFrame, useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Box3,
|
||||
BoxGeometry,
|
||||
type BufferAttribute,
|
||||
BufferGeometry,
|
||||
Euler,
|
||||
Float32BufferAttribute,
|
||||
type InterleavedBufferAttribute,
|
||||
Matrix4,
|
||||
Mesh,
|
||||
MeshBasicMaterial,
|
||||
type Object3D,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
} from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||
import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2'
|
||||
import { SCENE_LAYER } from '../../lib/layers'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import BVHEcctrl, { type BVHEcctrlApi, type MovementInput } from './bvh-ecctrl'
|
||||
|
||||
// Eye/capsule geometry mirrors the editor's first-person controller so the
|
||||
// baked walkthrough feels identical. The capsule centre sits below the eye; the
|
||||
// camera rides the capsule with a small offset and the controller floats it to
|
||||
// the ground.
|
||||
const CAMERA_EYE_OFFSET = 0.45
|
||||
const CONTROLLER_CENTER_FROM_EYE = 0.85
|
||||
const SPAWN_EYE_HEIGHT = 1.65
|
||||
const LOOK_SENSITIVITY = 0.002
|
||||
const VOID_FALL_RESPAWN_DEPTH = 12
|
||||
|
||||
// Kinds that must not block the player: room helpers, the spawn marker, the
|
||||
// ceiling/roof shell (you walk under them), and door/window leaves — excluding
|
||||
// the latter lets you pass any doorway whether the leaf is open or shut (the
|
||||
// wall already has the opening cut into its baked geometry).
|
||||
const COLLIDER_EXCLUDED_KINDS = new Set(['zone', 'spawn', 'ceiling', 'roof', 'door', 'window'])
|
||||
|
||||
const colliderMaterial = new MeshBasicMaterial({ visible: false })
|
||||
|
||||
const keyboardMap: Array<{ name: Exclude<keyof MovementInput, 'joystick'>; keys: string[] }> = [
|
||||
{ name: 'forward', keys: ['ArrowUp', 'KeyW'] },
|
||||
{ name: 'backward', keys: ['ArrowDown', 'KeyS'] },
|
||||
{ name: 'leftward', keys: ['ArrowLeft', 'KeyA'] },
|
||||
{ name: 'rightward', keys: ['ArrowRight', 'KeyD'] },
|
||||
{ name: 'jump', keys: ['Space'] },
|
||||
{ name: 'run', keys: ['ShiftLeft', 'ShiftRight'] },
|
||||
]
|
||||
|
||||
const cameraOffset = new Vector3(0, CAMERA_EYE_OFFSET, 0)
|
||||
const cameraEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const spawnQuat = new Quaternion()
|
||||
const spawnEuler = new Euler(0, 0, 0, 'YXZ')
|
||||
const spawnPos = new Vector3()
|
||||
|
||||
type GlbColliderWorld = { mesh: Mesh; minY: number; dispose: () => void }
|
||||
|
||||
/** Effective visibility — an invisible ancestor hides the whole subtree. */
|
||||
function isEffectivelyVisible(object: Object3D) {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
if (!current.visible) return false
|
||||
current = current.parent
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function kindOf(object: Object3D): string | undefined {
|
||||
let current: Object3D | null = object
|
||||
while (current) {
|
||||
const kind = (current.userData as { kind?: string }).kind
|
||||
if (kind) return kind
|
||||
current = current.parent
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Coerce any position attribute (quantized/interleaved) to a plain Float32 one
|
||||
// so mergeGeometries can combine geometries that don't share an array type.
|
||||
function toFloat32Position(source: BufferAttribute | InterleavedBufferAttribute) {
|
||||
const array = new Float32Array(source.count * 3)
|
||||
for (let i = 0; i < source.count; i++) {
|
||||
array[i * 3] = source.getX(i)
|
||||
array[i * 3 + 1] = source.getY(i)
|
||||
array[i * 3 + 2] = source.getZ(i)
|
||||
}
|
||||
return new Float32BufferAttribute(array, 3)
|
||||
}
|
||||
|
||||
const FALLBACK_THICKNESS = 0.08
|
||||
const GROUND_MIN = 2000
|
||||
|
||||
/** A thin position-only floor box whose top face sits at `topY`. */
|
||||
function boxFloorGeometry(
|
||||
cx: number,
|
||||
topY: number,
|
||||
cz: number,
|
||||
width: number,
|
||||
depth: number,
|
||||
): BufferGeometry {
|
||||
const box = new BoxGeometry(width, FALLBACK_THICKNESS, depth).toNonIndexed()
|
||||
const geometry = new BufferGeometry()
|
||||
geometry.setAttribute('position', (box.getAttribute('position') as BufferAttribute).clone())
|
||||
box.dispose()
|
||||
geometry.applyMatrix4(new Matrix4().makeTranslation(cx, topY - FALLBACK_THICKNESS / 2, cz))
|
||||
return geometry
|
||||
}
|
||||
|
||||
// Fallback ground so the player never falls into the void: a single large
|
||||
// ground plane at the lowest level (level 0). Upper levels rely on their own
|
||||
// baked slabs — a slab-less upper floor lets you fall down to the ground, which
|
||||
// the ground plane catches. (Mirrors the editor walkthrough's site ground.)
|
||||
function addFallbackFloors(scene: Object3D, geometries: BufferGeometry[]) {
|
||||
const sceneBounds = new Box3()
|
||||
for (const geometry of geometries) {
|
||||
geometry.computeBoundingBox()
|
||||
if (geometry.boundingBox) sceneBounds.union(geometry.boundingBox)
|
||||
}
|
||||
|
||||
const center = new Vector3()
|
||||
const levelPos = new Vector3()
|
||||
let lowestLevelY = Number.POSITIVE_INFINITY
|
||||
|
||||
scene.traverse((object) => {
|
||||
if ((object.userData as { kind?: string }).kind !== 'level') return
|
||||
object.updateWorldMatrix(true, false)
|
||||
object.getWorldPosition(levelPos)
|
||||
lowestLevelY = Math.min(lowestLevelY, levelPos.y)
|
||||
})
|
||||
|
||||
if (sceneBounds.isEmpty()) return
|
||||
sceneBounds.getCenter(center)
|
||||
const groundY = Number.isFinite(lowestLevelY) ? lowestLevelY : sceneBounds.min.y
|
||||
geometries.push(boxFloorGeometry(center.x, groundY, center.z, GROUND_MIN, GROUND_MIN))
|
||||
}
|
||||
|
||||
/** Merge the baked GLB's walkable/blocking meshes into one BVH collider. */
|
||||
function buildGlbColliderWorld(scene: Object3D): GlbColliderWorld | null {
|
||||
scene.updateWorldMatrix(true, true)
|
||||
const geometries: BufferGeometry[] = []
|
||||
|
||||
scene.traverse((object) => {
|
||||
const mesh = object as Mesh
|
||||
if (!mesh.isMesh) return
|
||||
// Zone fills live on a separate layer (and never collide).
|
||||
if (!mesh.layers.isEnabled(SCENE_LAYER)) return
|
||||
if (!isEffectivelyVisible(mesh)) return
|
||||
const kind = kindOf(mesh)
|
||||
if (kind && COLLIDER_EXCLUDED_KINDS.has(kind)) return
|
||||
const position = mesh.geometry?.getAttribute('position')
|
||||
if (!position || position.count < 3) return
|
||||
|
||||
const geometry = new BufferGeometry()
|
||||
const source = mesh.geometry.index ? mesh.geometry.toNonIndexed() : mesh.geometry
|
||||
geometry.setAttribute('position', toFloat32Position(source.getAttribute('position')))
|
||||
if (mesh.geometry.index) source.dispose()
|
||||
geometry.applyMatrix4(mesh.matrixWorld)
|
||||
geometries.push(geometry)
|
||||
})
|
||||
|
||||
if (geometries.length === 0) return null
|
||||
|
||||
addFallbackFloors(scene, geometries)
|
||||
|
||||
const merged = mergeGeometries(geometries, false)
|
||||
for (const geometry of geometries) geometry.dispose()
|
||||
if (!merged || merged.getAttribute('position') == null) {
|
||||
merged?.dispose()
|
||||
return null
|
||||
}
|
||||
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype
|
||||
;(merged as any).computeBoundsTree = computeBoundsTree
|
||||
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh patches the geometry prototype
|
||||
;(merged as any).disposeBoundsTree = disposeBoundsTree
|
||||
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension
|
||||
;(merged as any).computeBoundsTree({ maxLeafSize: 12, strategy: 0 })
|
||||
merged.computeBoundingBox()
|
||||
|
||||
const mesh = new Mesh(merged, colliderMaterial)
|
||||
mesh.raycast = acceleratedRaycast
|
||||
mesh.visible = true
|
||||
mesh.userData = {
|
||||
type: 'STATIC',
|
||||
friction: 0.8,
|
||||
restitution: 0.05,
|
||||
excludeFloatHit: false,
|
||||
excludeCollisionCheck: false,
|
||||
}
|
||||
mesh.updateMatrixWorld(true)
|
||||
|
||||
return {
|
||||
mesh,
|
||||
minY: merged.boundingBox?.min.y ?? 0,
|
||||
dispose: () => {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: three-mesh-bvh runtime extension
|
||||
;(merged as any).disposeBoundsTree?.()
|
||||
merged.dispose()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The baked spawn marker's eye position + yaw, if the artifact carries one. */
|
||||
function resolveGlbSpawn(
|
||||
scene: Object3D,
|
||||
): { position: [number, number, number]; yaw: number } | null {
|
||||
let spawn: Object3D | null = null
|
||||
scene.traverse((object) => {
|
||||
if ((object.userData as { kind?: string }).kind === 'spawn') spawn = object
|
||||
})
|
||||
if (!spawn) return null
|
||||
const node = spawn as Object3D
|
||||
node.updateWorldMatrix(true, false)
|
||||
node.getWorldPosition(spawnPos)
|
||||
node.getWorldQuaternion(spawnQuat)
|
||||
spawnEuler.setFromQuaternion(spawnQuat, 'YXZ')
|
||||
return {
|
||||
position: [spawnPos.x, spawnPos.y + SPAWN_EYE_HEIGHT, spawnPos.z],
|
||||
yaw: spawnEuler.y,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First-person walkthrough controller for the baked GLB. Reuses the editor's
|
||||
* `BVHEcctrl` capsule character controller (gravity, jump, sprint, ground-float,
|
||||
* mesh collision) fed a collider built from the artifact's own geometry — so the
|
||||
* baked viewer walks the building with the same physics as the editor, without
|
||||
* the parametric scene. Pointer-lock drives look; WASD moves; Space jumps; Shift
|
||||
* sprints. Door/window interaction stays in `GlbScene` (its centre-ray HUD).
|
||||
*/
|
||||
export function GlbWalkthroughController({ url }: { url: string }) {
|
||||
const { camera, gl } = useThree()
|
||||
const gltf = useGLTFKTX2(url) as unknown as { scene: Object3D }
|
||||
|
||||
const worldRef = useRef<GlbColliderWorld | null>(null)
|
||||
const controllerRef = useRef<BVHEcctrlApi | null>(null)
|
||||
const yawRef = useRef(0)
|
||||
const pitchRef = useRef(0)
|
||||
const [start, setStart] = useState<{ position: [number, number, number] } | null>(null)
|
||||
const [world, setWorld] = useState<GlbColliderWorld | null>(null)
|
||||
|
||||
// Build the collider on the first frame (priority after GlbScene's level loop,
|
||||
// which snaps the floors to their stacked world positions in walkthrough) so it
|
||||
// matches the rendered building rather than a mid-lerp / exploded layout.
|
||||
const builtRef = useRef(false)
|
||||
useFrame(() => {
|
||||
if (builtRef.current) return
|
||||
builtRef.current = true
|
||||
setWorld(buildGlbColliderWorld(gltf.scene))
|
||||
}, 6)
|
||||
|
||||
// First-person needs a perspective camera — an orthographic projection has no
|
||||
// foreshortening and makes the walkthrough unusable. Force perspective while
|
||||
// walking and restore the prior projection on exit.
|
||||
useEffect(() => {
|
||||
const prevMode = useViewer.getState().cameraMode
|
||||
if (prevMode === 'orthographic') useViewer.getState().setCameraMode('perspective')
|
||||
return () => {
|
||||
if (prevMode === 'orthographic') useViewer.getState().setCameraMode('orthographic')
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
worldRef.current = world
|
||||
if (world) {
|
||||
const triangles = world.mesh.geometry.getAttribute('position').count / 3
|
||||
console.warn('[glb-walkthrough] collider built', {
|
||||
triangles,
|
||||
minY: world.minY,
|
||||
hasBoundsTree: !!(world.mesh.geometry as { boundsTree?: unknown }).boundsTree,
|
||||
spawn: resolveGlbSpawn(gltf.scene),
|
||||
})
|
||||
} else {
|
||||
console.warn('[glb-walkthrough] NO collider world (no eligible meshes)')
|
||||
}
|
||||
return () => {
|
||||
world?.dispose()
|
||||
worldRef.current = null
|
||||
}
|
||||
}, [world, gltf.scene])
|
||||
|
||||
// Resolve the spawn once the collider exists; the capsule centre sits below
|
||||
// the eye, then the controller floats it onto the ground.
|
||||
useEffect(() => {
|
||||
if (!world || start) return
|
||||
const spawn = resolveGlbSpawn(gltf.scene)
|
||||
const eye = spawn?.position ?? [0, SPAWN_EYE_HEIGHT, 0]
|
||||
yawRef.current = spawn?.yaw ?? 0
|
||||
pitchRef.current = 0
|
||||
setStart({ position: [eye[0], eye[1] - CONTROLLER_CENTER_FROM_EYE, eye[2]] })
|
||||
}, [world, start, gltf.scene])
|
||||
|
||||
// Pointer-lock look + click-to-lock fallback + Esc/unlock to exit. Once the
|
||||
// pointer has been locked, releasing it (Esc — the browser swallows that
|
||||
// keydown, so we can't rely on it; or any other unlock) leaves the walkthrough
|
||||
// in a single press rather than just freeing the cursor.
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
let wasLocked = false
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
if (document.pointerLockElement !== canvas) return
|
||||
yawRef.current -= event.movementX * LOOK_SENSITIVITY
|
||||
pitchRef.current = Math.max(
|
||||
-(Math.PI / 2 - 0.05),
|
||||
Math.min(Math.PI / 2 - 0.05, pitchRef.current - event.movementY * LOOK_SENSITIVITY),
|
||||
)
|
||||
}
|
||||
const onClick = () => {
|
||||
if (document.pointerLockElement !== canvas) canvas.requestPointerLock?.()
|
||||
}
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
// When locked, the browser intercepts Esc to release the pointer and the
|
||||
// pointerlockchange handler below exits; this only covers Esc while the
|
||||
// pointer is already free (e.g. lock never engaged).
|
||||
if (event.code === 'Escape' && document.pointerLockElement !== canvas) {
|
||||
useViewer.getState().setWalkthroughMode(false)
|
||||
}
|
||||
}
|
||||
const onPointerLockChange = () => {
|
||||
if (document.pointerLockElement === canvas) wasLocked = true
|
||||
else if (wasLocked) useViewer.getState().setWalkthroughMode(false)
|
||||
}
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
canvas.addEventListener('click', onClick)
|
||||
document.addEventListener('keydown', onKeyDown)
|
||||
document.addEventListener('pointerlockchange', onPointerLockChange)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
canvas.removeEventListener('click', onClick)
|
||||
document.removeEventListener('keydown', onKeyDown)
|
||||
document.removeEventListener('pointerlockchange', onPointerLockChange)
|
||||
if (document.pointerLockElement === canvas) document.exitPointerLock()
|
||||
}
|
||||
}, [gl])
|
||||
|
||||
// Lock the pointer the moment the walkthrough is ready, so the user doesn't
|
||||
// have to click the canvas first. The walkthrough toggle is itself a user
|
||||
// gesture; if the browser still rejects the request (no transient activation
|
||||
// left), the click-to-lock fallback above covers it.
|
||||
useEffect(() => {
|
||||
if (!(world && start)) return
|
||||
const canvas = gl.domElement
|
||||
if (document.pointerLockElement === canvas) return
|
||||
const result = canvas.requestPointerLock?.() as Promise<void> | undefined
|
||||
if (result && typeof result.catch === 'function') result.catch(() => {})
|
||||
}, [gl, world, start])
|
||||
|
||||
const setControllerApi = useCallback((api: BVHEcctrlApi | null) => {
|
||||
controllerRef.current = api
|
||||
}, [])
|
||||
|
||||
// Drive the camera from the capsule each frame + respawn if it falls into void.
|
||||
useFrame(() => {
|
||||
const group = controllerRef.current?.group
|
||||
if (!group) return
|
||||
|
||||
if (start && world && group.position.y < world.minY - VOID_FALL_RESPAWN_DEPTH) {
|
||||
group.position.set(start.position[0], start.position[1], start.position[2])
|
||||
controllerRef.current?.resetLinVel()
|
||||
}
|
||||
|
||||
group.rotation.y = 0
|
||||
camera.position.copy(group.position).add(cameraOffset)
|
||||
cameraEuler.set(pitchRef.current, yawRef.current, 0, 'YXZ')
|
||||
camera.quaternion.setFromEuler(cameraEuler)
|
||||
camera.updateMatrixWorld(true)
|
||||
}, 2.5)
|
||||
|
||||
if (!(world && start)) return null
|
||||
|
||||
return (
|
||||
<KeyboardControls map={keyboardMap}>
|
||||
<BVHEcctrl
|
||||
acceleration={26}
|
||||
airDragFactor={0.3}
|
||||
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
|
||||
colliderMeshes={[world.mesh]}
|
||||
collisionCheckIteration={3}
|
||||
collisionPushBackDamping={0.1}
|
||||
collisionPushBackThreshold={0.001}
|
||||
deceleration={30}
|
||||
delay={0}
|
||||
fallGravityFactor={4}
|
||||
floatCheckType="BOTH"
|
||||
floatDampingC={36}
|
||||
floatHeight={0.5}
|
||||
floatPullBackHeight={0.35}
|
||||
floatSensorRadius={0.15}
|
||||
floatSpringK={1200}
|
||||
gravity={9.81}
|
||||
jumpVel={6}
|
||||
maxRunSpeed={5.5}
|
||||
maxSlope={1.2}
|
||||
maxWalkSpeed={4}
|
||||
position={start.position}
|
||||
ref={setControllerApi}
|
||||
/>
|
||||
</KeyboardControls>
|
||||
)
|
||||
}
|
||||
@@ -7,8 +7,15 @@ import {
|
||||
sceneRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { Canvas, extend, type ThreeToJSXElements, useFrame, useThree } from '@react-three/fiber'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useLayoutEffect, useRef } from 'react'
|
||||
import { Canvas, extend, type ThreeElement, useFrame, useThree } from '@react-three/fiber'
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import * as THREE from 'three/webgpu'
|
||||
import { hasDrawableGeometry } from '../../lib/drawable-geometry'
|
||||
import { PERF_OVERLAY_ENABLED, pushGpuSample } from '../../lib/gpu-perf'
|
||||
@@ -31,7 +38,15 @@ import { SelectionManager } from './selection-manager'
|
||||
import { ViewerCamera } from './viewer-camera'
|
||||
|
||||
declare module '@react-three/fiber' {
|
||||
interface ThreeElements extends ThreeToJSXElements<typeof THREE> {}
|
||||
// The TS 7 native compiler (tsgo) rejects mapping the entire `three/webgpu`
|
||||
// namespace into JSX — `ThreeToJSXElements<typeof THREE>` triggers a TS2320
|
||||
// heritage conflict with R3F's core-three base plus a TS2590 "union too
|
||||
// complex". tsc 6 tolerates it; tsgo does not. R3F's base ThreeElements
|
||||
// already covers core three, so we extract only the webgpu/TSL node materials
|
||||
// we actually use as JSX (see r3f.docs.pmnd.rs/api/typescript).
|
||||
interface ThreeElements {
|
||||
lineBasicNodeMaterial: ThreeElement<typeof THREE.LineBasicNodeMaterial>
|
||||
}
|
||||
}
|
||||
|
||||
extend(THREE as any)
|
||||
@@ -67,6 +82,38 @@ const DIRTY_BUILD_KINDS = new Set([
|
||||
|
||||
const warnedEmptyDraw = process.env.NODE_ENV === 'production' ? null : new WeakSet<object>()
|
||||
|
||||
function canCreateWebGLContext() {
|
||||
if (typeof document === 'undefined') return false
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
try {
|
||||
return Boolean(canvas.getContext('webgl2') ?? canvas.getContext('webgl'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function canMountGpuViewer() {
|
||||
if (typeof window === 'undefined') return false
|
||||
if (!('gpu' in navigator) && !canCreateWebGLContext()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function UnsupportedGpuViewerFallback() {
|
||||
return (
|
||||
<div className="flex h-full min-h-64 w-full items-center justify-center bg-[#fafafa] p-6 text-center text-neutral-900">
|
||||
<div className="max-w-md rounded-2xl border border-neutral-200 bg-white p-6 shadow-sm">
|
||||
<h2 className="font-semibold text-lg">3D viewer unavailable</h2>
|
||||
<p className="mt-2 text-neutral-600 text-sm">
|
||||
This browser or environment does not expose WebGPU or WebGL, so Pascal cannot render the
|
||||
3D scene here. Try opening the editor in a browser with hardware acceleration enabled.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renderer-level safety net against the empty-vertex-buffer crash.
|
||||
*
|
||||
@@ -349,6 +396,16 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
}
|
||||
}, [isolate])
|
||||
|
||||
const [rendererInitFailed, setRendererInitFailed] = useState(false)
|
||||
// Capability detection runs after mount. We start optimistic (true) so the
|
||||
// server-rendered markup and the first client render agree (no hydration
|
||||
// mismatch); the effect flips it to false only on environments that expose
|
||||
// neither WebGPU nor WebGL.
|
||||
const [canMountViewer, setCanMountViewer] = useState(true)
|
||||
useEffect(() => {
|
||||
if (!canMountGpuViewer()) setCanMountViewer(false)
|
||||
}, [])
|
||||
|
||||
const isDark = useViewer((state) => getSceneTheme(state.sceneTheme).appearance === 'dark')
|
||||
const transparentBackground = useViewer((state) => state.transparentBackground)
|
||||
useLayoutEffect(() => {
|
||||
@@ -401,6 +458,17 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
// Desktops (fine pointer) keep the original 1.5 cap.
|
||||
const maxDpr =
|
||||
typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches ? 1.25 : 1.5
|
||||
const showGpuFallback = !canMountViewer || rendererInitFailed
|
||||
// When we can't mount the GPU canvas, the SceneReadyTracker never mounts and
|
||||
// the host editor would otherwise wait on its scene-readiness timeout. Signal
|
||||
// readiness explicitly so the host can drop its loader immediately.
|
||||
useEffect(() => {
|
||||
if (showGpuFallback) onSceneReadyChange?.(true)
|
||||
}, [showGpuFallback, onSceneReadyChange])
|
||||
|
||||
if (showGpuFallback) {
|
||||
return <UnsupportedGpuViewerFallback />
|
||||
}
|
||||
return (
|
||||
<Canvas
|
||||
camera={{ position: [50, 50, 50], fov: 50 }}
|
||||
@@ -430,6 +498,7 @@ const Viewer = forwardRef<ViewerHandle, ViewerProps>(function Viewer(
|
||||
// rejection forever.
|
||||
if (canvas) WEBGPU_RENDERER_CACHE.delete(canvas)
|
||||
console.error('[viewer] WebGPURenderer init failed', err)
|
||||
setRendererInitFailed(true)
|
||||
throw err
|
||||
}
|
||||
})()
|
||||
|
||||
@@ -338,7 +338,13 @@ const PostProcessingPasses = ({
|
||||
const hasGeometry = scenePassColor.a
|
||||
const contentAlpha = hasGeometry.max(zonePass.a)
|
||||
|
||||
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4>
|
||||
// Composite the zone-pass tint into the base scene so rooms show whether or
|
||||
// not SSGI is enabled. When SSGI is on, the branch below overwrites this
|
||||
// with its own zone-inclusive composite (no double-add).
|
||||
let sceneColor = vec4(
|
||||
add(scenePassColor.rgb, zonePass.rgb),
|
||||
contentAlpha,
|
||||
) as unknown as ReturnType<typeof vec4>
|
||||
|
||||
// Depth + normal MRT — shared by SSGI (diffuse/normal) and the ink pass
|
||||
// (depth/normal). Built whenever either is active.
|
||||
|
||||
@@ -13,6 +13,25 @@ export { ErrorBoundary } from './components/error-boundary'
|
||||
// — no per-kind re-exports needed.
|
||||
export { NodeRenderer } from './components/renderers/node-renderer'
|
||||
export { default as Viewer, type ViewerHandle } from './components/viewer'
|
||||
export {
|
||||
type BVHEcctrlApi,
|
||||
default as BVHEcctrl,
|
||||
type MovementInput,
|
||||
} from './components/viewer/bvh-ecctrl'
|
||||
export {
|
||||
buildGlbInteractiveItems,
|
||||
GlbInteractive,
|
||||
type GlbInteractiveItem,
|
||||
} from './components/viewer/glb-interactive'
|
||||
export { buildGlbReferenceNodes } from './components/viewer/glb-reference-nodes'
|
||||
export {
|
||||
type GlbHover,
|
||||
type GlbIdentity,
|
||||
type GlbLevel,
|
||||
GlbScene,
|
||||
type GlbWalkthrough,
|
||||
} from './components/viewer/glb-scene'
|
||||
export { GlbWalkthroughController } from './components/viewer/glb-walkthrough-controller'
|
||||
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
|
||||
export {
|
||||
DEFAULT_HOVER_STYLES,
|
||||
@@ -157,6 +176,9 @@ export { getVisibleWallMaterials } from './systems/wall/wall-materials'
|
||||
// 800+ lines of CSG / mitering logic during Phase 3. These exports are
|
||||
// removed in Phase 6 when the legacy mount points are deleted.
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
export { WindowAnimationSystem } from './systems/window/window-animation-system'
|
||||
export {
|
||||
poseWindowMovingParts,
|
||||
WindowAnimationSystem,
|
||||
} from './systems/window/window-animation-system'
|
||||
export { buildWindowPreviewMesh, WindowSystem } from './systems/window/window-system'
|
||||
export { ZoneSystem } from './systems/zone/zone-system'
|
||||
|
||||
@@ -63,8 +63,8 @@ type ViewerState = {
|
||||
levelMode: 'stacked' | 'exploded' | 'solo' | 'manual'
|
||||
setLevelMode: (mode: 'stacked' | 'exploded' | 'solo' | 'manual') => void
|
||||
|
||||
wallMode: 'up' | 'cutaway' | 'down'
|
||||
setWallMode: (mode: 'up' | 'cutaway' | 'down') => void
|
||||
wallMode: 'up' | 'cutaway' | 'down' | 'translucent'
|
||||
setWallMode: (mode: 'up' | 'cutaway' | 'down' | 'translucent') => void
|
||||
|
||||
showScans: boolean
|
||||
setShowScans: (show: boolean) => void
|
||||
@@ -145,7 +145,7 @@ const COLOR_PRESETS = ['clay', 'white', 'mono', 'blueprint'] as const
|
||||
const EDGE_MODES = ['off', 'soft', 'strong'] as const
|
||||
const UNITS = ['metric', 'imperial'] as const
|
||||
const LEVEL_MODES = ['stacked', 'exploded', 'solo', 'manual'] as const
|
||||
const WALL_MODES = ['up', 'cutaway', 'down'] as const
|
||||
const WALL_MODES = ['up', 'cutaway', 'down', 'translucent'] as const
|
||||
|
||||
function pickString<T extends string>(value: unknown, allowed: readonly T[], fallback: T): T {
|
||||
return typeof value === 'string' && allowed.includes(value as T) ? (value as T) : fallback
|
||||
|
||||
@@ -112,6 +112,7 @@ export const DoorSystem = () => {
|
||||
// Editing a scene material a door slot references must rebuild that door
|
||||
// (door meshes are built by this system, not <GeometrySystem>).
|
||||
useEffect(() => {
|
||||
void sceneMaterials
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node?.type !== 'door') continue
|
||||
@@ -1109,6 +1110,7 @@ function addDoorLeaf(
|
||||
hingeX,
|
||||
hingeSide,
|
||||
swingRotation,
|
||||
openRotationY,
|
||||
segments,
|
||||
contentPadding,
|
||||
handle,
|
||||
@@ -1134,6 +1136,10 @@ function addDoorLeaf(
|
||||
hingeX: number
|
||||
hingeSide: 'left' | 'right'
|
||||
swingRotation: number
|
||||
// Leaf rotation (radians, about the hinge Y axis) at fully-open. The GLB
|
||||
// exporter reads this off the leaf group to bake an open/close clip; it is
|
||||
// the kinematic endpoint, independent of the current `swingRotation`.
|
||||
openRotationY: number
|
||||
segments: DoorNode['segments']
|
||||
contentPadding: DoorNode['contentPadding']
|
||||
handle: boolean
|
||||
@@ -1164,6 +1170,10 @@ function addDoorLeaf(
|
||||
const leafGroup = new THREE.Group()
|
||||
leafGroup.position.set(hingeX, 0, 0)
|
||||
leafGroup.rotation.y = swingRotation
|
||||
// Marks this group as the swing leaf and records its fully-open angle so the
|
||||
// GLB exporter can bake an open/close animation clip from a single pose. The
|
||||
// exporter strips this marker before writing the file.
|
||||
leafGroup.userData.pascalSwingLeaf = { axis: 'y', openRotationY }
|
||||
mesh.add(leafGroup)
|
||||
|
||||
const addLeafBox = (
|
||||
@@ -2486,6 +2496,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
||||
hingeX: -insideWidth / 2,
|
||||
hingeSide: 'left',
|
||||
swingRotation: -clampedSwingAngle * swingDirectionSign,
|
||||
openRotationY: (-Math.PI / 2) * swingDirectionSign,
|
||||
segments,
|
||||
contentPadding,
|
||||
handle,
|
||||
@@ -2514,6 +2525,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
||||
hingeX: insideWidth / 2,
|
||||
hingeSide: 'right',
|
||||
swingRotation: clampedSwingAngle * swingDirectionSign,
|
||||
openRotationY: (Math.PI / 2) * swingDirectionSign,
|
||||
segments,
|
||||
contentPadding,
|
||||
handle,
|
||||
@@ -2545,6 +2557,7 @@ function updateDoorMesh(rawNode: DoorNode, mesh: THREE.Mesh) {
|
||||
hingeX,
|
||||
hingeSide: hingesSide,
|
||||
swingRotation: clampedSwingAngle * swingDirectionSign * hingeDirectionSign,
|
||||
openRotationY: (Math.PI / 2) * swingDirectionSign * hingeDirectionSign,
|
||||
segments,
|
||||
contentPadding,
|
||||
handle,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// @ts-expect-error — bun:test is provided by the Bun runtime; viewer does not
|
||||
// depend on @types/bun so the import type is unresolved at compile time.
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { Group } from 'three'
|
||||
import { type GeometryBuildCacheEntry, shouldReuseGeometryBuild } from './geometry-system'
|
||||
|
||||
describe('shouldReuseGeometryBuild', () => {
|
||||
test('rebuilds when the same node id remounts into a new group with the same key', () => {
|
||||
const cache = new Map<string, GeometryBuildCacheEntry>()
|
||||
const firstGroup = new Group()
|
||||
const remountedGroup = new Group()
|
||||
|
||||
expect(shouldReuseGeometryBuild(cache, 'duct_1', firstGroup, 'same-key')).toBe(false)
|
||||
expect(shouldReuseGeometryBuild(cache, 'duct_1', firstGroup, 'same-key')).toBe(true)
|
||||
expect(shouldReuseGeometryBuild(cache, 'duct_1', remountedGroup, 'same-key')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -66,7 +66,7 @@ export const GeometrySystem = () => {
|
||||
// `def.geometryKey`). Lets us skip a dispose+rebuild when a node is dirty
|
||||
// but its geometry inputs are unchanged — e.g. an item reparenting onto a
|
||||
// shelf dirties the shelf without altering its boards.
|
||||
const builtGeometryKeyRef = useRef<Map<string, string>>(new Map())
|
||||
const builtGeometryKeyRef = useRef<Map<string, GeometryBuildCacheEntry>>(new Map())
|
||||
|
||||
// Re-mark every geometry-backed node dirty whenever a viewer appearance
|
||||
// value changes, so `def.geometry` builders re-run and pick up the new
|
||||
@@ -93,6 +93,7 @@ export const GeometrySystem = () => {
|
||||
// then mark it dirty. Scoped to nodes carrying a `scene:` ref so an
|
||||
// unrelated material edit doesn't churn the whole scene.
|
||||
useEffect(() => {
|
||||
void sceneMaterials
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const node of Object.values(nodes)) {
|
||||
const def = nodeRegistry.get(node.type)
|
||||
@@ -187,11 +188,10 @@ export const GeometrySystem = () => {
|
||||
// churn when an item reparents onto a shelf.
|
||||
if (def.geometryKey) {
|
||||
const builtKey = `${shading}|${textures}|${colorPreset}|${sceneTheme}|${def.geometryKey(effectiveNode)}`
|
||||
if (builtGeometryKeyRef.current.get(id) === builtKey) {
|
||||
if (shouldReuseGeometryBuild(builtGeometryKeyRef.current, id, group, builtKey)) {
|
||||
clearDirty(id as AnyNodeId)
|
||||
continue
|
||||
}
|
||||
builtGeometryKeyRef.current.set(id, builtKey)
|
||||
}
|
||||
|
||||
const parentId = (node.parentId ?? null) as AnyNodeId | null
|
||||
@@ -380,3 +380,20 @@ function isCachedMaterial(value: unknown): boolean {
|
||||
}
|
||||
|
||||
export default GeometrySystem
|
||||
|
||||
export type GeometryBuildCacheEntry = {
|
||||
group: Group
|
||||
key: string
|
||||
}
|
||||
|
||||
export function shouldReuseGeometryBuild(
|
||||
cache: Map<string, GeometryBuildCacheEntry>,
|
||||
id: string,
|
||||
group: Group,
|
||||
key: string,
|
||||
): boolean {
|
||||
const cached = cache.get(id)
|
||||
if (cached?.group === group && cached.key === key) return true
|
||||
cache.set(id, { group, key })
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import type { Control, ControlValue } from '@pascal-app/core'
|
||||
|
||||
/** One interactive control (toggle / slider / temperature) rendered inside the
|
||||
* item controls overlay. Shared by the parametric `InteractiveSystem` and the
|
||||
* baked-GLB `GlbInteractive` overlay so both look and behave identically. */
|
||||
export const ControlWidget = ({
|
||||
control,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
control: Control
|
||||
value: ControlValue
|
||||
onChange: (v: ControlValue) => void
|
||||
}) => {
|
||||
const labelStyle: React.CSSProperties = {
|
||||
color: 'white',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}
|
||||
|
||||
if (control.kind === 'toggle') {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!value)}
|
||||
style={{
|
||||
background: value ? '#4ade80' : '#374151',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
{control.label ?? (value ? 'On' : 'Off')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (control.kind === 'slider') {
|
||||
return (
|
||||
<label style={labelStyle}>
|
||||
<span>
|
||||
{control.label}: {value}
|
||||
{control.unit ? ` ${control.unit}` : ''}
|
||||
</span>
|
||||
<input
|
||||
max={control.max}
|
||||
min={control.min}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
step={control.step}
|
||||
type="range"
|
||||
value={value as number}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (control.kind === 'temperature') {
|
||||
return (
|
||||
<label style={labelStyle}>
|
||||
<span>
|
||||
{control.label}: {value}°{control.unit}
|
||||
</span>
|
||||
<input
|
||||
max={control.max}
|
||||
min={control.min}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
step={1}
|
||||
type="range"
|
||||
value={value as number}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type Control,
|
||||
type ControlValue,
|
||||
type ItemNode,
|
||||
pointInPolygon,
|
||||
sceneRegistry,
|
||||
@@ -17,6 +15,7 @@ import { useEffect, useState } from 'react'
|
||||
import { type Object3D, Vector3 } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
import { ControlWidget } from './control-widget'
|
||||
|
||||
const _tempVec = new Vector3()
|
||||
|
||||
@@ -146,86 +145,3 @@ const ItemControlsOverlay = ({
|
||||
itemObj,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Control widgets ----
|
||||
|
||||
const ControlWidget = ({
|
||||
control,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
control: Control
|
||||
value: ControlValue
|
||||
onChange: (v: ControlValue) => void
|
||||
}) => {
|
||||
const labelStyle: React.CSSProperties = {
|
||||
color: 'white',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}
|
||||
|
||||
if (control.kind === 'toggle') {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!value)}
|
||||
style={{
|
||||
background: value ? '#4ade80' : '#374151',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
{control.label ?? (value ? 'On' : 'Off')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (control.kind === 'slider') {
|
||||
return (
|
||||
<label style={labelStyle}>
|
||||
<span>
|
||||
{control.label}: {value}
|
||||
{control.unit ? ` ${control.unit}` : ''}
|
||||
</span>
|
||||
<input
|
||||
max={control.max}
|
||||
min={control.min}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
step={control.step}
|
||||
type="range"
|
||||
value={value as number}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (control.kind === 'temperature') {
|
||||
return (
|
||||
<label style={labelStyle}>
|
||||
<span>
|
||||
{control.label}: {value}°{control.unit}
|
||||
</span>
|
||||
<input
|
||||
max={control.max}
|
||||
min={control.min}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
step={1}
|
||||
type="range"
|
||||
value={value as number}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ function getWallHideState(
|
||||
return hideWall
|
||||
}
|
||||
|
||||
function sameMaterialArray(a: Material | Material[], b: Material[]): boolean {
|
||||
return Array.isArray(a) && a.length === b.length && a.every((material, i) => material === b[i])
|
||||
}
|
||||
|
||||
export const WallCutout = () => {
|
||||
const lastCameraPosition = useRef(new Vector3())
|
||||
const lastCameraTarget = useRef(new Vector3())
|
||||
@@ -113,7 +117,13 @@ export const WallCutout = () => {
|
||||
useScene.getState().materials,
|
||||
)
|
||||
|
||||
if (hideWall) {
|
||||
if (wallMode === 'translucent') {
|
||||
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||
? materials.deleteTranslucent
|
||||
: isSelectionHighlighted
|
||||
? getSelectionHighlightMaterials(materials.translucent)
|
||||
: materials.translucent
|
||||
} else if (hideWall) {
|
||||
;(wallMesh as Mesh).material = isDeleteHighlighted
|
||||
? materials.deleteInvisible
|
||||
: isSelectionHighlighted
|
||||
@@ -160,6 +170,11 @@ export const WallCutout = () => {
|
||||
wallMesh.material = mats.visible
|
||||
} else if (current === mats.deleteInvisible) {
|
||||
wallMesh.material = mats.invisible
|
||||
} else if (
|
||||
current === mats.deleteTranslucent ||
|
||||
sameMaterialArray(current, getSelectionHighlightMaterials(mats.translucent))
|
||||
) {
|
||||
wallMesh.material = mats.translucent
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -46,8 +46,10 @@ export type WallMaterialArray = [Material, Material, Material]
|
||||
export interface WallMaterials {
|
||||
visible: WallMaterialArray
|
||||
invisible: WallMaterialArray
|
||||
translucent: WallMaterialArray
|
||||
deleteVisible: WallMaterialArray
|
||||
deleteInvisible: WallMaterialArray
|
||||
deleteTranslucent: WallMaterialArray
|
||||
materialHash: string
|
||||
}
|
||||
|
||||
@@ -297,6 +299,25 @@ function createInvisibleWallMaterial(color: string, shading: RenderShading): Mat
|
||||
return material
|
||||
}
|
||||
|
||||
function createTranslucentWallMaterial(color: string, shading: RenderShading): Material {
|
||||
const material =
|
||||
shading === 'solid'
|
||||
? new MeshLambertNodeMaterial({
|
||||
transparent: true,
|
||||
color,
|
||||
opacity: 0.35,
|
||||
depthWrite: false,
|
||||
})
|
||||
: new MeshStandardNodeMaterial({
|
||||
transparent: true,
|
||||
color,
|
||||
opacity: 0.35,
|
||||
depthWrite: false,
|
||||
})
|
||||
|
||||
return material
|
||||
}
|
||||
|
||||
function mapWallMaterialArray(
|
||||
materials: WallMaterialArray,
|
||||
iteratee: (material: Material, index: number) => Material,
|
||||
@@ -347,7 +368,13 @@ export function getMaterialsForWall(
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
disposeOwnedMaterials([existing.invisible, existing.deleteVisible, existing.deleteInvisible])
|
||||
disposeOwnedMaterials([
|
||||
existing.invisible,
|
||||
existing.translucent,
|
||||
existing.deleteVisible,
|
||||
existing.deleteInvisible,
|
||||
existing.deleteTranslucent,
|
||||
])
|
||||
}
|
||||
|
||||
const wallRoleMaterial = createSurfaceRoleMaterial('wall', colorPreset, undefined, sceneTheme)
|
||||
@@ -381,18 +408,39 @@ export function getMaterialsForWall(
|
||||
),
|
||||
]
|
||||
|
||||
const translucent: WallMaterialArray = [
|
||||
createTranslucentWallMaterial(wallRoleColor, textures ? shading : 'solid'),
|
||||
createTranslucentWallMaterial(
|
||||
textures
|
||||
? resolveWallFaceColor(wallNode, 'interior', sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
),
|
||||
createTranslucentWallMaterial(
|
||||
textures
|
||||
? resolveWallFaceColor(wallNode, 'exterior', sceneMaterials, wallRoleColor)
|
||||
: wallRoleColor,
|
||||
textures ? shading : 'solid',
|
||||
),
|
||||
]
|
||||
|
||||
const deleteVisible = mapWallMaterialArray(visible, (material) =>
|
||||
createHighlightedWallMaterial(material, 'delete'),
|
||||
)
|
||||
const deleteInvisible = mapWallMaterialArray(invisible, (material) =>
|
||||
createHighlightedWallMaterial(material, 'delete'),
|
||||
)
|
||||
const deleteTranslucent = mapWallMaterialArray(translucent, (material) =>
|
||||
createHighlightedWallMaterial(material, 'delete'),
|
||||
)
|
||||
|
||||
const result: WallMaterials = {
|
||||
visible,
|
||||
invisible,
|
||||
translucent,
|
||||
deleteVisible,
|
||||
deleteInvisible,
|
||||
deleteTranslucent,
|
||||
materialHash,
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type WindowNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import type { Object3D } from 'three'
|
||||
import {
|
||||
AWNING_WINDOW_SASH_NAME,
|
||||
CASEMENT_WINDOW_SASH_NAME,
|
||||
@@ -28,12 +29,20 @@ function markWindowDirty(windowId: AnyNodeId) {
|
||||
scene.dirtyNodes.add(windowId)
|
||||
}
|
||||
|
||||
function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
||||
const node = useScene.getState().nodes[windowId]
|
||||
if (node?.type !== 'window') return false
|
||||
|
||||
const mesh = sceneRegistry.nodes.get(windowId)
|
||||
|
||||
/**
|
||||
* Pose a window's moving parts (sash/panel/slats) at `value` (0 = closed,
|
||||
* 1 = open) by mutating the named child groups under `mesh`. Returns true when
|
||||
* the window type has a direct pose path and the named parts were found.
|
||||
*
|
||||
* This is the single source of truth for window kinematics: the live animation
|
||||
* system poses the registered scene mesh, and the GLB exporter poses an export
|
||||
* clone to sample the open/close keyframes for a baked animation clip.
|
||||
*/
|
||||
export function poseWindowMovingParts(
|
||||
node: WindowNode,
|
||||
mesh: Object3D | undefined,
|
||||
value: number,
|
||||
): boolean {
|
||||
if (node.windowType === 'sliding') {
|
||||
const activePanel = mesh?.getObjectByName(SLIDING_WINDOW_ACTIVE_PANEL_NAME)
|
||||
if (!activePanel) return false
|
||||
@@ -120,6 +129,12 @@ function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
||||
return false
|
||||
}
|
||||
|
||||
function applyDirectWindowAnimation(windowId: AnyNodeId, value: number) {
|
||||
const node = useScene.getState().nodes[windowId]
|
||||
if (node?.type !== 'window') return false
|
||||
return poseWindowMovingParts(node, sceneRegistry.nodes.get(windowId), value)
|
||||
}
|
||||
|
||||
export const WindowAnimationSystem = () => {
|
||||
useFrame(({ clock }) => {
|
||||
const interactive = useInteractive.getState()
|
||||
|
||||
@@ -89,6 +89,7 @@ export const WindowSystem = () => {
|
||||
// (window meshes are built by this system, not <GeometrySystem>, so its
|
||||
// scene-material re-dirty doesn't cover them).
|
||||
useEffect(() => {
|
||||
void sceneMaterials
|
||||
const nodes = useScene.getState().nodes
|
||||
for (const node of Object.values(nodes)) {
|
||||
if (node?.type !== 'window') continue
|
||||
|
||||
Reference in New Issue
Block a user