better placing tools

This commit is contained in:
wass08
2026-02-12 10:17:22 +09:00
parent ed1912346f
commit ae3af4318e
12 changed files with 169 additions and 33 deletions
+2
View File
@@ -11,6 +11,7 @@ import { ActionMenu } from '../ui/action-menu'
import { CloudSaveButton } from '@/features/community/components/cloud-save-button' import { CloudSaveButton } from '@/features/community/components/cloud-save-button'
import { PascalRadio } from '../pascal-radio' import { PascalRadio } from '../pascal-radio'
import { PanelManager } from '../ui/panels/panel-manager' import { PanelManager } from '../ui/panels/panel-manager'
import { HelperManager } from '../ui/helpers/helper-manager'
import { SidebarProvider } from '../ui/primitives/sidebar' import { SidebarProvider } from '../ui/primitives/sidebar'
import { AppSidebar } from '../ui/sidebar/app-sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar'
import { CustomCameraControls } from './custom-camera-controls' import { CustomCameraControls } from './custom-camera-controls'
@@ -34,6 +35,7 @@ export default function Editor() {
<div className="w-full h-full"> <div className="w-full h-full">
<ActionMenu /> <ActionMenu />
<PanelManager /> <PanelManager />
<HelperManager />
{/* Top-right controls */} {/* Top-right controls */}
<div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2"> <div className="pointer-events-none fixed top-4 right-4 z-50 flex items-start gap-2">
@@ -17,12 +17,17 @@ import { useFrame } from '@react-three/fiber'
import { useEffect, useRef } from 'react' import { useEffect, useRef } from 'react'
import { import {
BoxGeometry, BoxGeometry,
EdgesGeometry,
Euler, Euler,
type Group,
type LineSegments,
type Mesh, type Mesh,
type MeshStandardMaterial, PlaneGeometry,
Quaternion, Quaternion,
Vector3, Vector3,
} from 'three' } from 'three'
import { distance, smoothstep, uv, vec2 } from 'three/tsl'
import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu'
import { sfxEmitter } from '@/lib/sfx-bus' import { sfxEmitter } from '@/lib/sfx-bus'
import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies' import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies'
import type { PlacementState, TransitionResult } from './placement-types' import type { PlacementState, TransitionResult } from './placement-types'
@@ -30,6 +35,28 @@ import type { DraftNodeHandle } from './use-draft-node'
const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1] const DEFAULT_DIMENSIONS: [number, number, number] = [1, 1, 1]
// Shared materials for placement cursor - we just change colors, not swap materials
// Note: EdgesGeometry doesn't work with dashed lines, so using solid lines
const edgeMaterial = new LineBasicNodeMaterial({
color: 0xef4444, // red-500 (invalid)
linewidth: 3,
depthTest: false,
depthWrite: false,
})
const basePlaneMaterial = new MeshBasicNodeMaterial({
color: 0xef4444, // red-500 (invalid)
transparent: true,
depthTest: false,
depthWrite: false,
})
// Create radial opacity: transparent in center, opaque at edges
const center = vec2(0.5, 0.5)
const dist = distance(uv(), center)
const radialOpacity = smoothstep(0, 0.7, dist).mul(0.6)
basePlaneMaterial.opacityNode = radialOpacity
export interface PlacementCoordinatorConfig { export interface PlacementCoordinatorConfig {
asset: AssetInput asset: AssetInput
draftNode: DraftNodeHandle draftNode: DraftNodeHandle
@@ -40,7 +67,9 @@ export interface PlacementCoordinatorConfig {
} }
export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode { export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode {
const cursorRef = useRef<Mesh>(null!) const cursorGroupRef = useRef<Group>(null!)
const edgesRef = useRef<LineSegments>(null!)
const basePlaneRef = useRef<Mesh>(null!)
const gridPosition = useRef(new Vector3(0, 0, 0)) const gridPosition = useRef(new Vector3(0, 0, 0))
const placementState = useRef<PlacementState>( const placementState = useRef<PlacementState>(
config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null }, config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null },
@@ -77,7 +106,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const revalidate = (): boolean => { const revalidate = (): boolean => {
const placeable = checkCanPlace(getContext(), validators) const placeable = checkCanPlace(getContext(), validators)
;(cursorRef.current.material as MeshStandardMaterial).color.set(placeable ? 'green' : 'red') const color = placeable ? 0x22c55e : 0xef4444 // green-500 : red-500
edgeMaterial.color.setHex(color)
basePlaneMaterial.color.setHex(color)
return placeable return placeable
} }
@@ -85,8 +116,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
Object.assign(placementState.current, result.stateUpdate) Object.assign(placementState.current, result.stateUpdate)
gridPosition.current.set(...result.gridPosition) gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition) cursorGroupRef.current.position.set(...result.cursorPosition)
cursorRef.current.rotation.y = result.cursorRotationY cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current const draft = draftNode.current
if (draft) { if (draft) {
@@ -98,8 +129,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const ensureDraft = (result: TransitionResult) => { const ensureDraft = (result: TransitionResult) => {
gridPosition.current.set(...result.gridPosition) gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition) cursorGroupRef.current.position.set(...result.cursorPosition)
cursorRef.current.rotation.y = result.cursorRotationY cursorGroupRef.current.rotation.y = result.cursorRotationY
draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0]) draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0])
@@ -122,14 +153,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (draftNode.current) { if (draftNode.current) {
const mesh = sceneRegistry.nodes.get(draftNode.current.id) const mesh = sceneRegistry.nodes.get(draftNode.current.id)
if (mesh) { if (mesh) {
mesh.getWorldPosition(cursorRef.current.position) mesh.getWorldPosition(cursorGroupRef.current.position)
// Extract world Y rotation (handles wall-parented items correctly) // Extract world Y rotation (handles wall-parented items correctly)
const q = new Quaternion() const q = new Quaternion()
mesh.getWorldQuaternion(q) mesh.getWorldQuaternion(q)
cursorRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y
} else { } else {
cursorRef.current.position.copy(gridPosition.current) cursorGroupRef.current.position.copy(gridPosition.current)
cursorRef.current.rotation.y = draftNode.current.rotation[1] ?? 0 cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0
} }
} }
@@ -144,17 +175,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!result) return if (!result) return
// Play snap sound when grid position changes // Play snap sound when grid position changes
if (previousGridPos && if (
previousGridPos &&
(result.gridPosition[0] !== previousGridPos[0] || (result.gridPosition[0] !== previousGridPos[0] ||
result.gridPosition[2] !== previousGridPos[2])) { result.gridPosition[2] !== previousGridPos[2])
) {
sfxEmitter.emit('sfx:grid-snap') sfxEmitter.emit('sfx:grid-snap')
} }
previousGridPos = [...result.gridPosition] previousGridPos = [...result.gridPosition]
gridPosition.current.set(...result.gridPosition) gridPosition.current.set(...result.gridPosition)
// Only update X and Z for cursor - useFrame will handle Y (slab elevation) // Only update X and Z for cursor - useFrame will handle Y (slab elevation)
cursorRef.current.position.x = result.cursorPosition[0] cursorGroupRef.current.position.x = result.cursorPosition[0]
cursorRef.current.position.z = result.cursorPosition[2] cursorGroupRef.current.position.z = result.cursorPosition[2]
const draft = draftNode.current const draft = draftNode.current
if (draft) draft.position = result.gridPosition if (draft) draft.position = result.gridPosition
@@ -167,7 +200,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (!result) return if (!result) return
// Preserve cursor rotation for the next draft // Preserve cursor rotation for the next draft
const currentRotation: [number, number, number] = [0, cursorRef.current.rotation.y, 0] const currentRotation: [number, number, number] = [0, cursorGroupRef.current.rotation.y, 0]
draftNode.commit(result.nodeUpdate) draftNode.commit(result.nodeUpdate)
if (configRef.current.onCommitted()) { if (configRef.current.onCommitted()) {
@@ -237,8 +270,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
gridPosition.current.z !== result.gridPosition[2] gridPosition.current.z !== result.gridPosition[2]
gridPosition.current.set(...result.gridPosition) gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition) cursorGroupRef.current.position.set(...result.cursorPosition)
cursorRef.current.rotation.y = result.cursorRotationY cursorGroupRef.current.rotation.y = result.cursorRotationY
const draft = draftNode.current const draft = draftNode.current
if (draft && result.nodeUpdate) { if (draft && result.nodeUpdate) {
@@ -361,7 +394,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
event.stopPropagation() event.stopPropagation()
gridPosition.current.set(...result.gridPosition) gridPosition.current.set(...result.gridPosition)
cursorRef.current.position.set(...result.cursorPosition) cursorGroupRef.current.position.set(...result.cursorPosition)
revalidate() revalidate()
@@ -441,12 +474,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
if (rotationDelta !== 0) { if (rotationDelta !== 0) {
event.preventDefault() event.preventDefault()
sfxEmitter.emit('sfx:item-rotate')
const currentRotation = draft.rotation const currentRotation = draft.rotation
const newRotationY = (currentRotation[1] ?? 0) + rotationDelta const newRotationY = (currentRotation[1] ?? 0) + rotationDelta
draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]] draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]]
// Ref + cursor mesh + item mesh — no store update during drag // Ref + cursor mesh + item mesh — no store update during drag
cursorRef.current.rotation.y = newRotationY cursorGroupRef.current.rotation.y = newRotationY
const mesh = sceneRegistry.nodes.get(draft.id) const mesh = sceneRegistry.nodes.get(draft.id)
if (mesh) mesh.rotation.y = newRotationY if (mesh) mesh.rotation.y = newRotationY
revalidate() revalidate()
@@ -468,7 +502,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
const dims = asset.dimensions ?? DEFAULT_DIMENSIONS const dims = asset.dimensions ?? DEFAULT_DIMENSIONS
const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
boxGeometry.translate(0, dims[1] / 2, 0) boxGeometry.translate(0, dims[1] / 2, 0)
cursorRef.current.geometry = boxGeometry const edgesGeometry = new EdgesGeometry(boxGeometry)
edgesRef.current.geometry = edgesGeometry
// ---- Subscribe ---- // ---- Subscribe ----
@@ -532,17 +567,26 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea
draftNode.current.rotation, draftNode.current.rotation,
) )
mesh.position.y = slabElevation mesh.position.y = slabElevation
cursorRef.current.position.y = slabElevation cursorGroupRef.current.position.y = slabElevation
} }
} }
}) })
const dims = config.asset.dimensions ?? DEFAULT_DIMENSIONS
const initialBoxGeometry = new BoxGeometry(dims[0], dims[1], dims[2])
initialBoxGeometry.translate(0, dims[1] / 2, 0)
// Base plane geometry (colored rectangle on the ground)
const basePlaneGeometry = new PlaneGeometry(dims[0], dims[2])
basePlaneGeometry.rotateX(-Math.PI / 2) // Make it horizontal
basePlaneGeometry.translate(0, 0.01, 0) // Slightly above ground to avoid z-fighting
return ( return (
<group> <group ref={cursorGroupRef}>
<mesh ref={cursorRef}> <lineSegments ref={edgesRef} material={edgeMaterial}>
<boxGeometry args={[0.1, 0.1, 0.1]} /> <edgesGeometry args={[initialBoxGeometry]} />
<meshStandardMaterial color="red" wireframe /> </lineSegments>
</mesh> <mesh ref={basePlaneRef} geometry={basePlaneGeometry} material={basePlaneMaterial} />
</group> </group>
) )
} }
@@ -98,6 +98,7 @@ export const WallTool: React.FC = () => {
const startingPoint = useRef(new Vector3(0, 0, 0)) const startingPoint = useRef(new Vector3(0, 0, 0))
const endingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0))
const buildingState = useRef(0) const buildingState = useRef(0)
const shiftPressed = useRef(false)
useEffect(() => { useEffect(() => {
let gridPosition: [number, number] = [0, 0] let gridPosition: [number, number] = [0, 0]
@@ -111,8 +112,10 @@ export const WallTool: React.FC = () => {
cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1]) cursorRef.current.position.set(gridPosition[0], event.position[1], gridPosition[1])
if (buildingState.current === 1) { if (buildingState.current === 1) {
// Snap to 45° angles // Snap to 45° angles only if shift is not pressed
const snapped = snapTo45Degrees(startingPoint.current, cursorPosition) const snapped = shiftPressed.current
? cursorPosition
: snapTo45Degrees(startingPoint.current, cursorPosition)
endingPoint.current.copy(snapped) endingPoint.current.copy(snapped)
// Play snap sound only when the actual wall end position changes // Play snap sound only when the actual wall end position changes
@@ -143,12 +146,28 @@ export const WallTool: React.FC = () => {
} }
} }
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = true
}
}
const onKeyUp = (e: KeyboardEvent) => {
if (e.key === 'Shift') {
shiftPressed.current = false
}
}
emitter.on('grid:move', onGridMove) emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick) emitter.on('grid:click', onGridClick)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => { return () => {
emitter.off('grid:move', onGridMove) emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick) emitter.off('grid:click', onGridClick)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
} }
}, []) }, [])
@@ -156,8 +175,8 @@ export const WallTool: React.FC = () => {
<group> <group>
{/* Cursor indicator */} {/* Cursor indicator */}
<mesh ref={cursorRef}> <mesh ref={cursorRef}>
<boxGeometry args={[0.2, 0.2, 0.2]} /> <sphereGeometry args={[0.1, 16, 16]} />
<meshStandardMaterial color="red" /> <meshBasicMaterial color="#a3a3a3" depthTest={false} depthWrite={false} />
</mesh> </mesh>
{/* Wall preview */} {/* Wall preview */}
@@ -0,0 +1,24 @@
'use client'
import useEditor from '@/store/use-editor'
import { ItemHelper } from './item-helper'
import { WallHelper } from './wall-helper'
export function HelperManager() {
const tool = useEditor((s) => s.tool)
const movingNode = useEditor((state) => state.movingNode)
if (movingNode) {
return <ItemHelper />
}
// Show appropriate helper based on current tool
switch (tool) {
case 'wall':
return <WallHelper />
case 'item':
return <ItemHelper />
default:
return null
}
}
@@ -0,0 +1,18 @@
export function ItemHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex flex-col gap-2 rounded-lg border border-border bg-background/95 px-4 py-3 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">R</kbd>
<span className="text-muted-foreground">Rotate counterclockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">T</kbd>
<span className="text-muted-foreground">Rotate clockwise</span>
</div>
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Esc</kbd>
<span className="text-muted-foreground">Cancel</span>
</div>
</div>
)
}
@@ -0,0 +1,10 @@
export function WallHelper() {
return (
<div className="pointer-events-none fixed right-4 top-1/2 -translate-y-1/2 z-40 flex items-center gap-2 rounded-lg border border-border bg-background/95 px-4 py-2 shadow-lg backdrop-blur-md">
<div className="flex items-center gap-2 text-sm">
<kbd className="rounded bg-muted px-2 py-1 text-xs font-medium">Shift</kbd>
<span className="text-muted-foreground">Allow non-45° angles</span>
</div>
</div>
)
}
@@ -53,6 +53,7 @@ export function ItemPanel() {
const handleDelete = useCallback(() => { const handleDelete = useCallback(() => {
if (!selectedId) return if (!selectedId) return
sfxEmitter.emit('sfx:item-delete')
deleteNode(selectedId as AnyNode['id']) deleteNode(selectedId as AnyNode['id'])
setSelection({ selectedIds: [] }) setSelection({ selectedIds: [] })
}, [selectedId, deleteNode, setSelection]) }, [selectedId, deleteNode, setSelection])
@@ -144,6 +145,7 @@ export function ItemPanel() {
type="button" type="button"
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer" className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => { onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI const currentDegrees = (node.rotation[1] * 180) / Math.PI
const newDegrees = currentDegrees - 90 const newDegrees = currentDegrees - 90
const radians = (newDegrees * Math.PI) / 180 const radians = (newDegrees * Math.PI) / 180
@@ -156,6 +158,7 @@ export function ItemPanel() {
type="button" type="button"
className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer" className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer"
onClick={() => { onClick={() => {
sfxEmitter.emit('sfx:item-rotate')
const currentDegrees = (node.rotation[1] * 180) / Math.PI const currentDegrees = (node.rotation[1] * 180) / Math.PI
const newDegrees = currentDegrees + 90 const newDegrees = currentDegrees + 90
const radians = (newDegrees * Math.PI) / 180 const radians = (newDegrees * Math.PI) / 180
+13
View File
@@ -2,6 +2,7 @@ import { type AnyNodeId, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer' import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react' import { useEffect } from 'react'
import useEditor from '@/store/use-editor' import useEditor from '@/store/use-editor'
import { sfxEmitter } from '@/lib/sfx-bus'
export const useKeyboard = () => { export const useKeyboard = () => {
useEffect(() => { useEffect(() => {
@@ -47,6 +48,18 @@ export const useKeyboard = () => {
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
if (selectedNodeIds.length > 0) { if (selectedNodeIds.length > 0) {
// Play appropriate SFX based on what's being deleted
if (selectedNodeIds.length === 1) {
const node = useScene.getState().nodes[selectedNodeIds[0]!]
if (node?.type === 'item') {
sfxEmitter.emit('sfx:item-delete')
} else {
sfxEmitter.emit('sfx:structure-delete')
}
} else {
sfxEmitter.emit('sfx:structure-delete')
}
useScene.getState().deleteNodes(selectedNodeIds) useScene.getState().deleteNodes(selectedNodeIds)
} }
} }
+2
View File
@@ -9,6 +9,7 @@ type SFXEvents = {
'sfx:item-delete': undefined 'sfx:item-delete': undefined
'sfx:item-pick': undefined 'sfx:item-pick': undefined
'sfx:item-place': undefined 'sfx:item-place': undefined
'sfx:item-rotate': undefined
'sfx:structure-build': undefined 'sfx:structure-build': undefined
'sfx:structure-delete': undefined 'sfx:structure-delete': undefined
} }
@@ -29,6 +30,7 @@ export function initSFXBus() {
sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete')) sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete'))
sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick')) sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick'))
sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace')) sfxEmitter.on('sfx:item-place', () => playSFX('itemPlace'))
sfxEmitter.on('sfx:item-rotate', () => playSFX('itemRotate'))
sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild')) sfxEmitter.on('sfx:structure-build', () => playSFX('structureBuild'))
sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete')) sfxEmitter.on('sfx:structure-delete', () => playSFX('structureDelete'))
} }
+1
View File
@@ -7,6 +7,7 @@ export const SFX = {
itemDelete: '/audios/sfx/item_delete.mp3', itemDelete: '/audios/sfx/item_delete.mp3',
itemPick: '/audios/sfx/item_pick.mp3', itemPick: '/audios/sfx/item_pick.mp3',
itemPlace: '/audios/sfx/item_place.mp3', itemPlace: '/audios/sfx/item_place.mp3',
itemRotate: '/audios/sfx/item_rotate.mp3',
structureBuild: '/audios/sfx/structure_build.mp3', structureBuild: '/audios/sfx/structure_build.mp3',
structureDelete: '/audios/sfx/structure_delete.mp3', structureDelete: '/audios/sfx/structure_delete.mp3',
} as const } as const
Binary file not shown.
+1 -1
View File
@@ -21,7 +21,7 @@ const useAudio = create<AudioState>()(
(set) => ({ (set) => ({
masterVolume: 70, masterVolume: 70,
sfxVolume: 50, sfxVolume: 50,
radioVolume: 50, radioVolume: 25,
muted: false, muted: false,
autoplay: true, autoplay: true,
setMasterVolume: (v) => set({ masterVolume: v }), setMasterVolume: (v) => set({ masterVolume: v }),