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:
Wassim SAMAD
2026-06-28 16:22:47 -04:00
co-authored by Claude Opus 4.8
171 changed files with 19294 additions and 2224 deletions
+1 -1
View File
@@ -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>
+23 -1
View File
@@ -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({
+267
View File
@@ -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)
})
})
+653
View File
@@ -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
}
}
+2
View File
@@ -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'))
+10
View File
@@ -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',