Reset core/viewer/editor/mcp packages and apps/editor from private-editor

Wholesale swap of packages/{core,viewer,editor,mcp} and apps/editor with the
versions from the private editor repo, which is the production source of truth.

Setup changes:
- packages/{core,viewer,editor} versions held at 0.7.0 baseline (matching
  the most recent published release) so a bump=minor publishes 0.8.0
- packages/mcp held at 0.1.1 (never published; first publish will go through
  the new release.yml flow)
- peerDependencies and devDependencies for inter-package @pascal-app/*
  references pinned to ^0.7.0 instead of '*' / 'workspace:*' so they are
  valid for npm consumers
- Root package.json: TypeScript bumped to 6.0.2, added overrides for
  @types/react, @types/react-dom, @types/three to prevent JSX namespace
  fragmentation across the workspace
- release.yml extended to also publish editor and mcp; 'both' option renamed
  to 'all'; added a sync step that updates inter-package peerDeps/devDeps to
  match the new versions on every bump (so viewer/editor/mcp tarballs always
  reference the version of core they were built against)
- Root scripts gained release:editor and release:mcp shortcuts

Verification:
- bun install --frozen-lockfile is consistent
- packages/{core,viewer,mcp} build cleanly, dist/index.d.ts emitted
- packages/editor check-types reports 21 pre-existing errors, identical to
  what private-editor currently reports

Open PRs against editor-v2 will need rebasing/conflict resolution.
This commit is contained in:
Wawa
2026-05-09 20:52:26 +00:00
parent 146f0a846f
commit e618175bba
196 changed files with 5899 additions and 3966 deletions
+5 -5
View File
@@ -44,24 +44,24 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"howler": "^2.2.4",
"lucide-react": "^0.562.0",
"lucide-react": "^1.7.0",
"mitt": "^3.0.1",
"motion": "^12.34.3",
"nanoid": "^5.1.6",
"tailwind-merge": "^3.5.0",
"three-mesh-bvh": "^0.9.8",
"zod": "^4.3.6",
"zustand": "^5.0.11"
"zustand": "^5.0.11",
"three-mesh-bvh": "~0.9.8"
},
"devDependencies": {
"@pascal-app/core": "^0.7.0",
"@pascal-app/viewer": "^0.7.0",
"@pascal/typescript-config": "*",
"@types/bun": "^1.3.0",
"@types/howler": "^2.2.12",
"@types/node": "^22.19.12",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.2",
"@types/three": "^0.184.0",
"typescript": "5.9.3"
"typescript": "6.0.2"
}
}
@@ -72,7 +72,12 @@ export const FloorplanDraftLayer = memo(function FloorplanDraftLayer({
)}
{polygonDraftPolygonPoints && (
<polygon fill={draftFill} fillOpacity={0.2} points={polygonDraftPolygonPoints} stroke="none" />
<polygon
fill={draftFill}
fillOpacity={0.2}
points={polygonDraftPolygonPoints}
stroke="none"
/>
)}
{polygonDraftPolylinePoints && (
@@ -125,7 +125,12 @@ export const FloorplanMeasurementsLayer = memo(function FloorplanMeasurementsLay
return (
<>
{measurements.map((measurement) => (
<g className={className} key={measurement.id} pointerEvents="none" style={{ userSelect: 'none' }}>
<g
className={className}
key={measurement.id}
pointerEvents="none"
style={{ userSelect: 'none' }}
>
<FloorplanMeasurementLine
dashed={measurement.dashedExtensions ?? true}
isSelected={measurement.isSelected}
@@ -396,8 +396,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<>
<polyline
fill="none"
points={formatSvgPolygonPoints(arrow.polyline)}
pointerEvents="none"
points={formatSvgPolygonPoints(arrow.polyline)}
stroke={straightAccent}
strokeWidth="1.15"
vectorEffect="non-scaling-stroke"
@@ -411,8 +411,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
/>
<polygon
fill={straightAccent}
points={formatSvgPolygonPoints(arrow.head)}
pointerEvents="none"
points={formatSvgPolygonPoints(arrow.head)}
/>
</>
) : null}
@@ -438,8 +438,6 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
}
: undefined
}
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
onPointerDown={
canFocusStairs && stairSelected
? (event) => {
@@ -449,6 +447,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
}
: undefined
}
onPointerEnter={canSelectStairs ? () => onStairHoverEnter(stair.id) : undefined}
onPointerLeave={canSelectStairs ? () => onStairHoverChange(null) : undefined}
pointerEvents={canSelectStairs ? undefined : 'none'}
style={canSelectStairs ? { cursor } : undefined}
>
@@ -456,8 +456,8 @@ export const FloorplanStairLayer = memo(function FloorplanStairLayer({
<polygon
fill="transparent"
key={`${stair.id}:hit:${polygonIndex}`}
points={formatSvgPolygonPoints(polygon)}
pointerEvents={canSelectStairs ? 'all' : 'none'}
points={formatSvgPolygonPoints(polygon)}
stroke="transparent"
strokeLinejoin="round"
strokeWidth={hitStrokeWidth}
@@ -1,4 +1,5 @@
'use client'
import {
type CameraControlEvent,
type CameraControlFitSceneEvent,
@@ -6,7 +7,7 @@ import {
sceneRegistry,
useScene,
} from '@pascal-app/core'
import { useViewer, ZONE_LAYER } from '@pascal-app/viewer'
import { useViewer, WalkthroughControls, ZONE_LAYER } from '@pascal-app/viewer'
import { CameraControls, CameraControlsImpl } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
import { useCallback, useEffect, useMemo, useRef } from 'react'
+4 -3
View File
@@ -205,6 +205,7 @@ export function EditorLayoutV2({
viewerContent,
overlays,
}: EditorLayoutV2Props) {
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const isMobile = useIsMobile()
if (isMobile) {
@@ -229,7 +230,7 @@ export function EditorLayoutV2({
{/* Main content: left column + right column */}
<div className="flex min-h-0 flex-1">
{sidebarTabs.length > 0 && (
{!isCaptureMode && sidebarTabs.length > 0 && (
<LeftColumn
renderTabContent={renderTabContent}
sidebarOverlay={sidebarOverlay}
@@ -238,8 +239,8 @@ export function EditorLayoutV2({
)}
<RightColumn
overlays={overlays}
toolbarLeft={viewerToolbarLeft}
toolbarRight={viewerToolbarRight}
toolbarLeft={isCaptureMode ? undefined : viewerToolbarLeft}
toolbarRight={isCaptureMode ? undefined : viewerToolbarRight}
>
{viewerContent}
</RightColumn>
@@ -455,14 +455,15 @@ export const FirstPersonControls = () => {
{controllerStart && (
<KeyboardControls map={keyboardMap}>
<BVHEcctrl
ref={controllerRef}
key="first-person-controller"
acceleration={26}
airDragFactor={0.3}
colliderCapsuleArgs={[0.25, 0.8, 4, 8]}
colliderMeshes={[world.mesh]}
collisionCheckIteration={3}
collisionPushBackDamping={0.1}
collisionPushBackThreshold={0.001}
debug={false}
deceleration={30}
delay={0}
fallGravityFactor={4}
floatCheckType="BOTH"
@@ -473,13 +474,12 @@ export const FirstPersonControls = () => {
floatSpringK={1200}
gravity={9.81}
jumpVel={6}
key="first-person-controller"
maxRunSpeed={5.5}
maxSlope={1.2}
maxWalkSpeed={4}
position={controllerStart.position}
acceleration={26}
airDragFactor={0.3}
deceleration={30}
ref={controllerRef}
/>
</KeyboardControls>
)}
@@ -551,12 +551,12 @@ export const FirstPersonOverlay = ({ onExit }: { onExit: () => void }) => {
{isLocked && (
<div className="pointer-events-none fixed top-1/2 right-6 z-40 -translate-y-1/2">
<div className="flex min-w-[148px] flex-col gap-3 rounded-2xl border border-border/35 bg-background/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<ControlHint label="Move" keys={['W', 'A', 'S', 'D']} />
<ControlHint keys={['W', 'A', 'S', 'D']} label="Move" />
<div className="h-px w-full bg-border/30" />
<InlineControlHint label="Jump" keyLabel="Space" />
<InlineControlHint label="Sprint" keyLabel="Shift" />
<InlineControlHint label="Interact" keyLabel="E / R" />
<InlineControlHint label="Close" keyLabel="T" />
<InlineControlHint keyLabel="Space" label="Jump" />
<InlineControlHint keyLabel="Shift" label="Sprint" />
<InlineControlHint keyLabel="E / R" label="Interact" />
<InlineControlHint keyLabel="T" label="Close" />
<div className="h-px w-full bg-border/30" />
<span className="text-center text-muted-foreground/60 text-xs">
Click to look around
@@ -591,7 +591,7 @@ function ControlHint({ label, keys }: { label: string; keys: string[] }) {
function InlineControlHint({ label, keyLabel }: { label: string; keyLabel: string }) {
return (
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-[10px] text-muted-foreground/60 tracking-[0.03em] uppercase">
<span className="font-medium text-[10px] text-muted-foreground/60 uppercase tracking-[0.03em]">
{label}
</span>
<kbd className="flex h-5 min-w-5 items-center justify-center rounded border border-border/50 bg-accent/40 px-1.5 font-mono text-[10px] text-foreground/80 leading-none">
@@ -1,7 +1,7 @@
import {
getGarageVisibleOpeningRatio,
type AnyNodeId,
type DoorNode,
getGarageVisibleOpeningRatio,
isOperationDoorType,
sceneRegistry,
useInteractive,
@@ -97,11 +97,11 @@ function shouldSkipColliderNode(nodeId: string, type: (typeof COLLIDER_NODE_TYPE
const node = useScene.getState().nodes[nodeId as AnyNodeId]
if (!node || node.type !== 'door') return false
if (node.openingKind === 'opening') return true
if (!node.segments.length) return true
return node.segments.every((segment) => segment.type === 'empty')
if (node.openingKind === 'opening') return true
return node.segments.every((segment: { type: string }) => segment.type === 'empty')
}
function createDoorLeafColliderGeometry(root: THREE.Object3D, node: DoorNode) {
@@ -273,9 +273,7 @@ export function buildFirstPersonColliderWorldFromRegistry(): FirstPersonCollider
}
const mergedGeometry = mergeGeometries(geometries, false)
geometries.forEach((geometry) => {
geometry.dispose()
})
geometries.forEach((geometry) => geometry.dispose())
if (!mergedGeometry || mergedGeometry.getAttribute('position') == null) {
mergedGeometry?.dispose()
@@ -1,15 +1,8 @@
import '../../../three-types'
import { TransformControls, useKeyboardControls } from '@react-three/drei'
import { useFrame, useThree, type ThreeElements } from '@react-three/fiber'
import {
Suspense,
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react'
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'
@@ -156,16 +149,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const moveDirRef = useRef<THREE.ArrowHelper | null>(null)
const elapsedRef = useRef(0)
function useIsInsideKeyboardControls() {
try {
return !!useKeyboardControls()
} catch {
return false
}
}
const isInsideKeyboardControls = useIsInsideKeyboardControls()
const [_, getKeys] = isInsideKeyboardControls ? useKeyboardControls() : [null, null]
const [, getKeys] = useKeyboardControls()
const presetKeys = {
forward: false,
backward: false,
@@ -222,11 +206,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const scaledContactRadiusVec = useRef(new THREE.Vector3())
const deltaDist = useRef(new THREE.Vector3())
const currSlopeAngle = useRef(0)
const localMinDistance = useRef(Infinity)
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(Infinity)
const globalMinDistance = useRef(Number.POSITIVE_INFINITY)
const globalClosestPoint = useRef(new THREE.Vector3())
const triHitPoint = useRef(new THREE.Vector3())
const segHitPoint = useRef(new THREE.Vector3())
@@ -286,7 +270,13 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const moving = currentLinVel.current.lengthSq() > 1e-6
const platformIsMoving = totalPlatformDeltaPos.current.lengthSq() > 1e-6
if (!moving && isOnGround.current && !jump && !isOnMovingPlatform.current && !platformIsMoving) {
if (
!moving &&
isOnGround.current &&
!jump &&
!isOnMovingPlatform.current &&
!platformIsMoving
) {
idleTime.current += delta
if (idleTime.current > sleepTimeout) isSleeping.current = true
} else {
@@ -340,7 +330,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
upAxis.current,
)
characterModelTargetQuat.current.setFromRotationMatrix(characterModelLookMatrix.current)
characterModelRef.current.quaternion.slerp(characterModelTargetQuat.current, delta * turnSpeed)
characterModelRef.current.quaternion.slerp(
characterModelTargetQuat.current,
delta * turnSpeed,
)
}
const maxSpeed = run ? maxRunSpeed : maxWalkSpeed
@@ -358,18 +351,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
)
currentLinVel.current.add(deltaLinVel.current)
} else if (isOnGround.current) {
deltaLinVel.current.copy(currentLinVelOnPlane.current).clampLength(0, deceleration * friction * delta)
deltaLinVel.current
.copy(currentLinVelOnPlane.current)
.clampLength(0, deceleration * friction * delta)
currentLinVel.current.sub(deltaLinVel.current)
}
},
[acceleration, airDragFactor, counterAccFactor, deceleration, maxRunSpeed, maxWalkSpeed, turnSpeed, characterOrigin],
[
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)
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()
@@ -394,11 +402,18 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const collisionCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4, delta: number) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeCollisionCheck) return
if (!(mesh.visible && mesh.geometry.boundsTree) || mesh.userData.excludeCollisionCheck)
return
originMatrix.decompose(contactTempPos.current, contactTempQuat.current, contactTempScale.current)
originMatrix.decompose(
contactTempPos.current,
contactTempQuat.current,
contactTempScale.current,
)
collideInvertMatrix.current.copy(originMatrix).invert()
localCharacterSegment.current.copy(characterSegment.current).applyMatrix4(collideInvertMatrix.current)
localCharacterSegment.current
.copy(characterSegment.current)
.applyMatrix4(collideInvertMatrix.current)
scaledContactRadiusVec.current.set(
capsuleRadius / contactTempScale.current.x,
@@ -445,7 +460,10 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
contactDepth.current =
capsuleRadius - capsuleContactPoint.current.distanceTo(triContactPoint.current)
accumulatedContactNormal.current.addScaledVector(contactNormal.current, contactDepth.current)
accumulatedContactNormal.current.addScaledVector(
contactNormal.current,
contactDepth.current,
)
accumulatedContactPoint.current.add(triContactPoint.current)
totalDepth.current += contactDepth.current
triangleCount.current += 1
@@ -492,14 +510,16 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const floatingCheck = useCallback(
(mesh: THREE.Mesh, originMatrix: THREE.Matrix4) => {
if (!mesh.visible || !mesh.geometry.boundsTree || mesh.userData.excludeFloatHit) return
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)
localFloatSensorSegment.current
.copy(floatSensorSegment.current)
.applyMatrix4(floatInvertMatrix.current)
localFloatSensorBboxExpendPoint.current
.copy(floatSensorBboxExpendPoint.current)
.applyMatrix4(floatInvertMatrix.current)
@@ -517,20 +537,33 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
localFloatSensorBbox.current.min.addScaledVector(scaledFloatRadiusVec.current, -1)
localFloatSensorBbox.current.max.add(scaledFloatRadiusVec.current)
localMinDistance.current = Infinity
localClosestPoint.current.set(Infinity, Infinity, Infinity)
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()
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 verticalLength =
Math.abs(dot) /
((capsuleRadius + floatHeight + floatPullBackHeight) / floatSensorRadius)
const horizontalLength = Math.sqrt(Math.max(0, totalLengthSq - dot * dot))
if (horizontalLength < 1 && verticalLength < 1) {
@@ -560,9 +593,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
const handleFloatingResponse = useCallback(
(meshes: THREE.Mesh[], jump: boolean, delta: number) => {
if (meshes.length === 0) return
let shouldJump = jump
globalMinDistance.current = Infinity
globalClosestPoint.current.set(Infinity, Infinity, Infinity)
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)
@@ -574,7 +612,11 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
}
}
if (floatCheckType !== 'SHAPECAST' && floatRaycastCandidates.length > 0 && globalMinDistance.current === Infinity) {
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)
@@ -582,23 +624,28 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
if (hit?.point) {
globalClosestPoint.current.copy(hit.point)
if (hit.face) {
floatHitNormal.current.copy(hit.face.normal).transformDirection(hit.object.matrixWorld).normalize()
floatHitNormal.current
.copy(hit.face.normal)
.transformDirection(hit.object.matrixWorld)
.normalize()
}
}
}
if (globalClosestPoint.current.x === Infinity) return
if (globalClosestPoint.current.x === Number.POSITIVE_INFINITY) return
relativeHitPoint.current.copy(globalClosestPoint.current).sub(floatSensorSegment.current.start)
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
jump = false
shouldJump = false
}
if (!jump) {
if (!shouldJump) {
const displacement = floatHeight + capsuleRadius - currentDistance
const velocityOnHitNormal = currentLinVel.current.dot(floatHitNormal.current)
const springForce = displacement * floatSpringK
@@ -608,7 +655,17 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
currentLinVel.current.addScaledVector(floatHitNormal.current, (totalForce / mass) * delta)
}
},
[capsuleRadius, floatCheckType, floatDampingC, floatHeight, floatRaycastCandidates, floatSpringK, floatingCheck, gravity, mass],
[
capsuleRadius,
floatCheckType,
floatDampingC,
floatHeight,
floatRaycastCandidates,
floatSpringK,
floatingCheck,
gravity,
mass,
],
)
const updateCharacterWithPlatform = useCallback(() => {
@@ -646,8 +703,14 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
)
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 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
@@ -682,7 +745,9 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
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?.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)
@@ -698,7 +763,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
if (paused || elapsedRef.current < delay) return
const deltaTime = Math.min(1 / 45, delta) * slowMotionFactor
const keys = isInsideKeyboardControls && getKeys ? getKeys() : presetKeys
const keys = getKeys() ?? presetKeys
const forward = forwardState.current || keys.forward
const backward = backwardState.current || keys.backward
const leftward = leftwardState.current || keys.leftward
@@ -740,7 +805,7 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
return (
<Suspense fallback={null}>
<group {...props} ref={characterGroupRef} dispose={null}>
<group {...props} dispose={null} ref={characterGroupRef}>
{debug && (
<mesh ref={characterColliderRef}>
<capsuleGeometry args={colliderCapsuleArgs} />
@@ -777,8 +842,8 @@ const BVHEcctrl = forwardRef<BVHEcctrlApi, EcctrlProps>(
<octahedronGeometry args={[0.1, 0]} />
<meshNormalMaterial />
</mesh>
<arrowHelper ref={inputDirRef} args={[undefined, undefined, undefined, '#00f']} />
<arrowHelper ref={moveDirRef} args={[undefined, undefined, undefined, '#f00']} />
<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 />
View File
@@ -4092,7 +4092,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.slabFill}
fillRule="evenodd"
opacity={slabFillOpacity}
onClick={
canSelectSlabs
? (event) => {
@@ -4111,9 +4110,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
}
onPointerEnter={canSelectSlabs ? () => onSlabHoverChange(slab.id) : undefined}
onPointerLeave={canSelectSlabs ? () => onSlabHoverChange(null) : undefined}
opacity={slabFillOpacity}
pointerEvents={canSelectSlabs ? undefined : 'none'}
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
stroke="none"
style={canSelectSlabs ? { cursor: EDITOR_CURSOR } : undefined}
/>
{isSelected && !isDeleteHovered ? (
<path
@@ -4168,7 +4168,6 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={path}
fill={isDeleteHovered ? palette.deleteFill : palette.ceilingFill}
fillRule="evenodd"
opacity={ceilingFillOpacity}
onClick={
canSelectCeilings
? (event) => {
@@ -4189,9 +4188,10 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
canSelectCeilings ? () => onCeilingHoverChange(ceiling.id) : undefined
}
onPointerLeave={canSelectCeilings ? () => onCeilingHoverChange(null) : undefined}
opacity={ceilingFillOpacity}
pointerEvents={canSelectCeilings ? undefined : 'none'}
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
stroke="none"
style={canSelectCeilings ? { cursor: EDITOR_CURSOR } : undefined}
/>
{isSelected && !isDeleteHovered ? (
<path
@@ -4298,8 +4298,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
<polygon
fill={`url(#${wallSelectionHatchId})`}
opacity={1}
points={points}
pointerEvents="none"
points={points}
/>
) : null}
</g>
@@ -4427,8 +4427,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && (
<polygon
fill="transparent"
points={points}
pointerEvents="all"
points={points}
stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
@@ -4538,8 +4538,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && (
<polygon
fill="transparent"
points={points}
pointerEvents="all"
points={points}
stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
@@ -5218,8 +5218,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
{canSelectGeometry && (
<polygon
fill="transparent"
points={points}
pointerEvents="all"
points={points}
stroke="transparent"
strokeWidth={FLOORPLAN_OPENING_HIT_STROKE_WIDTH}
vectorEffect="non-scaling-stroke"
@@ -5290,8 +5290,8 @@ const FloorplanGeometryLayer = memo(function FloorplanGeometryLayer({
d={foldingPath}
fill="none"
stroke={doorStroke}
strokeLinejoin="round"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={isSelected || isSelectionHighlighted ? '1.8' : '1.25'}
vectorEffect="non-scaling-stroke"
/>
@@ -5963,6 +5963,12 @@ function FloorplanItemImage({
}) {
const resolvedUrl = useResolvedAssetUrl(url)
if (!resolvedUrl) return null
// The PNG is captured with the modal's top-down camera (default up = +Y),
// so its pixel-right is world +X and pixel-up is world -Z. The plan SVG
// negates both axes (`toSvgX(v) = -v`, `toSvgY(v) = -v`), which together
// are a 180° rotation — so the captured image lands upside-down /
// mirrored when overlaid as-is. Bake that 180° into the image transform
// here; the panel / modal previews use the PNG directly and stay correct.
const rotationDeg = (-rotation * 180) / Math.PI + 180
return (
<g
@@ -6168,6 +6174,7 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
}
points={points}
stroke={stroke}
strokeOpacity={1}
strokeWidth={
isSelectionActive ? FLOORPLAN_SELECTED_WALL_STROKE_WIDTH : FLOORPLAN_WALL_STROKE_WIDTH
}
@@ -6217,8 +6224,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
<polygon
fill={`url(#${wallSelectionHatchId})`}
opacity={1}
points={points}
pointerEvents="none"
points={points}
/>
) : null}
{itemDimensionMeasurements.length > 0 ? (
@@ -6308,8 +6315,8 @@ const FloorplanNodeLayer = memo(function FloorplanNodeLayer({
<polygon
fill={fill}
fillOpacity={isDeleteHovered ? 0.82 : 0.92}
points={FLOORPLAN_SPAWN_ARROW_POINTS}
pointerEvents="none"
points={FLOORPLAN_SPAWN_ARROW_POINTS}
stroke={stroke}
strokeLinejoin="round"
strokeWidth={0.055}
@@ -7224,6 +7231,11 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
y2={endSvg.y}
/>
<line
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
pointerEvents="stroke"
stroke="transparent"
strokeLinecap="round"
@@ -7234,11 +7246,6 @@ const FloorplanPolygonHandleLayer = memo(function FloorplanPolygonHandleLayer({
x2={endSvg.x}
y1={startSvg.y}
y2={endSvg.y}
onPointerDown={
onEdgePointerDown
? (event) => onEdgePointerDown(nodeId, edgeIndex, event)
: undefined
}
/>
</g>
)
@@ -9838,7 +9845,7 @@ export function FloorplanPanel() {
zoneVertexDragState != null ||
isPolygonDraftBuildActive
if (!hasUserAdjustedViewportRef.current && !transientFloorplanFit) {
if (!(hasUserAdjustedViewportRef.current || transientFloorplanFit)) {
setViewport((current) =>
floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport,
)
@@ -10970,6 +10977,14 @@ export function FloorplanPanel() {
}
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
// Subscribe to the live-transforms store so rotation/position changes that
// *don't* go through pointer events still refresh the floorplan — e.g. R/T
// keyboard rotation during placement updates `useLiveTransforms` but emits
@@ -10985,14 +11000,6 @@ export function FloorplanPanel() {
return unsubscribe
}, [isItemPlacementPreviewActive, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!hasPendingItemMeshFootprints) {
return
}
scheduleMovingFloorplanNodeRefresh()
}, [hasPendingItemMeshFootprints, scheduleMovingFloorplanNodeRefresh])
useEffect(() => {
if (!(movingNode?.type === 'door' || movingNode?.type === 'window')) {
return
@@ -16132,17 +16139,13 @@ export function FloorplanPanel() {
onDuplicate: handleSelectedItemDuplicate,
onMove: handleSelectedItemMove,
}}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
opening={{
position: selectedOpeningActionMenuPosition,
onDelete: handleSelectedOpeningDelete,
onDuplicate: handleSelectedOpeningDuplicate,
onMove: handleSelectedOpeningMove,
}}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
roof={{
position: selectedRoofActionMenuPosition,
onDelete: handleSelectedRoofDelete,
@@ -16157,6 +16160,11 @@ export function FloorplanPanel() {
: handleSelectedSlabDelete,
onMove: selectedSlabEditingHole ? handleSelectedSlabHoleMove : handleSelectedSlabMove,
}}
spawn={{
position: selectedSpawnActionMenuPosition,
onDelete: handleSelectedSpawnDelete,
onMove: handleSelectedSpawnMove,
}}
stair={{
position: selectedStairActionMenuPosition,
onDelete: handleSelectedStairDelete,
@@ -16168,7 +16176,6 @@ export function FloorplanPanel() {
onDelete: handleSelectedWallDelete,
onMove: handleSelectedWallMove,
}}
offsetY={FLOORPLAN_ACTION_MENU_OFFSET_Y}
/>
{referenceScaleDraft && (
@@ -16201,7 +16208,7 @@ export function FloorplanPanel() {
</div>
<div className="mb-3 rounded-xl border border-border/70 bg-white/5 px-3 py-2">
<div className="text-muted-foreground text-[11px] uppercase tracking-wide">
<div className="text-[11px] text-muted-foreground uppercase tracking-wide">
Drawn line
</div>
<div className="mt-1 font-medium text-sm">
@@ -16363,8 +16370,8 @@ export function FloorplanPanel() {
<FloorplanGuideLayer
activeGuideInteractionGuideId={activeGuideInteractionGuideId}
activeGuideInteractionMode={activeGuideInteractionMode}
guideUi={guideUi}
guides={displayGuides}
guideUi={guideUi}
isInteractive={canInteractWithGuides}
onGuideSelect={handleGuideSelect}
onGuideTranslateStart={handleGuideTranslateStart}
@@ -16385,6 +16392,8 @@ export function FloorplanPanel() {
hoveredSlabId={hoveredSlabId}
hoveredWallId={hoveredWallId}
isDeleteMode={isDeleteMode}
isGuideTraceVisible={isGuideTraceVisible}
metersPerUnit={calibratedMetersPerUnit}
onCeilingDoubleClick={handleCeilingDoubleClick}
onCeilingHoverChange={handleCeilingHoverChange}
onCeilingSelect={handleCeilingSelect}
@@ -16401,11 +16410,9 @@ export function FloorplanPanel() {
openingsPolygons={openingsPolygons}
palette={palette}
selectedIdSet={selectedIdSet}
slabSelectionHatchId={slabSelectionHatchId}
slabPolygons={displaySlabPolygons}
slabSelectionHatchId={slabSelectionHatchId}
unit={unit}
metersPerUnit={calibratedMetersPerUnit}
isGuideTraceVisible={isGuideTraceVisible}
wallPolygons={displayWallPolygons}
wallSelectionHatchId={wallSelectionHatchId}
/>
@@ -16483,8 +16490,8 @@ export function FloorplanPanel() {
<FloorplanReferenceScaleLayer
draft={referenceScaleDraft}
guideUi={guideUi}
guides={displayGuides}
guideUi={guideUi}
palette={palette}
unit={unit}
unitsPerPixel={floorplanUnitsPerPixel}
+68 -53
View File
@@ -20,6 +20,7 @@ import {
import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
import { useAutoFrame } from '../../hooks/use-auto-frame'
import { type SaveStatus, useAutoSave } from '../../hooks/use-auto-save'
import { useKeyboard } from '../../hooks/use-keyboard'
import {
@@ -64,6 +65,7 @@ import { Grid } from './grid'
import { PresetThumbnailGenerator } from './preset-thumbnail-generator'
import { SelectionManager } from './selection-manager'
import { SiteEdgeLabels } from './site-edge-labels'
import { SnapshotCaptureOverlay } from './snapshot-capture-overlay'
import { type SnapshotCameraData, ThumbnailGenerator } from './thumbnail-generator'
import { WallMeasurementLabel } from './wall-measurement-label'
@@ -76,12 +78,12 @@ const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false },
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true },
default: { visibleColor: 0x00_aa_ff, hiddenColor: 0xf3_ff_47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_44_44, hiddenColor: 0x99_1b_1b, strength: 6, pulse: false },
'paint-ready': { visibleColor: 0xf5_9e_0b, hiddenColor: 0xfd_e0_68, strength: 5, pulse: true },
'paint-disabled': {
visibleColor: 0x94_a3b8,
hiddenColor: 0x47_5569,
visibleColor: 0x94_a3_b8,
hiddenColor: 0x47_55_69,
strength: 4,
pulse: false,
},
@@ -462,7 +464,7 @@ function ViewerCanvasControlsHint({
<div className="pointer-events-none absolute top-14 left-1/2 z-40 max-w-[calc(100%-2rem)] -translate-x-1/2">
<section
aria-label="Camera controls hint"
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-[0_22px_40px_-28px_rgba(15,23,42,0.65),0_10px_24px_-20px_rgba(15,23,42,0.55)] backdrop-blur-xl"
className="pointer-events-auto flex items-start gap-3 rounded-2xl border border-border/35 bg-background/90 px-3.5 py-2.5 shadow-elevation-4 backdrop-blur-xl"
>
<div className="grid min-w-0 flex-1 grid-cols-3 items-start divide-x divide-border/18">
{hints.map((hint) => (
@@ -753,7 +755,7 @@ function PaintCursorLayer({
(activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined),
)
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
const label = hasMaterial ? `Paint ${activePaintTarget}` : 'Choose material'
const icon = 'mdi:format-color-fill'
useLayoutEffect(() => {
@@ -786,16 +788,16 @@ function PaintCursorLayer({
const ViewerCanvas = memo(function ViewerCanvas({
isVersionPreviewMode,
isLoading,
isFirstPersonMode,
hasLoadedInitialScene,
showLoader,
isFirstPersonMode,
onThumbnailCapture,
}: {
isVersionPreviewMode: boolean
isLoading: boolean
isFirstPersonMode: boolean
hasLoadedInitialScene: boolean
showLoader: boolean
isFirstPersonMode: boolean
onThumbnailCapture?: (blob: Blob, cameraData: SnapshotCameraData) => void
}) {
const viewMode = useEditor((s) => s.viewMode)
@@ -837,7 +839,7 @@ const ViewerCanvas = memo(function ViewerCanvas({
window.removeEventListener('pointermove', handlePointerMove)
window.removeEventListener('pointerup', handlePointerUp)
}
}, [setFloorplanPaneRatio])
}, [])
useEffect(() => {
setIsCameraControlsHintVisible(!readCameraControlsHintDismissed())
@@ -940,7 +942,9 @@ export default function Editor({
commandPaletteEmptyAction,
}: EditorProps) {
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
useKeyboard({ isVersionPreviewMode, disabled: isFirstPersonMode })
const { isLoadingSceneRef } = useAutoSave({
onSave,
onDirty,
@@ -951,8 +955,7 @@ export default function Editor({
const [isSceneLoading, setIsSceneLoading] = useState(false)
const [hasLoadedInitialScene, setHasLoadedInitialScene] = useState(false)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const sidebarWidth = useSidebarStore((s) => s.width)
const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed)
@@ -970,39 +973,6 @@ export default function Editor({
}
}, [projectId])
useEffect(() => {
const wasFirstPersonMode = wasFirstPersonModeRef.current
wasFirstPersonModeRef.current = isFirstPersonMode
if (isFirstPersonMode && !wasFirstPersonMode) {
const viewer = useViewer.getState()
firstPersonPreviousLevelRef.current = viewer.selection.levelId
viewer.setCameraMode('perspective')
viewer.setWallMode('up')
viewer.setWalkthroughMode(true)
viewer.setSelection({ selectedIds: [], zoneId: null })
return
}
if (!(wasFirstPersonMode && !isFirstPersonMode)) return
const viewer = useViewer.getState()
const previousLevelId = firstPersonPreviousLevelRef.current
firstPersonPreviousLevelRef.current = null
viewer.setWalkthroughMode(false)
if (!previousLevelId) return
const previousLevelNode = useScene.getState().nodes[previousLevelId]
if (previousLevelNode?.type === 'level') {
viewer.setSelection({
levelId: previousLevelId,
zoneId: null,
selectedIds: [],
})
}
}, [isFirstPersonMode])
// Load scene on mount (or when onLoad identity changes, e.g. project switch)
useEffect(() => {
let cancelled = false
@@ -1064,6 +1034,42 @@ export default function Editor({
const showLoader = isLoading || isSceneLoading
const firstPersonPreviousLevelRef = useRef(useViewer.getState().selection.levelId)
const wasFirstPersonModeRef = useRef(isFirstPersonMode)
useEffect(() => {
const wasFirstPersonMode = wasFirstPersonModeRef.current
wasFirstPersonModeRef.current = isFirstPersonMode
if (isFirstPersonMode && !wasFirstPersonMode) {
const viewer = useViewer.getState()
firstPersonPreviousLevelRef.current = viewer.selection.levelId
viewer.setCameraMode('perspective')
viewer.setWallMode('up')
viewer.setWalkthroughMode(true)
viewer.setSelection({ selectedIds: [], zoneId: null })
return
}
if (!(wasFirstPersonMode && !isFirstPersonMode)) return
const viewer = useViewer.getState()
const previousLevelId = firstPersonPreviousLevelRef.current
firstPersonPreviousLevelRef.current = null
viewer.setWalkthroughMode(false)
if (!previousLevelId) return
const previousLevelNode = useScene.getState().nodes[previousLevelId]
if (previousLevelNode?.type === 'level') {
viewer.setSelection({
levelId: previousLevelId,
zoneId: null,
selectedIds: [],
})
}
}, [isFirstPersonMode])
const previewViewerContent = (
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
<ExportManager />
@@ -1135,21 +1141,24 @@ export default function Editor({
navbarSlot={navbarSlot}
overlays={
<>
<FloatingLevelSelector />
{!isVersionPreviewMode && (
{!isCaptureMode && <FloatingLevelSelector />}
{!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto">
<ActionMenu />
</div>
)}
{!isVersionPreviewMode && (
{!(isVersionPreviewMode || isCaptureMode) && (
<div className="pointer-events-auto">
<PanelManager />
</div>
)}
<div className="pointer-events-auto">
<HelperManager />
</div>
{!isCaptureMode && (
<div className="pointer-events-auto">
<HelperManager />
</div>
)}
{viewerBanner}
{projectId ? <SnapshotCaptureOverlay projectId={projectId} /> : null}
</>
}
renderTabContent={renderTabContent}
@@ -1159,14 +1168,14 @@ export default function Editor({
viewerToolbarLeft={viewerToolbarLeft}
viewerToolbarRight={viewerToolbarRight}
/>
<EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
{/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && (
<div className="pointer-events-none fixed inset-0 z-50">
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
</div>
)}
<EditorCommands />
<CommandPalette emptyAction={commandPaletteEmptyAction} />
</>
)}
</PresetsProvider>
@@ -1222,6 +1231,12 @@ export default function Editor({
<HelperManager />
</div>
</ViewerOverlays>
{/* First-person overlay — rendered on top of normal layout */}
{isFirstPersonMode && (
<div className="pointer-events-none fixed inset-0 z-50">
<FirstPersonOverlay onExit={() => useEditor.getState().setFirstPersonMode(false)} />
</div>
)}
</>
)}
</div>
+2 -2
View File
@@ -341,7 +341,7 @@ function applySingleSurfacePaintPreview(
if (node.type === 'ceiling') {
const root = getRegisteredMesh(node.id)
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
if (!root || !overlay) return null
if (!(root && overlay)) return null
const previewColor =
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
@@ -999,7 +999,6 @@ export const SelectionManager = () => {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
'zone',
@@ -1392,6 +1391,7 @@ export const SelectionManager = () => {
'roof-segment',
'stair',
'stair-segment',
'spawn',
'window',
'door',
'zone',
@@ -0,0 +1,465 @@
'use client'
import { emitter } from '@pascal-app/core'
import { Camera, Check, Crop, Loader2, Maximize2, Monitor, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useIsMobile } from '../../hooks/use-mobile'
import { triggerSFX } from '../../lib/sfx-bus'
import useEditor from '../../store/use-editor'
type CaptureMode = 'standard' | 'viewport' | 'area'
type CaptureState = 'idle' | 'capturing' | 'saved'
interface DragPoint {
x: number
y: number
}
interface Drag {
start: DragPoint
end: DragPoint
}
function getResolution(
mode: CaptureMode,
overlayEl: HTMLDivElement | null,
drag: Drag | null,
): { w: number; h: number } | null {
if (mode === 'standard') return { w: 1920, h: 1080 }
if (!overlayEl) return null
const rect = overlayEl.getBoundingClientRect()
const dpr = Math.min(window.devicePixelRatio, 1.5)
if (mode === 'viewport') {
return { w: Math.round(rect.width * dpr), h: Math.round(rect.height * dpr) }
}
if (mode === 'area' && drag) {
const w = Math.abs(drag.end.x - drag.start.x)
const h = Math.abs(drag.end.y - drag.start.y)
if (w < 4 || h < 4) return null
return { w: Math.round(w * dpr), h: Math.round(h * dpr) }
}
return null
}
export function SnapshotCaptureOverlay({ projectId }: { projectId: string }) {
const isCaptureMode = useEditor((s) => s.isCaptureMode)
const setCaptureMode = useEditor((s) => s.setCaptureMode)
const isMobile = useIsMobile()
const [mode, setMode] = useState<CaptureMode>('standard')
const [drag, setDrag] = useState<Drag | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [captureState, setCaptureState] = useState<CaptureState>('idle')
const overlayRef = useRef<HTMLDivElement>(null)
// Dismiss on Esc
useEffect(() => {
if (!isCaptureMode) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setCaptureMode(false)
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [isCaptureMode, setCaptureMode])
// Reset local state when entering capture mode
useEffect(() => {
if (isCaptureMode) {
setMode('standard')
setDrag(null)
setIsDragging(false)
setCaptureState('idle')
}
}, [isCaptureMode])
// Listen for snapshot saved to show feedback then exit
useEffect(() => {
const handler = () => {
setCaptureState('saved')
setTimeout(() => {
setCaptureMode(false)
setCaptureState('idle')
}, 1500)
}
emitter.on('snapshot:saved', handler)
return () => emitter.off('snapshot:saved', handler)
}, [setCaptureMode])
const dismiss = useCallback(() => setCaptureMode(false), [setCaptureMode])
// Tracks whether the active drag is a "move entire rect" gesture
const moveStartRef = useRef<{ pt: DragPoint; drag: Drag } | null>(null)
// Area drag handlers — relative to the overlay container
const onPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (mode !== 'area' || captureState !== 'idle') return
e.preventDefault()
const rect = overlayRef.current!.getBoundingClientRect()
const pt = { x: e.clientX - rect.left, y: e.clientY - rect.top }
// If clicking inside an existing selection → move mode
if (drag) {
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) {
moveStartRef.current = { pt, drag }
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
return
}
}
// Outside / no selection → start new drag
moveStartRef.current = null
setDrag({ start: pt, end: pt })
setIsDragging(true)
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
},
[mode, captureState, drag],
)
const onPointerMove = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
if (!isDragging) return
const rect = overlayRef.current!.getBoundingClientRect()
const pt = {
x: Math.max(0, Math.min(e.clientX - rect.left, rect.width)),
y: Math.max(0, Math.min(e.clientY - rect.top, rect.height)),
}
if (moveStartRef.current) {
// Move mode: translate the whole rect by the delta
const { pt: origin, drag: snapshot } = moveStartRef.current
const dx = pt.x - origin.x
const dy = pt.y - origin.y
setDrag({
start: { x: snapshot.start.x + dx, y: snapshot.start.y + dy },
end: { x: snapshot.end.x + dx, y: snapshot.end.y + dy },
})
} else {
setDrag((d) => (d ? { start: d.start, end: pt } : null))
}
},
[isDragging],
)
const onPointerUp = useCallback(() => {
const wasMoving = moveStartRef.current !== null
setIsDragging(false)
moveStartRef.current = null
// Clear the rect if the user just clicked without drawing (not a move gesture)
if (!wasMoving) {
setDrag((d) => {
if (!d) return null
const w = Math.abs(d.end.x - d.start.x)
const h = Math.abs(d.end.y - d.start.y)
return w < 4 && h < 4 ? null : d
})
}
}, [])
// Corner-handle resize: re-anchor to the opposite corner then reuse the same drag machinery
const onCornerPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>, cornerIndex: number) => {
if (captureState !== 'idle' || !drag) return
e.stopPropagation()
e.preventDefault()
moveStartRef.current = null
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
// anchor = opposite corner; dragged = current corner
const corners: [DragPoint, DragPoint][] = [
[
{ x: x1, y: y1 },
{ x: x0, y: y0 },
], // TL → anchor BR
[
{ x: x0, y: y1 },
{ x: x1, y: y0 },
], // TR → anchor BL
[
{ x: x1, y: y0 },
{ x: x0, y: y1 },
], // BL → anchor TR
[
{ x: x0, y: y0 },
{ x: x1, y: y1 },
], // BR → anchor TL
]
const [anchor, current] = corners[cornerIndex]!
setDrag({ start: anchor, end: current })
setIsDragging(true)
},
[captureState, drag],
)
const handleCapture = useCallback(() => {
if (captureState !== 'idle') return
let cropRegion: { x: number; y: number; width: number; height: number } | undefined
if (mode === 'area' && drag && overlayRef.current) {
const rect = overlayRef.current.getBoundingClientRect()
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const w = Math.abs(drag.end.x - drag.start.x)
const h = Math.abs(drag.end.y - drag.start.y)
cropRegion = {
x: x0 / rect.width,
y: y0 / rect.height,
width: w / rect.width,
height: h / rect.height,
}
}
setCaptureState('capturing')
triggerSFX('sfx:snapshot-capture')
emitter.emit('camera-controls:generate-thumbnail', {
projectId,
captureMode: mode,
cropRegion,
})
}, [captureState, mode, drag, projectId])
if (!isCaptureMode) return null
const resolution = getResolution(mode, overlayRef.current, drag)
// Area selection rect (CSS px, relative to overlay)
const selectionStyle =
mode === 'area' && drag
? {
left: Math.min(drag.start.x, drag.end.x),
top: Math.min(drag.start.y, drag.end.y),
width: Math.abs(drag.end.x - drag.start.x),
height: Math.abs(drag.end.y - drag.start.y),
}
: null
const hasSelection =
selectionStyle != null && selectionStyle.width > 3 && selectionStyle.height > 3
const captureDisabled = captureState !== 'idle' || (mode === 'area' && !hasSelection)
return (
<div className="pointer-events-none absolute inset-0 z-40" ref={overlayRef}>
{/* Area mode: dim layer + crosshair cursor + drag-to-select */}
{mode === 'area' && (
<div
className="pointer-events-auto absolute inset-0 bg-black/30"
onPointerDown={onPointerDown}
onPointerMove={(e) => {
onPointerMove(e)
// Update cursor: 'move' when hovering inside an existing selection
if (!isDragging && drag && overlayRef.current) {
const rect = overlayRef.current.getBoundingClientRect()
const px = e.clientX - rect.left
const py = e.clientY - rect.top
const x0 = Math.min(drag.start.x, drag.end.x)
const y0 = Math.min(drag.start.y, drag.end.y)
const x1 = Math.max(drag.start.x, drag.end.x)
const y1 = Math.max(drag.start.y, drag.end.y)
e.currentTarget.style.cursor =
px >= x0 && px <= x1 && py >= y0 && py <= y1 ? 'move' : 'crosshair'
}
}}
onPointerUp={onPointerUp}
style={{ cursor: 'crosshair' }}
>
{/* "No selection" hint */}
{!selectionStyle && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="rounded-full bg-black/40 px-4 py-2 text-sm text-white backdrop-blur-sm">
Drag the area you want to capture
</span>
</div>
)}
{/* Selection rect */}
{selectionStyle && (
<div
style={{
position: 'absolute',
left: selectionStyle.left,
top: selectionStyle.top,
width: selectionStyle.width,
height: selectionStyle.height,
pointerEvents: 'none',
boxShadow: '0 0 0 9999px rgba(0,0,0,0.35)',
border: '2px dashed rgba(255,255,255,0.85)',
background: 'rgba(255,255,255,0.04)',
}}
>
{/* Corner handles */}
{(
[
{ pos: { top: -5, left: -5 }, cursor: 'nwse-resize' },
{ pos: { top: -5, right: -5 }, cursor: 'nesw-resize' },
{ pos: { bottom: -5, left: -5 }, cursor: 'nesw-resize' },
{ pos: { bottom: -5, right: -5 }, cursor: 'nwse-resize' },
] as const
).map(({ pos, cursor }, i) => (
<div
key={i}
onPointerDown={(e) => onCornerPointerDown(e, i)}
style={{
position: 'absolute',
width: 10,
height: 10,
borderRadius: '50%',
background: 'white',
boxShadow: '0 1px 4px rgba(0,0,0,0.4)',
pointerEvents: 'auto',
cursor,
...pos,
}}
/>
))}
</div>
)}
</div>
)}
{/* Top-right dismiss button (icon-only on mobile) */}
<div className="pointer-events-auto absolute top-4 right-4">
<button
aria-label="Close capture mode"
className="flex items-center gap-1.5 rounded-full border border-white/20 bg-black/60 px-3 py-1.5 text-white/80 text-xs backdrop-blur-sm transition-colors hover:bg-black/80 hover:text-white"
onClick={dismiss}
type="button"
>
<X className="h-3 w-3" />
{!isMobile && 'Esc to cancel'}
</button>
</div>
{/* Bottom-center mode toolbar */}
<div className="pointer-events-auto absolute bottom-6 left-1/2 -translate-x-1/2">
{(() => {
const modeButtons = (
<>
<ModeButton
active={mode === 'standard'}
badge="16:9"
icon={<Monitor className="h-3.5 w-3.5" />}
label="Standard"
onClick={() => {
setMode('standard')
setDrag(null)
}}
/>
<ModeButton
active={mode === 'viewport'}
icon={<Maximize2 className="h-3.5 w-3.5" />}
label="Viewport"
onClick={() => {
setMode('viewport')
setDrag(null)
}}
/>
<ModeButton
active={mode === 'area'}
icon={<Crop className="h-3.5 w-3.5" />}
label="Area"
onClick={() => setMode('area')}
/>
</>
)
const resolutionDisplay = (
<span className="min-w-[80px] text-center text-white/50 text-xs tabular-nums">
{resolution ? `${resolution.w} × ${resolution.h}` : '—'}
</span>
)
const captureButton = (
<button
className="flex items-center gap-1.5 rounded-full bg-primary px-4 py-1.5 font-medium text-primary-foreground text-xs transition-opacity disabled:opacity-50"
disabled={captureDisabled}
onClick={handleCapture}
type="button"
>
{captureState === 'capturing' ? (
<>
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Capturing
</>
) : captureState === 'saved' ? (
<>
<Check className="h-3.5 w-3.5" />
Saved
</>
) : (
<>
<Camera className="h-3.5 w-3.5" />
Capture
</>
)}
</button>
)
if (isMobile) {
return (
<div className="flex flex-col items-stretch gap-2 rounded-2xl border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
<div className="flex items-center justify-center gap-1">{modeButtons}</div>
<div className="flex items-center justify-center gap-2 border-white/10 border-t pt-2">
{resolutionDisplay}
{captureButton}
</div>
</div>
)
}
return (
<div className="flex items-center gap-1 rounded-full border border-white/10 bg-neutral-900/95 px-2 py-2 shadow-xl backdrop-blur-md">
{modeButtons}
<div className="mx-1 h-4 w-px bg-white/10" />
{resolutionDisplay}
<div className="mx-1 h-4 w-px bg-white/10" />
{captureButton}
</div>
)
})()}
</div>
</div>
)
}
function ModeButton({
active,
icon,
label,
badge,
onClick,
}: {
active: boolean
icon: React.ReactNode
label: string
badge?: string
onClick: () => void
}) {
return (
<button
className={`flex items-center gap-1.5 rounded-full px-3 py-1.5 text-xs transition-colors ${
active ? 'bg-white/15 text-white ring-1 ring-white/20' : 'text-white/50 hover:text-white/90'
}`}
onClick={onClick}
type="button"
>
{icon}
{label}
{badge && (
<span className="rounded-sm bg-white/10 px-1 py-0.5 font-medium text-[10px] text-white/40 leading-none">
{badge}
</span>
)}
</button>
)
}
@@ -1,6 +1,6 @@
'use client'
import { emitter, sceneRegistry, useScene } from '@pascal-app/core'
import { emitter, sceneRegistry } from '@pascal-app/core'
import { SSGI_PARAMS, snapLevelsToTruePositions, useViewer } from '@pascal-app/viewer'
import type { CameraControls } from '@react-three/drei'
import { useThree } from '@react-three/fiber'
@@ -28,7 +28,6 @@ import { EDITOR_LAYER } from '../../lib/constants'
const THUMBNAIL_WIDTH = 1920
const THUMBNAIL_HEIGHT = 1080
const AUTO_SAVE_DELAY = 10_000
export interface SnapshotCameraData {
position: [number, number, number]
@@ -49,8 +48,6 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const mainCamera = useThree((state) => state.camera)
const controls = useThree((state) => state.controls) as CameraControls | null
const isGenerating = useRef(false)
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const pendingAutoRef = useRef(false)
const onThumbnailCaptureRef = useRef(onThumbnailCapture)
const thumbnailCameraRef = useRef<THREE.PerspectiveCamera | null>(null)
@@ -61,7 +58,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
onThumbnailCaptureRef.current = onThumbnailCapture
}, [onThumbnailCapture])
// Build the thumbnail camera, SSGI pipeline, and render target once, reused on every capture.
// Build the thumbnail camera, SSGI pipeline, and render target once reused on every capture.
useEffect(() => {
const cam = new THREE.PerspectiveCamera(60, THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT, 0.1, 1000)
cam.layers.disable(EDITOR_LAYER)
@@ -75,7 +72,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (!mounted) return
// pass() handles MRT internally for all material types, including custom
// shaders, unlike renderer.setMRT() which crashes on non-NodeMaterials.
// shaders unlike renderer.setMRT() which crashes on non-NodeMaterials.
// pass() also respects camera.layers, so EDITOR_LAYER objects are filtered.
const scenePass = pass(scene, cam)
scenePass.setMRT(
@@ -117,15 +114,15 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
const ao = (denoisePass as any).r
const finalOutput = vec4(scenePassColor.rgb.mul(ao), scenePassColor.a)
// FXAA requires a texture node as input, convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbor UV offsets.
// FXAA requires a texture node as input; convertToTexture renders finalOutput
// into an intermediate RT so FXAA can sample it with neighbour UV offsets.
const aaOutput = fxaa(convertToTexture(finalOutput))
const pipeline = new RenderPipeline(gl as unknown as WebGPURenderer)
pipeline.outputNode = aaOutput
pipelineRef.current = pipeline
// Dedicated render target, pipeline outputs here instead of the canvas,
// Dedicated render target pipeline outputs here instead of the canvas,
// so R3F's main render loop can never overwrite our capture.
const { width, height } = gl.domElement
renderTargetRef.current = new RenderTarget(width, height, { depthBuffer: true })
@@ -176,7 +173,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
thumbnailCamera.aspect = width / height
thumbnailCamera.updateProjectionMatrix()
// Capture camera data for snapshot storage.
// Capture camera data for snapshot storage
const pos = mainCamera.position
let tgt: [number, number, number] | null = null
if (controls && 'getTarget' in controls) {
@@ -192,7 +189,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
...(isOrtho && { zoom: (mainCamera as THREE.OrthographicCamera).zoom }),
}
// For auto-save, snap levels to stacked positions and reset levelMode.
// For auto-save: snap levels to stacked positions and reset levelMode
let restoreLevelMode: (() => void) | null = null
let restoreLevels: () => void = () => {}
if (snapLevels) {
@@ -205,7 +202,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
}
// Hide scan and guide nodes directly so they are excluded from the
// thumbnail regardless of whether ScanSystem or GuideSystem listeners are
// thumbnail regardless of whether ScanSystem/GuideSystem listeners are
// registered. Returns a function that restores the original visibility.
const restoreNodeVisibility = (() => {
const saved = new Map<THREE.Object3D, boolean>()
@@ -231,7 +228,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (pipelineRef.current && renderTargetRef.current) {
const rt = renderTargetRef.current
// Resize RT if the canvas dimensions changed.
// Resize RT if the canvas dimensions changed
if (rt.width !== width || rt.height !== height) {
rt.setSize(width, height)
}
@@ -248,7 +245,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
emitter.emit('thumbnail:after-capture', undefined)
// Restore level positions, levelMode, and node visibility immediately after the
// render, before the async GPU readback.
// render before the async GPU readback.
restoreLevels()
restoreLevelMode?.()
restoreNodeVisibility()
@@ -339,6 +336,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
offscreen.getContext('2d')!.drawImage(srcCanvas, sx, sy, outW, outH, 0, 0, outW, outH)
blob = await offscreen.convertToBlob({ type: 'image/png' })
} else {
// Standard: center-crop to 1920×1080 aspect ratio
const srcAspect = width / height
const dstAspect = THUMBNAIL_WIDTH / THUMBNAIL_HEIGHT
let sx = 0,
@@ -364,7 +362,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
if (captureMode !== undefined) cameraData.captureMode = captureMode
cameraData.resolution = { w: outW, h: outH }
} else {
// Fallback: plain render directly to the canvas.
// Fallback: plain render directly to the canvas
emitter.emit('thumbnail:before-capture', undefined)
gl.render(scene, thumbnailCamera)
emitter.emit('thumbnail:after-capture', undefined)
@@ -450,10 +448,11 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
)
// Thumbnail request via emitter. Two call shapes:
// - user-driven capture: `{ projectId, captureMode, cropRegion }`, captures
// - user-driven capture: `{ projectId, captureMode, cropRegion }` captures
// the current pose with the supplied crop.
// - auto-save capture: `{ projectId, snapLevels: true }`, snaps levels to
// their true positions first for a consistent auto-thumbnail angle.
// - host-driven auto-save: `{ projectId, snapLevels: true }` snaps levels
// to their true positions first for a consistent auto-thumbnail angle.
// The caller owns policy (when to fire, whether the tab is visible).
useEffect(() => {
if (!onThumbnailCapture) return
@@ -469,49 +468,7 @@ export const ThumbnailGenerator = ({ onThumbnailCapture }: ThumbnailGeneratorPro
return () => emitter.off('camera-controls:generate-thumbnail', handleGenerateThumbnail)
}, [generate, onThumbnailCapture])
// OSS adaptation: keep local debounced auto-capture behavior because the
// community host-side autosave hook is not part of this repo.
useEffect(() => {
if (!onThumbnailCapture) return
const triggerNow = () => {
void generate(true)
}
const scheduleOrDefer = () => {
if (document.visibilityState === 'visible') {
triggerNow()
} else {
pendingAutoRef.current = true
}
}
const onSceneChange = () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
debounceTimerRef.current = setTimeout(scheduleOrDefer, AUTO_SAVE_DELAY)
}
const onVisibilityChange = () => {
if (document.visibilityState === 'visible' && pendingAutoRef.current) {
pendingAutoRef.current = false
triggerNow()
}
}
const unsubscribe = useScene.subscribe((state, prevState) => {
if (state.nodes !== prevState.nodes) onSceneChange()
})
document.addEventListener('visibilitychange', onVisibilityChange)
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
unsubscribe()
document.removeEventListener('visibilitychange', onVisibilityChange)
}
}, [generate, onThumbnailCapture])
// Go-to-camera: animate camera to a saved snapshot position or target.
// Go-to-camera: animate camera to a saved snapshot position/target
useEffect(() => {
const handler = ({
position,
@@ -144,11 +144,11 @@ export function useFloorplanBackgroundPlacement({
emitFloorplanGridEvent('click', snappedPoint, event)
setCursorPoint(snappedPoint)
if (!roofDraftStart) {
if (roofDraftStart) {
clearRoofPlacementDraft()
} else {
setRoofDraftStart(snappedPoint)
setRoofDraftEnd(snappedPoint)
} else {
clearRoofPlacementDraft()
}
return true
}
View File
@@ -1,265 +0,0 @@
'use client'
import { useScene } from '@pascal-app/core'
import { ImageIcon, MessageSquare, X } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { Button } from './ui/primitives/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from './ui/primitives/dialog'
const MAX_IMAGES = 5
const MAX_IMAGE_SIZE = 5 * 1024 * 1024
type ImagePreview = { file: File; url: string }
export function FeedbackDialog({
projectId: projectIdProp,
onSubmit,
}: {
projectId?: string
onSubmit?: (data: {
message: string
projectId?: string
sceneGraph: unknown
images: File[]
}) => Promise<{ success: boolean; error?: string }>
}) {
const projectId = projectIdProp
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
const [images, setImages] = useState<ImagePreview[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [sent, setSent] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const dragCounter = useRef(0)
const handleOpen = () => {
setOpen(true)
setSent(false)
setError(null)
setMessage('')
setImages([])
setIsDragging(false)
dragCounter.current = 0
}
const handleClose = () => {
if (isSubmitting) return
setOpen(false)
images.forEach((img) => {
URL.revokeObjectURL(img.url)
})
}
const addFiles = useCallback((files: FileList | File[]) => {
const incoming = Array.from(files).filter(
(f) => f.type.startsWith('image/') && f.size <= MAX_IMAGE_SIZE,
)
setImages((prev) => {
const remaining = MAX_IMAGES - prev.length
const added = incoming.slice(0, remaining).map((file) => ({
file,
url: URL.createObjectURL(file),
}))
return [...prev, ...added]
})
}, [])
const removeImage = (index: number) => {
setImages((prev) => {
const img = prev[index]
if (img) URL.revokeObjectURL(img.url)
return prev.filter((_, i) => i !== index)
})
}
// ── Drag handlers (on the entire dialog content) ──
const onDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current++
if (e.dataTransfer.types.includes('Files')) {
setIsDragging(true)
}
}
const onDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current--
if (dragCounter.current === 0) {
setIsDragging(false)
}
}
const onDragOver = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const onDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current = 0
setIsDragging(false)
if (e.dataTransfer.files.length > 0) {
addFiles(e.dataTransfer.files)
}
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError(null)
setIsSubmitting(true)
try {
if (!onSubmit) return
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds }
const result = await onSubmit({
message,
projectId,
sceneGraph,
images: images.map((img) => img.file),
})
if (result.success) {
setSent(true)
setTimeout(() => setOpen(false), 1500)
} else {
setError(result.error ?? 'Something went wrong')
}
} finally {
setIsSubmitting(false)
}
}
return (
<>
<button
className="flex items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
onClick={handleOpen}
>
<MessageSquare className="h-4 w-4" />
Feedback
</button>
<Dialog onOpenChange={handleClose} open={open}>
<DialogContent
className="sm:max-w-[460px]"
onDragEnter={onDragEnter}
onDragLeave={onDragLeave}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* Drag overlay — only visible when dragging files over the dialog */}
{isDragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg border-2 border-primary/50 border-dashed bg-primary/5 backdrop-blur-sm transition-all">
<div className="flex flex-col items-center gap-2 text-primary/70">
<ImageIcon className="h-8 w-8" />
<p className="font-medium text-sm">Drop images here</p>
</div>
</div>
)}
<DialogHeader>
<DialogTitle>Send Feedback</DialogTitle>
<DialogDescription>We&apos;d love to hear your thoughts</DialogDescription>
</DialogHeader>
{sent ? (
<p className="py-4 text-center text-muted-foreground text-sm">
Thanks for your feedback!
</p>
) : (
<form className="space-y-4" onSubmit={handleSubmit}>
<div>
<label className="font-medium text-sm" htmlFor="feedback-message">
Your feedback
</label>
<textarea
autoFocus
className="mt-1 w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary"
disabled={isSubmitting}
id="feedback-message"
onChange={(e) => setMessage(e.target.value)}
placeholder="Share your thoughts, suggestions, feature requests, or report issues..."
rows={5}
value={message}
/>
</div>
{/* Image thumbnails */}
{images.length > 0 && (
<div className="flex flex-wrap gap-2">
{images.map((img, i) => (
<div
className="group relative h-14 w-14 overflow-hidden rounded-md border border-border"
key={img.url}
>
<img alt="" className="h-full w-full object-cover" src={img.url} />
<button
className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => removeImage(i)}
type="button"
>
<X className="h-4 w-4 text-white" />
</button>
</div>
))}
</div>
)}
{error && <p className="text-destructive text-sm">{error}</p>}
<div className="flex items-center justify-between">
{/* Subtle attach button */}
<button
className="flex items-center gap-1.5 text-muted-foreground text-xs transition-colors hover:text-foreground disabled:opacity-40"
disabled={isSubmitting || images.length >= MAX_IMAGES}
onClick={() => fileInputRef.current?.click()}
type="button"
>
<ImageIcon className="h-3.5 w-3.5" />
{images.length > 0 ? `${images.length}/${MAX_IMAGES}` : 'Attach'}
</button>
<input
accept="image/*"
className="hidden"
multiple
onChange={(e) => {
if (e.target.files) addFiles(e.target.files)
e.target.value = ''
}}
ref={fileInputRef}
type="file"
/>
<div className="flex gap-2">
<Button
disabled={isSubmitting}
onClick={handleClose}
type="button"
variant="outline"
>
Cancel
</Button>
<Button disabled={isSubmitting || !message.trim() || !onSubmit} type="submit">
{isSubmitting ? 'Sending...' : 'Send Feedback'}
</Button>
</div>
</div>
</form>
)}
</DialogContent>
</Dialog>
</>
)
}
@@ -1,280 +0,0 @@
'use client'
import { Howl } from 'howler'
import { Disc3, Settings2, SkipBack, SkipForward, Volume2, VolumeX } from 'lucide-react'
import { AnimatePresence, motion } from 'motion/react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { Slider } from '../components/ui/slider'
import { cn } from '../lib/utils'
import useAudio from '../store/use-audio'
const PLAYLIST = [
{
title: 'Ballroom in Miniature',
file: '/audios/radios/classic/Ballroom in Miniature.mp3',
},
{
title: 'Blueprints in Springtime',
file: '/audios/radios/classic/Blueprints in Springtime.mp3',
},
{
title: 'Clockwork Tea Party',
file: '/audios/radios/classic/Clockwork Tea Party.mp3',
},
{
title: 'Clockwork Tea Party (Alternate)',
file: '/audios/radios/classic/Clockwork Tea Party (Alternate).mp3',
},
{
title: 'Clockwork Teacups',
file: '/audios/radios/classic/Clockwork Teacups.mp3',
},
{
title: 'Evening in the Parlor',
file: '/audios/radios/classic/Evening in the Parlor.mp3',
},
{
title: 'Glass Atrium',
file: '/audios/radios/classic/Glass Atrium.mp3',
},
{
title: 'Moonlight On The Drafting Table',
file: '/audios/radios/classic/Moonlight On The Drafting Table.mp3',
},
{
title: 'Sunlit Garden Reverie',
file: '/audios/radios/classic/Sunlit Garden Reverie.mp3',
},
{
title: 'Sunlit Waltz in Pastel Hues',
file: '/audios/radios/classic/Sunlit Waltz in Pastel Hues.mp3',
},
]
// Shuffle array helper
function shuffleArray<T>(array: T[]): T[] {
const shuffled = [...array]
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1))
;[shuffled[i], shuffled[j]] = [shuffled[j]!, shuffled[i]!]
}
return shuffled
}
export function PascalRadio() {
const [shuffledPlaylist] = useState(() => shuffleArray(PLAYLIST))
const [currentTrackIndex, setCurrentTrackIndex] = useState(0)
const { masterVolume, radioVolume, muted, isRadioPlaying, setRadioPlaying } = useAudio()
const soundRef = useRef<Howl | null>(null)
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const currentTrack = shuffledPlaylist[currentTrackIndex]!
// Calculate effective volume (masterVolume * radioVolume, both are 0-100)
const effectiveVolume = (masterVolume / 100) * (radioVolume / 100)
// Keep a ref so the track-init effect can read current volume/muted/isRadioPlaying
// without those values being part of its dependency array (which would restart the song).
const effectiveVolumeRef = useRef(effectiveVolume)
const mutedRef = useRef(muted)
const isPlayingRef = useRef(isRadioPlaying)
effectiveVolumeRef.current = effectiveVolume
mutedRef.current = muted
isPlayingRef.current = isRadioPlaying
const handleNext = useCallback(() => {
setCurrentTrackIndex((prev) => (prev + 1) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
const handlePrevious = useCallback(() => {
setCurrentTrackIndex((prev) => (prev - 1 + shuffledPlaylist.length) % shuffledPlaylist.length)
}, [shuffledPlaylist.length])
// Initialize Howler only when the track changes — not on volume/mute/play-state changes.
// Volume and mute are handled by the separate effect below.
useEffect(() => {
if (soundRef.current) {
soundRef.current.unload()
}
const wasPlaying = isPlayingRef.current
soundRef.current = new Howl({
src: [currentTrack.file],
volume: mutedRef.current ? 0 : effectiveVolumeRef.current,
onend: handleNext,
})
if (wasPlaying && !mutedRef.current) {
soundRef.current?.play()
}
return () => {
soundRef.current?.unload()
}
}, [handleNext, currentTrack.file])
// Update volume when settings change
useEffect(() => {
if (soundRef.current) {
soundRef.current.volume(muted ? 0 : effectiveVolume)
// Pause if muted, resume if unmuted and was playing
if (muted && isRadioPlaying) {
soundRef.current.pause()
} else if (!muted && isRadioPlaying && !soundRef.current.playing()) {
soundRef.current.play()
} else if (!isRadioPlaying && soundRef.current.playing()) {
soundRef.current.pause()
}
}
}, [effectiveVolume, muted, isRadioPlaying])
const handlePlayPause = () => {
if (!soundRef.current || muted) return
if (isRadioPlaying) {
soundRef.current.pause()
} else {
soundRef.current.play()
}
setRadioPlaying(!isRadioPlaying)
}
const handleVolumeChange = (value: number[]) => {
useAudio.setState({ radioVolume: value[0] })
}
// Handle click outside to close
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside)
}
return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [isOpen])
return (
<motion.div
className={cn(
'flex flex-col overflow-hidden rounded-lg border border-border bg-background/95 shadow-lg backdrop-blur-md',
!isOpen && 'cursor-pointer transition-colors hover:bg-accent/90',
)}
layout
onClick={() => {
if (!isOpen) setIsOpen(true)
}}
ref={containerRef}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="flex items-center justify-between gap-2 px-3 py-2 font-medium text-sm">
<div className="flex items-center gap-2">
<Disc3 className={cn('h-4 w-4 shrink-0', isRadioPlaying && 'animate-spin')} />
<span className="hidden whitespace-nowrap sm:inline">Radio Pascal</span>
</div>
<div className="flex items-center gap-2">
<div
aria-label={isRadioPlaying ? 'Pause' : 'Play'}
className="cursor-pointer rounded-sm bg-accent/30 p-1 transition-all hover:bg-accent hover:text-accent-foreground hover:shadow-sm"
onClick={(e) => {
e.stopPropagation()
handlePlayPause()
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
handlePlayPause()
}
}}
role="button"
tabIndex={0}
>
{isRadioPlaying ? (
<Volume2 className="h-3.5 w-3.5" />
) : (
<VolumeX className="h-3.5 w-3.5" />
)}
</div>
<button
aria-label="Radio Settings"
className={cn(
'cursor-pointer rounded-sm p-1 transition-all hover:bg-accent hover:text-accent-foreground',
isOpen && 'bg-accent text-accent-foreground',
)}
onClick={(e) => {
e.stopPropagation()
setIsOpen(!isOpen)
}}
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
<AnimatePresence>
{isOpen && (
<motion.div
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
initial={{ opacity: 0, height: 0 }}
transition={{ type: 'spring', bounce: 0.2, duration: 0.6 }}
>
<div className="w-[16rem] space-y-3 px-3 pb-3">
<div className="mb-3 h-px w-full bg-border/50" />
{/* Current song info with prev/next */}
<div>
<p className="mb-2 text-muted-foreground text-xs">Now Playing</p>
<div className="flex items-center justify-between gap-2">
<button
aria-label="Previous"
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
onClick={handlePrevious}
>
<SkipBack className="h-4 w-4" />
</button>
<p
className="flex-1 truncate text-center font-medium text-sm"
title={currentTrack.title}
>
{currentTrack.title}
</p>
<button
aria-label="Next"
className="shrink-0 rounded-full p-1.5 transition-colors hover:bg-accent"
onClick={handleNext}
>
<SkipForward className="h-4 w-4" />
</button>
</div>
</div>
{/* Volume control */}
<div className="flex items-center gap-2">
<Volume2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<Slider
aria-label="Radio Volume"
className="flex-1"
max={100}
onValueChange={handleVolumeChange}
step={1}
value={[radioVolume]}
/>
<span className="w-8 shrink-0 text-right text-muted-foreground text-xs">
{radioVolume}%
</span>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)
}
@@ -1,16 +0,0 @@
'use client'
import { Eye } from 'lucide-react'
import useEditor from '../store/use-editor'
export function PreviewButton() {
return (
<button
className="flex cursor-pointer items-center gap-2 rounded-lg border border-border bg-background/95 px-3 py-2 font-medium text-sm shadow-lg backdrop-blur-md transition-colors hover:bg-accent/90"
onClick={() => useEditor.getState().setPreviewMode(true)}
>
<Eye className="h-4 w-4 shrink-0" />
<span className="hidden whitespace-nowrap sm:inline">Preview</span>
</button>
)
}
@@ -75,7 +75,9 @@ const CeilingSelectionAffordance = ({
ceiling: CeilingNode
levelId: string
}) => {
const [levelObject, setLevelObject] = useState<Object3D | null>(() => sceneRegistry.nodes.get(levelId) ?? null)
const [levelObject, setLevelObject] = useState<Object3D | null>(
() => sceneRegistry.nodes.get(levelId) ?? null,
)
const corners = useMemo(() => buildCornerBrackets(ceiling.polygon), [ceiling.polygon])
@@ -110,11 +112,7 @@ const CeilingSelectionAffordance = ({
return createPortal(
<group position={[0, (ceiling.height ?? 2.5) + BRACKET_Y_OFFSET, 0]}>
{corners.map((corner, index) => (
<CornerBracket
ceiling={ceiling}
corner={corner}
key={`${ceiling.id}-corner-${index}`}
/>
<CornerBracket ceiling={ceiling} corner={corner} key={`${ceiling.id}-corner-${index}`} />
))}
</group>,
levelObject,
@@ -210,11 +208,7 @@ const BracketLeg = ({
]
return (
<mesh
onClick={onClick}
position={position}
rotation={[0, angle, 0]}
>
<mesh onClick={onClick} position={position} rotation={[0, angle, 0]}>
<boxGeometry args={[length, BRACKET_HEIGHT, BRACKET_THICKNESS]} />
<meshBasicMaterial color={color} depthWrite={false} opacity={opacity} transparent />
</mesh>
@@ -234,7 +228,11 @@ function buildCornerBrackets(polygon: Array<[number, number]>): CornerBracketDat
const incomingLength = Math.hypot(incomingVector[0], incomingVector[1])
const outgoingLength = Math.hypot(outgoingVector[0], outgoingVector[1])
const cornerStrength = 1 - Math.abs(incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1])
const cornerStrength =
1 -
Math.abs(
incomingDirection[0] * outgoingDirection[0] + incomingDirection[1] * outgoingDirection[1],
)
return {
corner,
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
* Imperatively toggles the Three.js visibility of roof objects based on the
* editor selection — without causing React re-renders in RoofRenderer.
*
* When a roof-segment is selected:
* When a roof (or one of its segments) is selected:
* - merged-roof mesh is hidden
* - segments-wrapper group is shown (individual segments visible for editing)
* - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -68,7 +68,7 @@ export const StairEditSystem = () => {
const segmentsWrapper = group.getObjectByName('segments-wrapper')
const isActive = activeStairIds.has(stairId)
if (mergedMesh) mergedMesh.visible = !isActive && !isCurved
if (mergedMesh) mergedMesh.visible = !(isActive || isCurved)
if (segmentsWrapper) segmentsWrapper.visible = isActive && !isCurved
if (stairNode?.children?.length) {
View File
@@ -1,13 +1,19 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type CeilingNode } from '@pascal-app/core'
import {
type AnyNodeId,
type CeilingNode,
emitter,
type GridEvent,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { BufferGeometry, DoubleSide, Path, Shape, ShapeGeometry, Vector3 } from 'three'
function snap(value: number) {
return Math.round(value * 2) / 2
@@ -202,6 +208,7 @@ export const MoveCeilingTool: React.FC<{ node: CeilingNode }> = ({ node }) => {
transparent
/>
</mesh>
{/* @ts-ignore */}
<line geometry={previewOutlineGeometry} position={[0, (node.height ?? 2.5) + 0.02, 0]}>
<lineBasicMaterial color="#ffffff" depthWrite={false} opacity={0.95} transparent />
</line>
@@ -83,11 +83,10 @@ export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
@@ -297,7 +297,7 @@ export const FenceTool: React.FC = () => {
return (
<group>
<CursorSphere ref={cursorRef} height={FENCE_PREVIEW_HEIGHT} />
<CursorSphere height={FENCE_PREVIEW_HEIGHT} ref={cursorRef} />
<mesh layers={EDITOR_LAYER} ref={previewRef} renderOrder={1} visible={false}>
<shapeGeometry />
<meshBasicMaterial
@@ -338,7 +338,7 @@ function DraftMeasurementLabel({
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
@@ -131,10 +131,12 @@ function getLinkedFenceSnapshots(args: {
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
@@ -303,8 +305,9 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged =
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
@@ -406,7 +409,7 @@ export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> =
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80'
@@ -430,7 +433,7 @@ function EndpointAngleLabel({
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
@@ -17,8 +17,8 @@ import type * as THREE from 'three'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from './fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere'
import { snapFenceDraftPoint } from './fence-drafting'
function samePoint(a: [number, number], b: [number, number]) {
return a[0] === b[0] && a[1] === b[1]
@@ -50,10 +50,12 @@ function getLinkedFenceSnapshots(args: {
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
@@ -164,7 +166,9 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
}
}
const applyNodePreview = (updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>) => {
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: [number, number]; end: [number, number] }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
@@ -275,13 +279,13 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
emitter.on('tool:cancel', onCancel)
return () => {
if (!wasCommitted) {
clearPreviewState()
} else {
if (wasCommitted) {
useLiveTransforms.getState().clear(nodeId)
for (const linkedFence of linkedOriginalsRef.current) {
useLiveTransforms.getState().clear(linkedFence.id)
}
} else {
clearPreviewState()
}
useScene.temporal.getState().resume()
emitter.off('grid:move', onGridMove)
@@ -90,9 +90,7 @@ function MoveItemContent({ movingNode }: { movingNode: ItemNode }) {
return <>{cursor}</>
}
export const MoveTool: React.FC<{
onSpawnMoved?: (nodeId: SpawnNode['id']) => void
}> = ({ onSpawnMoved }) => {
export const MoveTool: React.FC = () => {
const movingNode = useEditor((state) => state.movingNode)
if (!movingNode) return null
@@ -100,15 +98,14 @@ export const MoveTool: React.FC<{
return <MoveBuildingContent node={movingNode as BuildingNode} />
if (movingNode.type === 'door') return <MoveDoorTool node={movingNode as DoorNode} />
if (movingNode.type === 'window') return <MoveWindowTool node={movingNode as WindowNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'ceiling') return <MoveCeilingTool node={movingNode as CeilingNode} />
if (movingNode.type === 'column') return <MoveColumnTool node={movingNode as ColumnNode} />
if (movingNode.type === 'slab') return <MoveSlabTool node={movingNode as SlabNode} />
if (movingNode.type === 'wall') return <MoveWallTool node={movingNode as WallNode} />
if (movingNode.type === 'fence') return <MoveFenceTool node={movingNode as FenceNode} />
if (movingNode.type === 'roof' || movingNode.type === 'roof-segment')
return <MoveRoofTool node={movingNode as RoofNode | RoofSegmentNode} />
if (movingNode.type === 'spawn')
return <MoveSpawnTool node={movingNode as SpawnNode} onCommitted={onSpawnMoved} />
if (movingNode.type === 'spawn') return <MoveSpawnTool node={movingNode as SpawnNode} />
if (movingNode.type === 'stair' || movingNode.type === 'stair-segment')
return <MoveRoofTool node={movingNode as StairNode | StairSegmentNode} />
return <MoveItemContent movingNode={movingNode as ItemNode} />
@@ -10,9 +10,7 @@ function positiveModulo(value: number, divisor: number): number {
}
/**
* Snaps a position to 0.5 grid, with an offset to align item edges to grid lines.
* For items with dimensions like 2.5, the center would be at 1.25 from the edge,
* which doesn't align with 0.5 grid. This adds an offset so edges align instead.
* Snaps a position to the active grid step, aligning item edges to grid lines.
*/
export function snapToGrid(position: number, dimension: number, step = getGridSnapStep()): number {
const halfDim = dimension / 2
@@ -21,7 +19,7 @@ export function snapToGrid(position: number, dimension: number, step = getGridSn
}
/**
* Snap a value to 0.5 increments (used for wall-local positions).
* Snap a value to the active grid step (used for wall-local positions).
*/
export function snapToHalf(value: number, step = getGridSnapStep()): number {
return Math.round(value / step) * step
@@ -506,21 +506,22 @@ export const itemSurfaceStrategy = {
const worldSnapped = surfaceMesh.localToWorld(new Vector3(x, y, z))
// Counter-rotate so the draft's world Y rotation stays continuous when
// the user drags onto a rotated surface item. The cursor wireframe
// already shows the user's intended world rotation; we just need to
// store the right local value relative to the new parent.
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
const localRotationY = ctx.currentCursorRotationY - surfaceWorldY
const draftRotation = ctx.draftItem?.rotation ?? [0, 0, 0]
return {
stateUpdate: { surface: 'item-surface', surfaceItemId: surfaceItem.id },
nodeUpdate: {
position: [x, y, z],
parentId: surfaceItem.id,
rotation: [
(ctx.draftItem?.rotation ?? [0, 0, 0])[0],
(() => {
const surfaceQuat = new Quaternion()
surfaceMesh.getWorldQuaternion(surfaceQuat)
const surfaceWorldY = new Euler().setFromQuaternion(surfaceQuat, 'YXZ').y
return ctx.currentCursorRotationY - surfaceWorldY
})(),
(ctx.draftItem?.rotation ?? [0, 0, 0])[2],
] as [number, number, number],
rotation: [draftRotation[0], localRotationY, draftRotation[2]],
},
cursorRotationY: ctx.currentCursorRotationY,
gridPosition: [x, y, z],
@@ -130,7 +130,6 @@ export function useDraftNode(): DraftNodeHandle {
useScene.getState().updateNode(draft.id, {
position: updateProps.position ?? draft.position,
rotation: updateProps.rotation ?? draft.rotation,
scale: updateProps.scale ?? draft.scale,
side: updateProps.side ?? draft.side,
metadata: updateProps.metadata ?? stripTransient(draft.metadata),
parentId: parentId as string,
@@ -20,15 +20,12 @@ import { Html } from '@react-three/drei'
import { useFrame } from '@react-three/fiber'
import { useEffect, useMemo, useRef, useState } from 'react'
import {
Box3,
BufferGeometry,
Euler,
Float32BufferAttribute,
type Group,
type LineSegments,
Matrix4,
type Mesh,
type Object3D,
PlaneGeometry,
Quaternion,
Vector3,
@@ -117,79 +114,6 @@ function expandBoundsToGrid(
}
}
function getPreviewBoundsFromObject(object: Object3D | null): PreviewBounds | null {
if (!object) return null
object.updateWorldMatrix(true, true)
const inverseRootMatrix = new Matrix4().copy(object.matrixWorld).invert()
const localMatrix = new Matrix4()
const localBounds = new Box3()
const scratchBounds = new Box3()
const hasBounds = { current: false }
const registeredNodeObjects = new Set(sceneRegistry.nodes.values())
const expandBounds = (child: Object3D) => {
if (child !== object && registeredNodeObjects.has(child)) {
return
}
const mesh = child as Object3D & {
isMesh?: boolean
name?: string
geometry?: {
boundingBox: Box3 | null
computeBoundingBox?: () => void
}
}
if (mesh.isMesh && mesh.name !== 'cutout' && mesh.geometry) {
if (!mesh.geometry.boundingBox && mesh.geometry.computeBoundingBox) {
mesh.geometry.computeBoundingBox()
}
if (mesh.geometry.boundingBox) {
localMatrix.copy(inverseRootMatrix).multiply(mesh.matrixWorld)
scratchBounds.copy(mesh.geometry.boundingBox).applyMatrix4(localMatrix)
if (Number.isFinite(scratchBounds.min.x) && Number.isFinite(scratchBounds.max.x)) {
if (!hasBounds.current) {
localBounds.copy(scratchBounds)
hasBounds.current = true
} else {
localBounds.union(scratchBounds)
}
}
}
}
for (const grandchild of child.children) {
expandBounds(grandchild)
}
}
for (const child of object.children) {
expandBounds(child)
}
if (!hasBounds.current) return null
const size = new Vector3()
const center = new Vector3()
localBounds.getSize(size)
localBounds.getCenter(center)
if (size.x <= 0 || size.y <= 0 || size.z <= 0) {
return null
}
return {
min: [localBounds.min.x, localBounds.min.y, localBounds.min.z],
max: [localBounds.max.x, localBounds.max.y, localBounds.max.z],
dimensions: [size.x, size.y, size.z],
center: [center.x, center.y, center.z],
}
}
function getFallbackPreviewBounds(
item: import('@pascal-app/core').ItemNode | null,
asset: AssetInput,
@@ -366,7 +290,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
)
const shiftFreeRef = useRef(false)
const previewBoundsSignatureRef = useRef<string | null>(null)
const meshPreviewAppliedRef = useRef(false)
const [dimensionBounds, setDimensionBounds] = useState<PreviewBounds | null>(null)
// Store config callbacks in refs to avoid re-running effect when they change
@@ -503,7 +426,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useEffect(() => {
if (!asset) return
useScene.temporal.getState().pause()
meshPreviewAppliedRef.current = false
const validators = { canPlaceOnFloor, canPlaceOnWall, canPlaceOnCeiling }
@@ -1264,21 +1186,17 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
window.addEventListener('contextmenu', onContextMenu)
// ---- Bounding box geometry ----
// Always derive the wireframe from `asset.dimensions × scale` rather than
// the rendered mesh bounds. Asset dimensions describe the item's footprint
// (e.g. only the trunk for a palm tree), while the mesh bbox would include
// foliage or other visual overhang the snap logic intentionally ignores.
const draft = draftNode.current
const fallbackBounds = expandBoundsToGrid(
const previewBounds = expandBoundsToGrid(
getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo,
gridSnapStep,
)
const previewBounds = draft
? expandBoundsToGrid(
getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null) ??
getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo,
gridSnapStep,
)
: fallbackBounds
updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds)
@@ -1324,7 +1242,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
return () => {
tearingDown = true
meshPreviewAppliedRef.current = false
unsubDraftWatch()
// Clear live transform for any remaining draft
if (draftNode.current) {
@@ -1358,17 +1275,11 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
useEffect(() => {
if (!asset) return
const draft = draftNode.current
const fallbackBounds = expandBoundsToGrid(
const previewBounds = expandBoundsToGrid(
getFallbackPreviewBounds(draft, asset, asset.attachTo),
asset.attachTo,
gridSnapStep,
)
const meshBounds = draft
? getPreviewBoundsFromObject(sceneRegistry.nodes.get(draft.id) ?? null)
: null
const previewBounds = meshBounds
? expandBoundsToGrid(meshBounds, asset.attachTo, gridSnapStep)
: fallbackBounds
updatePreviewGeometry(previewBounds)
updateDimensionGuides(previewBounds)
}, [gridSnapStep, asset, draftNode])
@@ -1388,19 +1299,6 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!draftNode.current) return
const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (!mesh) return
if (!meshPreviewAppliedRef.current) {
const previewBounds = getPreviewBoundsFromObject(mesh)
if (previewBounds) {
const expandedBounds = expandBoundsToGrid(
previewBounds,
asset.attachTo,
useEditor.getState().gridSnapStep,
)
updatePreviewGeometry(expandedBounds)
updateDimensionGuides(expandedBounds)
meshPreviewAppliedRef.current = true
}
}
// Hide wall/ceiling-attached items when between surfaces (only cursor visible)
if (asset.attachTo && placementState.current.surface === 'floor') {
@@ -1490,22 +1388,22 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const measurementContent = (
<>
<lineSegments
layers={EDITOR_LAYER}
geometry={initialWidthGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial}
ref={measurementWidthRef}
renderOrder={998}
/>
<lineSegments
layers={EDITOR_LAYER}
geometry={initialDepthGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial}
ref={measurementDepthRef}
renderOrder={998}
/>
<lineSegments
layers={EDITOR_LAYER}
geometry={initialHeightGuideGeometry}
layers={EDITOR_LAYER}
material={measurementMaterial}
ref={measurementHeightRef}
renderOrder={998}
@@ -158,7 +158,9 @@ export const MoveRoofTool: React.FC<{
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) {
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
const worldPoint = buildingObj.localToWorld(
new THREE.Vector3(localPoint[0], y, localPoint[1]),
)
return [worldPoint.x, worldPoint.y, worldPoint.z]
}
@@ -211,7 +213,10 @@ export const MoveRoofTool: React.FC<{
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
@@ -192,7 +192,17 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
const result: string[] = []
if (phase === 'structure' && structureLayer === 'elements') {
if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node || node.type !== 'zone') continue
const zone = node as ZoneNode
if (polygonIntersectsBounds(zone.polygon, bounds)) {
result.push(zone.id)
}
}
} else {
// structure (elements) and furnish: collect all node types
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node) continue
@@ -240,22 +250,7 @@ function collectNodeIdsInBounds(bounds: Bounds): string[] {
if (objectBoundsIntersectsBounds(node.id, bounds)) {
result.push(node.id)
}
}
}
} else if (phase === 'structure' && structureLayer === 'zones') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node || node.type !== 'zone') continue
const zone = node as ZoneNode
if (polygonIntersectsBounds(zone.polygon, bounds)) {
result.push(zone.id)
}
}
} else if (phase === 'furnish') {
for (const childId of levelNode.children) {
const node = nodes[childId as AnyNodeId]
if (!node) continue
if (node.type === 'item') {
} else if (node.type === 'item') {
const item = node as ItemNode
if (item.asset.category === 'door' || item.asset.category === 'window') continue
const xz = getNodeWorldXZ(item.id)
@@ -145,7 +145,7 @@ export function getSegmentAngleReferenceAtPoint(
}
const projected = getProjectedPointOnSegment(point, segment)
if (!projected || !pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE)) {
if (!(projected && pointsMatch(point, projected, SEGMENT_POINT_TOLERANCE))) {
return null
}
@@ -45,6 +45,7 @@ const tools: Record<Phase, Partial<Record<Tool, React.FC>>> = {
door: DoorTool,
item: ItemTool,
zone: ZoneTool,
spawn: SpawnTool,
window: WindowTool,
},
furnish: {
@@ -63,10 +64,9 @@ export const ToolManager: React.FC = () => {
const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const selectedLevelId = useViewer((state) => state.selection.levelId)
const buildingId = useViewer((state) => state.selection.buildingId)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const setSelection = useViewer((state) => state.setSelection)
const buildingId = useViewer((state) => state.selection.buildingId)
const activeLevelId = useViewer((state) => state.selection.levelId)
const nodes = useScene((state) => state.nodes)
// Building transform for the local group — all building-relative tools live inside this group
@@ -128,13 +128,13 @@ export const ToolManager: React.FC = () => {
const BuildToolComponent = showBuildTool ? tools[phase]?.[tool] : null
const handlePlacedNodeSelected = (nodeId: AnyNodeId) => {
setSelection({ selectedIds: [nodeId] })
useViewer.getState().setSelection({ selectedIds: [nodeId] })
}
return (
<>
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{/* World-space tools: site boundary and building movement operate in world coordinates */}
{showSiteBoundaryEditor && <SiteBoundaryEditor />}
{movingNode?.type === 'building' && <MoveTool onSpawnMoved={handlePlacedNodeSelected} />}
{/* Building-local group: all other tools are relative to the selected building.
@@ -162,13 +162,13 @@ export const ToolManager: React.FC = () => {
{movingNode && movingNode.type !== 'building' && (
<MoveTool onSpawnMoved={handlePlacedNodeSelected} />
)}
{!movingNode && showBuildTool && tool === 'spawn' && (
<SpawnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && showBuildTool && tool === 'column' && (
<ColumnTool currentLevelId={selectedLevelId} onPlaced={handlePlacedNodeSelected} />
)}
{!movingNode && BuildToolComponent && tool !== 'column' && <BuildToolComponent />}
{!movingNode && BuildToolComponent && tool === 'spawn' ? (
<SpawnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
) : !movingNode && showBuildTool && tool === 'column' ? (
<ColumnTool currentLevelId={activeLevelId ?? null} onPlaced={handlePlacedNodeSelected} />
) : !movingNode && BuildToolComponent && tool !== 'column' ? (
<BuildToolComponent />
) : null}
</group>
</>
)
@@ -81,15 +81,17 @@ export const CurveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const offsetFromMidpoint = -(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(node, Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)))
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
@@ -112,10 +112,12 @@ function getLinkedWallSnapshots(args: {
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
@@ -286,8 +288,9 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged =
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
const hasChanged = !(
samePoint(preview.start, originalStart) && samePoint(preview.end, originalEnd)
)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
@@ -391,7 +394,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
className={`whitespace-nowrap rounded-full border px-2 py-1 font-medium text-[11px] shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100'
: 'border-border bg-background/95 text-muted-foreground'
@@ -415,7 +418,7 @@ function EndpointAngleLabel({
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
@@ -63,10 +63,12 @@ function getLinkedWallSnapshots(args: {
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
!(
samePoint(node.start, originalStart) ||
samePoint(node.start, originalEnd) ||
samePoint(node.end, originalStart) ||
samePoint(node.end, originalEnd)
)
) {
continue
}
View File
+3 -3
View File
@@ -156,6 +156,8 @@ export const WallTool: React.FC = () => {
const unit = useViewer((state) => state.unit)
const cursorRef = useRef<Group>(null)
const wallPreviewRef = useRef<Mesh>(null!)
// All positions are building-local: this tool is inside the ToolManager building group,
// so local coords are used for both data and visual positioning.
const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0)
@@ -166,8 +168,6 @@ export const WallTool: React.FC = () => {
let gridPosition: WallPlanPoint = [0, 0]
let previousWallEnd: [number, number] | null = null
// All positions are building-local: this tool is inside the ToolManager building group,
// so local coords are used for both data and visual positioning.
const onGridMove = (event: GridEvent) => {
if (!(cursorRef.current && wallPreviewRef.current)) return
@@ -324,7 +324,7 @@ function DraftMeasurementLabel({
}) {
return (
<Html center position={position} style={{ pointerEvents: 'none' }} zIndexRange={[100, 0]}>
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono text-[11px] font-semibold text-foreground shadow-lg backdrop-blur-md">
<div className="whitespace-nowrap rounded-full border border-border bg-background/95 px-2 py-1 font-mono font-semibold text-[11px] text-foreground shadow-lg backdrop-blur-md">
{label}
</div>
</Html>
+20 -5
View File
@@ -3,6 +3,7 @@ import { useViewer } from '@pascal-app/viewer'
import { useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, DoubleSide, type Group, type Line, Shape, Vector3 } from 'three'
import { EDITOR_LAYER } from './../../../lib/constants'
import { sfxEmitter } from './../../../lib/sfx-bus'
import useEditor from './../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
@@ -67,6 +68,9 @@ const commitZoneDrawing = (levelId: LevelNode['id'], points: Array<[number, numb
// Select the newly created zone
useViewer.getState().setSelection({ zoneId: zone.id })
// Play structure build sound
sfxEmitter.emit('sfx:structure-build')
}
type PreviewState = {
@@ -86,6 +90,7 @@ export const ZoneTool: React.FC = () => {
const mainLineRef = useRef<Line>(null!)
const closingLineRef = useRef<Line>(null!)
const pointsRef = useRef<Array<[number, number]>>([])
const previousSnappedPointRef = useRef<[number, number] | null>(null)
const levelYRef = useRef(0) // Track current level Y position
const currentLevelId = useViewer((state) => state.selection.levelId)
const setTool = useEditor((state) => state.setTool)
@@ -181,12 +186,22 @@ export const ZoneTool: React.FC = () => {
// If we have points, snap to axis from last point
const lastPoint = pointsRef.current[pointsRef.current.length - 1]
if (lastPoint) {
const snapped = calculateSnapPoint(lastPoint, cursorPosition)
cursorRef.current.position.set(snapped[0], event.localPosition[1], snapped[1])
} else {
cursorRef.current.position.set(gridX, event.localPosition[1], gridZ)
const displayPoint = lastPoint
? calculateSnapPoint(lastPoint, cursorPosition)
: cursorPosition
// Play snap sound when the snapped position changes during drawing
if (
pointsRef.current.length > 0 &&
previousSnappedPointRef.current &&
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
displayPoint[1] !== previousSnappedPointRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousSnappedPointRef.current = displayPoint
cursorRef.current.position.set(displayPoint[0], event.localPosition[1], displayPoint[1])
updatePreview()
}
View File
+7 -1
View File
@@ -104,7 +104,9 @@ export function ControlModes() {
const setPhase = useEditor((state) => state.setPhase)
const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection)
const primeMaterialPaintFromSelection = useEditor(
(state) => state.primeMaterialPaintFromSelection,
)
const levelId = useViewer((s) => s.selection.levelId)
// Only subscribe to the primitive `level` number — when walls are added to
@@ -148,6 +150,8 @@ export function ControlModes() {
// setPhase('site') calls viewer.resetSelection() which clears levelId,
// breaking the 2D floorplan (it needs a level to render the SVG).
useEditor.setState({ phase: 'site', mode: 'select', tool: null, catalogCategory: null })
// Clear object selection so the polygon editor handles receive pointer events
useViewer.getState().setSelection({ selectedIds: [] })
}
return
}
@@ -188,6 +192,8 @@ export function ControlModes() {
} else {
setPhase('furnish')
setMode('build')
// Auto-switch sidebar to the items panel so the user can pick furniture
useEditor.getState().setActiveSidebarPanel('items')
}
} else if (id === 'zone') {
if (getIsActive('zone')) {
@@ -1,9 +1,4 @@
'use client'
import NextImage from 'next/image'
import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { ActionButton } from './action-button'
import type { CatalogCategory } from './../../../store/use-editor'
export type FurnishToolConfig = {
id: 'item'
@@ -12,91 +7,10 @@ export type FurnishToolConfig = {
catalogCategory: CatalogCategory
}
// Furnish mode tools: furniture, appliances, decoration (painting is now a control mode)
export const furnishTools: FurnishToolConfig[] = [
{
id: 'item',
iconSrc: '/icons/couch.png',
label: 'Furniture',
catalogCategory: 'furniture',
},
{
id: 'item',
iconSrc: '/icons/appliance.png',
label: 'Appliance',
catalogCategory: 'appliance',
},
{
id: 'item',
iconSrc: '/icons/kitchen.png',
label: 'Kitchen',
catalogCategory: 'kitchen',
},
{
id: 'item',
iconSrc: '/icons/bathroom.png',
label: 'Bathroom',
catalogCategory: 'bathroom',
},
{
id: 'item',
iconSrc: '/icons/tree.png',
label: 'Outdoor',
catalogCategory: 'outdoor',
},
{ id: 'item', iconSrc: '/icons/couch.png', label: 'Furniture', catalogCategory: 'furniture' },
{ id: 'item', iconSrc: '/icons/appliance.png', label: 'Appliance', catalogCategory: 'appliance' },
{ id: 'item', iconSrc: '/icons/kitchen.png', label: 'Kitchen', catalogCategory: 'kitchen' },
{ id: 'item', iconSrc: '/icons/bathroom.png', label: 'Bathroom', catalogCategory: 'bathroom' },
{ id: 'item', iconSrc: '/icons/tree.png', label: 'Outdoor', catalogCategory: 'outdoor' },
]
export function FurnishTools() {
const mode = useEditor((state) => state.mode)
const activeTool = useEditor((state) => state.tool)
const setActiveTool = useEditor((state) => state.setTool)
const setMode = useEditor((state) => state.setMode)
const catalogCategory = useEditor((state) => state.catalogCategory)
const setCatalogCategory = useEditor((state) => state.setCatalogCategory)
const hasActiveTool = furnishTools.some(
(tool) => mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory,
)
return (
<div className="flex items-center gap-1.5 px-1">
{furnishTools.map((tool, index) => {
// For item tools with catalog category, check both tool and category match
const isActive =
mode === 'build' && activeTool === 'item' && catalogCategory === tool.catalogCategory
return (
<ActionButton
className={cn(
'rounded-lg duration-300',
isActive
? 'z-10 scale-110 bg-black/40 hover:bg-black/40'
: 'scale-95 bg-transparent opacity-60 grayscale hover:bg-black/20 hover:opacity-100 hover:grayscale-0',
)}
key={`${tool.id}-${tool.catalogCategory ?? index}`}
label={tool.label}
onClick={() => {
if (!isActive) {
setCatalogCategory(tool.catalogCategory)
setActiveTool('item')
if (mode !== 'build') {
setMode('build')
}
}
}}
size="icon"
variant="ghost"
>
<NextImage
alt={tool.label}
className="size-full object-contain"
height={28}
src={tool.iconSrc}
width={28}
/>
</ActionButton>
)
})}
</div>
)
}
@@ -1,22 +1,25 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useMemo } from 'react'
import { useViewer } from '@pascal-app/viewer'
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import { useIsMobile } from './../../../hooks/use-mobile'
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { useIsMobile } from './../../../hooks/use-mobile'
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor'
import { ItemCatalog } from '../item-catalog/item-catalog'
import { CameraActions } from './camera-actions'
import { ControlModes } from './control-modes'
import { FurnishTools } from './furnish-tools'
import { StructureTools } from './structure-tools'
import { ViewToggles } from './view-toggles'
import { GridSnapControl, SecondaryToggles } from './view-toggles'
// Mobile bottom offset matches the viewer's overlap behind the sheet's
// rounded corners (SHEET_OVERLAP_PX in editor-layout-mobile) so the menu sits
// just above that strip instead of inside it.
const MOBILE_BOTTOM_OFFSET = 24
function PaintMaterialTray() {
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
@@ -84,84 +87,16 @@ export function ActionMenu({ className }: { className?: string }) {
<TooltipProvider>
<motion.div
className={cn(
'fixed bottom-6 left-1/2 z-50 -translate-x-1/2',
'left-1/2 z-50 -translate-x-1/2',
isMobile ? 'absolute origin-bottom scale-90' : 'fixed bottom-6',
'rounded-2xl border border-border bg-background/90 shadow-2xl backdrop-blur-md',
'transition-colors duration-200 ease-out',
className,
)}
layout
style={isMobile ? { bottom: MOBILE_BOTTOM_OFFSET } : undefined}
transition={transition}
>
{/* Item Catalog Row - Only show when in build mode with item tool */}
<AnimatePresence>
{mode === 'build' && tool === 'item' && catalogCategory && (
<motion.div
animate={{
opacity: 1,
maxHeight: 160,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
className={cn('overflow-hidden border-border border-b px-2 py-2')}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<ItemCatalog category={catalogCategory} key={catalogCategory} />
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{phase === 'furnish' && mode === 'build' && (
<motion.div
animate={{
opacity: 1,
maxHeight: 80,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
className={cn(
'overflow-hidden border-border',
'max-h-20 border-b px-2 py-2 opacity-100',
)}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<div className="mx-auto w-max">
<FurnishTools />
</div>
</motion.div>
)}
</AnimatePresence>
{/* Structure Tools Row - Animated */}
<AnimatePresence>
{phase === 'structure' && mode === 'build' && (
@@ -228,14 +163,28 @@ export function ActionMenu({ className }: { className?: string }) {
</motion.div>
)}
</AnimatePresence>
{/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes />
<div className="mx-1 h-5 w-px bg-border" />
<ViewToggles />
<div className="mx-1 h-5 w-px bg-border" />
<CameraActions />
</div>
{isMobile ? (
<div className="flex flex-col items-stretch gap-0.5 px-2 py-1.5">
{/* Row 1: control modes only */}
<div className="flex items-center justify-center gap-1">
<ControlModes />
</div>
{/* Row 2: grid snap + secondary toggles (orbit + top view hidden) */}
<div className="flex items-center justify-center gap-1 border-border/50 border-t pt-1">
<GridSnapControl />
<SecondaryToggles />
</div>
</div>
) : (
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes />
<div className="mx-1 h-5 w-px bg-border" />
<GridSnapControl />
<SecondaryToggles />
<div className="mx-1 h-5 w-px bg-border" />
<CameraActions />
</div>
)}
</motion.div>
</TooltipProvider>
)
+19 -31
View File
@@ -1,5 +1,6 @@
'use client'
import { Icon } from '@iconify/react'
import {
type AnyNodeId,
type BuildingNode,
@@ -258,7 +259,7 @@ function GuidesControl() {
<PopoverContent
align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
side="top"
sideOffset={14}
>
@@ -353,9 +354,9 @@ function GuidesControl() {
)
}
// ── Grid snap ──────────────────────────────────────────────────────────────
// ── Grid snap toggle ────────────────────────────────────────────────────────
export function GridSnapControl() {
function GridSnapControl() {
const [isOpen, setIsOpen] = useState(false)
const gridSnapStep = useEditor((state) => state.gridSnapStep)
const setGridSnapStep = useEditor((state) => state.setGridSnapStep)
@@ -374,20 +375,7 @@ export function GridSnapControl() {
)}
type="button"
>
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M3 3h7v7H3V3zm11 0h7v7h-7V3zm0 11h7v7h-7v-7zm-11 0h7v7H3v-7z"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<Icon height={16} icon="lucide:grid-2x2" width={16} />
<span className="mt-1 font-medium text-[9px] leading-none">
{formatGridSnapStep(gridSnapStep)}
</span>
@@ -510,7 +498,7 @@ function ScansControl() {
<PopoverContent
align="center"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-[0_14px_28px_-18px_rgba(15,23,42,0.55),0_6px_16px_-10px_rgba(15,23,42,0.2)] backdrop-blur-xl"
className="w-72 rounded-xl border-border/45 bg-background/96 p-3 shadow-elevation-3 backdrop-blur-xl"
side="top"
sideOffset={14}
>
@@ -605,6 +593,8 @@ function ScansControl() {
)
}
// ── Reference floor control ────────────────────────────────────────────────────────────────────
function ReferenceFloorControl() {
const showReferenceFloor = useEditor((state) => state.showReferenceFloor)
const toggleReferenceFloor = useEditor((state) => state.toggleReferenceFloor)
@@ -694,7 +684,11 @@ function ReferenceFloorControl() {
onClick={toggleReferenceFloor}
type="button"
>
{showReferenceFloor ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
{showReferenceFloor ? (
<Eye className="h-3.5 w-3.5" />
) : (
<EyeOff className="h-3.5 w-3.5" />
)}
</button>
</div>
@@ -728,9 +722,7 @@ function ReferenceFloorControl() {
)}
/>
<span className="min-w-0 flex-1 truncate">{levelName}</span>
<span className="text-[10px] text-muted-foreground">
{index + 1} below
</span>
<span className="text-[10px] text-muted-foreground">{index + 1} below</span>
</button>
)
})}
@@ -757,24 +749,20 @@ function ReferenceFloorControl() {
)
}
// ── Main ViewToggles ────────────────────────────────────────────────────────
// ── Exports ─────────────────────────────────────────────────────────────────
export function ViewToggles() {
export { GridSnapControl }
export function SecondaryToggles() {
return (
<div className="flex items-center gap-1">
{/* Scans (toggle + dropdown) */}
<ScansControl />
{/* Guides (toggle + dropdown) */}
<GuidesControl />
<ReferenceFloorControl />
</div>
)
}
// Secondary toggles for mobile (grid snap + scans + guides)
export function SecondaryToggles() {
export function ViewToggles() {
return (
<div className="flex items-center gap-1">
<GridSnapControl />
@@ -35,8 +35,8 @@ import {
Video,
} from 'lucide-react'
import { useEffect } from 'react'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { runRedo, runUndo } from '../../../lib/history'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { useCommandRegistry } from '../../../store/use-command-registry'
import type { StructureTool } from '../../../store/use-editor'
import useEditor from '../../../store/use-editor'
@@ -294,12 +294,14 @@ export function EditorCommands() {
},
{
id: 'editor.viewer.camera-snapshot',
label: 'Camera Snapshot',
label: 'Take Snapshot',
group: 'Viewer Controls',
icon: <Camera className="h-4 w-4" />,
keywords: ['camera', 'snapshot', 'capture', 'save', 'view', 'bookmark'],
navigate: true,
execute: () => navigateTo('camera-view'),
execute: () => {
setOpen(false)
useEditor.getState().setCaptureMode(true)
},
},
// ── View ─────────────────────────────────────────────────────────────
+4 -255
View File
@@ -27,15 +27,13 @@ interface CommandPaletteStore {
setInputValue: (value: string) => void
navigateTo: (page: string) => void
goBack: () => void
cameraScope: { nodeId: string; label: string } | null
setCameraScope: (scope: { nodeId: string; label: string } | null) => void
}
export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
open: false,
setOpen: (open) => {
set({ open })
if (!open) set({ pages: [], inputValue: '', cameraScope: null, mode: 'command' })
if (!open) set({ pages: [], inputValue: '', mode: 'command' })
},
mode: 'command',
setMode: (mode) => set({ mode }),
@@ -44,12 +42,8 @@ export const useCommandPalette = create<CommandPaletteStore>((set, get) => ({
setInputValue: (value) => set({ inputValue: value }),
navigateTo: (page) => set((s) => ({ pages: [...s.pages, page], inputValue: '' })),
goBack: () => {
const { pages } = get()
if (pages[pages.length - 1] === 'camera-scope') set({ cameraScope: null })
set((s) => ({ pages: s.pages.slice(0, -1), inputValue: '' }))
},
cameraScope: null,
setCameraScope: (scope) => set({ cameraScope: scope }),
}))
// ---------------------------------------------------------------------------
@@ -157,8 +151,6 @@ const PAGE_LABEL: Record<string, string> = {
'level-mode': 'Level Mode',
'rename-level': 'Rename Level',
'goto-level': 'Go to Level',
'camera-view': 'Camera Snapshot',
'camera-scope': '',
}
// ---------------------------------------------------------------------------
@@ -196,19 +188,8 @@ function EmptyActionItem({ action }: { action: CommandPaletteEmptyAction }) {
// Main component
// ---------------------------------------------------------------------------
export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEmptyAction }) {
const {
open,
setOpen,
mode,
setMode,
pages,
inputValue,
setInputValue,
navigateTo,
goBack,
cameraScope,
setCameraScope,
} = useCommandPalette()
const { open, setOpen, mode, setMode, pages, inputValue, setInputValue, navigateTo, goBack } =
useCommandPalette()
const [meta, setMeta] = useState('⌘')
const [isFullscreen, setIsFullscreen] = useState(false)
@@ -233,11 +214,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
),
)
const cameraScopeNode = useScene((s) =>
cameraScope ? s.nodes[cameraScope.nodeId as AnyNodeId] : null,
)
const hasScopeSnapshot = !!(cameraScopeNode as any)?.camera
// Platform detection
useEffect(() => {
setMeta(/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? '⌘' : 'Ctrl')
@@ -279,7 +255,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
solo: 'Solo',
}
// Camera snapshot helpers (used by sub-pages registered via EditorCommands)
const confirmRename = () => {
if (!(activeLevelId && inputValue.trim())) return
run(() => {
@@ -287,29 +262,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
})
}
const takeSnapshot = () => {
if (!cameraScope) return
import('@pascal-app/core').then(({ emitter }) => {
run(() =>
emitter.emit('camera-controls:capture', { nodeId: cameraScope.nodeId as AnyNodeId }),
)
})
}
const viewSnapshot = () => {
if (!(cameraScope && hasScopeSnapshot)) return
import('@pascal-app/core').then(({ emitter }) => {
run(() => emitter.emit('camera-controls:view', { nodeId: cameraScope.nodeId as AnyNodeId }))
})
}
const clearSnapshot = () => {
if (!(cameraScope && hasScopeSnapshot)) return
run(() => {
useScene.getState().updateNode(cameraScope.nodeId as AnyNodeId, { camera: undefined } as any)
})
}
// ---------------------------------------------------------------------------
// Group registered actions by group (preserving insertion order)
// ---------------------------------------------------------------------------
@@ -363,9 +315,7 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
onClick={goBack}
type="button"
>
{page === 'camera-scope'
? (cameraScope?.label ?? 'Snapshot')
: (PAGE_LABEL[page] ?? views.get(page)?.label ?? page)}
{PAGE_LABEL[page] ?? views.get(page)?.label ?? page}
</button>
)}
<Command.Input
@@ -500,207 +450,6 @@ export function CommandPalette({ emptyAction }: { emptyAction?: CommandPaletteEm
</Command.Item>
</Command.Group>
)}
{/* ── Camera Snapshot: scope picker ─────────────────────────── */}
{page === 'camera-view' && (
<Command.Group heading="Camera Snapshot — Select Scope">
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path d="M3 3h18v18H3z" strokeLinecap="round" strokeLinejoin="round" />
<path d="M3 9h18M9 21V9" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
label="Site"
onSelect={() => {
const { rootNodeIds } = useScene.getState()
const siteId = rootNodeIds[0]
if (siteId) {
setCameraScope({ nodeId: siteId, label: 'Site' })
navigateTo('camera-scope')
}
}}
/>
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<polyline
points="9 22 9 12 15 12 15 22"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label="Building"
onSelect={() => {
const building = Object.values(useScene.getState().nodes).find(
(n) => n.type === 'building',
)
if (building) {
setCameraScope({ nodeId: building.id, label: 'Building' })
navigateTo('camera-scope')
}
}}
/>
<OptionItem
disabled={!activeLevelId}
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M12 2L2 7l10 5 10-5-10-5z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M2 17l10 5 10-5M2 12l10 5 10-5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label="Level"
onSelect={() => {
if (activeLevelId) {
setCameraScope({ nodeId: activeLevelId, label: 'Level' })
navigateTo('camera-scope')
}
}}
/>
<OptionItem
disabled={!useViewer.getState().selection.selectedIds.length}
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path d="M5 3l14 9-14 9V3z" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
label="Selection"
onSelect={() => {
const firstId = useViewer.getState().selection.selectedIds[0]
if (firstId) {
setCameraScope({ nodeId: firstId, label: 'Selection' })
navigateTo('camera-scope')
}
}}
/>
</Command.Group>
)}
{/* ── Camera Snapshot: actions for selected scope ───────────── */}
{page === 'camera-scope' && cameraScope && (
<Command.Group heading={`${cameraScope.label} Snapshot`}>
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="13"
r="4"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label={hasScopeSnapshot ? 'Update Snapshot' : 'Take Snapshot'}
onSelect={takeSnapshot}
/>
{hasScopeSnapshot && (
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<path
d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle
cx="12"
cy="12"
r="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
}
label="View Snapshot"
onSelect={viewSnapshot}
/>
)}
{hasScopeSnapshot && (
<OptionItem
icon={
<svg
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth={2}
viewBox="0 0 24 24"
>
<polyline
points="3 6 5 6 21 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M19 6l-1 14H6L5 6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M10 11v6M14 11v6" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9 6V4h6v2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
}
label="Clear Snapshot"
onSelect={clearSnapshot}
/>
)}
</Command.Group>
)}
</Command.List>
{/* Footer hint */}
+8 -5
View File
@@ -4,9 +4,11 @@ import {
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getMaterialsForCategory,
getMaterialsForTarget,
MATERIAL_CATEGORIES,
toLibraryMaterialRef,
type MaterialSchema,
type MaterialTarget,
toLibraryMaterialRef,
} from '@pascal-app/core'
import { useEffect, useRef, useState } from 'react'
import useEditor from '../../../store/use-editor'
@@ -17,6 +19,8 @@ type MaterialPickerProps = {
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
disabled?: boolean
nodeType?: MaterialTarget
hideSideControl?: boolean
}
export function MaterialPicker({
@@ -48,8 +52,7 @@ export function MaterialPicker({
return
}
const catalogId =
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
const selectedCatalogEntry = getCatalogMaterialById(catalogId)
if (selectedCatalogEntry?.category) {
setSelectedCategory(selectedCatalogEntry.category)
@@ -177,7 +180,7 @@ export function MaterialPicker({
title={item.label}
type="button"
>
<div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" />
<div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-white/12 ring-inset" />
{item.previewThumbnailUrl ? (
<img
alt={item.label}
@@ -193,7 +196,7 @@ export function MaterialPicker({
))}
{selectedCategory === 'other' && onChange ? (
<button
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border font-medium text-[10px] transition-all ${
showCustom
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400'
+1 -1
View File
@@ -162,7 +162,7 @@ function LevelRow({
{...dragHandleProps}
aria-label={`Reorder ${getLevelDisplayLabel(level)}`}
className={cn(
'ml-0.5 flex h-6 w-4 shrink-0 touch-none cursor-grab items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
'ml-0.5 flex h-6 w-4 shrink-0 cursor-grab touch-none items-center justify-center rounded-md text-muted-foreground/35 opacity-0 transition-colors hover:bg-white/5 hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/50 group-hover/level:opacity-100',
isDragging && 'cursor-grabbing opacity-100',
)}
onClick={(e) => {
@@ -1,5 +1,6 @@
'use client'
import { useIsMobile } from '../../../hooks/use-mobile'
import useEditor from '../../../store/use-editor'
import { BuildingHelper } from './building-helper'
import { CeilingHelper } from './ceiling-helper'
@@ -12,6 +13,10 @@ export function HelperManager() {
const mode = useEditor((s) => s.mode)
const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode)
const isMobile = useIsMobile()
// Helpers are keyboard-driven hints (Esc, R, etc.) — irrelevant on touch.
if (isMobile) return null
if (movingNode) {
if (movingNode.type === 'building') return <BuildingHelper showRotate />
File diff suppressed because it is too large Load Diff
@@ -13,13 +13,53 @@ import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { CATALOG_ITEMS } from './catalog-items'
export function ItemCatalog({ category }: { category: CatalogCategory }) {
export function ItemCatalog({
category,
items: itemsOverride,
activePlacementTag = null,
activeFunctionalTag = null,
search = '',
overrideItems,
leadingTile,
emptyState,
}: {
category: CatalogCategory
items?: AssetInput[]
activePlacementTag?: string | null
activeFunctionalTag?: string | null
search?: string
/** When set, bypasses all filtering and displays these items directly (used for server search results) */
overrideItems?: AssetInput[]
/** Rendered as the first grid cell, always visible when there are items. */
leadingTile?: React.ReactNode
/** Rendered when there are no items to show. Replaces the empty grid. */
emptyState?: React.ReactNode
}) {
const selectedItem = useEditor((state) => state.selectedItem)
const setSelectedItem = useEditor((state) => state.setSelectedItem)
const setMode = useEditor((state) => state.setMode)
const setTool = useEditor((state) => state.setTool)
const categoryItems = CATALOG_ITEMS.filter((item) => item.category === category)
const sourceItems = itemsOverride ?? CATALOG_ITEMS
// Server-provided results bypass all local filtering; otherwise filter by category/search/tags
const filteredItems =
overrideItems ??
(() => {
const categoryItems = search
? sourceItems
: sourceItems.filter((item) => item.category === category)
return categoryItems.filter((item) => {
const tags = item.tags ?? []
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false
return true
})
})()
// Auto-select first item if current selection is not in this category
const categoryItems = filteredItems
// Auto-select first item if current selection is not in the filtered list
useEffect(() => {
const isCurrentItemInCategory = categoryItems.some((item) => item.src === selectedItem?.src)
if (!isCurrentItemInCategory && categoryItems.length > 0) {
@@ -27,58 +67,60 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
}
}, [categoryItems, selectedItem?.src, setSelectedItem])
// Get attachment icon based on attachTo type
const getAttachmentIcon = (attachTo: AssetInput['attachTo']) => {
if (attachTo === 'wall' || attachTo === 'wall-side') {
return '/icons/wall.png'
}
if (attachTo === 'ceiling') {
return '/icons/ceiling.png'
}
if (attachTo === 'wall' || attachTo === 'wall-side') return '/icons/wall.png'
if (attachTo === 'ceiling') return '/icons/ceiling.png'
return null
}
if (filteredItems.length === 0 && emptyState) {
return <>{emptyState}</>
}
return (
<div className="-mx-2 -my-2 flex max-w-xl gap-2 overflow-x-auto p-2">
{categoryItems.map((item, index) => {
<div
className="grid gap-2"
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(90px, 1fr))' }}
>
{leadingTile}
{filteredItems.map((item, index) => {
const isSelected = selectedItem?.src === item?.src
const attachmentIcon = getAttachmentIcon(item?.attachTo)
return (
<Tooltip key={index}>
<TooltipTrigger asChild>
<button
className={cn(
'relative aspect-square h-14 min-h-14 w-14 min-w-14 shrink-0 flex-col gap-px rounded-lg transition-all duration-200 ease-out hover:scale-105 hover:cursor-pointer',
isSelected && 'ring-2 ring-primary-foreground',
)}
onClick={() => setSelectedItem(item)}
type="button"
>
<Image
alt={item.name}
className="rounded-lg object-cover"
fill
loading="eager"
sizes="56px"
src={resolveCdnUrl(item.thumbnail) || ''}
/>
{attachmentIcon && (
<div className="absolute right-0.5 bottom-0.5 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<Image
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
className="h-4 w-4"
height={16}
src={attachmentIcon}
width={16}
/>
</div>
)}
</button>
</TooltipTrigger>
<TooltipContent className="text-xs" side="top">
<button
className={cn(
'group relative flex flex-col gap-1.5 rounded-xl p-1.5 transition-colors hover:cursor-pointer hover:bg-sidebar-accent',
isSelected && 'bg-sidebar-accent ring-2 ring-primary-foreground',
)}
key={index}
onClick={() => {
setSelectedItem(item)
setTool('item')
setMode('build')
}}
type="button"
>
<div className="relative aspect-square w-full overflow-hidden rounded-lg">
<img
alt={item.name}
className="h-full w-full object-cover"
loading="eager"
src={resolveCdnUrl(item.thumbnail) || ''}
/>
{attachmentIcon && (
<div className="absolute right-1 bottom-1 flex h-4 w-4 items-center justify-center rounded bg-black/60">
<img
alt={item.attachTo === 'ceiling' ? 'Ceiling attachment' : 'Wall attachment'}
className="h-4 w-4"
src={attachmentIcon}
/>
</div>
)}
</div>
<span className="truncate px-0.5 text-left font-medium text-[11px] text-muted-foreground group-hover:text-foreground">
{item.name}
</TooltipContent>
</Tooltip>
</span>
</button>
)
})}
</div>
@@ -2,8 +2,8 @@
import type { LevelNode } from '@pascal-app/core'
import { useEffect, useState } from 'react'
import { cn } from '../../lib/utils'
import type { LevelDuplicatePreset } from '../../lib/level-duplication'
import { cn } from '../../lib/utils'
import {
Dialog,
DialogContent,
@@ -69,9 +69,7 @@ export function LevelDuplicateDialog({
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Duplicate Level</DialogTitle>
<DialogDescription>
Choose what to copy from {getLevelLabel(level)}.
</DialogDescription>
<DialogDescription>Choose what to copy from {getLevelLabel(level)}.</DialogDescription>
</DialogHeader>
<div className="grid gap-2">
@@ -95,7 +93,7 @@ export function LevelDuplicateDialog({
<DialogFooter>
<button
className="cursor-pointer rounded-md px-4 py-2 text-sm text-muted-foreground transition-colors hover:bg-accent"
className="cursor-pointer rounded-md px-4 py-2 text-muted-foreground text-sm transition-colors hover:bg-accent"
onClick={() => onOpenChange(false)}
type="button"
>
+2 -3
View File
@@ -120,9 +120,8 @@ export function CeilingPanel() {
const n = polygon.length
for (let i = 0; i < n; i++) {
const j = (i + 1) % n
const current = polygon[i]
const next = polygon[j]
if (!(current && next)) continue
const current = polygon[i]!
const next = polygon[j]!
area += current[0] * next[1]
area -= next[0] * current[1]
}
@@ -70,10 +70,12 @@ const COLUMN_PROPORTION_PRESETS = {
type ColumnProportionPresetId = keyof typeof COLUMN_PROPORTION_PRESETS
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(([value, preset]) => ({
value: value as ColumnProportionPresetId,
label: preset.label,
}))
const COLUMN_PROPORTION_OPTIONS = Object.entries(COLUMN_PROPORTION_PRESETS).map(
([value, preset]) => ({
value: value as ColumnProportionPresetId,
label: preset.label,
}),
)
function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value))
@@ -201,7 +203,12 @@ export function ColumnPanel() {
const shaftProfile = node.shaftProfile ?? 'straight'
return (
<PanelWrapper icon="/icons/column.png" onClose={handleClose} title={node.name || 'Column'} width={300}>
<PanelWrapper
icon="/icons/column.png"
onClose={handleClose}
title={node.name || 'Column'}
width={300}
>
<PanelSection title="Preset">
<select
className={SELECT_CLASS}
@@ -223,7 +230,9 @@ export function ColumnPanel() {
<PanelSection title="Shape">
<select
className={SELECT_CLASS}
onChange={(event) => handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })}
onChange={(event) =>
handleUpdate({ crossSection: event.target.value as ColumnNode['crossSection'] })
}
value={node.crossSection}
>
<option value="round">Round</option>
@@ -313,7 +322,9 @@ export function ColumnPanel() {
<PanelSection title="Shaft">
<select
className={SELECT_CLASS}
onChange={(event) => handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))}
onChange={(event) =>
handleUpdate(shaftProfileUpdates(event.target.value as ColumnNode['shaftProfile']))
}
value={shaftProfile}
>
<option value="straight">Straight</option>
@@ -487,10 +498,22 @@ export function ColumnPanel() {
? {}
: {
capitalHeight: Math.max(node.capitalHeight, 0.12),
capitalTierCount: capitalStyle === 'stepped' ? Math.max(node.capitalTierCount ?? 3, 3) : node.capitalTierCount,
capitalWidthScale: Math.max(node.capitalWidthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
capitalDepthScale: Math.max(node.capitalDepthScale ?? 1.3, capitalStyle === 'stepped' ? 1.42 : 1.28),
capitalStepSpread: capitalStyle === 'stepped' ? Math.max(node.capitalStepSpread ?? 0.34, 0.34) : node.capitalStepSpread,
capitalTierCount:
capitalStyle === 'stepped'
? Math.max(node.capitalTierCount ?? 3, 3)
: node.capitalTierCount,
capitalWidthScale: Math.max(
node.capitalWidthScale ?? 1.3,
capitalStyle === 'stepped' ? 1.42 : 1.28,
),
capitalDepthScale: Math.max(
node.capitalDepthScale ?? 1.3,
capitalStyle === 'stepped' ? 1.42 : 1.28,
),
capitalStepSpread:
capitalStyle === 'stepped'
? Math.max(node.capitalStepSpread ?? 0.34, 0.34)
: node.capitalStepSpread,
}),
})
}}
@@ -572,13 +595,34 @@ export function ColumnPanel() {
? {}
: {
baseHeight: Math.max(node.baseHeight, 0.12),
baseTierCount: baseStyle === 'stepped-square' ? Math.max(node.baseTierCount ?? 3, 3) : node.baseTierCount,
baseWidthScale: Math.max(node.baseWidthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
baseDepthScale: Math.max(node.baseDepthScale ?? 1.24, baseStyle === 'stepped-square' ? 1.42 : 1.24),
baseStepSpread: baseStyle === 'stepped-square' ? Math.max(node.baseStepSpread ?? 0.34, 0.34) : node.baseStepSpread,
basePlinthHeightRatio: baseStyle === 'round-rings' ? (node.basePlinthHeightRatio ?? 0.44) : node.basePlinthHeightRatio,
baseRoundBandScale: baseStyle === 'round-rings' ? (node.baseRoundBandScale ?? 0.92) : node.baseRoundBandScale,
baseNeckScale: baseStyle === 'round-rings' ? (node.baseNeckScale ?? 0.72) : node.baseNeckScale,
baseTierCount:
baseStyle === 'stepped-square'
? Math.max(node.baseTierCount ?? 3, 3)
: node.baseTierCount,
baseWidthScale: Math.max(
node.baseWidthScale ?? 1.24,
baseStyle === 'stepped-square' ? 1.42 : 1.24,
),
baseDepthScale: Math.max(
node.baseDepthScale ?? 1.24,
baseStyle === 'stepped-square' ? 1.42 : 1.24,
),
baseStepSpread:
baseStyle === 'stepped-square'
? Math.max(node.baseStepSpread ?? 0.34, 0.34)
: node.baseStepSpread,
basePlinthHeightRatio:
baseStyle === 'round-rings'
? (node.basePlinthHeightRatio ?? 0.44)
: node.basePlinthHeightRatio,
baseRoundBandScale:
baseStyle === 'round-rings'
? (node.baseRoundBandScale ?? 0.92)
: node.baseRoundBandScale,
baseNeckScale:
baseStyle === 'round-rings'
? (node.baseNeckScale ?? 0.72)
: node.baseNeckScale,
}),
})
}}
+278 -271
View File
@@ -12,8 +12,8 @@ import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, DoorOpen, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context'
import { cn } from '../../../lib/utils'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
@@ -148,7 +148,13 @@ export function DoorPanel() {
const liveNode = useScene.getState().nodes[selectedId as AnyNodeId]
if (liveNode?.type !== 'door') return
if (!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)) {
if (
!(
previewRef.current &&
previewRef.current.id === selectedId &&
previewRef.current.key === key
)
) {
previewRef.current = {
id: selectedId as AnyNodeId,
key,
@@ -333,7 +339,8 @@ export function DoorPanel() {
const normHeights = node.segments.map((seg) => seg.heightRatio / hSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const doorShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const doorShape =
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingTopRadii = node.openingTopRadii ?? [0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15
@@ -578,7 +585,8 @@ export function DoorPanel() {
isSelected
? 'border-orange-400/60 bg-orange-400/10 text-foreground'
: 'border-border/50 bg-[#2C2C2E] text-muted-foreground hover:bg-[#3e3e3e] hover:text-foreground',
!option.available && 'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground',
!option.available &&
'cursor-not-allowed opacity-45 hover:bg-[#2C2C2E] hover:text-muted-foreground',
)}
disabled={!option.available}
key={option.value}
@@ -962,312 +970,311 @@ export function DoorPanel() {
/>
</PanelSection>
{!isGarageDoor && (
<PanelSection title="Content Padding">
<SliderControl
label="Horizontal"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[0] * 1000) / 1000}
/>
<SliderControl
label="Vertical"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.contentPadding[1] * 1000) / 1000}
/>
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Hinges Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.hingesSide}
/>
</div>
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
value={node.swingDirection}
/>
</div>
</div>
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Threshold">
<ToggleControl
checked={node.threshold}
label="Enable Threshold"
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
{!isGarageDoor && (
<PanelSection title="Content Padding">
<SliderControl
label="Height"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
label="Horizontal"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [v, node.contentPadding[1]] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.thresholdHeight * 1000) / 1000}
value={Math.round(node.contentPadding[0] * 1000) / 1000}
/>
</div>
)}
</PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Handle">
{isSwingDoor && (
<ToggleControl
checked={node.handle}
label="Enable Handle"
onChange={(checked) => handleUpdate({ handle: checked })}
/>
)}
{(node.handle || !isSwingDoor) && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ handleHeight: v })}
precision={2}
step={0.05}
label="Vertical"
max={0.2}
min={0}
onChange={(v) => handleUpdate({ contentPadding: [node.contentPadding[0], v] })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.handleHeight * 100) / 100}
value={Math.round(node.contentPadding[1] * 1000) / 1000}
/>
{supportsHandleSide && (
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Swing">
<div className="flex flex-col gap-2 px-1 pb-1">
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Handle Side
Hinges Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ handleSide: v })}
onChange={(v) => handleUpdate({ hingesSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.handleSide}
value={node.hingesSide}
/>
</div>
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Direction
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ swingDirection: v })}
options={[
{ label: 'Inward', value: 'inward' },
{ label: 'Outward', value: 'outward' },
]}
value={node.swingDirection}
/>
</div>
</div>
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Threshold">
<ToggleControl
checked={node.threshold}
label="Enable Threshold"
onChange={(checked) => handleUpdate({ threshold: checked })}
/>
{node.threshold && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={0.1}
min={0.005}
onChange={(v) => handleUpdate({ thresholdHeight: v })}
precision={3}
step={0.005}
unit="m"
value={Math.round(node.thresholdHeight * 1000) / 1000}
/>
</div>
)}
</div>
</PanelSection>
)}
</PanelSection>
)}
{isSwingDoor && (
<PanelSection title="Hardware">
<ToggleControl
checked={node.doorCloser}
label="Door Closer"
onChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
<ToggleControl
checked={node.panicBar}
label="Panic Bar"
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.panicBarHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map((r) => r / colSum)
return (
<div className="mb-2 flex flex-col gap-1" key={i}>
<div className="flex items-center justify-between pb-1">
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
</div>
<SegmentedControl
onChange={(t) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated })
}}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
value={seg.type}
{!isGarageDoor && (
<PanelSection title="Handle">
{isSwingDoor && (
<ToggleControl
checked={node.handle}
label="Enable Handle"
onChange={(checked) => handleUpdate({ handle: checked })}
/>
<SliderControl
label="Height"
max={95}
min={5}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
/>
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
precision={0}
step={1}
value={numCols}
/>
{numCols > 1 && (
<div className="mt-1 border-border/50 border-t pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
max={95}
min={5}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
)}
{(node.handle || !isSwingDoor) && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ handleHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.handleHeight * 100) / 100}
/>
{supportsHandleSide && (
<div className="space-y-1">
<span className="font-medium text-[10px] text-muted-foreground/80 uppercase tracking-wider">
Handle Side
</span>
<SegmentedControl
onChange={(v) => handleUpdate({ handleSide: v })}
options={[
{ label: 'Left', value: 'left' },
{ label: 'Right', value: 'right' },
]}
value={node.handleSide}
/>
))}
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.dividerThickness * 1000) / 1000}
/>
</div>
)}
</div>
)}
</div>
)}
</PanelSection>
)}
{seg.type === 'panel' && (
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Inset"
max={0.1}
min={0}
onChange={(v) => {
{isSwingDoor && (
<PanelSection title="Hardware">
<ToggleControl
checked={node.doorCloser}
label="Door Closer"
onChange={(checked) => handleUpdate({ doorCloser: checked })}
/>
<ToggleControl
checked={node.panicBar}
label="Panic Bar"
onChange={(checked) => handleUpdate({ panicBar: checked })}
/>
{node.panicBar && (
<div className="mt-1 flex flex-col gap-1">
<SliderControl
label="Bar Height"
max={node.height - 0.1}
min={0.5}
onChange={(v) => handleUpdate({ panicBarHeight: v })}
precision={2}
step={0.05}
unit="m"
value={Math.round(node.panicBarHeight * 100) / 100}
/>
</div>
)}
</PanelSection>
)}
{!isGarageDoor && (
<PanelSection title="Segments">
{node.segments.map((seg, i) => {
const numCols = seg.columnRatios.length
const colSum = seg.columnRatios.reduce((a, b) => a + b, 0)
const normCols = seg.columnRatios.map((r) => r / colSum)
return (
<div className="mb-2 flex flex-col gap-1" key={i}>
<div className="flex items-center justify-between pb-1">
<span className="font-medium text-white/80 text-xs">Segment {i + 1}</span>
</div>
<SegmentedControl
onChange={(t) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
idx === i ? { ...s, type: t } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelInset * 1000) / 1000}
options={[
{ label: 'Panel', value: 'panel' },
{ label: 'Glass', value: 'glass' },
{ label: 'Empty', value: 'empty' },
]}
value={seg.type}
/>
<SliderControl
label="Depth"
max={0.1}
min={0}
label="Height"
max={95}
min={5}
onChange={(v) => setSegmentHeightRatio(i, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(normHeights[i]! * 100 * 10) / 10}
/>
<SliderControl
label="Columns"
max={8}
min={1}
onChange={(v) => {
const n = Math.max(1, Math.min(8, Math.round(v)))
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
idx === i ? { ...s, columnRatios: Array(n).fill(1 / n) } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelDepth * 1000) / 1000}
precision={0}
step={1}
value={numCols}
/>
{numCols > 1 && (
<div className="mt-1 border-border/50 border-t pt-1">
{normCols.map((ratio, ci) => (
<SliderControl
key={`c-${ci}`}
label={`C${ci + 1}`}
max={95}
min={5}
onChange={(v) => setSegmentColumnRatio(i, ci, v / 100)}
precision={1}
step={1}
unit="%"
value={Math.round(ratio * 100 * 10) / 10}
/>
))}
<SliderControl
label="Divider"
max={0.1}
min={0.005}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, dividerThickness: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.dividerThickness * 1000) / 1000}
/>
</div>
)}
{seg.type === 'panel' && (
<div className="mt-1 border-border/50 border-t pt-1">
<SliderControl
label="Inset"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelInset: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelInset * 1000) / 1000}
/>
<SliderControl
label="Depth"
max={0.1}
min={0}
onChange={(v) => {
const updated = node.segments.map((s, idx) =>
idx === i ? { ...s, panelDepth: v } : s,
)
handleUpdate({ segments: updated })
}}
precision={3}
step={0.005}
unit="m"
value={Math.round(seg.panelDepth * 1000) / 1000}
/>
</div>
)}
</div>
)
})}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{
type: 'panel' as const,
heightRatio: 1,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<ActionButton
className="text-white/60 hover:text-white"
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
/>
)}
</div>
)
})}
<div className="flex gap-1.5 px-1 pt-1">
<ActionButton
label="+ Add Segment"
onClick={() => {
const updated = [
...node.segments,
{
type: 'panel' as const,
heightRatio: 1,
columnRatios: [1],
dividerThickness: 0.03,
panelDepth: 0.01,
panelInset: 0.04,
},
]
handleUpdate({ segments: updated })
}}
/>
{node.segments.length > 1 && (
<ActionButton
className="text-white/60 hover:text-white"
label="- Remove"
onClick={() => handleUpdate({ segments: node.segments.slice(0, -1) })}
/>
)}
</div>
</PanelSection>
)}
</PanelSection>
)}
</>
)}
@@ -1,6 +1,5 @@
'use client'
import {
type AnyNode,
type AnyNodeId,
@@ -86,10 +85,6 @@ export function FencePanel() {
setSelection({ selectedIds: [] })
}, [setSelection])
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const length = getWallCurveLength(node)
@@ -1,9 +1,8 @@
'use client'
import useEditor from '../../../store/use-editor'
import { SliderControl } from '../controls/slider-control'
import { Input } from '../primitives/input'
import { PanelSection } from '../controls/panel-section'
import { Input } from '../primitives/input'
import { PanelWrapper } from './panel-wrapper'
function buildDefaultCustomMaterial() {
@@ -53,11 +52,7 @@ export function PaintPanel() {
}
return (
<PanelWrapper
onClose={() => setPaintPanelOpen(false)}
title="Material"
width={320}
>
<PanelWrapper onClose={() => setPaintPanelOpen(false)} title="Material" width={320}>
<PanelSection title="Custom Material">
<div className="space-y-3">
<div className="space-y-2">
@@ -78,41 +73,71 @@ export function PaintPanel() {
</div>
</div>
<div className="space-y-1">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Surface
</label>
<div className="space-y-1 rounded-lg border border-border/50 bg-background/40 p-2">
<SliderControl
label="Roughness"
max={1}
min={0}
onChange={(roughness) => updateCustomMaterial({ roughness })}
precision={2}
step={0.01}
value={currentProps.roughness}
/>
<SliderControl
label="Metalness"
max={1}
min={0}
onChange={(metalness) => updateCustomMaterial({ metalness })}
precision={2}
step={0.01}
value={currentProps.metalness}
/>
<SliderControl
label="Opacity"
max={1}
min={0}
onChange={(opacity) =>
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}
precision={2}
step={0.01}
value={currentProps.opacity}
/>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Roughness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.roughness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) =>
updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })
}
step={0.01}
type="range"
value={currentProps.roughness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Metalness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) =>
updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })
}
step={0.01}
type="range"
value={currentProps.metalness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Opacity
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => {
const opacity = Number.parseFloat(e.target.value)
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
</div>
<div className="space-y-2">
+3 -32
View File
@@ -92,6 +92,8 @@ function panelForType(type: string | null) {
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'spawn':
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
@@ -238,36 +240,5 @@ export function PanelManager() {
}
// Show appropriate panel based on selected node type
if (selectedNodeType) {
switch (selectedNodeType) {
case 'item':
return <ItemPanel />
case 'roof':
return <RoofPanel />
case 'roof-segment':
return <RoofSegmentPanel />
case 'stair':
return <StairPanel />
case 'stair-segment':
return <StairSegmentPanel />
case 'slab':
return <SlabPanel />
case 'spawn':
return <SpawnPanel />
case 'ceiling':
return <CeilingPanel />
case 'column':
return <ColumnPanel />
case 'wall':
return <WallPanel />
case 'fence':
return <FencePanel />
case 'door':
return <DoorPanel />
case 'window':
return <WindowPanel />
}
}
return null
return panelForType(selectedNodeType)
}
@@ -4,11 +4,21 @@ import {
type AnyNode,
type GuideNode,
loadAssetUrl,
saveAsset,
type ScanNode,
saveAsset,
useScene,
} from '@pascal-app/core'
import { Eye, EyeOff, LocateFixed, Lock, RotateCcw, Ruler, Trash2, Unlock, Upload } from 'lucide-react'
import {
Eye,
EyeOff,
LocateFixed,
Lock,
RotateCcw,
Ruler,
Trash2,
Unlock,
Upload,
} from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { guideEmitter } from '../../../lib/guide-events'
import { getGuideImageName } from '../../../lib/local-guide-image'
@@ -32,7 +42,9 @@ function getScaleStatus(guide: GuideNode, scaleReferenceVisible: boolean) {
export function ReferencePanel() {
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const setSelectedReferenceId = useEditor((s) => s.setSelectedReferenceId)
const guideUi = useEditor((s) => (selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined))
const guideUi = useEditor((s) =>
selectedReferenceId ? s.guideUi[selectedReferenceId] : undefined,
)
const setGuideLocked = useEditor((s) => s.setGuideLocked)
const setGuideScaleReferenceVisible = useEditor((s) => s.setGuideScaleReferenceVisible)
const clearGuideUi = useEditor((s) => s.clearGuideUi)
@@ -77,11 +89,14 @@ export function ReferencePanel() {
try {
const assetUrl = await saveAsset(file)
updateNode(selectedReferenceId as AnyNode['id'], {
name: getGuideImageName(file.name),
url: assetUrl,
scaleReference: null,
} as Partial<GuideNode>)
updateNode(
selectedReferenceId as AnyNode['id'],
{
name: getGuideImageName(file.name),
url: assetUrl,
scaleReference: null,
} as Partial<GuideNode>,
)
setGuideScaleReferenceVisible(selectedReferenceId, true)
} catch {
setReplaceError('Could not replace that image.')
@@ -138,7 +153,7 @@ export function ReferencePanel() {
const isScan = node.type === 'scan'
const guideLocked = !isScan && guideUi?.locked === true
const scaleReferenceVisible = !isScan && guideUi?.scaleReferenceVisible !== false
const scaleStatus = !isScan ? getScaleStatus(node, scaleReferenceVisible) : null
const scaleStatus = isScan ? null : getScaleStatus(node, scaleReferenceVisible)
return (
<PanelWrapper
@@ -165,16 +180,16 @@ export function ReferencePanel() {
<ActionGroup>
<ActionButton
disabled={isReplacing}
icon={<Upload className="h-3.5 w-3.5" />}
label={isReplacing ? 'Replacing...' : 'Replace'}
onClick={() => replaceInputRef.current?.click()}
disabled={isReplacing}
/>
<ActionButton
className="text-destructive hover:bg-destructive/10"
icon={<Trash2 className="h-3.5 w-3.5" />}
label="Delete"
onClick={handleDeleteGuide}
className="text-destructive hover:bg-destructive/10"
/>
</ActionGroup>
@@ -232,16 +247,16 @@ export function ReferencePanel() {
<ActionGroup>
<ActionButton
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
disabled={!node.scaleReference}
label={scaleReferenceVisible ? 'Hide Scale' : 'Show Scale'}
onClick={() => {
if (!node.scaleReference) return
setGuideScaleReferenceVisible(node.id, !scaleReferenceVisible)
}}
/>
<ActionButton
label="Clear Scale"
disabled={!node.scaleReference}
label="Clear Scale"
onClick={() => handleUpdate({ scaleReference: null } as Partial<GuideNode>)}
/>
</ActionGroup>
+52 -2
View File
@@ -3,21 +3,25 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
type MaterialSchema,
type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofSurfaceMaterialRole,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint'
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
@@ -28,6 +32,7 @@ export function RoofPanel() {
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -50,6 +55,35 @@ export function RoofPanel() {
[selectedId, updateNode],
)
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'top' ||
selectedMaterialTarget.role === 'edge' ||
selectedMaterialTarget.role === 'wall')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetRole)) return
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate, materialTargetRole, node],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetRole)) return
handleUpdate(
buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
)
},
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -225,6 +259,22 @@ export function RoofPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
{materialTargetRole ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the roof surface you want to edit. Materials apply to one target at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="roof"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper>
)
}
View File
View File
@@ -87,7 +87,7 @@ export function SpawnPanel() {
if (!(node && node.type === 'spawn' && selectedId)) return null
const rotationDegrees = Math.round((((draftRotation ?? node.rotation) * 180) / Math.PI))
const rotationDegrees = Math.round(((draftRotation ?? node.rotation) * 180) / Math.PI)
const storedRotationDegrees = Math.round((node.rotation * 180) / Math.PI)
return (
@@ -97,7 +97,9 @@ export function SpawnPanel() {
label="X"
max={node.position[0] + 2}
min={node.position[0] - 2}
onChange={(value) => handleUpdate({ position: [value, node.position[1], node.position[2]] })}
onChange={(value) =>
handleUpdate({ position: [value, node.position[1], node.position[2]] })
}
precision={2}
step={0.01}
unit="m"
@@ -107,7 +109,9 @@ export function SpawnPanel() {
label="Y"
max={node.position[1] + 2}
min={node.position[1] - 2}
onChange={(value) => handleUpdate({ position: [node.position[0], value, node.position[2]] })}
onChange={(value) =>
handleUpdate({ position: [node.position[0], value, node.position[2]] })
}
precision={2}
step={0.01}
unit="m"
@@ -117,7 +121,9 @@ export function SpawnPanel() {
label="Z"
max={node.position[2] + 2}
min={node.position[2] - 2}
onChange={(value) => handleUpdate({ position: [node.position[0], node.position[1], value] })}
onChange={(value) =>
handleUpdate({ position: [node.position[0], node.position[1], value] })
}
precision={2}
step={0.01}
unit="m"
@@ -3,25 +3,31 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveStairSurfaceMaterial,
type LevelNode,
type MaterialSchema,
type StairNode,
StairNode as StairNodeSchema,
type StairRailingMode,
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
type StairSlabOpeningMode,
type StairSurfaceMaterialRole,
type StairTopLandingMode,
type StairType,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import { useShallow } from 'zustand/react/shallow'
import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { duplicateStairSubtree } from '../../../lib/stair-duplication'
import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
@@ -59,6 +65,7 @@ export function StairPanel() {
const updateNode = useScene((s) => s.updateNode)
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -89,6 +96,35 @@ export function StairPanel() {
[selectedId, updateNode],
)
const materialTargetRole =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'railing' ||
selectedMaterialTarget.role === 'tread' ||
selectedMaterialTarget.role === 'side')
? selectedMaterialTarget.role
: null
const materialPickerValue =
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetRole)) return
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
[handleUpdate, materialTargetRole, node],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetRole)) return
handleUpdate(
buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset),
)
},
[handleUpdate, materialTargetRole, node],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -222,11 +258,11 @@ export function StairPanel() {
/>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
From Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ fromLevelId: event.target.value })}
value={resolvedFromLevelId ?? ''}
>
@@ -239,11 +275,11 @@ export function StairPanel() {
</div>
<div className="space-y-1.5">
<div className="px-1 text-[11px] uppercase tracking-[0.14em] text-muted-foreground">
<div className="px-1 text-[11px] text-muted-foreground uppercase tracking-[0.14em]">
To Level
</div>
<select
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-sm text-foreground"
className="h-9 w-full rounded-lg border border-border/50 bg-[#2C2C2E] px-3 text-foreground text-sm"
onChange={(event) => handleUpdate({ toLevelId: event.target.value })}
value={resolvedToLevelId ?? ''}
>
@@ -262,7 +298,7 @@ export function StairPanel() {
/>
{(node.slabOpeningMode ?? 'none') === 'destination' ? (
<SliderControl
<MetricControl
label="Opening Offset"
max={0.5}
min={0}
@@ -308,7 +344,7 @@ export function StairPanel() {
{(node.stairType === 'curved' || node.stairType === 'spiral') && (
<PanelSection title="Geometry">
<SliderControl
<MetricControl
label="Width"
max={10}
min={0.4}
@@ -318,7 +354,7 @@ export function StairPanel() {
unit="m"
value={Math.round((node.width ?? 1) * 100) / 100}
/>
<SliderControl
<MetricControl
label="Rise"
max={10}
min={0.2}
@@ -328,7 +364,7 @@ export function StairPanel() {
unit="m"
value={Math.round((node.totalRise ?? 2.5) * 100) / 100}
/>
<SliderControl
<MetricControl
label="Steps"
max={32}
min={2}
@@ -346,7 +382,7 @@ export function StairPanel() {
/>
)}
{(node.stairType === 'spiral' || !(node.fillToFloor ?? true)) && (
<SliderControl
<MetricControl
label="Thickness"
max={1}
min={0.02}
@@ -357,7 +393,7 @@ export function StairPanel() {
value={Math.round((node.thickness ?? 0.25) * 100) / 100}
/>
)}
<SliderControl
<MetricControl
label="Inner Radius"
max={10}
min={node.stairType === 'spiral' ? 0.05 : 0.2}
@@ -385,7 +421,7 @@ export function StairPanel() {
value={node.topLandingMode ?? 'none'}
/>
{(node.topLandingMode ?? 'none') === 'integrated' && (
<SliderControl
<MetricControl
label="Top Landing"
max={5}
min={0.3}
@@ -520,6 +556,22 @@ export function StairPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
{materialTargetRole ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the stair surface you want to edit. Materials apply to one target at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetRole}
hideSideControl
nodeType="stair"
onChange={handleTargetedMaterialChange}
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
</PanelWrapper>
)
}
+97 -1
View File
@@ -4,28 +4,60 @@ import {
type AnyNode,
type AnyNodeId,
getClampedWallCurveOffset,
getEffectiveWallSurfaceMaterial,
getMaxWallCurveOffset,
getWallCurveLength,
getWallSurfaceMaterialSignature,
type MaterialSchema,
normalizeWallCurveOffset,
useScene,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide | null,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === null || targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === null || targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -56,6 +88,35 @@ export function WallPanel() {
[selectedId, updateNode],
)
const effectiveInteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
[node],
)
const effectiveExteriorMaterial = useMemo(
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
[node],
)
const surfaceMaterialsMatch = useMemo(
() =>
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
[effectiveExteriorMaterial, effectiveInteriorMaterial],
)
const materialTargetSide =
selectedMaterialTarget &&
selectedMaterialTarget.nodeId === node?.id &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
? selectedMaterialTarget.role
: null
const materialPickerValue =
materialTargetSide === 'interior'
? effectiveInteriorMaterial
: materialTargetSide === 'exterior'
? effectiveExteriorMaterial
: surfaceMaterialsMatch
? effectiveInteriorMaterial
: {}
const handleUpdateLength = useCallback(
(newLength: number) => {
if (!node || newLength <= 0) return
@@ -79,6 +140,24 @@ export function WallPanel() {
[node, handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!(node && materialTargetSide)) return
handleUpdate(
buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset),
)
},
[handleUpdate, materialTargetSide, node],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!(node && materialTargetSide)) return
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
},
[handleUpdate, materialTargetSide, node],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -160,6 +239,23 @@ export function WallPanel() {
)}
</PanelSection>
<PanelSection title="Material">
{materialTargetSide ? null : (
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
Click the wall face you want to edit. Materials now apply to one side at a time.
</div>
)}
<MaterialPicker
disabled={!materialTargetSide}
hideSideControl
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={materialPickerValue.materialPreset}
value={materialPickerValue.material}
/>
</PanelSection>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
+13 -5
View File
@@ -12,8 +12,8 @@ import { useViewer } from '@pascal-app/viewer'
import { BookMarked, Copy, FlipHorizontal2, Move, Trash2 } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { usePresetsAdapter } from '../../../contexts/presets-context'
import { cn } from '../../../lib/utils'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { cn } from '../../../lib/utils'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MetricControl } from '../controls/metric-control'
@@ -129,7 +129,11 @@ export function WindowPanel() {
if (liveNode?.type !== 'window') return
if (
!(previewRef.current && previewRef.current.id === selectedId && previewRef.current.key === key)
!(
previewRef.current &&
previewRef.current.id === selectedId &&
previewRef.current.key === key
)
) {
previewRef.current = {
id: selectedId as AnyNodeId,
@@ -299,7 +303,8 @@ export function WindowPanel() {
const normRows = node.rowRatios.map((r) => r / rowSum)
const isOpening = node.openingKind === 'opening'
const openingShape = node.openingShape ?? 'rectangle'
const windowShape = openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const windowShape =
openingShape === 'arch' || openingShape === 'rounded' ? openingShape : 'rectangle'
const openingRadiusMode = node.openingRadiusMode ?? 'all'
const openingCornerRadii = node.openingCornerRadii ?? [0.15, 0.15, 0.15, 0.15]
const cornerRadius = node.cornerRadius ?? 0.15
@@ -340,7 +345,10 @@ export function WindowPanel() {
nextUpdates.openingCornerRadii = nextRadii
}
} else {
const nextRadius = Math.min(Math.max(cornerRadius, 0), getMaxSharedWindowRadius(nextWidth, nextHeight))
const nextRadius = Math.min(
Math.max(cornerRadius, 0),
getMaxSharedWindowRadius(nextWidth, nextHeight),
)
if (Math.abs(nextRadius - cornerRadius) > 1e-6) {
nextUpdates.cornerRadius = nextRadius
}
@@ -597,7 +605,7 @@ export function WindowPanel() {
/>
</PanelSection>
{!isOpening && !rectangleOnlyWindowTypes.has(node.windowType) && (
{!(isOpening || rectangleOnlyWindowTypes.has(node.windowType)) && (
<PanelSection title="Corner Shape">
<SegmentedControl
onChange={(value) =>
@@ -144,7 +144,7 @@ export function NumberInput({
}}
/>
<div
className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
className={`relative z-10 flex items-center overflow-hidden rounded-lg border shadow-elevation-0 transition-all focus-within:border-primary focus-within:ring-1 focus-within:ring-primary ${isDragging ? 'border-neutral-300 bg-transparent ring-1 ring-neutral-200/60 dark:border-border dark:ring-border/50' : 'border-neutral-200/60 bg-white hover:border-neutral-300 dark:border-border/50 dark:bg-accent/30 dark:hover:border-border/80'}`}
>
<div
className={`z-10 select-none truncate py-1.5 pr-1 pl-2 font-barlow font-medium text-muted-foreground text-xs ${
View File
View File
View File
@@ -0,0 +1,330 @@
'use client'
import type { AssetInput } from '@pascal-app/core'
import NextImage from 'next/image'
import { useEffect, useState } from 'react'
import { cn } from '../../../../../lib/utils'
import type { CatalogCategory } from '../../../../../store/use-editor'
import useEditor from '../../../../../store/use-editor'
import { furnishTools } from '../../../action-menu/furnish-tools'
import { CATALOG_ITEMS } from '../../../item-catalog/catalog-items'
import { ItemCatalog } from '../../../item-catalog/item-catalog'
const PLACEMENT_TAGS = new Set(['floor', 'wall', 'ceiling', 'countertop'])
export function ItemsPanel({
items,
onSearchChange,
searchResults,
leadingTile,
emptyState,
}: {
items?: AssetInput[]
/** Called when the search query changes (community edition uses this for server-side search) */
onSearchChange?: (query: string) => void
/** When non-null and search is active, these results bypass local filtering (server search results) */
searchResults?: AssetInput[] | null
/**
* Optional node rendered as the first grid cell, always visible. Used by the
* community edition to inject a "+ Generate with AI" tile.
*/
leadingTile?: React.ReactNode
/**
* Optional node rendered when the grid has no items to show (empty category
* or no search results). Replaces the default "No results" message.
*/
emptyState?: React.ReactNode
}) {
const mode = useEditor((s) => s.mode)
const catalogCategory = useEditor((s) => s.catalogCategory)
const setMode = useEditor((s) => s.setMode)
const setTool = useEditor((s) => s.setTool)
const setCatalogCategory = useEditor((s) => s.setCatalogCategory)
const [activePlacementTag, setActivePlacementTag] = useState<string | null>(null)
const [activeFunctionalTag, setActiveFunctionalTag] = useState<string | null>(null)
// Library / Community / Mine. Default to Library so first-time users see
// the curated catalog rather than every uploaded item; clicking the chip
// again clears the filter (`null` = show everything).
const [activeSource, setActiveSource] = useState<AssetInput['source'] | null>('library')
const [search, setSearch] = useState('')
const isServerSearch = onSearchChange !== undefined
// True when server search is active but results haven't come back yet
const isSearchPending = isServerSearch && search.length > 0 && searchResults === null
// Auto-select the first category when the panel mounts without one
useEffect(() => {
if (!(catalogCategory && furnishTools.some((c) => c.catalogCategory === catalogCategory))) {
setCatalogCategory(furnishTools[0]!.catalogCategory)
}
}, [catalogCategory, setCatalogCategory])
const activeCategory =
furnishTools.find((c) => c.catalogCategory === catalogCategory) ?? furnishTools[0]!
function selectCategory(categoryId: CatalogCategory) {
setCatalogCategory(categoryId)
setTool('item')
setActivePlacementTag(null)
setActiveFunctionalTag(null)
setSearch('')
if (mode !== 'build') setMode('build')
}
// Compute tags for the current category (for filter chips)
const baseItems = items ?? CATALOG_ITEMS
// Apply the Library/Community/Mine filter before any category/tag work.
// Items that don't carry a source field (e.g. seeded built-in catalog
// entries from `CATALOG_ITEMS`) fall under "library".
//
// Community is broader than just other users' uploads: my own *published*
// items show up there too so I can preview my catalog the way other users
// see it. My drafts only appear under Mine.
const matchesSource = (item: AssetInput) => {
if (!activeSource) return true
const itemSource = item.source ?? 'library'
if (activeSource === 'mine') return itemSource === 'mine'
if (activeSource === 'library') return itemSource === 'library'
if (activeSource === 'community') {
if (itemSource === 'community') return true
if (itemSource === 'mine') return !item.isDraft
return false
}
return true
}
const sourceItems = baseItems.filter(matchesSource)
const categoryItems = sourceItems.filter(
(item) => item.category === activeCategory.catalogCategory,
)
// The three source chips are always shown so users can discover the
// filter even before they own any items. Selecting "Mine" with no
// matching items falls through to the empty/no-results state.
const sourceChips: Array<{ id: AssetInput['source']; label: string }> = [
{ id: 'library', label: 'Library' },
{ id: 'community', label: 'Community' },
{ id: 'mine', label: 'Mine' },
]
const allTags = Array.from(new Set(categoryItems.flatMap((item) => item.tags ?? [])))
const placementTags = allTags.filter((t) => PLACEMENT_TAGS.has(t))
const functionalTags = allTags.filter((t) => !PLACEMENT_TAGS.has(t))
const hasFilters = allTags.length > 1
const placementCount = (tag: string | null) =>
categoryItems.filter((item) => {
const tags = item.tags ?? []
if (tag !== null && !tags.includes(tag)) return false
if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
return true
}).length
const functionalCount = (tag: string) =>
categoryItems.filter((item) => {
const tags = item.tags ?? []
if (!tags.includes(tag)) return false
if (activePlacementTag && !tags.includes(activePlacementTag)) return false
return true
}).length
return (
<div className="flex h-full flex-col">
{/* Category tabs */}
<div className="flex shrink-0 gap-1 overflow-x-auto border-border/70 border-b p-2">
{furnishTools.map((cat) => {
const isActive = activeCategory.catalogCategory === cat.catalogCategory
return (
<button
className={cn(
'flex shrink-0 flex-col items-center gap-1 rounded-xl px-3 py-2 transition-colors',
isActive
? 'bg-sidebar-accent text-sidebar-accent-foreground'
: 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-foreground',
)}
key={cat.catalogCategory}
onClick={() => selectCategory(cat.catalogCategory)}
type="button"
>
<NextImage
alt={cat.label}
className={cn('size-7 object-contain', !isActive && 'opacity-60 grayscale')}
height={28}
src={cat.iconSrc}
width={28}
/>
<span className="font-medium text-[10px] leading-none">{cat.label}</span>
</button>
)
})}
</div>
{/* Search + filters (non-scrollable) */}
<div className="flex shrink-0 flex-col gap-2 border-border/70 border-b p-2">
<div className="flex items-center gap-1.5">
{/* Search and source filter take 50/50 of the row. `min-w-0` on
both sides lets each half shrink to fit when the panel narrows. */}
<input
className="w-1/2 min-w-0 shrink-0 rounded-lg bg-muted px-2.5 py-1.5 text-xs placeholder:text-muted-foreground focus:outline-none"
onChange={(e) => {
setSearch(e.target.value)
onSearchChange?.(e.target.value)
}}
placeholder="Search..."
type="text"
value={search}
/>
{sourceChips.length > 0 && (
<div className="flex w-1/2 min-w-0 shrink-0 rounded-lg bg-muted p-0.5">
{sourceChips.map((chip) => {
const isActive = activeSource === chip.id
return (
<button
className={cn(
'min-w-0 flex-1 truncate rounded-md px-1 py-1 text-center font-medium text-[10px] transition-colors',
isActive
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
key={chip.id}
onClick={() => setActiveSource(isActive ? null : chip.id)}
type="button"
>
{chip.label}
</button>
)
})}
</div>
)}
</div>
{hasFilters && !search && !isServerSearch && (
<div className="flex flex-col gap-1.5">
{placementTags.length > 0 && (
<div className="flex flex-wrap gap-1">
<button
className={cn(
'cursor-pointer rounded-md px-2 py-0.5 font-medium text-xs transition-colors',
activePlacementTag === null
? 'bg-blue-500 text-white'
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
)}
onClick={() => setActivePlacementTag(null)}
type="button"
>
All
</button>
{placementTags.map((tag) => {
const count = placementCount(tag)
const isActive = activePlacementTag === tag
const isEmpty = count === 0 && !isActive
return (
<button
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
isActive
? 'bg-blue-500 text-white'
: isEmpty
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
: 'bg-blue-950/50 text-blue-300 hover:bg-blue-900/60 hover:text-blue-200',
)}
disabled={isEmpty}
key={tag}
onClick={() => setActivePlacementTag(isActive ? null : tag)}
type="button"
>
{tag}
<span
className={cn(
'text-[10px]',
isActive
? 'text-blue-200'
: isEmpty
? 'text-zinc-600'
: 'text-blue-500/70',
)}
>
{count}
</span>
</button>
)
})}
</div>
)}
{functionalTags.length > 0 && (
<div className="flex flex-wrap gap-1">
{functionalTags.map((tag) => {
const count = functionalCount(tag)
const isActive = activeFunctionalTag === tag
const isEmpty = count === 0 && !isActive
return (
<button
className={cn(
'inline-flex cursor-pointer items-center gap-1 rounded-md py-0.5 pr-1.5 pl-2 font-medium text-xs capitalize transition-colors',
isActive
? 'bg-violet-500 text-white'
: isEmpty
? 'cursor-not-allowed bg-zinc-800 text-zinc-500'
: 'bg-muted text-muted-foreground hover:bg-muted/80 hover:text-foreground',
)}
disabled={isEmpty}
key={tag}
onClick={() => setActiveFunctionalTag(isActive ? null : tag)}
type="button"
>
{tag}
<span
className={cn(
'text-[10px]',
isActive
? 'text-violet-200'
: isEmpty
? 'text-zinc-600'
: 'text-zinc-500/70',
)}
>
{count}
</span>
</button>
)
})}
</div>
)}
</div>
)}
</div>
{/* Item grid */}
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{isSearchPending ? (
<div className="flex h-full items-center justify-center">
<div className="size-5 animate-spin rounded-full border-2 border-muted-foreground/20 border-t-muted-foreground" />
</div>
) : isServerSearch && search && searchResults?.length === 0 ? (
(emptyState ?? (
<div className="flex h-full items-center justify-center text-muted-foreground text-xs">
No results for &ldquo;{search}&rdquo;
</div>
))
) : (
<ItemCatalog
activeFunctionalTag={isServerSearch ? null : activeFunctionalTag}
activePlacementTag={isServerSearch ? null : activePlacementTag}
category={activeCategory.catalogCategory}
emptyState={emptyState}
items={activeSource && items ? items.filter(matchesSource) : items}
key={activeCategory.catalogCategory}
leadingTile={leadingTile}
overrideItems={
isServerSearch && search
? activeSource && searchResults
? searchResults.filter(matchesSource)
: (searchResults ?? undefined)
: undefined
}
search={isServerSearch ? '' : search}
/>
)}
</div>
</div>
)
}
+4 -6
View File
@@ -3,9 +3,9 @@ import {
type AnyNodeId,
type BuildingNode,
emitter,
GuideNode,
type GuideNode,
LevelNode,
ScanNode,
type ScanNode,
type SiteNode,
useScene,
type ZoneNode,
@@ -32,14 +32,12 @@ import {
PopoverContent,
PopoverTrigger,
} from './../../../../../components/ui/primitives/popover'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import {
buildLevelDuplicateCreateOps,
type LevelDuplicatePreset,
} from './../../../../../lib/level-duplication'
import { deleteLevelWithFallbackSelection } from './../../../../../lib/level-selection'
import { createLocalGuideImage } from './../../../../../lib/local-guide-image'
import { cn } from './../../../../../lib/utils'
import useEditor from './../../../../../store/use-editor'
import { useUploadStore } from '../../../../../store/use-upload'
@@ -50,13 +50,7 @@ export const SpawnTreeNode = memo(function SpawnTreeNode({
expanded={false}
hasChildren={false}
icon={
<Image
alt=""
className="object-contain"
height={14}
src="/icons/site.png"
width={14}
/>
<Image alt="" className="object-contain" height={14} src="/icons/site.png" width={14} />
}
isHovered={isHovered}
isLast={isLast}
@@ -82,7 +82,9 @@ export const TreeNode = memo(function TreeNode({ nodeId, depth = 0, isLast }: Tr
switch (nodeType) {
case 'building':
return <BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
return (
<BuildingTreeNode depth={depth} isLast={isLast} nodeId={nodeId as `building_${string}`} />
)
case 'ceiling':
return <CeilingTreeNode depth={depth} isLast={isLast} nodeId={nodeId} />
case 'column':
+3 -1
View File
@@ -44,7 +44,9 @@ export const ZoneTreeNode = memo(function ZoneTreeNode({
depth={depth}
expanded={false}
hasChildren={false}
icon={<ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />}
icon={
<ColorDot color={color ?? '#3b82f6'} onChange={(c) => updateNode(nodeId, { color: c })} />
}
isHovered={isHovered}
isLast={isLast}
isSelected={isSelected}
@@ -41,7 +41,7 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
className={cn(
'group/row mx-1 mb-0.5 flex h-8 cursor-pointer select-none items-center rounded-lg border px-2 text-sm transition-all duration-200',
isSelected
? 'border-neutral-200/60 bg-white text-foreground shadow-[0_1px_2px_0px_rgba(0,0,0,0.05)] ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'
? 'border-neutral-200/60 bg-white text-foreground shadow-elevation-0 ring-1 ring-white/50 ring-inset dark:border-border/50 dark:bg-accent/50 dark:ring-white/10'
: 'border-transparent text-muted-foreground hover:border-neutral-200/50 hover:bg-white/40 hover:text-foreground dark:hover:border-border/40 dark:hover:bg-accent/30',
)}
onClick={handleClick}
+1 -1
View File
@@ -17,7 +17,7 @@ const sliderVariants = cva(
[&_[data-slot=slider-track]]:border
[&_[data-slot=slider-track]]:border-neutral-300
[&_[data-slot=slider-track]]:bg-white/50
[&_[data-slot=slider-track]]:shadow-[0_1px_2px_0px_rgba(0,0,0,0.1)]
[&_[data-slot=slider-track]]:shadow-elevation-0
[&_[data-slot=slider-track]]:ring-1
[&_[data-slot=slider-track]]:ring-white
[&_[data-slot=slider-track]]:ring-inset
@@ -1,436 +0,0 @@
'use client'
import { Icon as IconifyIcon } from '@iconify/react'
import { useViewer } from '@pascal-app/viewer'
import {
Check,
ChevronsLeft,
ChevronsRight,
Columns2,
Eye,
EyeOff,
Footprints,
Grid2X2,
Moon,
Sun,
} from 'lucide-react'
import { useCallback } from 'react'
import { cn } from '../../lib/utils'
import useEditor from '../../store/use-editor'
import type { GridSnapStep, ViewMode } from '../../store/use-editor'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from './primitives/dropdown-menu'
import { useSidebarStore } from './primitives/sidebar'
import { Tooltip, TooltipContent, TooltipTrigger } from './primitives/tooltip'
// ── Shared styles ───────────────────────────────────────────────────────────
/** Container for a group of buttons — no padding, overflow-hidden clips children flush. */
const TOOLBAR_CONTAINER =
'inline-flex h-8 items-stretch overflow-hidden rounded-xl border border-border bg-background/90 shadow-2xl backdrop-blur-md'
/** Ghost button inside a container — flush edges, no individual border/radius. */
const TOOLBAR_BTN =
'flex items-center justify-center w-8 text-muted-foreground/80 transition-colors hover:bg-white/8 hover:text-foreground/90'
// ── View mode segmented control ─────────────────────────────────────────────
const VIEW_MODES: { id: ViewMode; label: string; icon: React.ReactNode }[] = [
{
id: '3d',
label: '3D',
icon: <img alt="" className="h-3.5 w-3.5 object-contain" src="/icons/building.png" />,
},
{
id: '2d',
label: '2D',
icon: <img alt="" className="h-3.5 w-3.5 object-contain" src="/icons/blueprint.png" />,
},
{
id: 'split',
label: 'Split',
icon: <Columns2 className="h-3 w-3" />,
},
]
function ViewModeControl() {
const viewMode = useEditor((s) => s.viewMode)
const setViewMode = useEditor((s) => s.setViewMode)
return (
<div className={TOOLBAR_CONTAINER}>
{VIEW_MODES.map((mode) => {
const isActive = viewMode === mode.id
return (
<button
className={cn(
'flex items-center justify-center gap-1.5 px-2.5 font-medium text-xs transition-colors',
isActive
? 'bg-white/10 text-foreground'
: 'text-muted-foreground/70 hover:bg-white/8 hover:text-muted-foreground',
)}
key={mode.id}
onClick={() => setViewMode(mode.id)}
type="button"
>
{mode.icon}
<span>{mode.label}</span>
</button>
)
})}
</div>
)
}
// ── Collapse sidebar button ─────────────────────────────────────────────────
function CollapseSidebarButton() {
const isCollapsed = useSidebarStore((s) => s.isCollapsed)
const setIsCollapsed = useSidebarStore((s) => s.setIsCollapsed)
const toggle = useCallback(() => {
setIsCollapsed(!isCollapsed)
}, [isCollapsed, setIsCollapsed])
return (
<div className={TOOLBAR_CONTAINER}>
<button
className={TOOLBAR_BTN}
onClick={toggle}
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
type="button"
>
{isCollapsed ? <ChevronsRight className="h-4 w-4" /> : <ChevronsLeft className="h-4 w-4" />}
</button>
</div>
)
}
// ── Right toolbar buttons ───────────────────────────────────────────────────
function WalkthroughButton() {
const isFirstPersonMode = useEditor((s) => s.isFirstPersonMode)
const setFirstPersonMode = useEditor((s) => s.setFirstPersonMode)
const toggle = () => {
setFirstPersonMode(!isFirstPersonMode)
}
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(
TOOLBAR_BTN,
isFirstPersonMode && 'bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20',
)}
onClick={toggle}
type="button"
>
<Footprints className="h-4 w-4" />
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Walkthrough</TooltipContent>
</Tooltip>
)
}
function UnitToggle() {
const unit = useViewer((s) => s.unit)
const setUnit = useViewer((s) => s.setUnit)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={TOOLBAR_BTN}
onClick={() => setUnit(unit === 'metric' ? 'imperial' : 'metric')}
type="button"
>
<span className="font-semibold text-[10px]">{unit === 'metric' ? 'm' : 'ft'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{unit === 'metric' ? 'Metric (m)' : 'Imperial (ft)'}
</TooltipContent>
</Tooltip>
)
}
function ThemeToggle() {
const theme = useViewer((s) => s.theme)
const setTheme = useViewer((s) => s.setTheme)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(TOOLBAR_BTN, theme === 'dark' ? 'text-indigo-400/60' : 'text-amber-400/60')}
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
type="button"
>
{theme === 'dark' ? <Moon className="h-3.5 w-3.5" /> : <Sun className="h-3.5 w-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">{theme === 'dark' ? 'Dark' : 'Light'}</TooltipContent>
</Tooltip>
)
}
// ── Level mode toggle ───────────────────────────────────────────────────────
const levelModeOrder = ['stacked', 'exploded', 'solo'] as const
const levelModeLabels: Record<string, string> = {
manual: 'Stack',
stacked: 'Stack',
exploded: 'Exploded',
solo: 'Solo',
}
const gridSnapOrder: GridSnapStep[] = [0.5, 0.25, 0.1, 0.05]
const gridSnapLabels: Record<GridSnapStep, string> = {
0.5: '0.50',
0.25: '0.25',
0.1: '0.10',
0.05: '0.05',
}
function formatGridSnapStep(step: GridSnapStep): string {
return gridSnapLabels[step]
}
function LevelModeToggle() {
const levelMode = useViewer((s) => s.levelMode)
const setLevelMode = useViewer((s) => s.setLevelMode)
const cycle = () => {
if (levelMode === 'manual') {
setLevelMode('stacked')
return
}
const idx = levelModeOrder.indexOf(levelMode as (typeof levelModeOrder)[number])
const next = levelModeOrder[(idx + 1) % levelModeOrder.length]
if (next) setLevelMode(next)
}
const isDefault = levelMode === 'stacked' || levelMode === 'manual'
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(
TOOLBAR_BTN,
'w-auto gap-1.5 px-2.5',
!isDefault && 'bg-white/10 text-foreground/90',
)}
onClick={cycle}
type="button"
>
{levelMode === 'solo' ? (
<IconifyIcon height={14} icon="lucide:diamond" width={14} />
) : levelMode === 'exploded' ? (
<IconifyIcon height={14} icon="charm:stack-pop" width={14} />
) : (
<IconifyIcon height={14} icon="charm:stack-push" width={14} />
)}
<span className="font-medium text-xs">{levelModeLabels[levelMode] ?? 'Stack'}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
Levels: {levelMode === 'manual' ? 'Manual' : levelModeLabels[levelMode]}
</TooltipContent>
</Tooltip>
)
}
function GridSnapToggle() {
const gridSnapStep = useEditor((s) => s.gridSnapStep)
const setGridSnapStep = useEditor((s) => s.setGridSnapStep)
return (
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button className={cn(TOOLBAR_BTN, 'w-auto gap-1.5 px-2.5')} type="button">
<IconifyIcon height={14} icon="lucide:grid-2x2" width={14} />
<span className="font-medium text-xs">{formatGridSnapStep(gridSnapStep)}</span>
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Grid snap: {formatGridSnapStep(gridSnapStep)}</TooltipContent>
</Tooltip>
<DropdownMenuContent align="center" side="bottom">
{gridSnapOrder.map((step) => {
const isActive = step === gridSnapStep
return (
<DropdownMenuItem key={step} onSelect={() => setGridSnapStep(step)}>
<span className="flex min-w-12 items-center justify-between gap-3">
<span>{formatGridSnapStep(step)}</span>
{isActive ? <Check className="h-3.5 w-3.5" /> : <span className="h-3.5 w-3.5" />}
</span>
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}
function GridVisibilityToggle() {
const showGrid = useViewer((s) => s.showGrid)
const setShowGrid = useViewer((s) => s.setShowGrid)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
aria-label={`Grid: ${showGrid ? 'Visible' : 'Hidden'}`}
aria-pressed={showGrid}
className={cn(
TOOLBAR_BTN,
'w-auto gap-1.5 px-2.5',
showGrid
? 'bg-white/10 text-foreground/90'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
)}
onClick={() => setShowGrid(!showGrid)}
type="button"
>
<Grid2X2 className="h-3.5 w-3.5" />
{showGrid ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Grid: {showGrid ? 'Visible' : 'Hidden'}</TooltipContent>
</Tooltip>
)
}
// ── Wall mode toggle ────────────────────────────────────────────────────────
const wallModeOrder = ['cutaway', 'up', 'down'] as const
const wallModeConfig: Record<string, { icon: string; label: string }> = {
up: { icon: '/icons/room.png', label: 'Full height' },
cutaway: { icon: '/icons/wallcut.png', label: 'Cutaway' },
down: { icon: '/icons/walllow.png', label: 'Low' },
}
function WallModeToggle() {
const wallMode = useViewer((s) => s.wallMode)
const setWallMode = useViewer((s) => s.setWallMode)
const cycle = () => {
const idx = wallModeOrder.indexOf(wallMode as (typeof wallModeOrder)[number])
const next = wallModeOrder[(idx + 1) % wallModeOrder.length]
if (next) setWallMode(next)
}
const config = wallModeConfig[wallMode] ?? wallModeConfig.cutaway!
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(
TOOLBAR_BTN,
'w-auto gap-1.5 px-2.5',
wallMode !== 'cutaway'
? 'bg-white/10'
: 'opacity-60 grayscale hover:opacity-100 hover:grayscale-0',
)}
onClick={cycle}
type="button"
>
<img alt={config.label} className="h-4 w-4 object-contain" src={config.icon} />
<span className="font-medium text-xs">{config.label}</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Walls: {config.label}</TooltipContent>
</Tooltip>
)
}
// ── Camera mode toggle ──────────────────────────────────────────────────────
function CameraModeToggle() {
const cameraMode = useViewer((s) => s.cameraMode)
const setCameraMode = useViewer((s) => s.setCameraMode)
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(
TOOLBAR_BTN,
cameraMode === 'orthographic' && 'bg-white/10 text-foreground/90',
)}
onClick={() =>
setCameraMode(cameraMode === 'perspective' ? 'orthographic' : 'perspective')
}
type="button"
>
{cameraMode === 'perspective' ? (
<IconifyIcon height={16} icon="icon-park-outline:perspective" width={16} />
) : (
<IconifyIcon height={16} icon="vaadin:grid" width={16} />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{cameraMode === 'perspective' ? 'Perspective' : 'Orthographic'}
</TooltipContent>
</Tooltip>
)
}
function PreviewButton() {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1.5 px-2.5 font-medium text-muted-foreground/80 text-xs transition-colors hover:bg-white/8 hover:text-foreground/90"
onClick={() => useEditor.getState().setPreviewMode(true)}
type="button"
>
<Eye className="h-3.5 w-3.5 shrink-0" />
<span>Preview</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom">Preview mode</TooltipContent>
</Tooltip>
)
}
// ── Composed toolbar sections ───────────────────────────────────────────────
export function ViewerToolbarLeft() {
return (
<>
<CollapseSidebarButton />
<ViewModeControl />
</>
)
}
export function ViewerToolbarRight() {
return (
<div className={TOOLBAR_CONTAINER}>
<LevelModeToggle />
<WallModeToggle />
<GridSnapToggle />
<GridVisibilityToggle />
<div className="my-1.5 w-px bg-border/50" />
<UnitToggle />
<ThemeToggle />
<CameraModeToggle />
<div className="my-1.5 w-px bg-border/50" />
<WalkthroughButton />
<PreviewButton />
</div>
)
}
View File
View File
@@ -60,6 +60,7 @@ export function useAutoSave({
// Stable subscription to scene changes
useEffect(() => {
let lastNodesSnapshot = JSON.stringify(useScene.getState().nodes)
let lastNodeCount = Object.keys(useScene.getState().nodes).length
async function executeSave() {
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
@@ -71,6 +72,19 @@ export function useAutoSave({
const { nodes, rootNodeIds } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds } as SceneGraph
// Guard: refuse to autosave if the scene went from populated to nearly empty.
// This catches accidental full deletions before they're persisted.
const currentNodeCount = Object.keys(nodes).length
const STRUCTURAL_NODE_COUNT = 4 // site + building + levels (empty scene skeleton)
if (lastNodeCount > STRUCTURAL_NODE_COUNT && currentNodeCount <= STRUCTURAL_NODE_COUNT) {
console.warn(
`[autosave] Blocked: scene dropped from ${lastNodeCount} to ${currentNodeCount} nodes. Likely accidental deletion.`,
)
setSaveStatus('error')
return
}
lastNodeCount = currentNodeCount
isSavingRef.current = true
pendingSaveRef.current = false
setSaveStatus('saving')
+10
View File
@@ -77,6 +77,7 @@ export const useKeyboard = ({
e.preventDefault()
useEditor.getState().setPhase('furnish')
useEditor.getState().setMode('build')
useEditor.getState().setActiveSidebarPanel('items')
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
@@ -252,6 +253,15 @@ export const useKeyboard = ({
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length > 0) {
// Guard against accidental bulk deletion (e.g. box-select all + Delete)
const BULK_DELETE_THRESHOLD = 10
if (selectedNodeIds.length >= BULK_DELETE_THRESHOLD) {
const confirmed = window.confirm(
`Delete ${selectedNodeIds.length} selected elements? This cannot be undone if the undo history is exhausted.`,
)
if (!confirmed) return
}
// Play appropriate SFX based on what's being deleted
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
+8 -1
View File
@@ -1,13 +1,21 @@
export type { EditorProps } from './components/editor'
export { default as Editor } from './components/editor'
export {
type SnapshotCameraData,
ThumbnailGenerator,
} from './components/editor/thumbnail-generator'
export { CameraActions as ViewerToolbarRight } from './components/ui/action-menu/camera-actions'
export { ViewToggles as ViewerToolbarLeft } from './components/ui/action-menu/view-toggles'
export { useCommandPalette } from './components/ui/command-palette'
export { SliderControl } from './components/ui/controls/slider-control'
export { FloatingLevelSelector } from './components/ui/floating-level-selector'
export { CATALOG_ITEMS } from './components/ui/item-catalog/catalog-items'
export { PALETTE_COLORS } from './components/ui/primitives/color-dot'
export { useSidebarStore } from './components/ui/primitives/sidebar'
export { Slider } from './components/ui/primitives/slider'
export { SceneLoader } from './components/ui/scene-loader'
export type { ExtraPanel } from './components/ui/sidebar/icon-rail'
export { ItemsPanel } from './components/ui/sidebar/panels/items-panel'
export {
type ProjectVisibility,
SettingsPanel,
@@ -15,7 +23,6 @@ export {
} from './components/ui/sidebar/panels/settings-panel'
export type { SitePanelProps } from './components/ui/sidebar/panels/site-panel'
export type { SidebarTab } from './components/ui/sidebar/tab-bar'
export { ViewerToolbarLeft, ViewerToolbarRight } from './components/ui/viewer-toolbar'
export type { PresetsAdapter, PresetsTab } from './contexts/presets-context'
export { PresetsProvider } from './contexts/presets-context'
export type { SaveStatus } from './hooks/use-auto-save'
@@ -1,5 +1,3 @@
// @ts-expect-error — bun:test is provided by the Bun runtime; editor does not
// depend on @types/bun so the import type is unresolved at compile time.
import { describe, expect, test } from 'bun:test'
import {
type AnyNode,
+1 -1
View File
@@ -123,7 +123,7 @@ export function buildLevelDuplicateCreateOps({
const keptIds = new Set(filteredNodes.map((node) => node.id))
const cleanedNodes = filteredNodes.map((node) => {
if (!('children' in node) || !Array.isArray(node.children)) {
if (!('children' in node && Array.isArray(node.children))) {
return node
}
+1 -1
View File
@@ -154,7 +154,7 @@ export function resolveActivePaintMaterialFromSelection(params: {
} | null
}): ActivePaintMaterial | null {
const { nodes, selectedId, selectedMaterialTarget } = params
if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId)
if (!(selectedId && selectedMaterialTarget) || selectedMaterialTarget.nodeId !== selectedId)
return null
const selectedNode = nodes[selectedId]
+1 -1
View File
@@ -134,7 +134,7 @@ export function duplicateRoofSubtree(
createdParent && 'children' in createdParent && Array.isArray(createdParent.children)
? (createdParent.children as AnyNodeId[])
: null
if (!createdParent || !parentChildIds?.includes(createdRoof.id as AnyNodeId)) {
if (!(createdParent && parentChildIds?.includes(createdRoof.id as AnyNodeId))) {
throw new Error(`Duplicated roof "${createdRoof.id}" was not linked to parent "${parentId}"`)
}
+1 -1
View File
@@ -33,7 +33,7 @@ function extendPoint(
z: unknown,
): void {
if (typeof x !== 'number' || typeof z !== 'number') return
if (!Number.isFinite(x) || !Number.isFinite(z)) return
if (!(Number.isFinite(x) && Number.isFinite(z))) return
if (x < acc.minX) acc.minX = x
if (x > acc.maxX) acc.maxX = x
if (z < acc.minZ) acc.minZ = z
Executable → Regular
View File
+2
View File
@@ -12,6 +12,7 @@ type SFXEvents = {
'sfx:item-rotate': undefined
'sfx:structure-build': undefined
'sfx:structure-delete': undefined
'sfx:snapshot-capture': undefined
}
/**
@@ -37,6 +38,7 @@ export function initSFXBus() {
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
sfxEmitter.on('sfx:snapshot-capture', () => playSFX('snapshotCapture'))
}
/**
+5 -5
View File
@@ -2,7 +2,7 @@ import { Howl } from 'howler'
import useAudio from '../store/use-audio'
// Per-sound variation config. Playback rate also shifts pitch (one semitone ≈ 1.0595×),
// so a rate range of ~0.881.12 reads as a subtle ±2 semitones, enough to kill the
// so a rate range of ~0.881.12 reads as a subtle ±2 semitones enough to kill the
// machine-gun feeling when the same SFX fires in rapid succession.
type SFXConfig = {
src: string
@@ -13,8 +13,8 @@ type SFXConfig = {
// Minimum gap between two plays of this SFX. Triggers within this window
// are silently dropped so bursty sequences don't phase-stack into noise.
minIntervalMs?: number
// Random stereo pan per play, max absolute offset (0 = center, 1 = hard
// right). A small value like 0.15 keeps things centered but adds just enough
// Random stereo pan per play max absolute offset (0 = center, 1 = hard
// right). A small value like 0.15 keeps things centred but adds just enough
// spread to stop repeats from stacking on the same point in the field.
panJitter?: number
}
@@ -66,7 +66,7 @@ export const SFX: Record<string, SFXConfig> = {
panJitter: 0.15,
},
snapshotCapture: {
// Shutter should sound consistent, no variation.
// Shutter should sound consistent no variation.
src: '/audios/sfx/snapshot_capture.mp3',
},
} as const
@@ -102,7 +102,7 @@ export function playSFX(name: SFXName) {
}
const config = SFX[name]!
// Drop rapid repeats, two plays of the same SFX within minIntervalMs just
// Drop rapid repeats two plays of the same SFX within minIntervalMs just
// smear into noise, they don't add useful information.
const now = performance.now()
const minInterval = config.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS

Some files were not shown because too many files have changed in this diff Show More