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 @@
|
||||
"./catalog": "./src/components/ui/item-catalog/catalog-items.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"check-types": "tsc --noEmit"
|
||||
"check-types": "tsgo --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "^0.9.1",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { exportSceneToGlb } from '../../lib/glb-export'
|
||||
|
||||
export function BakeExporter({
|
||||
active,
|
||||
onComplete,
|
||||
onError,
|
||||
}: {
|
||||
active: boolean
|
||||
onComplete: (buffer: ArrayBuffer) => void
|
||||
onError: (message: string) => void
|
||||
}) {
|
||||
const scene = useThree((s) => s.scene)
|
||||
const doneRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!(active && !doneRef.current)) return
|
||||
doneRef.current = true
|
||||
const run = async () => {
|
||||
try {
|
||||
const sceneGroup = scene.getObjectByName('scene-renderer')
|
||||
if (!sceneGroup) throw new Error('scene-renderer group not found')
|
||||
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
||||
onComplete(buffer)
|
||||
} catch (err) {
|
||||
onError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
void run()
|
||||
}, [active, scene, onComplete, onError])
|
||||
return null
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import type { Mesh, Object3D } from 'three'
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
||||
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
||||
import { exportSceneToGlb, prepareSceneForExport } from '../../lib/glb-export'
|
||||
|
||||
export function ExportManager() {
|
||||
const scene = useThree((state) => state.scene)
|
||||
@@ -22,7 +22,26 @@ export function ExportManager() {
|
||||
}
|
||||
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
const exportScene = prepareSceneForExport(sceneGroup)
|
||||
|
||||
if (format === 'glb') {
|
||||
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
|
||||
const blob = new Blob([buffer], { type: 'model/gltf-binary' })
|
||||
downloadBlob(blob, `model_${date}.glb`)
|
||||
return
|
||||
}
|
||||
|
||||
// Hide editor affordances that live on the scene layer (selection handles,
|
||||
// ceiling/site brackets) and let wall-cutout reveal all walls — the same
|
||||
// synchronous capture path thumbnails use. We clone the scene inside the
|
||||
// window, so the export snapshots the clean building, then restore.
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
let prepared: ReturnType<typeof prepareSceneForExport>
|
||||
try {
|
||||
prepared = prepareSceneForExport(sceneGroup, useScene.getState().nodes)
|
||||
} finally {
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
}
|
||||
const { scene: exportScene, animations } = prepared
|
||||
|
||||
if (format === 'stl') {
|
||||
const exporter = new STLExporter()
|
||||
@@ -39,25 +58,6 @@ export function ExportManager() {
|
||||
downloadBlob(blob, `model_${date}.obj`)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: GLB export (existing behavior)
|
||||
const exporter = new GLTFExporter()
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
exporter.parse(
|
||||
exportScene,
|
||||
(gltf) => {
|
||||
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
|
||||
downloadBlob(blob, `model_${date}.glb`)
|
||||
resolve()
|
||||
},
|
||||
(error) => {
|
||||
console.error('Export error:', error)
|
||||
reject(error)
|
||||
},
|
||||
{ binary: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
setExportScene(exportFn)
|
||||
@@ -70,33 +70,6 @@ export function ExportManager() {
|
||||
return null
|
||||
}
|
||||
|
||||
function prepareSceneForExport(source: Object3D) {
|
||||
const clone = source.clone(true)
|
||||
const meshesToRemove: Mesh[] = []
|
||||
|
||||
clone.traverse((object) => {
|
||||
if (isMeshWithInvalidGeometry(object)) meshesToRemove.push(object)
|
||||
})
|
||||
|
||||
for (const mesh of meshesToRemove) {
|
||||
mesh.removeFromParent()
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
function isMeshWithInvalidGeometry(object: Object3D): object is Mesh {
|
||||
if (!isMesh(object)) return false
|
||||
|
||||
// Three exporters can crash when a Mesh has no readable position attribute.
|
||||
const position = object.geometry?.getAttribute('position')
|
||||
return !position || position.count === 0
|
||||
}
|
||||
|
||||
function isMesh(object: Object3D): object is Mesh {
|
||||
return (object as Mesh).isMesh === true
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from 'three'
|
||||
import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh'
|
||||
import '../../three-types'
|
||||
import { BVHEcctrl, type BVHEcctrlApi, type MovementInput } from '@pascal-app/viewer'
|
||||
import {
|
||||
closeDoorOpenState,
|
||||
DOOR_SWING_OPEN_ANGLE,
|
||||
@@ -64,8 +65,6 @@ import {
|
||||
type FirstPersonColliderWorld,
|
||||
type FirstPersonSpawn,
|
||||
} from './first-person/build-collider-world'
|
||||
import type { BVHEcctrlApi, MovementInput } from './first-person/bvh-ecctrl'
|
||||
import BVHEcctrl from './first-person/bvh-ecctrl'
|
||||
|
||||
const CAMERA_EYE_OFFSET = 0.45
|
||||
const LOOK_SENSITIVITY = 0.002
|
||||
|
||||
@@ -1,860 +0,0 @@
|
||||
import '../../../three-types'
|
||||
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
|
||||
@@ -51,6 +51,13 @@ const CHEVRON_DEPTH = 0.08
|
||||
const CHEVRON_BEVEL_THICKNESS = 0.035
|
||||
const CHEVRON_BEVEL_SIZE = 0.03
|
||||
const CHEVRON_BEVEL_SEGMENTS = 10
|
||||
// Slimmer extrude profile matching the legacy wall side handles
|
||||
// (`wall-move-side-handles.tsx`) — opt-in via the `thin` prop so the chunkier
|
||||
// default is preserved for every other handle that uses the shared chevron.
|
||||
const CHEVRON_THIN_DEPTH = 0.045
|
||||
const CHEVRON_THIN_BEVEL_THICKNESS = 0.018
|
||||
const CHEVRON_THIN_BEVEL_SIZE = 0.02
|
||||
const CHEVRON_THIN_BEVEL_SEGMENTS = 8
|
||||
const MOVE_CROSS_HALF_LENGTH = 0.36
|
||||
const MOVE_CROSS_SHAFT_HALF_WIDTH = 0.03
|
||||
const MOVE_CROSS_HEAD_HALF_WIDTH = 0.12
|
||||
@@ -64,7 +71,7 @@ const ROTATE_HANDLE_HALF_SWEEP = Math.PI / 3
|
||||
const ROTATE_RIBBON_HALF_WIDTH = 0.02
|
||||
const ROTATE_HEAD_HALF_WIDTH = 0.045
|
||||
const TRACKER_CUBE_SIZE = 0.16
|
||||
export const CORNER_HEX_RADIUS = 0.16
|
||||
export const CORNER_HEX_RADIUS = 0.11
|
||||
|
||||
export type HandleArrowShape = 'chevron' | 'cross' | 'curved-arrow' | 'tracker' | 'corner-picker'
|
||||
export type HandleArrowInputShape = HandleArrowShape | 'arrow' | 'move-cross'
|
||||
@@ -90,6 +97,8 @@ export type HandleArrowProps = {
|
||||
indicatorRotation?: readonly [number, number, number]
|
||||
onPointerEnter?: PointerHandler
|
||||
onPointerLeave?: PointerHandler
|
||||
// Extrude the slimmer wall-handle chevron profile (chevron shape only).
|
||||
thin?: boolean
|
||||
}
|
||||
|
||||
function normalizeHandleArrowShape(shape: HandleArrowInputShape, cursor: Cursor): HandleArrowShape {
|
||||
@@ -179,8 +188,9 @@ export function createRotateArrowHandleGeometry() {
|
||||
|
||||
// Reused chevron+shaft silhouette. The chevron points along +X by default;
|
||||
// callers rotate it around Y for Z-axis handles and into a vertical frame for
|
||||
// Y-axis handles.
|
||||
export function createArrowHandleGeometry() {
|
||||
// Y-axis handles. `thin` extrudes the slimmer wall-handle profile.
|
||||
export function createArrowHandleGeometry(thin = false) {
|
||||
const depth = thin ? CHEVRON_THIN_DEPTH : CHEVRON_DEPTH
|
||||
const shape = new Shape()
|
||||
shape.moveTo(CHEVRON_MAX_X, 0)
|
||||
shape.lineTo(CHEVRON_NOTCH_X, CHEVRON_HALF_WIDTH)
|
||||
@@ -191,16 +201,16 @@ export function createArrowHandleGeometry() {
|
||||
shape.lineTo(CHEVRON_NOTCH_X, -CHEVRON_HALF_WIDTH)
|
||||
shape.lineTo(CHEVRON_MAX_X, 0)
|
||||
const geometry = new ExtrudeGeometry(shape, {
|
||||
depth: CHEVRON_DEPTH,
|
||||
depth,
|
||||
bevelEnabled: true,
|
||||
bevelThickness: CHEVRON_BEVEL_THICKNESS,
|
||||
bevelSize: CHEVRON_BEVEL_SIZE,
|
||||
bevelThickness: thin ? CHEVRON_THIN_BEVEL_THICKNESS : CHEVRON_BEVEL_THICKNESS,
|
||||
bevelSize: thin ? CHEVRON_THIN_BEVEL_SIZE : CHEVRON_BEVEL_SIZE,
|
||||
bevelOffset: 0,
|
||||
bevelSegments: CHEVRON_BEVEL_SEGMENTS,
|
||||
bevelSegments: thin ? CHEVRON_THIN_BEVEL_SEGMENTS : CHEVRON_BEVEL_SEGMENTS,
|
||||
curveSegments: 16,
|
||||
steps: 1,
|
||||
})
|
||||
geometry.translate(0, 0, -CHEVRON_DEPTH / 2)
|
||||
geometry.translate(0, 0, -depth / 2)
|
||||
geometry.rotateX(-Math.PI / 2)
|
||||
geometry.computeVertexNormals()
|
||||
geometry.computeBoundingSphere()
|
||||
@@ -326,8 +336,8 @@ export function createEndpointHitAreaGeometry(radius: number) {
|
||||
return geometry
|
||||
}
|
||||
|
||||
function createHandleArrowGeometry(shape: HandleArrowShape) {
|
||||
if (shape === 'chevron') return createArrowHandleGeometry()
|
||||
function createHandleArrowGeometry(shape: HandleArrowShape, thin = false) {
|
||||
if (shape === 'chevron') return createArrowHandleGeometry(thin)
|
||||
if (shape === 'cross') return createMoveCrossHandleGeometry()
|
||||
if (shape === 'curved-arrow') return createRotateArrowHandleGeometry()
|
||||
if (shape === 'tracker') {
|
||||
@@ -471,9 +481,10 @@ export function HandleArrow({
|
||||
onPointerDown,
|
||||
onPointerEnter,
|
||||
onPointerLeave,
|
||||
thin = false,
|
||||
}: HandleArrowProps) {
|
||||
const visualShape = normalizeHandleArrowShape(shape, cursor)
|
||||
const geometry = useMemo(() => createHandleArrowGeometry(visualShape), [visualShape])
|
||||
const geometry = useMemo(() => createHandleArrowGeometry(visualShape, thin), [visualShape, thin])
|
||||
const hitGeometry = useMemo(() => createHandleArrowHitGeometry(visualShape), [visualShape])
|
||||
const indicatorMaterial = useHandleArrowMaterial(visualShape)
|
||||
const hitMaterial = useInvisibleHitAreaMaterial()
|
||||
|
||||
@@ -1261,6 +1261,7 @@ export default function Editor({
|
||||
<CeilingSystem />
|
||||
<RoofEditSystem />
|
||||
<StairEditSystem />
|
||||
{isFirstPersonMode && <FirstPersonControls />}
|
||||
<CustomCameraControls />
|
||||
<ThumbnailGenerator onThumbnailCapture={onThumbnailCapture} />
|
||||
<InteractiveSystem />
|
||||
@@ -1319,8 +1320,14 @@ export default function Editor({
|
||||
|
||||
{!isLoading && isPreviewMode ? (
|
||||
<div className="dark flex h-full w-full flex-col bg-neutral-100 text-foreground">
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
<div className="h-full w-full">{previewViewerContent}</div>
|
||||
{isFirstPersonMode ? (
|
||||
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
||||
) : (
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
)}
|
||||
<div className="h-full w-full" data-pascal-viewer-3d>
|
||||
{previewViewerContent}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
@@ -1384,8 +1391,14 @@ export default function Editor({
|
||||
|
||||
{!isLoading && isPreviewMode ? (
|
||||
<>
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
<div className="h-full w-full">{previewViewerContent}</div>
|
||||
{isFirstPersonMode ? (
|
||||
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
|
||||
) : (
|
||||
<ViewerOverlay onBack={() => useEditor.getState().setPreviewMode(false)} />
|
||||
)}
|
||||
<div className="h-full w-full" data-pascal-viewer-3d>
|
||||
{previewViewerContent}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
DEFAULT_ANGLE_STEP,
|
||||
type HandleDescriptor,
|
||||
type HandlePortal,
|
||||
type LatchHandle,
|
||||
type LinearResizeHandle,
|
||||
nodeRegistry,
|
||||
type RadialResizeHandle,
|
||||
@@ -44,6 +45,7 @@ import { MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
import { EDITOR_LAYER } from '../../lib/constants'
|
||||
import { RESIZE_HANDLE_DRAG_LABEL, ROTATE_HANDLE_DRAG_LABEL } from '../../lib/contextual-help'
|
||||
import { createEditorApi } from '../../lib/editor-api'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useDirectManipulationFeedback from '../../store/use-direct-manipulation-feedback'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import useInteractionScope, {
|
||||
@@ -112,11 +114,16 @@ export {
|
||||
ARROW_COLOR,
|
||||
ARROW_HOVER_COLOR,
|
||||
ARROW_SCALE,
|
||||
createArrowHandleGeometry,
|
||||
createArrowHitAreaGeometry,
|
||||
createEndpointHitAreaGeometry,
|
||||
createMoveCrossHandleGeometry,
|
||||
createRotateArrowHandleGeometry,
|
||||
createRotateArrowHitAreaGeometry,
|
||||
HandleArrow,
|
||||
type HandleArrowInputShape,
|
||||
type HandleArrowPlacement,
|
||||
type HandleArrowProps,
|
||||
HIT_AREA_MARGIN,
|
||||
InvisibleHandleHitArea,
|
||||
NO_RAYCAST,
|
||||
@@ -374,6 +381,21 @@ function NodeArrowHandlesForNode({
|
||||
// hook count between renders and trip React's rules-of-hooks check.
|
||||
const [activeIndex, setActiveIndex] = useState<number | null>(null)
|
||||
const [preDragNode, setPreDragNode] = useState<AnyNode | null>(null)
|
||||
// Latch groups currently toggled open. A `latch` cube descriptor flips its
|
||||
// group here on click; arrows tagged with a `latchGroup` only render while
|
||||
// their group is in this set. Local to this mount, so it resets on deselect
|
||||
// (the rig remounts per selection — see the `key` on NodeArrowHandlesForNode).
|
||||
const [openLatchGroups, setOpenLatchGroups] = useState<ReadonlySet<string>>(() => new Set())
|
||||
const toggleLatchGroup = useMemo(
|
||||
() => (group: string) =>
|
||||
setOpenLatchGroups((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(group)) next.delete(group)
|
||||
else next.add(group)
|
||||
return next
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const dragControls = useMemo<HandleDragControls>(
|
||||
() => ({
|
||||
onStart: (index: number, snapshot: AnyNode) => {
|
||||
@@ -411,6 +433,21 @@ function NodeArrowHandlesForNode({
|
||||
|
||||
const arrows = descriptors.map((descriptor, index) => {
|
||||
if (activeIsRotate && 'shape' in descriptor && descriptor.shape === 'move-cross') return null
|
||||
// A `latch` cube toggles its group's visibility; render it always.
|
||||
if (descriptor.kind === 'latch') {
|
||||
return (
|
||||
<LatchCube
|
||||
descriptor={descriptor}
|
||||
key={index}
|
||||
node={node}
|
||||
onToggle={toggleLatchGroup}
|
||||
open={openLatchGroups.has(descriptor.group)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// Arrows tagged with a latch group stay hidden until that group is open.
|
||||
const latchGroup = descriptor.kind === 'linear-resize' ? descriptor.latchGroup : undefined
|
||||
if (latchGroup && !openLatchGroups.has(latchGroup)) return null
|
||||
return (
|
||||
<ArrowHandle
|
||||
activeIndex={activeIndex}
|
||||
@@ -670,6 +707,11 @@ function LinearArrow({
|
||||
? 1
|
||||
: -1
|
||||
|
||||
// Last value an emitted resize tick fired at — a new tick fires only
|
||||
// when the (snapped + clamped) value actually changes, so the cue
|
||||
// tracks real size steps instead of every sub-pixel pointer jitter.
|
||||
let lastTickValue = initialValue
|
||||
|
||||
return {
|
||||
overrideId,
|
||||
onBegin: () => {
|
||||
@@ -701,6 +743,10 @@ function LinearArrow({
|
||||
? snapScalar(rawNext, gridSnapStep)
|
||||
: rawNext
|
||||
const next = Math.min(maxBound, Math.max(minBound, snappedNext))
|
||||
if (next !== lastTickValue) {
|
||||
lastTickValue = next
|
||||
sfxEmitter.emit('sfx:resize')
|
||||
}
|
||||
const patch = descriptor.apply(initialNode as never, next, sceneApi) as Partial<AnyNode>
|
||||
// Let the kind publish live guides for the edge being resized.
|
||||
onDrag?.({ ...(initialNode as object), ...patch } as AnyNode, sceneApi)
|
||||
@@ -714,10 +760,19 @@ function LinearArrow({
|
||||
// X+Z rotation chain matching DoorHeightArrowHandle. When the handle
|
||||
// sits below the node (placement Y < 0, e.g. window bottom arrow),
|
||||
// flip the Z rotation so the chevron points outward (downward).
|
||||
//
|
||||
// For axis === 'x' with `faceNormal` (wall-mounted opening width arrows),
|
||||
// roll the blade 90° about its own pointing (X) axis so it stands up from
|
||||
// the horizontal XZ plane into the node's facing plane (XY = the wall
|
||||
// face) — otherwise the blade is seen edge-on from the front.
|
||||
const faceNormalX =
|
||||
descriptor.kind === 'linear-resize' && descriptor.axis === 'x' && descriptor.faceNormal === true
|
||||
const innerRotation: [number, number, number] =
|
||||
descriptor.axis === 'y'
|
||||
? [0, Math.PI / 2, position[1] < 0 ? -Math.PI / 2 : Math.PI / 2]
|
||||
: [0, 0, 0]
|
||||
: faceNormalX
|
||||
? [Math.PI / 2, 0, 0]
|
||||
: [0, 0, 0]
|
||||
|
||||
// Optional guide decoration — linear handles use it for curved-stair
|
||||
// width / inner-radius rings; radial handles use it for the column's
|
||||
@@ -803,6 +858,7 @@ function LinearArrow({
|
||||
onPointerDown={activate}
|
||||
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
||||
shape="chevron"
|
||||
thin
|
||||
>
|
||||
{showLabel ? <DimensionLabel position={[0, 0.22, 0]} text={labelText} /> : null}
|
||||
</HandleArrow>
|
||||
@@ -1213,6 +1269,7 @@ function ArcArrow({
|
||||
baseScale,
|
||||
}}
|
||||
shape={isRotateShape ? 'curved-arrow' : 'chevron'}
|
||||
thin
|
||||
/>
|
||||
</>
|
||||
)
|
||||
@@ -1276,6 +1333,56 @@ function TapActionArrow({
|
||||
onPointerDown={onActivate}
|
||||
placement={{ position, rotation, baseScale }}
|
||||
shape={shape === 'move-cross' ? 'move-cross' : 'chevron'}
|
||||
thin
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Click-to-latch cube. A persistent grip (the `tracker` cube) that toggles
|
||||
// the visibility of every arrow tagged with its `latchGroup` on click. Sized
|
||||
// to match the duct selection cube (`baseScale = zoom`, full TRACKER_CUBE_SIZE)
|
||||
// so every latch grip reads the same across the app. Stays highlighted while
|
||||
// its group is open so the user can tell it's engaged.
|
||||
function LatchCube({
|
||||
descriptor,
|
||||
node,
|
||||
open,
|
||||
onToggle,
|
||||
}: {
|
||||
descriptor: LatchHandle<AnyNode>
|
||||
node: AnyNode
|
||||
open: boolean
|
||||
onToggle: (group: string) => void
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const { camera } = useThree()
|
||||
const zoom = camera instanceof OrthographicCamera ? 1 / camera.zoom : 1
|
||||
const baseScale = zoom
|
||||
|
||||
const placementSceneApi = useMemo(() => createSceneApi(useScene), [])
|
||||
const position = descriptor.placement.position(node, placementSceneApi)
|
||||
const rotationY = descriptor.placement.rotationY?.(node, placementSceneApi) ?? 0
|
||||
|
||||
// Route through the shared tap path so the cube click is swallowed before it
|
||||
// reaches the select tool — stops R3F propagation, suppresses box-select, and
|
||||
// eats the trailing DOM click that would otherwise select the host node.
|
||||
const onPointerDown = useHandleDrag({
|
||||
kind: 'tap',
|
||||
onTap: () => {
|
||||
setIsHovered(false)
|
||||
onToggle(descriptor.group)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<HandleArrow
|
||||
cursor="grab"
|
||||
hover={isHovered || open}
|
||||
hoverScale={1.15}
|
||||
onHoverChange={setIsHovered}
|
||||
onPointerDown={onPointerDown}
|
||||
placement={{ position, rotation: [0, rotationY, 0], baseScale }}
|
||||
shape="tracker"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ const ARROW_HOVER_COLOR = '#a5b4fc'
|
||||
// Match the door arrows: scale the rendered chevron down to ~two-thirds
|
||||
// so the in-world handles read as a single UI family.
|
||||
const ARROW_SCALE = 0.65
|
||||
const CORNER_HEX_RADIUS = 0.16
|
||||
const CORNER_HEX_RADIUS = 0.11
|
||||
const CORNER_DASH_SIZE = 0.1
|
||||
const CORNER_GAP_SIZE = 0.07
|
||||
const CORNER_DASH_THICKNESS = 0.006
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import type { ThreeElements } from '@react-three/fiber'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import {
|
||||
@@ -34,12 +33,6 @@ import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { isBoxSelectPointerSuppressed, markBoxSelectHandled } from './box-select-state'
|
||||
import { collectSelectableCandidateIds } from './select-candidates'
|
||||
|
||||
declare module 'react/jsx-runtime' {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements extends ThreeElements {}
|
||||
}
|
||||
}
|
||||
|
||||
type Bounds = { minX: number; maxX: number; minZ: number; maxZ: number }
|
||||
|
||||
const BOX_SELECT_ACCENT_COLOR = '#818cf8'
|
||||
|
||||
@@ -12,12 +12,28 @@ interface CursorSphereProps extends Omit<ThreeElements['group'], 'ref'> {
|
||||
depthWrite?: boolean
|
||||
showTooltip?: boolean
|
||||
height?: number
|
||||
/**
|
||||
* Put the bright marker dot at the TIP of the vertical line (y = height)
|
||||
* instead of on the ground ring. Used when the point being placed hangs
|
||||
* above the floor (e.g. duct drawn against the ceiling): the dot rides at
|
||||
* the cursor / placement point while the line drops to a floor ring that
|
||||
* keeps the plan position readable.
|
||||
*/
|
||||
dotAtTip?: boolean
|
||||
/** Custom tooltip content — overrides the auto-detected build tool icon */
|
||||
tooltipContent?: React.ReactNode
|
||||
}
|
||||
|
||||
export const CursorSphere = forwardRef<Group, CursorSphereProps>(function CursorSphere(
|
||||
{ color = '#818cf8', showTooltip = true, height = 2.5, visible = true, tooltipContent, ...props },
|
||||
{
|
||||
color = '#818cf8',
|
||||
showTooltip = true,
|
||||
height = 2.5,
|
||||
dotAtTip = false,
|
||||
visible = true,
|
||||
tooltipContent,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const tool = useEditor((s) => s.tool)
|
||||
@@ -39,19 +55,23 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
|
||||
return (
|
||||
<group ref={ref} {...props} visible={isVisible}>
|
||||
{/* Flat marker on the ground */}
|
||||
{/* Flat marker on the ground. The bright center dot moves to the tip
|
||||
of the line in `dotAtTip` mode (the placement point hangs above the
|
||||
floor), leaving a faint ring here so the plan position stays read. */}
|
||||
<group rotation={[-Math.PI / 2, 0, 0]}>
|
||||
{/* Center dot */}
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<circleGeometry args={[0.06, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.9}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
{/* Center dot — at the ground unless the placement point is elevated */}
|
||||
{!dotAtTip && (
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
<circleGeometry args={[0.06, 32]} />
|
||||
<meshBasicMaterial
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.9}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Outer ring / glow */}
|
||||
<mesh layers={EDITOR_LAYER} renderOrder={2}>
|
||||
@@ -60,7 +80,7 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
color={color}
|
||||
depthTest={false}
|
||||
depthWrite={false}
|
||||
opacity={0.25}
|
||||
opacity={dotAtTip ? 0.2 : 0.25}
|
||||
transparent
|
||||
/>
|
||||
</mesh>
|
||||
@@ -80,6 +100,15 @@ export const CursorSphere = forwardRef<Group, CursorSphereProps>(function Cursor
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Bright marker dot at the tip of the line — the actual placement
|
||||
point, riding at the cursor while the line drops to the floor. */}
|
||||
{dotAtTip && height > 0 && (
|
||||
<mesh layers={EDITOR_LAYER} position={[0, height, 0]} renderOrder={2}>
|
||||
<sphereGeometry args={[0.08, 20, 14]} />
|
||||
<meshBasicMaterial color={color} depthTest={false} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
|
||||
{/* Tool Icon Tooltip at the top of the line */}
|
||||
{isVisible && showTooltip && (activeToolConfig || tooltipContent) && (
|
||||
<Html
|
||||
|
||||
@@ -245,10 +245,10 @@ export function EditorCommands() {
|
||||
label: 'Wall Mode',
|
||||
group: 'Viewer Controls',
|
||||
icon: <Layers className="h-4 w-4" />,
|
||||
keywords: ['wall', 'cutaway', 'up', 'down', 'view'],
|
||||
keywords: ['wall', 'cutaway', 'up', 'down', 'translucent', 'view'],
|
||||
badge: () => {
|
||||
const mode = useViewer.getState().wallMode
|
||||
return { cutaway: 'Cutaway', up: 'Up', down: 'Down' }[mode]
|
||||
return { cutaway: 'Cutaway', up: 'Up', down: 'Down', translucent: 'Translucent' }[mode]
|
||||
},
|
||||
navigate: true,
|
||||
execute: () => navigateTo('wall-mode'),
|
||||
|
||||
@@ -244,10 +244,11 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const wallModeLabel: Record<'cutaway' | 'up' | 'down', string> = {
|
||||
const wallModeLabel: Record<'cutaway' | 'up' | 'down' | 'translucent', string> = {
|
||||
cutaway: 'Cutaway',
|
||||
up: 'Up',
|
||||
down: 'Down',
|
||||
translucent: 'Translucent',
|
||||
}
|
||||
const levelModeLabel: Record<'manual' | 'stacked' | 'exploded' | 'solo', string> = {
|
||||
manual: 'Manual',
|
||||
@@ -373,7 +374,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
|
||||
{/* ── Wall Mode sub-page ────────────────────────────────────── */}
|
||||
{page === 'wall-mode' && (
|
||||
<Command.Group heading="Wall Mode">
|
||||
{(['cutaway', 'up', 'down'] as const).map((mode) => (
|
||||
{(['cutaway', 'up', 'down', 'translucent'] as const).map((mode) => (
|
||||
<OptionItem
|
||||
isActive={wallMode === mode}
|
||||
key={mode}
|
||||
|
||||
@@ -119,17 +119,23 @@ export function HelperManager() {
|
||||
() => getActiveContinuationContext(),
|
||||
[scope, mode, tool],
|
||||
)
|
||||
const selectModeHints = useMemo(
|
||||
() =>
|
||||
resolveSelectModeHelpHints({
|
||||
selectedCount: selectedNodes.length,
|
||||
hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
|
||||
hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
|
||||
commandPressed: modifiers.command,
|
||||
shiftPressed: modifiers.shift,
|
||||
}),
|
||||
[modifiers.command, modifiers.shift, selectedNodes],
|
||||
)
|
||||
const selectModeHints = useMemo(() => {
|
||||
const single = selectedNodes.length === 1 ? selectedNodes[0] : null
|
||||
const mepSelection =
|
||||
single?.type === 'duct-segment' || single?.type === 'pipe-segment'
|
||||
? 'run'
|
||||
: single?.type === 'duct-fitting' || single?.type === 'pipe-fitting'
|
||||
? 'fitting'
|
||||
: null
|
||||
return resolveSelectModeHelpHints({
|
||||
selectedCount: selectedNodes.length,
|
||||
hasMovableSelection: selectedNodes.some((node) => canDirectMoveNode(node)),
|
||||
hasRotatableSelection: selectedNodes.some((node) => canDirectRotateNode(node)),
|
||||
commandPressed: modifiers.command,
|
||||
shiftPressed: modifiers.shift,
|
||||
mepSelection,
|
||||
})
|
||||
}, [modifiers.command, modifiers.shift, selectedNodes])
|
||||
|
||||
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
|
||||
if (isMobile) return null
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Check,
|
||||
ChevronRight,
|
||||
Diamond,
|
||||
Footprints,
|
||||
Layers,
|
||||
Palette,
|
||||
PenLine,
|
||||
@@ -32,8 +33,10 @@ import {
|
||||
Square,
|
||||
} from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { cn } from '../lib/utils'
|
||||
import useEditor from '../store/use-editor'
|
||||
import { ActionButton } from './ui/action-menu/action-button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -51,6 +54,24 @@ type ProjectOwner = {
|
||||
image: string | null
|
||||
}
|
||||
|
||||
function requestWalkthroughPointerLock() {
|
||||
const canvas = document.querySelector<HTMLCanvasElement>('[data-pascal-viewer-3d] canvas')
|
||||
if (!canvas) return
|
||||
|
||||
if (!canvas.hasAttribute('tabindex')) {
|
||||
canvas.tabIndex = -1
|
||||
}
|
||||
canvas.focus({ preventScroll: true })
|
||||
|
||||
if (document.pointerLockElement === canvas) return
|
||||
|
||||
try {
|
||||
canvas.requestPointerLock?.()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
stacked: 'Stacked',
|
||||
exploded: 'Exploded',
|
||||
@@ -83,6 +104,12 @@ const wallModeConfig = {
|
||||
),
|
||||
label: 'Low',
|
||||
},
|
||||
translucent: {
|
||||
icon: (props: any) => (
|
||||
<img alt="Translucent" height={28} src="/icons/wall.png" width={28} {...props} />
|
||||
),
|
||||
label: 'Translucent',
|
||||
},
|
||||
}
|
||||
|
||||
const SHADING_OPTIONS = [
|
||||
@@ -580,7 +607,12 @@ export const ViewerOverlay = ({
|
||||
}
|
||||
label={`Walls: ${wallModeConfig[wallMode as keyof typeof wallModeConfig].label}`}
|
||||
onClick={() => {
|
||||
const modes: ('cutaway' | 'up' | 'down')[] = ['cutaway', 'up', 'down']
|
||||
const modes: ('cutaway' | 'up' | 'down' | 'translucent')[] = [
|
||||
'cutaway',
|
||||
'up',
|
||||
'down',
|
||||
'translucent',
|
||||
]
|
||||
const nextIndex = (modes.indexOf(wallMode as any) + 1) % modes.length
|
||||
useViewer.getState().setWallMode(modes[nextIndex] ?? 'cutaway')
|
||||
}}
|
||||
@@ -641,6 +673,23 @@ export const ViewerOverlay = ({
|
||||
src="/icons/topview.webp"
|
||||
/>
|
||||
</ActionButton>
|
||||
|
||||
<div className="mx-1 h-5 w-px bg-border/40" />
|
||||
|
||||
{/* First-person walkthrough */}
|
||||
<ActionButton
|
||||
className="hover:bg-white/5 hover:text-emerald-400"
|
||||
label="Walkthrough"
|
||||
onClick={() => {
|
||||
flushSync(() => useEditor.getState().setFirstPersonMode(true))
|
||||
requestWalkthroughPointerLock()
|
||||
}}
|
||||
size="icon"
|
||||
tooltipSide="top"
|
||||
variant="ghost"
|
||||
>
|
||||
<Footprints className="h-6 w-6" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ export { default as Editor } from './components/editor'
|
||||
// they're referenced throughout the editor's own internals; the public
|
||||
// surface uses the shorter, shell-friendly names from the unified
|
||||
// preset-system spec.
|
||||
export { BakeExporter } from './components/editor/bake-exporter'
|
||||
export { FloatingActionMenu as FloatingMenu } from './components/editor/floating-action-menu'
|
||||
// Embed surface — the editor's real in-canvas affordances, so a host can mount
|
||||
// authentic selection handles, interactive build tools, and the mover on top
|
||||
@@ -39,7 +40,27 @@ export {
|
||||
formatMeasurement,
|
||||
MeasurementPill,
|
||||
} from './components/editor/measurement-pill'
|
||||
export { NodeArrowHandles } from './components/editor/node-arrow-handles'
|
||||
// In-world arrow handle primitives (chevron geometry, invisible hit area,
|
||||
// shared material, palette + scale constants). Re-exported so kind-owned
|
||||
// 3D selection affordances in `@pascal-app/nodes` (duct side-move / height /
|
||||
// extend arrows) reuse the same UI family as the wall / fence side handles.
|
||||
export {
|
||||
ARROW_COLOR,
|
||||
ARROW_HOVER_COLOR,
|
||||
ARROW_SCALE,
|
||||
createArrowHandleGeometry,
|
||||
createArrowHitAreaGeometry,
|
||||
HandleArrow,
|
||||
type HandleArrowInputShape,
|
||||
type HandleArrowPlacement,
|
||||
type HandleArrowProps,
|
||||
InvisibleHandleHitArea,
|
||||
NO_RAYCAST,
|
||||
NodeArrowHandles,
|
||||
swallowNextClick,
|
||||
useArrowMaterial,
|
||||
useInvisibleHitAreaMaterial,
|
||||
} from './components/editor/node-arrow-handles'
|
||||
export {
|
||||
type SnapshotCameraData,
|
||||
ThumbnailGenerator,
|
||||
@@ -259,6 +280,7 @@ export {
|
||||
getFloorplanWallThickness,
|
||||
} from './lib/floorplan'
|
||||
export { commitFreshPlacementSubtree } from './lib/fresh-planar-placement'
|
||||
export { exportSceneToGlb } from './lib/glb-export'
|
||||
export {
|
||||
boundaryReshapeScope,
|
||||
curveReshapeScope,
|
||||
|
||||
@@ -38,12 +38,19 @@ export type SelectModeHelpContext = {
|
||||
hasRotatableSelection: boolean
|
||||
commandPressed: boolean
|
||||
shiftPressed: boolean
|
||||
// When a single MEP node is selected its in-world handle rig (click a dot to
|
||||
// reveal move arrows) is the real editing path, so the panel leads with the
|
||||
// handle-specific hints instead of just the generic Cmd-drag tips.
|
||||
mepSelection?: 'run' | 'fitting' | null
|
||||
}
|
||||
|
||||
const COMMAND_KEY = 'Cmd/Ctrl'
|
||||
const LEFT_CLICK = 'Left click'
|
||||
const RIGHT_CLICK = 'Right click'
|
||||
const SHIFT_KEY = 'Shift'
|
||||
const CLICK = 'Click'
|
||||
const ALT_KEY = 'Alt'
|
||||
const ROTATE_KEYS = 'R / T'
|
||||
|
||||
export function resolveSelectModeHelpHints({
|
||||
selectedCount,
|
||||
@@ -51,6 +58,7 @@ export function resolveSelectModeHelpHints({
|
||||
hasRotatableSelection,
|
||||
commandPressed,
|
||||
shiftPressed,
|
||||
mepSelection = null,
|
||||
}: SelectModeHelpContext): ContextualShortcutHint[] {
|
||||
const hints: ContextualShortcutHint[] = []
|
||||
|
||||
@@ -65,6 +73,20 @@ export function resolveSelectModeHelpHints({
|
||||
return hints
|
||||
}
|
||||
|
||||
// MEP handle workflow — duct/pipe runs and fittings are edited through the
|
||||
// in-world arrow rig that a click on the handle dot reveals, so surface those
|
||||
// hints first. A run endpoint's side / up-down arrows swing the run and Alt
|
||||
// detaches the joint mid-drag; a fitting's cluster adds rotate arcs, with
|
||||
// R / T (and Alt to switch axis) for keyboard rotation.
|
||||
if (mepSelection === 'run') {
|
||||
hints.push({ keys: [CLICK], label: 'Click a handle dot to show move arrows' })
|
||||
hints.push({ keys: [ALT_KEY], label: 'Detach the joint while dragging an arrow' })
|
||||
} else if (mepSelection === 'fitting') {
|
||||
hints.push({ keys: [CLICK], label: 'Click the handle dot to show move + rotate handles' })
|
||||
hints.push({ keys: [ROTATE_KEYS], label: 'Rotate ±45°' })
|
||||
hints.push({ keys: [ALT_KEY], label: 'Switch the rotation axis (Y → X → Z)' })
|
||||
}
|
||||
|
||||
if (commandPressed) {
|
||||
if (hasMovableSelection) {
|
||||
hints.push({
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { type AnyNode, sceneRegistry } from '@pascal-app/core'
|
||||
import * as THREE from 'three'
|
||||
import { prepareSceneForExport } from './glb-export'
|
||||
|
||||
afterEach(() => {
|
||||
sceneRegistry.clear()
|
||||
})
|
||||
|
||||
function nodeMaterial(overrides: Record<string, unknown> = {}) {
|
||||
// Duck-typed stand-in for the viewer's MeshStandard/LambertNodeMaterial:
|
||||
// the exporter keys off `isNodeMaterial` and reads plain PBR props.
|
||||
return {
|
||||
isNodeMaterial: true,
|
||||
name: 'painted',
|
||||
color: new THREE.Color('#cc3300'),
|
||||
roughness: 0.3,
|
||||
metalness: 0.7,
|
||||
transparent: false,
|
||||
opacity: 1,
|
||||
side: THREE.FrontSide,
|
||||
alphaTest: 0,
|
||||
depthWrite: true,
|
||||
depthTest: true,
|
||||
vertexColors: false,
|
||||
toneMapped: true,
|
||||
...overrides,
|
||||
} as unknown as THREE.Material
|
||||
}
|
||||
|
||||
function meshWithNodeMaterial(material: THREE.Material): THREE.Mesh {
|
||||
const geometry = new THREE.BoxGeometry(1, 1, 1)
|
||||
return new THREE.Mesh(geometry, material)
|
||||
}
|
||||
|
||||
describe('prepareSceneForExport', () => {
|
||||
test('converts NodeMaterials to classic glTF-standard materials', () => {
|
||||
const root = new THREE.Group()
|
||||
root.name = 'scene-renderer'
|
||||
const mesh = meshWithNodeMaterial(nodeMaterial())
|
||||
root.add(mesh)
|
||||
|
||||
const { scene } = prepareSceneForExport(root, {})
|
||||
|
||||
const exported = scene.children[0] as THREE.Mesh
|
||||
const material = exported.material as THREE.MeshStandardMaterial
|
||||
expect(material.isMeshStandardMaterial).toBe(true)
|
||||
expect(material.roughness).toBeCloseTo(0.3)
|
||||
expect(material.metalness).toBeCloseTo(0.7)
|
||||
expect(material.color.getHexString()).toBe('cc3300')
|
||||
})
|
||||
|
||||
test('shared NodeMaterial instances convert to a single shared material', () => {
|
||||
const root = new THREE.Group()
|
||||
const shared = nodeMaterial()
|
||||
root.add(meshWithNodeMaterial(shared), meshWithNodeMaterial(shared))
|
||||
|
||||
const { scene } = prepareSceneForExport(root, {})
|
||||
|
||||
const meshes = scene.children as THREE.Mesh[]
|
||||
expect(meshes[0]!.material).toBe(meshes[1]!.material)
|
||||
})
|
||||
|
||||
test('strips editor overlays that live off the scene layer', () => {
|
||||
const root = new THREE.Group()
|
||||
const realMesh = meshWithNodeMaterial(nodeMaterial())
|
||||
const overlay = meshWithNodeMaterial(nodeMaterial())
|
||||
overlay.layers.set(1) // OVERLAY_LAYER / EDITOR_LAYER — off scene layer 0
|
||||
root.add(realMesh, overlay)
|
||||
|
||||
const { scene } = prepareSceneForExport(root, {})
|
||||
|
||||
const meshes: THREE.Mesh[] = []
|
||||
scene.traverse((o) => {
|
||||
if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh)
|
||||
})
|
||||
expect(meshes).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('neutralises an invisible hitbox root but keeps its visible children', () => {
|
||||
// Door/window roots are selection hitboxes: a box geometry with an invisible
|
||||
// material (object stays visible). Left intact it would plug the wall opening.
|
||||
const root = new THREE.Group()
|
||||
const hitbox = new THREE.Mesh(
|
||||
new THREE.BoxGeometry(1, 2, 0.2),
|
||||
new THREE.MeshBasicMaterial({ visible: false }),
|
||||
)
|
||||
const leaf = meshWithNodeMaterial(nodeMaterial())
|
||||
hitbox.add(leaf)
|
||||
root.add(hitbox)
|
||||
|
||||
const doorId = 'door_hitbox'
|
||||
sceneRegistry.nodes.set(doorId, hitbox)
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
[doorId]: { object: 'node', id: doorId, type: 'door' } as unknown as AnyNode,
|
||||
}
|
||||
|
||||
const { scene } = prepareSceneForExport(root, nodes)
|
||||
|
||||
const exported = scene.getObjectByProperty('name', doorId) as THREE.Mesh
|
||||
expect(exported).toBeDefined()
|
||||
// Geometry emptied -> GLTFExporter emits a plain node, no solid block.
|
||||
expect(exported.geometry.getAttribute('position')).toBeUndefined()
|
||||
// The visible leaf survives as a child.
|
||||
const visibleChildren = exported.children.filter((c) => (c as THREE.Mesh).isMesh)
|
||||
expect(visibleChildren).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('stamps identity from the scene registry and strips other userData', () => {
|
||||
const root = new THREE.Group()
|
||||
const doorGroup = new THREE.Group()
|
||||
const leaf = new THREE.Group()
|
||||
leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
|
||||
leaf.add(meshWithNodeMaterial(nodeMaterial()))
|
||||
doorGroup.add(leaf)
|
||||
root.add(doorGroup)
|
||||
|
||||
const doorId = 'door_test'
|
||||
sceneRegistry.nodes.set(doorId, doorGroup)
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
[doorId]: {
|
||||
object: 'node',
|
||||
id: doorId,
|
||||
type: 'door',
|
||||
name: 'Front door',
|
||||
} as unknown as AnyNode,
|
||||
}
|
||||
|
||||
const { scene } = prepareSceneForExport(root, nodes)
|
||||
|
||||
const exportedDoor = scene.getObjectByProperty('name', doorId)
|
||||
expect(exportedDoor).toBeDefined()
|
||||
expect(exportedDoor?.userData).toEqual({
|
||||
pascalId: doorId,
|
||||
kind: 'door',
|
||||
label: 'Front door',
|
||||
openable: true,
|
||||
clips: ['Front door: open'],
|
||||
})
|
||||
|
||||
// The swing-leaf marker must not survive into glTF extras.
|
||||
let leafMarkerSurvived = false
|
||||
scene.traverse((object) => {
|
||||
if (object.userData.pascalSwingLeaf) leafMarkerSurvived = true
|
||||
})
|
||||
expect(leafMarkerSurvived).toBe(false)
|
||||
})
|
||||
|
||||
test('does not flag a door/window openable when no open clip bakes', () => {
|
||||
// A cased opening (no swing leaf) / fixed window (no operable sash) builds
|
||||
// no movable part, so no clip bakes and the node must not claim openable.
|
||||
const root = new THREE.Group()
|
||||
const openingGroup = new THREE.Group()
|
||||
openingGroup.add(meshWithNodeMaterial(nodeMaterial()))
|
||||
root.add(openingGroup)
|
||||
|
||||
const openingId = 'door_opening'
|
||||
sceneRegistry.nodes.set(openingId, openingGroup)
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
[openingId]: {
|
||||
object: 'node',
|
||||
id: openingId,
|
||||
type: 'door',
|
||||
name: 'Cased opening',
|
||||
} as unknown as AnyNode,
|
||||
}
|
||||
|
||||
const { scene, animations } = prepareSceneForExport(root, nodes)
|
||||
|
||||
expect(animations).toHaveLength(0)
|
||||
const exported = scene.getObjectByProperty('name', openingId)
|
||||
expect(exported?.userData).toEqual({
|
||||
pascalId: openingId,
|
||||
kind: 'door',
|
||||
label: 'Cased opening',
|
||||
})
|
||||
expect(exported?.userData.openable).toBeUndefined()
|
||||
expect(exported?.userData.clips).toBeUndefined()
|
||||
})
|
||||
|
||||
test('keeps the zone identity node with its polygon and strips the fill mesh', () => {
|
||||
const root = new THREE.Group()
|
||||
const zoneGroup = new THREE.Group()
|
||||
const fill = meshWithNodeMaterial(nodeMaterial())
|
||||
fill.layers.set(2) // ZONE_LAYER
|
||||
zoneGroup.add(fill)
|
||||
zoneGroup.visible = false // the editor often hides zones at export time
|
||||
root.add(zoneGroup)
|
||||
|
||||
const zoneId = 'zone_living'
|
||||
const polygon: [number, number][] = [
|
||||
[0, 0],
|
||||
[4, 0],
|
||||
[4, 3],
|
||||
]
|
||||
sceneRegistry.nodes.set(zoneId, zoneGroup)
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
[zoneId]: {
|
||||
object: 'node',
|
||||
id: zoneId,
|
||||
type: 'zone',
|
||||
name: 'Living Room',
|
||||
polygon,
|
||||
color: '#ff0000',
|
||||
} as unknown as AnyNode,
|
||||
}
|
||||
|
||||
const { scene } = prepareSceneForExport(root, nodes)
|
||||
|
||||
const exported = scene.getObjectByProperty('name', zoneId)
|
||||
expect(exported).toBeDefined()
|
||||
// Forced visible so GLTFExporter's onlyVisible keeps the metadata node.
|
||||
expect(exported?.visible).toBe(true)
|
||||
expect(exported?.userData).toEqual({
|
||||
pascalId: zoneId,
|
||||
kind: 'zone',
|
||||
label: 'Living Room',
|
||||
polygon,
|
||||
color: '#ff0000',
|
||||
})
|
||||
// The ZONE_LAYER fill mesh must not survive (rebuilt in /viewer instead).
|
||||
let hasMesh = false
|
||||
exported?.traverse((o) => {
|
||||
if ((o as THREE.Mesh).isMesh) hasMesh = true
|
||||
})
|
||||
expect(hasMesh).toBe(false)
|
||||
})
|
||||
|
||||
test('bakes a swing door into an open quaternion clip', () => {
|
||||
const root = new THREE.Group()
|
||||
const doorGroup = new THREE.Group()
|
||||
const leaf = new THREE.Group()
|
||||
leaf.userData.pascalSwingLeaf = { axis: 'y', openRotationY: Math.PI / 2 }
|
||||
leaf.add(meshWithNodeMaterial(nodeMaterial()))
|
||||
doorGroup.add(leaf)
|
||||
root.add(doorGroup)
|
||||
|
||||
const doorId = 'door_swing'
|
||||
sceneRegistry.nodes.set(doorId, doorGroup)
|
||||
const nodes: Record<string, AnyNode> = {
|
||||
[doorId]: { object: 'node', id: doorId, type: 'door', name: 'Door' } as unknown as AnyNode,
|
||||
}
|
||||
|
||||
const { scene, animations } = prepareSceneForExport(root, nodes)
|
||||
|
||||
expect(animations).toHaveLength(1)
|
||||
const clip = animations[0]!
|
||||
expect(clip.name).toBe('Door: open')
|
||||
expect(clip.duration).toBe(1)
|
||||
// Playback intent carried in extras so consumers can play once and hold.
|
||||
expect(clip.userData).toEqual({ loop: false })
|
||||
|
||||
const track = clip.tracks[0]!
|
||||
expect(track).toBeInstanceOf(THREE.QuaternionKeyframeTrack)
|
||||
expect(track.name.endsWith('.quaternion')).toBe(true)
|
||||
expect(Array.from(track.times)).toEqual([0, 1])
|
||||
|
||||
// The track must target an object that exists in the exported tree.
|
||||
const targetUuid = track.name.replace('.quaternion', '')
|
||||
const target = scene.getObjectByProperty('uuid', targetUuid)
|
||||
expect(target).toBeDefined()
|
||||
|
||||
// Rest pose is closed: the first keyframe is the identity rotation.
|
||||
const closed = new THREE.Quaternion().fromArray(Array.from(track.values).slice(0, 4))
|
||||
expect(closed.angleTo(new THREE.Quaternion())).toBeCloseTo(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,653 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
emitter,
|
||||
getLevelDisplayName,
|
||||
itemClipRegistry,
|
||||
type LevelNode,
|
||||
sceneRegistry,
|
||||
type WindowNode,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { poseWindowMovingParts, SCENE_LAYER, snapLevelsToTruePositions } from '@pascal-app/viewer'
|
||||
import type { Object3D } from 'three'
|
||||
import * as THREE from 'three'
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
||||
import * as WebGPUTextureUtils from 'three/examples/jsm/utils/WebGPUTextureUtils.js'
|
||||
|
||||
/**
|
||||
* Two TRS samples (closed vs open) differing by less than this are treated as
|
||||
* stationary, so only genuinely moving parts get an animation track.
|
||||
*/
|
||||
const POSE_EPSILON = 1e-5
|
||||
|
||||
/**
|
||||
* Marker stamped on a door's swing-leaf group by the door system. `axis` is the
|
||||
* hinge axis and `openRotationY` is the fully-open angle (radians). The export
|
||||
* reads it to bake an open clip from a single closed pose; see `door-system`.
|
||||
*/
|
||||
type SwingLeafMarker = { axis: 'y'; openRotationY: number }
|
||||
|
||||
export type GlbExport = {
|
||||
scene: THREE.Object3D
|
||||
animations: THREE.AnimationClip[]
|
||||
}
|
||||
|
||||
export async function exportSceneToGlb(
|
||||
sceneGroup: Object3D,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): Promise<ArrayBuffer> {
|
||||
emitter.emit('thumbnail:before-capture', undefined)
|
||||
// Snap levels to their true stacked positions (like thumbnail capture) so the
|
||||
// export always reflects the clean stacked building, regardless of the live
|
||||
// levelMode (exploded/solo) or an unsettled level lerp that could otherwise
|
||||
// bake a level at a stray offset.
|
||||
const restoreLevels = snapLevelsToTruePositions()
|
||||
let prepared: ReturnType<typeof prepareSceneForExport>
|
||||
try {
|
||||
prepared = prepareSceneForExport(sceneGroup, nodes)
|
||||
} finally {
|
||||
restoreLevels()
|
||||
emitter.emit('thumbnail:after-capture', undefined)
|
||||
}
|
||||
const { scene: exportScene, animations } = prepared
|
||||
|
||||
const exporter = new GLTFExporter()
|
||||
// Painted finishes use KTX2 (GPU-compressed) maps; GLTFExporter can't read
|
||||
// those directly. WebGPUTextureUtils blits each one to RGBA on its own
|
||||
// offscreen renderer (passing the live renderer would resize/draw over the
|
||||
// editor canvas), letting the exporter embed standard textures.
|
||||
exporter.setTextureUtils(WebGPUTextureUtils)
|
||||
|
||||
return new Promise<ArrayBuffer>((resolve, reject) => {
|
||||
exporter.parse(
|
||||
exportScene,
|
||||
(gltf) => {
|
||||
resolve(gltf as ArrayBuffer)
|
||||
},
|
||||
(error) => {
|
||||
reject(error)
|
||||
},
|
||||
{ binary: true, animations },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an engine-agnostic export tree from the live scene graph. The result is
|
||||
* a standalone three.js scene plus glTF animation clips, ready for
|
||||
* `GLTFExporter` — it carries no Pascal runtime dependency.
|
||||
*
|
||||
* - Clones the source so live objects are never mutated.
|
||||
* - Converts WebGPU NodeMaterials to classic glTF-standard materials.
|
||||
* `GLTFExporter` only recognises `isMeshStandardMaterial` /
|
||||
* `isMeshBasicMaterial`; the viewer's `MeshStandard/LambertNodeMaterial` set
|
||||
* `isNodeMaterial` instead, so without this every surface exports as a blank
|
||||
* default material.
|
||||
* - Bakes each openable door/window's open motion into a glTF animation clip
|
||||
* via the build-once + pose-at-t primitives (`pascalSwingLeaf` for doors,
|
||||
* `poseWindowMovingParts` for windows).
|
||||
* - Stamps `name` + `extras` identity from `sceneRegistry` so selection/hover
|
||||
* survive the bake with no in-memory registry, and strips all other userData
|
||||
* so editor/runtime ephemera never leak into glTF extras.
|
||||
*/
|
||||
export function prepareSceneForExport(
|
||||
source: THREE.Object3D,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): GlbExport {
|
||||
const scene = source.clone(true)
|
||||
const cloneByOriginal = pairClones(source, scene)
|
||||
|
||||
// Scans (LiDAR meshes) and guides (floorplan images) are heavy reference
|
||||
// assets stored elsewhere and aren't part of the compiled building. Drop them
|
||||
// from the artifact entirely — `/viewer` re-adds them from the scene graph,
|
||||
// gated by the project's public-visibility flags, so they never bloat the
|
||||
// shared GLB nor slip past those flags into a static public file.
|
||||
for (const [id, original] of sceneRegistry.nodes) {
|
||||
const node = nodes[id]
|
||||
if (node?.type === 'scan' || node?.type === 'guide') {
|
||||
cloneByOriginal.get(original)?.removeFromParent()
|
||||
}
|
||||
}
|
||||
|
||||
// Object3Ds that carry node identity — never strip these even when they sit on
|
||||
// a non-scene layer. Some are metadata-only: a zone's visible fill/wall meshes
|
||||
// are stripped, but its identity node stays to carry the polygon that /viewer
|
||||
// reconstructs the room from.
|
||||
const identityNodes = new Set<THREE.Object3D>()
|
||||
for (const original of sceneRegistry.nodes.values()) {
|
||||
const clone = cloneByOriginal.get(original)
|
||||
if (clone) identityNodes.add(clone)
|
||||
}
|
||||
|
||||
pruneNonRenderableMeshes(scene, identityNodes)
|
||||
convertMaterials(scene)
|
||||
|
||||
const { clips, clipNamesByNode } = bakeAnimationClips(cloneByOriginal, nodes)
|
||||
|
||||
stampIdentity(scene, cloneByOriginal, nodes, clipNamesByNode)
|
||||
|
||||
return { scene, animations: clips }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair each original Object3D with its clone. `clone(true)` builds children in
|
||||
* source order, so parallel pre-order traversals line up 1:1 — this is how we
|
||||
* map `sceneRegistry`'s live refs onto the export tree without mutating either.
|
||||
*/
|
||||
function pairClones(
|
||||
source: THREE.Object3D,
|
||||
clone: THREE.Object3D,
|
||||
): Map<THREE.Object3D, THREE.Object3D> {
|
||||
const originals: THREE.Object3D[] = []
|
||||
const clones: THREE.Object3D[] = []
|
||||
source.traverse((object) => originals.push(object))
|
||||
clone.traverse((object) => clones.push(object))
|
||||
|
||||
const map = new Map<THREE.Object3D, THREE.Object3D>()
|
||||
for (let i = 0; i < originals.length; i++) {
|
||||
const target = clones[i]
|
||||
if (target) map.set(originals[i]!, target)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
// A single empty geometry shared by every container mesh we neutralise below —
|
||||
// it has no attributes, so GLTFExporter's processMesh returns null and emits a
|
||||
// plain transform node instead of a primitive.
|
||||
const EMPTY_GEOMETRY = new THREE.BufferGeometry()
|
||||
|
||||
// Hidden placeholder for a neutralised renderable that has no material: a valid
|
||||
// material keeps GLTFExporter from crashing on `material.isShaderMaterial`, while
|
||||
// EMPTY_GEOMETRY makes it emit a transform node instead of a primitive.
|
||||
const PLACEHOLDER_MATERIAL = new THREE.MeshBasicMaterial({ visible: false })
|
||||
|
||||
/**
|
||||
* Strip everything that must not bake into the model:
|
||||
* - Editor overlays on non-scene layers (gizmos, selection handles, ground
|
||||
* grid, zone fills). The editor camera shows them via extra layers; a
|
||||
* thumbnail/bake is layer 0 only. Scene-layer affordances that can't be
|
||||
* layer-filtered (ceiling/site brackets) are hidden by the caller's
|
||||
* `thumbnail:before-capture` emit before the clone instead.
|
||||
* - Selection hitboxes, whose invisibility lives on `material.visible = false`
|
||||
* (which GLTFExporter's `onlyVisible` does not catch). A door/window's hitbox
|
||||
* root is a box spanning the wall opening — left in, it plugs the cutout.
|
||||
* With children (it parents the visible frame + leaf) it keeps its node but
|
||||
* loses its geometry; childless ones are removed outright.
|
||||
*/
|
||||
function pruneNonRenderableMeshes(root: THREE.Object3D, identityNodes: Set<THREE.Object3D>) {
|
||||
const toRemove: THREE.Object3D[] = []
|
||||
root.traverse((object) => {
|
||||
// Editor-only overlays (gizmos, selection handles, ground grid, zone fills)
|
||||
// live off the scene layer; the editor camera shows them via extra layers
|
||||
// but a thumbnail/bake only wants layer 0. Drop the whole overlay subtree —
|
||||
// except identity nodes, which we keep (their off-layer mesh children are
|
||||
// still pruned as the traversal continues).
|
||||
if (!object.layers.isEnabled(SCENE_LAYER)) {
|
||||
if (identityNodes.has(object)) return
|
||||
toRemove.push(object)
|
||||
return
|
||||
}
|
||||
// A renderable (Mesh / Line / Points) with no material can't produce valid
|
||||
// glTF and crashes GLTFExporter, which reads `material.isShaderMaterial`
|
||||
// unconditionally — e.g. an imported sub-model that left a mesh material-less.
|
||||
// Non-Mesh renderables also slip past the `isMesh` checks below and the
|
||||
// material conversion. Neutralise it: keep the node (so children survive) but
|
||||
// strip its geometry + give it the hidden placeholder, or drop it if a leaf.
|
||||
const renderable = object as THREE.Mesh & { isLine?: boolean; isPoints?: boolean }
|
||||
if (
|
||||
(renderable.isMesh === true || renderable.isLine === true || renderable.isPoints === true) &&
|
||||
renderable.material == null
|
||||
) {
|
||||
if (object.children.length > 0) {
|
||||
renderable.geometry = EMPTY_GEOMETRY
|
||||
renderable.material = PLACEHOLDER_MATERIAL
|
||||
} else {
|
||||
toRemove.push(object)
|
||||
}
|
||||
return
|
||||
}
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh || isRenderableMesh(mesh)) return
|
||||
if (mesh.children.length > 0) {
|
||||
mesh.geometry = EMPTY_GEOMETRY
|
||||
} else {
|
||||
toRemove.push(mesh)
|
||||
}
|
||||
})
|
||||
for (const object of toRemove) {
|
||||
object.removeFromParent()
|
||||
}
|
||||
}
|
||||
|
||||
function isRenderableMesh(mesh: THREE.Mesh): boolean {
|
||||
const position = mesh.geometry?.getAttribute('position')
|
||||
if (!position || position.count === 0) return false
|
||||
const material = mesh.material
|
||||
return Array.isArray(material)
|
||||
? material.some((m) => m?.visible !== false)
|
||||
: material?.visible !== false
|
||||
}
|
||||
|
||||
// --- Material conversion -------------------------------------------------
|
||||
|
||||
const STANDARD_MAP_SLOTS = [
|
||||
'map',
|
||||
'normalMap',
|
||||
'roughnessMap',
|
||||
'metalnessMap',
|
||||
'aoMap',
|
||||
'emissiveMap',
|
||||
'alphaMap',
|
||||
'lightMap',
|
||||
'displacementMap',
|
||||
'bumpMap',
|
||||
] as const
|
||||
|
||||
function convertMaterials(root: THREE.Object3D) {
|
||||
const cache = new Map<THREE.Material, THREE.Material>()
|
||||
root.traverse((object) => {
|
||||
const mesh = object as THREE.Mesh
|
||||
if (!mesh.isMesh) return
|
||||
const material = mesh.material
|
||||
if (Array.isArray(material)) {
|
||||
mesh.material = material.map((m) => convertMaterial(m, cache))
|
||||
return
|
||||
}
|
||||
// glTF has no BackSide — GLTFExporter renders the *front* face for any
|
||||
// non-DoubleSide material, which inverts a BackSide surface (e.g. the
|
||||
// ceiling underside, meant to be seen from the room). Flip the mesh winding
|
||||
// so the intended face shows with the FrontSide material convertMaterial
|
||||
// produces. Per-mesh geometry clone keeps shared geometry untouched.
|
||||
if (
|
||||
(material as { isNodeMaterial?: boolean }).isNodeMaterial &&
|
||||
material.side === THREE.BackSide
|
||||
) {
|
||||
mesh.geometry = flipGeometryWinding(mesh.geometry)
|
||||
}
|
||||
mesh.material = convertMaterial(material, cache)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse triangle winding and negate normals so a surface authored for
|
||||
* `BackSide` reads correctly once exported as `FrontSide` (glTF can't express
|
||||
* back-face-only rendering).
|
||||
*/
|
||||
function flipGeometryWinding(geometry: THREE.BufferGeometry): THREE.BufferGeometry {
|
||||
const flipped = geometry.clone()
|
||||
const index = flipped.getIndex()
|
||||
if (index) {
|
||||
const a = index.array
|
||||
for (let i = 0; i < a.length; i += 3) {
|
||||
const tmp = a[i]!
|
||||
a[i] = a[i + 2]!
|
||||
a[i + 2] = tmp
|
||||
}
|
||||
index.needsUpdate = true
|
||||
} else {
|
||||
for (const attribute of Object.values(flipped.attributes)) {
|
||||
const { array, itemSize } = attribute
|
||||
for (let i = 0; i < array.length; i += itemSize * 3) {
|
||||
for (let k = 0; k < itemSize; k++) {
|
||||
const tmp = array[i + k]!
|
||||
array[i + k] = array[i + 2 * itemSize + k]!
|
||||
array[i + 2 * itemSize + k] = tmp
|
||||
}
|
||||
}
|
||||
attribute.needsUpdate = true
|
||||
}
|
||||
}
|
||||
const normal = flipped.getAttribute('normal')
|
||||
if (normal) {
|
||||
for (let i = 0; i < normal.array.length; i++) normal.array[i] = -normal.array[i]!
|
||||
normal.needsUpdate = true
|
||||
}
|
||||
return flipped
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a viewer NodeMaterial into the classic `MeshStandardMaterial` the
|
||||
* glTF exporter understands. Classic materials pass through untouched, and the
|
||||
* cache preserves material sharing (one source instance -> one target), so the
|
||||
* exporter still dedups shared surfaces.
|
||||
*/
|
||||
function convertMaterial(
|
||||
material: THREE.Material,
|
||||
cache: Map<THREE.Material, THREE.Material>,
|
||||
): THREE.Material {
|
||||
if ((material as { isNodeMaterial?: boolean }).isNodeMaterial !== true) return material
|
||||
|
||||
const cached = cache.get(material)
|
||||
if (cached) return cached
|
||||
|
||||
const src = material as THREE.Material & Record<string, unknown>
|
||||
const target = new THREE.MeshStandardMaterial()
|
||||
|
||||
target.name = material.name
|
||||
if (src.color instanceof THREE.Color) target.color.copy(src.color)
|
||||
if (src.emissive instanceof THREE.Color) target.emissive.copy(src.emissive)
|
||||
if (typeof src.emissiveIntensity === 'number') target.emissiveIntensity = src.emissiveIntensity
|
||||
// Lambert (solid-shading / glass) node materials carry no PBR scalars; a fully
|
||||
// rough, non-metallic surface is the faithful lit fallback.
|
||||
target.roughness = typeof src.roughness === 'number' ? src.roughness : 1
|
||||
target.metalness = typeof src.metalness === 'number' ? src.metalness : 0
|
||||
// Only genuinely see-through surfaces stay transparent. Several viewer
|
||||
// materials set `transparent: true` while fully opaque (opacity 1); exporting
|
||||
// those as alphaMode=BLEND makes them render see-through with no depth write
|
||||
// (e.g. the ceiling looked semi-transparent). Glass (opacity < 1) is kept.
|
||||
target.transparent = material.transparent && material.opacity < 1
|
||||
target.opacity = material.opacity
|
||||
// BackSide is flipped to FrontSide (with the mesh winding reversed in
|
||||
// convertMaterials) because glTF has no back-face-only mode.
|
||||
target.side = material.side === THREE.BackSide ? THREE.FrontSide : material.side
|
||||
target.alphaTest = material.alphaTest
|
||||
target.depthWrite = material.depthWrite
|
||||
target.depthTest = material.depthTest
|
||||
target.vertexColors = material.vertexColors
|
||||
target.toneMapped = material.toneMapped
|
||||
if (src.normalScale instanceof THREE.Vector2) target.normalScale.copy(src.normalScale)
|
||||
if (typeof src.aoMapIntensity === 'number') target.aoMapIntensity = src.aoMapIntensity
|
||||
if (typeof src.displacementScale === 'number') target.displacementScale = src.displacementScale
|
||||
|
||||
for (const slot of STANDARD_MAP_SLOTS) {
|
||||
const texture = src[slot]
|
||||
if (texture instanceof THREE.Texture) {
|
||||
;(target as unknown as Record<string, THREE.Texture>)[slot] = texture
|
||||
}
|
||||
}
|
||||
|
||||
cache.set(material, target)
|
||||
return target
|
||||
}
|
||||
|
||||
// --- Animation clip baking ----------------------------------------------
|
||||
|
||||
function bakeAnimationClips(
|
||||
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
||||
nodes: Record<string, AnyNode>,
|
||||
): { clips: THREE.AnimationClip[]; clipNamesByNode: Map<string, string[]> } {
|
||||
const clips: THREE.AnimationClip[] = []
|
||||
const clipNamesByNode = new Map<string, string[]>()
|
||||
|
||||
for (const [id, original] of sceneRegistry.nodes) {
|
||||
const node = nodes[id]
|
||||
const target = cloneByOriginal.get(original)
|
||||
if (!node || !target) continue
|
||||
|
||||
const clip =
|
||||
node.type === 'door'
|
||||
? bakeDoorClip(id, node, target)
|
||||
: node.type === 'window'
|
||||
? bakeWindowClip(id, node as WindowNode, target)
|
||||
: node.type === 'item'
|
||||
? bakeItemClip(id, target)
|
||||
: null
|
||||
|
||||
if (clip) {
|
||||
clips.push(clip)
|
||||
clipNamesByNode.set(id, [clip.name])
|
||||
}
|
||||
}
|
||||
|
||||
return { clips, clipNamesByNode }
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-emit a catalog item's ambient clip (e.g. a fan's spin) onto the baked
|
||||
* subtree. The source clip targets the item GLB's nodes by name (`lamp_018`);
|
||||
* since every fan shares those names, we rebind each track to the specific
|
||||
* cloned node's uuid so multiple fans animate independently. The clip is named
|
||||
* per node (`<id>: loop`) so the baked viewer can drive each one on its own.
|
||||
*/
|
||||
function bakeItemClip(id: string, itemObject: THREE.Object3D): THREE.AnimationClip | null {
|
||||
const entry = itemClipRegistry.get(id)
|
||||
if (!entry) return null
|
||||
|
||||
const tracks: THREE.KeyframeTrack[] = []
|
||||
// The catalog node names (e.g. "lamp_018") repeat across every instance of the
|
||||
// item, and the glTF export→import roundtrip rebinds clip tracks by node name —
|
||||
// so a shared name would make all fans share one clip. Uniquify the targeted
|
||||
// node's name per item once, then bind tracks by its (stable) uuid.
|
||||
const renamed = new Map<string, THREE.Object3D>()
|
||||
for (const track of entry.clip.tracks) {
|
||||
const dot = track.name.lastIndexOf('.')
|
||||
if (dot < 0) continue
|
||||
const targetName = track.name.slice(0, dot)
|
||||
const property = track.name.slice(dot + 1)
|
||||
let targetNode = renamed.get(targetName)
|
||||
if (!targetNode) {
|
||||
const found = itemObject.getObjectByName(targetName)
|
||||
if (!found) continue
|
||||
found.name = `${id}__${targetName}`
|
||||
renamed.set(targetName, found)
|
||||
targetNode = found
|
||||
}
|
||||
const retargeted = track.clone()
|
||||
retargeted.name = `${targetNode.uuid}.${property}`
|
||||
tracks.push(retargeted)
|
||||
}
|
||||
|
||||
if (tracks.length === 0) return null
|
||||
const clip = new THREE.AnimationClip(`${id}: loop`, entry.clip.duration, tracks)
|
||||
clip.userData = { loop: entry.loop }
|
||||
return clip
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake a swing door's open motion. Each marked leaf is rotated from closed
|
||||
* (rest pose) to its fully-open angle and emitted as a 1-second quaternion
|
||||
* track; the leaf is left at the closed pose so the GLB's rest state is shut.
|
||||
*/
|
||||
function bakeDoorClip(
|
||||
id: string,
|
||||
node: AnyNode,
|
||||
doorObject: THREE.Object3D,
|
||||
): THREE.AnimationClip | null {
|
||||
const tracks: THREE.KeyframeTrack[] = []
|
||||
|
||||
doorObject.traverse((object) => {
|
||||
const marker = object.userData.pascalSwingLeaf as SwingLeafMarker | undefined
|
||||
if (!marker || marker.axis !== 'y') return
|
||||
|
||||
object.rotation.y = 0
|
||||
const closed = object.quaternion.clone()
|
||||
object.rotation.y = marker.openRotationY
|
||||
const open = object.quaternion.clone()
|
||||
object.rotation.y = 0
|
||||
|
||||
tracks.push(
|
||||
new THREE.QuaternionKeyframeTrack(
|
||||
`${object.uuid}.quaternion`,
|
||||
[0, 1],
|
||||
[...closed.toArray(), ...open.toArray()],
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
if (tracks.length === 0) return null
|
||||
return openClip(id, node, tracks)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an open motion in a named 1-second clip. The name uses the node's label
|
||||
* when set (e.g. "Door 1: open") so a glTF player lists readable clips, falling
|
||||
* back to the id. glTF has no core loop flag — the player decides — so we stamp
|
||||
* `extras.loop = false` (via the clip's userData, which `GLTFExporter`
|
||||
* serialises onto the animation): Pascal's `/viewer` and any extras-aware
|
||||
* consumer play it once and hold the open pose; a dumb glTF player still loops.
|
||||
* Consumers map a clip back to its node by walking up from a channel's target to
|
||||
* the nearest ancestor carrying `extras.pascalId`, so the name stays cosmetic.
|
||||
*/
|
||||
function openClip(id: string, node: AnyNode, tracks: THREE.KeyframeTrack[]): THREE.AnimationClip {
|
||||
const clip = new THREE.AnimationClip(`${node.name ?? id}: open`, 1, tracks)
|
||||
clip.userData = { loop: false }
|
||||
return clip
|
||||
}
|
||||
|
||||
/**
|
||||
* Bake a window's open motion generically: snapshot every part's pose closed,
|
||||
* pose the subtree open, and emit a track for whichever parts actually moved
|
||||
* (translation for sliding/hung sashes, rotation for casement/awning/louvre).
|
||||
* Reusing the live `poseWindowMovingParts` keeps one source of truth for window
|
||||
* kinematics. The subtree is left posed closed as the GLB's rest state.
|
||||
*/
|
||||
function bakeWindowClip(
|
||||
id: string,
|
||||
node: WindowNode,
|
||||
windowObject: THREE.Object3D,
|
||||
): THREE.AnimationClip | null {
|
||||
poseWindowMovingParts(node, windowObject, 0)
|
||||
|
||||
const closedPoses = new Map<
|
||||
THREE.Object3D,
|
||||
{ position: THREE.Vector3; quaternion: THREE.Quaternion }
|
||||
>()
|
||||
windowObject.traverse((object) => {
|
||||
closedPoses.set(object, {
|
||||
position: object.position.clone(),
|
||||
quaternion: object.quaternion.clone(),
|
||||
})
|
||||
})
|
||||
|
||||
if (!poseWindowMovingParts(node, windowObject, 1)) return null
|
||||
|
||||
const tracks: THREE.KeyframeTrack[] = []
|
||||
windowObject.traverse((object) => {
|
||||
const closed = closedPoses.get(object)
|
||||
if (!closed) return
|
||||
|
||||
if (object.position.distanceToSquared(closed.position) > POSE_EPSILON) {
|
||||
tracks.push(
|
||||
new THREE.VectorKeyframeTrack(
|
||||
`${object.uuid}.position`,
|
||||
[0, 1],
|
||||
[...closed.position.toArray(), ...object.position.toArray()],
|
||||
),
|
||||
)
|
||||
}
|
||||
if (closed.quaternion.angleTo(object.quaternion) > POSE_EPSILON) {
|
||||
tracks.push(
|
||||
new THREE.QuaternionKeyframeTrack(
|
||||
`${object.uuid}.quaternion`,
|
||||
[0, 1],
|
||||
[...closed.quaternion.toArray(), ...object.quaternion.toArray()],
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
poseWindowMovingParts(node, windowObject, 0)
|
||||
|
||||
if (tracks.length === 0) return null
|
||||
return openClip(id, node, tracks)
|
||||
}
|
||||
|
||||
// --- Identity stamping ---------------------------------------------------
|
||||
|
||||
/**
|
||||
* Replace every clone's userData with `{}`, then stamp identity onto the nodes
|
||||
* that `sceneRegistry` tracks. Wiping first guarantees no editor/runtime marker
|
||||
* (e.g. `pascalSwingLeaf`, cached-material flags) leaks into glTF extras — the
|
||||
* file describes itself with exactly the fields a consumer needs.
|
||||
*/
|
||||
/**
|
||||
* Human-readable label for a baked node, mirroring the viewer's `getNodeName`:
|
||||
* an explicit name wins, items fall back to their catalog asset name, other
|
||||
* kinds to a capitalized type. Levels override this with their display name.
|
||||
*/
|
||||
function nodeDisplayLabel(node: AnyNode): string {
|
||||
if (node.name) return node.name
|
||||
switch (node.type) {
|
||||
case 'item':
|
||||
return (node as { asset?: { name?: string } }).asset?.name || 'Item'
|
||||
case 'wall':
|
||||
return 'Wall'
|
||||
case 'door':
|
||||
return 'Door'
|
||||
case 'window':
|
||||
return 'Window'
|
||||
case 'slab':
|
||||
return 'Slab'
|
||||
case 'ceiling':
|
||||
return 'Ceiling'
|
||||
case 'roof':
|
||||
return 'Roof'
|
||||
case 'fence':
|
||||
return 'Fence'
|
||||
case 'column':
|
||||
return 'Column'
|
||||
case 'stair':
|
||||
return 'Stairs'
|
||||
default:
|
||||
return node.type
|
||||
}
|
||||
}
|
||||
|
||||
function stampIdentity(
|
||||
scene: THREE.Object3D,
|
||||
cloneByOriginal: Map<THREE.Object3D, THREE.Object3D>,
|
||||
nodes: Record<string, AnyNode>,
|
||||
clipNamesByNode: Map<string, string[]>,
|
||||
) {
|
||||
scene.traverse((object) => {
|
||||
object.userData = {}
|
||||
})
|
||||
|
||||
for (const [id, original] of sceneRegistry.nodes) {
|
||||
const node = nodes[id]
|
||||
const target = cloneByOriginal.get(original)
|
||||
if (!node || !target) continue
|
||||
|
||||
target.name = id
|
||||
const extras: Record<string, unknown> = { pascalId: id, kind: node.type }
|
||||
// Stamp a human label for every node (catalog name for items, a type label
|
||||
// otherwise) so the viewer breadcrumb/hover read names, not raw pascalIds.
|
||||
extras.label = nodeDisplayLabel(node)
|
||||
// Camera bookmarks ride on the identity node (any kind can carry one) so the
|
||||
// baked viewer flies to a saved pose on selection without a side file.
|
||||
if (node.camera) extras.camera = node.camera
|
||||
// Levels carry no stored name; stamp the editor's display name ("Level 1")
|
||||
// so the baked viewer's level/breadcrumb UI reads the same labels. Force the
|
||||
// node visible: the bake must capture every floor regardless of the editor's
|
||||
// current level mode (solo/hidden floors would otherwise be dropped by
|
||||
// GLTFExporter's `onlyVisible`).
|
||||
if (node.type === 'level') {
|
||||
extras.label = getLevelDisplayName(node as LevelNode)
|
||||
target.visible = true
|
||||
}
|
||||
// Only doors/windows that actually baked an open clip are openable. A cased
|
||||
// opening (no leaf) or a fixed window (no operable sash) produces no clip, so
|
||||
// it stays unflagged — the file never claims a part opens when nothing moves.
|
||||
if (node.type === 'door' || node.type === 'window') {
|
||||
const clipNames = clipNamesByNode.get(id)
|
||||
if (clipNames?.length) {
|
||||
extras.openable = true
|
||||
extras.clips = clipNames
|
||||
}
|
||||
}
|
||||
// Items with a baked ambient clip (a fan's spin) carry the clip name but no
|
||||
// `openable` flag — nothing opens; the clip just loops.
|
||||
if (node.type === 'item') {
|
||||
const clipNames = clipNamesByNode.get(id)
|
||||
if (clipNames?.length) extras.clips = clipNames
|
||||
}
|
||||
if (node.type === 'zone') {
|
||||
// Zone fills are stripped from the bake; /viewer rebuilds the room from
|
||||
// this polygon. Force the identity node visible so GLTFExporter's
|
||||
// `onlyVisible` keeps it even when the editor had zones hidden at export.
|
||||
const zone = node as ZoneNode
|
||||
extras.polygon = zone.polygon
|
||||
extras.color = zone.color
|
||||
target.visible = true
|
||||
}
|
||||
if (node.type === 'spawn') {
|
||||
// The spawn marker's visible mesh lives on a non-scene overlay layer (and
|
||||
// is pruned), so this identity node is an empty transform. Keep it + force
|
||||
// visible so the baked walkthrough can read its world position/yaw and
|
||||
// start the player there (`extras.rotation` mirrors the node's yaw).
|
||||
extras.rotation = (node as { rotation?: number }).rotation ?? 0
|
||||
target.visible = true
|
||||
}
|
||||
target.userData = extras
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ type SFXEvents = {
|
||||
'sfx:item-pick': undefined
|
||||
'sfx:item-place': undefined
|
||||
'sfx:item-rotate': undefined
|
||||
'sfx:resize': undefined
|
||||
'sfx:structure-build-start': undefined
|
||||
'sfx:structure-build': undefined
|
||||
'sfx:structure-delete': undefined
|
||||
@@ -40,6 +41,7 @@ export function initSFXBus() {
|
||||
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
|
||||
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
|
||||
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
|
||||
sfxEmitter.on('sfx:resize', () => playSFX('resize'))
|
||||
sfxEmitter.on('sfx:structure-build-start', () => playSFX('structureBuildStart'))
|
||||
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuildEnd'))
|
||||
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
|
||||
|
||||
@@ -59,6 +59,16 @@ export const SFX: Record<string, SFXConfig> = {
|
||||
volumeRange: [0.92, 1.0],
|
||||
panJitter: 0.15,
|
||||
},
|
||||
// Ticks as a resize handle is dragged across snap steps. Fires in rapid
|
||||
// succession, so it mirrors gridSnap: three variations cycled round-robin
|
||||
// with pitch/pan jitter and a gap so the run reads as texture, not a tone.
|
||||
resize: {
|
||||
src: ['/audios/sfx/resize_0.mp3', '/audios/sfx/resize_1.mp3', '/audios/sfx/resize_2.mp3'],
|
||||
rateRange: [0.98, 1.02],
|
||||
volumeRange: [0.26, 0.34],
|
||||
panJitter: 0.15,
|
||||
minIntervalMs: 80,
|
||||
},
|
||||
// Fired when a structure draft begins (first click of a wall/slab/etc).
|
||||
structureBuildStart: {
|
||||
src: '/audios/sfx/structure_build_start.mp3',
|
||||
|
||||
Reference in New Issue
Block a user