diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx index e0280f34..e27abda6 100644 --- a/apps/editor/components/editor/index.tsx +++ b/apps/editor/components/editor/index.tsx @@ -11,6 +11,7 @@ import { ActionMenu } from '../ui/action-menu' import { CloudSaveButton } from '@/features/community/components/cloud-save-button' import { PascalRadio } from '../pascal-radio' import { PanelManager } from '../ui/panels/panel-manager' +import { HelperManager } from '../ui/helpers/helper-manager' import { SidebarProvider } from '../ui/primitives/sidebar' import { AppSidebar } from '../ui/sidebar/app-sidebar' import { CustomCameraControls } from './custom-camera-controls' @@ -34,6 +35,7 @@ export default function Editor() {
+ {/* Top-right controls */}
diff --git a/apps/editor/components/tools/item/use-placement-coordinator.tsx b/apps/editor/components/tools/item/use-placement-coordinator.tsx index 457b4fdf..056e465b 100644 --- a/apps/editor/components/tools/item/use-placement-coordinator.tsx +++ b/apps/editor/components/tools/item/use-placement-coordinator.tsx @@ -17,12 +17,17 @@ import { useFrame } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { BoxGeometry, + EdgesGeometry, Euler, + type Group, + type LineSegments, type Mesh, - type MeshStandardMaterial, + PlaneGeometry, Quaternion, Vector3, } from 'three' +import { distance, smoothstep, uv, vec2 } from 'three/tsl' +import { LineBasicNodeMaterial, MeshBasicNodeMaterial } from 'three/webgpu' import { sfxEmitter } from '@/lib/sfx-bus' import { ceilingStrategy, checkCanPlace, floorStrategy, wallStrategy } from './placement-strategies' 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] +// 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 { asset: AssetInput draftNode: DraftNodeHandle @@ -40,7 +67,9 @@ export interface PlacementCoordinatorConfig { } export function usePlacementCoordinator(config: PlacementCoordinatorConfig): React.ReactNode { - const cursorRef = useRef(null!) + const cursorGroupRef = useRef(null!) + const edgesRef = useRef(null!) + const basePlaneRef = useRef(null!) const gridPosition = useRef(new Vector3(0, 0, 0)) const placementState = useRef( config.initialState ?? { surface: 'floor', wallId: null, ceilingId: null }, @@ -77,7 +106,9 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const revalidate = (): boolean => { 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 } @@ -85,8 +116,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea Object.assign(placementState.current, result.stateUpdate) gridPosition.current.set(...result.gridPosition) - cursorRef.current.position.set(...result.cursorPosition) - cursorRef.current.rotation.y = result.cursorRotationY + cursorGroupRef.current.position.set(...result.cursorPosition) + cursorGroupRef.current.rotation.y = result.cursorRotationY const draft = draftNode.current if (draft) { @@ -98,8 +129,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const ensureDraft = (result: TransitionResult) => { gridPosition.current.set(...result.gridPosition) - cursorRef.current.position.set(...result.cursorPosition) - cursorRef.current.rotation.y = result.cursorRotationY + cursorGroupRef.current.position.set(...result.cursorPosition) + cursorGroupRef.current.rotation.y = result.cursorRotationY draftNode.create(gridPosition.current, asset, [0, result.cursorRotationY, 0]) @@ -122,14 +153,14 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (draftNode.current) { const mesh = sceneRegistry.nodes.get(draftNode.current.id) if (mesh) { - mesh.getWorldPosition(cursorRef.current.position) + mesh.getWorldPosition(cursorGroupRef.current.position) // Extract world Y rotation (handles wall-parented items correctly) const q = new Quaternion() mesh.getWorldQuaternion(q) - cursorRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y + cursorGroupRef.current.rotation.y = new Euler().setFromQuaternion(q, 'YXZ').y } else { - cursorRef.current.position.copy(gridPosition.current) - cursorRef.current.rotation.y = draftNode.current.rotation[1] ?? 0 + cursorGroupRef.current.position.copy(gridPosition.current) + cursorGroupRef.current.rotation.y = draftNode.current.rotation[1] ?? 0 } } @@ -144,17 +175,19 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return // Play snap sound when grid position changes - if (previousGridPos && - (result.gridPosition[0] !== previousGridPos[0] || - result.gridPosition[2] !== previousGridPos[2])) { + if ( + previousGridPos && + (result.gridPosition[0] !== previousGridPos[0] || + result.gridPosition[2] !== previousGridPos[2]) + ) { sfxEmitter.emit('sfx:grid-snap') } previousGridPos = [...result.gridPosition] gridPosition.current.set(...result.gridPosition) // Only update X and Z for cursor - useFrame will handle Y (slab elevation) - cursorRef.current.position.x = result.cursorPosition[0] - cursorRef.current.position.z = result.cursorPosition[2] + cursorGroupRef.current.position.x = result.cursorPosition[0] + cursorGroupRef.current.position.z = result.cursorPosition[2] const draft = draftNode.current if (draft) draft.position = result.gridPosition @@ -167,7 +200,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (!result) return // 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) if (configRef.current.onCommitted()) { @@ -237,8 +270,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea gridPosition.current.z !== result.gridPosition[2] gridPosition.current.set(...result.gridPosition) - cursorRef.current.position.set(...result.cursorPosition) - cursorRef.current.rotation.y = result.cursorRotationY + cursorGroupRef.current.position.set(...result.cursorPosition) + cursorGroupRef.current.rotation.y = result.cursorRotationY const draft = draftNode.current if (draft && result.nodeUpdate) { @@ -361,7 +394,7 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea event.stopPropagation() gridPosition.current.set(...result.gridPosition) - cursorRef.current.position.set(...result.cursorPosition) + cursorGroupRef.current.position.set(...result.cursorPosition) revalidate() @@ -441,12 +474,13 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea if (rotationDelta !== 0) { event.preventDefault() + sfxEmitter.emit('sfx:item-rotate') const currentRotation = draft.rotation const newRotationY = (currentRotation[1] ?? 0) + rotationDelta draft.rotation = [currentRotation[0], newRotationY, currentRotation[2]] // 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) if (mesh) mesh.rotation.y = newRotationY revalidate() @@ -468,7 +502,8 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea const dims = asset.dimensions ?? DEFAULT_DIMENSIONS const boxGeometry = new BoxGeometry(dims[0], dims[1], dims[2]) boxGeometry.translate(0, dims[1] / 2, 0) - cursorRef.current.geometry = boxGeometry + const edgesGeometry = new EdgesGeometry(boxGeometry) + edgesRef.current.geometry = edgesGeometry // ---- Subscribe ---- @@ -532,17 +567,26 @@ export function usePlacementCoordinator(config: PlacementCoordinatorConfig): Rea draftNode.current.rotation, ) 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 ( - - - - - + + + + + ) } diff --git a/apps/editor/components/tools/wall/wall-tool.tsx b/apps/editor/components/tools/wall/wall-tool.tsx index adf08431..12c19860 100644 --- a/apps/editor/components/tools/wall/wall-tool.tsx +++ b/apps/editor/components/tools/wall/wall-tool.tsx @@ -98,6 +98,7 @@ export const WallTool: React.FC = () => { const startingPoint = useRef(new Vector3(0, 0, 0)) const endingPoint = useRef(new Vector3(0, 0, 0)) const buildingState = useRef(0) + const shiftPressed = useRef(false) useEffect(() => { 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]) if (buildingState.current === 1) { - // Snap to 45° angles - const snapped = snapTo45Degrees(startingPoint.current, cursorPosition) + // Snap to 45° angles only if shift is not pressed + const snapped = shiftPressed.current + ? cursorPosition + : snapTo45Degrees(startingPoint.current, cursorPosition) endingPoint.current.copy(snapped) // 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:click', onGridClick) + window.addEventListener('keydown', onKeyDown) + window.addEventListener('keyup', onKeyUp) return () => { emitter.off('grid:move', onGridMove) emitter.off('grid:click', onGridClick) + window.removeEventListener('keydown', onKeyDown) + window.removeEventListener('keyup', onKeyUp) } }, []) @@ -156,8 +175,8 @@ export const WallTool: React.FC = () => { {/* Cursor indicator */} - - + + {/* Wall preview */} diff --git a/apps/editor/components/ui/helpers/helper-manager.tsx b/apps/editor/components/ui/helpers/helper-manager.tsx new file mode 100644 index 00000000..6db3e4ac --- /dev/null +++ b/apps/editor/components/ui/helpers/helper-manager.tsx @@ -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 + } + + // Show appropriate helper based on current tool + switch (tool) { + case 'wall': + return + case 'item': + return + default: + return null + } +} diff --git a/apps/editor/components/ui/helpers/item-helper.tsx b/apps/editor/components/ui/helpers/item-helper.tsx new file mode 100644 index 00000000..91345b4d --- /dev/null +++ b/apps/editor/components/ui/helpers/item-helper.tsx @@ -0,0 +1,18 @@ +export function ItemHelper() { + return ( +
+
+ R + Rotate counterclockwise +
+
+ T + Rotate clockwise +
+
+ Esc + Cancel +
+
+ ) +} diff --git a/apps/editor/components/ui/helpers/wall-helper.tsx b/apps/editor/components/ui/helpers/wall-helper.tsx new file mode 100644 index 00000000..7e880e80 --- /dev/null +++ b/apps/editor/components/ui/helpers/wall-helper.tsx @@ -0,0 +1,10 @@ +export function WallHelper() { + return ( +
+
+ Shift + Allow non-45° angles +
+
+ ) +} diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx index 79ee38e5..e9360923 100644 --- a/apps/editor/components/ui/panels/item-panel.tsx +++ b/apps/editor/components/ui/panels/item-panel.tsx @@ -53,6 +53,7 @@ export function ItemPanel() { const handleDelete = useCallback(() => { if (!selectedId) return + sfxEmitter.emit('sfx:item-delete') deleteNode(selectedId as AnyNode['id']) setSelection({ selectedIds: [] }) }, [selectedId, deleteNode, setSelection]) @@ -144,6 +145,7 @@ export function ItemPanel() { type="button" className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer" onClick={() => { + sfxEmitter.emit('sfx:item-rotate') const currentDegrees = (node.rotation[1] * 180) / Math.PI const newDegrees = currentDegrees - 90 const radians = (newDegrees * Math.PI) / 180 @@ -156,6 +158,7 @@ export function ItemPanel() { type="button" className="flex-1 rounded border border-border px-2 py-1.5 text-xs hover:bg-accent cursor-pointer" onClick={() => { + sfxEmitter.emit('sfx:item-rotate') const currentDegrees = (node.rotation[1] * 180) / Math.PI const newDegrees = currentDegrees + 90 const radians = (newDegrees * Math.PI) / 180 diff --git a/apps/editor/hooks/use-keyboard.ts b/apps/editor/hooks/use-keyboard.ts index 4026277f..ce8ee20f 100644 --- a/apps/editor/hooks/use-keyboard.ts +++ b/apps/editor/hooks/use-keyboard.ts @@ -2,6 +2,7 @@ import { type AnyNodeId, useScene } from '@pascal-app/core' import { useViewer } from '@pascal-app/viewer' import { useEffect } from 'react' import useEditor from '@/store/use-editor' +import { sfxEmitter } from '@/lib/sfx-bus' export const useKeyboard = () => { useEffect(() => { @@ -47,6 +48,18 @@ export const useKeyboard = () => { const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[] 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) } } diff --git a/apps/editor/lib/sfx-bus.ts b/apps/editor/lib/sfx-bus.ts index 9bc74fe2..308718d2 100644 --- a/apps/editor/lib/sfx-bus.ts +++ b/apps/editor/lib/sfx-bus.ts @@ -9,6 +9,7 @@ type SFXEvents = { 'sfx:item-delete': undefined 'sfx:item-pick': undefined 'sfx:item-place': undefined + 'sfx:item-rotate': undefined 'sfx:structure-build': undefined 'sfx:structure-delete': undefined } @@ -29,6 +30,7 @@ export function initSFXBus() { sfxEmitter.on('sfx:item-delete', () => playSFX('itemDelete')) sfxEmitter.on('sfx:item-pick', () => playSFX('itemPick')) 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-delete', () => playSFX('structureDelete')) } diff --git a/apps/editor/lib/sfx-player.ts b/apps/editor/lib/sfx-player.ts index 402eb446..f305893e 100644 --- a/apps/editor/lib/sfx-player.ts +++ b/apps/editor/lib/sfx-player.ts @@ -7,6 +7,7 @@ export const SFX = { itemDelete: '/audios/sfx/item_delete.mp3', itemPick: '/audios/sfx/item_pick.mp3', itemPlace: '/audios/sfx/item_place.mp3', + itemRotate: '/audios/sfx/item_rotate.mp3', structureBuild: '/audios/sfx/structure_build.mp3', structureDelete: '/audios/sfx/structure_delete.mp3', } as const diff --git a/apps/editor/public/audios/sfx/item_rotate.mp3 b/apps/editor/public/audios/sfx/item_rotate.mp3 new file mode 100644 index 00000000..e5c94a6a Binary files /dev/null and b/apps/editor/public/audios/sfx/item_rotate.mp3 differ diff --git a/apps/editor/store/use-audio.tsx b/apps/editor/store/use-audio.tsx index 6f8759b1..4f43964d 100644 --- a/apps/editor/store/use-audio.tsx +++ b/apps/editor/store/use-audio.tsx @@ -21,7 +21,7 @@ const useAudio = create()( (set) => ({ masterVolume: 70, sfxVolume: 50, - radioVolume: 50, + radioVolume: 25, muted: false, autoplay: true, setMasterVolume: (v) => set({ masterVolume: v }),