merge: resolve conflict with main — keep first-person mode bypass + tool cancel pattern + delete mode + counter-clockwise rotation
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/core",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "Core library for Pascal 3D building editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -68,4 +68,5 @@ export {
|
||||
} from './systems/wall/wall-mitering'
|
||||
export { WallSystem } from './systems/wall/wall-system'
|
||||
export { WindowSystem } from './systems/window/window-system'
|
||||
export { cloneSceneGraph } from './utils/clone-scene-graph'
|
||||
export { isObject } from './utils/types'
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { SceneState } from '../use-scene'
|
||||
|
||||
type AnyContainerNode = AnyNode & { children: string[] }
|
||||
|
||||
// Track pending RAF for updateNodesAction to prevent multiple queued callbacks
|
||||
let pendingRafId: number | null = null
|
||||
let pendingUpdates: Set<AnyNodeId> = new Set()
|
||||
|
||||
export const createNodesAction = (
|
||||
set: (fn: (state: SceneState) => Partial<SceneState>) => void,
|
||||
get: () => SceneState,
|
||||
@@ -58,6 +62,7 @@ export const updateNodesAction = (
|
||||
updates: { id: AnyNodeId; data: Partial<AnyNode> }[],
|
||||
) => {
|
||||
const parentsToUpdate = new Set<AnyNodeId>()
|
||||
const idsToMarkDirty = new Set<AnyNodeId>()
|
||||
|
||||
set((state) => {
|
||||
const nextNodes = { ...state.nodes }
|
||||
@@ -98,14 +103,25 @@ export const updateNodesAction = (
|
||||
return { nodes: nextNodes }
|
||||
})
|
||||
|
||||
// Mark dirty after the next frame to ensure React renders complete
|
||||
requestAnimationFrame(() => {
|
||||
updates.forEach((u) => {
|
||||
get().markDirty(u.id)
|
||||
})
|
||||
parentsToUpdate.forEach((pId) => {
|
||||
get().markDirty(pId)
|
||||
// Collect all IDs that need to be marked dirty
|
||||
updates.forEach((u) => idsToMarkDirty.add(u.id))
|
||||
parentsToUpdate.forEach((pId) => idsToMarkDirty.add(pId))
|
||||
|
||||
// Add to pending updates set
|
||||
idsToMarkDirty.forEach((id) => pendingUpdates.add(id))
|
||||
|
||||
// Cancel any pending RAF and schedule a new one
|
||||
if (pendingRafId !== null) {
|
||||
cancelAnimationFrame(pendingRafId)
|
||||
}
|
||||
|
||||
pendingRafId = requestAnimationFrame(() => {
|
||||
// Mark all pending updates as dirty
|
||||
pendingUpdates.forEach((id) => {
|
||||
get().markDirty(id)
|
||||
})
|
||||
pendingUpdates.clear()
|
||||
pendingRafId = null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,7 +137,26 @@ export const deleteNodesAction = (
|
||||
const nextCollections = { ...state.collections }
|
||||
let nextRootIds = [...state.rootNodeIds]
|
||||
|
||||
// Collect all IDs to delete (including descendants) in a first pass
|
||||
// This avoids issues with recursive calls during state mutation
|
||||
const allIdsToDelete = new Set<AnyNodeId>()
|
||||
const collectDescendants = (id: AnyNodeId) => {
|
||||
const node = nextNodes[id]
|
||||
if (!node) return
|
||||
allIdsToDelete.add(id)
|
||||
if ('children' in node && node.children) {
|
||||
for (const childId of node.children as AnyNodeId[]) {
|
||||
collectDescendants(childId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of ids) {
|
||||
collectDescendants(id)
|
||||
}
|
||||
|
||||
// Now process all nodes for deletion
|
||||
for (const id of allIdsToDelete) {
|
||||
const node = nextNodes[id]
|
||||
if (!node) continue
|
||||
|
||||
@@ -153,12 +188,6 @@ export const deleteNodesAction = (
|
||||
|
||||
// 4. Delete the node itself
|
||||
delete nextNodes[id]
|
||||
|
||||
// Inside the deleteNodes loop
|
||||
if ('children' in node && node.children.length > 0) {
|
||||
// Recursively delete all children first
|
||||
get().deleteNodes(node.children as AnyNodeId[])
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }
|
||||
|
||||
@@ -115,6 +115,11 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
collections: {} as Record<CollectionId, Collection>,
|
||||
|
||||
unloadScene: () => {
|
||||
// Clear temporal tracking to prevent memory leaks from stale node references
|
||||
prevPastLength = 0
|
||||
prevFutureLength = 0
|
||||
prevNodesSnapshot = null
|
||||
|
||||
set({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
@@ -136,6 +141,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
nodes: patchedNodes,
|
||||
rootNodeIds,
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: {},
|
||||
})
|
||||
// Mark all nodes as dirty to trigger re-validation
|
||||
Object.values(patchedNodes).forEach((node) => {
|
||||
@@ -305,13 +311,21 @@ let prevPastLength = 0
|
||||
let prevFutureLength = 0
|
||||
let prevNodesSnapshot: Record<AnyNodeId, AnyNode> | null = null
|
||||
|
||||
export function clearSceneHistory() {
|
||||
useScene.temporal.getState().clear()
|
||||
/**
|
||||
* Clears temporal history tracking variables to prevent memory leaks.
|
||||
* Should be called when unloading a scene to release node references.
|
||||
*/
|
||||
export function clearTemporalTracking() {
|
||||
prevPastLength = 0
|
||||
prevFutureLength = 0
|
||||
prevNodesSnapshot = null
|
||||
}
|
||||
|
||||
export function clearSceneHistory() {
|
||||
useScene.temporal.getState().clear()
|
||||
clearTemporalTracking()
|
||||
}
|
||||
|
||||
// Subscribe to the temporal store (Undo/Redo events)
|
||||
useScene.temporal.subscribe((state) => {
|
||||
const currentPastLength = state.pastStates.length
|
||||
|
||||
@@ -22,7 +22,6 @@ const csgEvaluator = new Evaluator()
|
||||
// WALL SYSTEM
|
||||
// ============================================================================
|
||||
|
||||
let useFrameNb = 0
|
||||
export const WallSystem = () => {
|
||||
const dirtyNodes = useScene((state) => state.dirtyNodes)
|
||||
const clearDirty = useScene((state) => state.clearDirty)
|
||||
@@ -35,7 +34,6 @@ export const WallSystem = () => {
|
||||
// Collect dirty walls and their levels
|
||||
const dirtyWallsByLevel = new Map<string, Set<string>>()
|
||||
|
||||
useFrameNb += 1
|
||||
dirtyNodes.forEach((id) => {
|
||||
const node = nodes[id]
|
||||
if (!node || node.type !== 'wall') return
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { AnyNode, AnyNodeId } from '../schema'
|
||||
import { generateId } from '../schema/base'
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
|
||||
export type SceneGraph = {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
rootNodeIds: AnyNodeId[]
|
||||
collections?: Record<CollectionId, Collection>
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the type prefix from a node ID (e.g., "wall_abc123" -> "wall")
|
||||
*/
|
||||
function extractIdPrefix(id: string): string {
|
||||
const underscoreIndex = id.indexOf('_')
|
||||
return underscoreIndex === -1 ? 'node' : id.slice(0, underscoreIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep clones a scene graph with all node IDs regenerated while preserving
|
||||
* parent-child relationships and other internal references.
|
||||
*
|
||||
* This is useful for:
|
||||
* - Copying nodes between different projects
|
||||
* - Duplicating a subset of a scene within the same project
|
||||
* - Multi-scene in-memory scenarios
|
||||
*/
|
||||
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
const { nodes, rootNodeIds, collections } = sceneGraph
|
||||
|
||||
// Build ID mapping: old ID -> new ID
|
||||
const idMap = new Map<string, string>()
|
||||
|
||||
// Pass 1: Generate new IDs for all nodes
|
||||
for (const nodeId of Object.keys(nodes)) {
|
||||
const prefix = extractIdPrefix(nodeId)
|
||||
idMap.set(nodeId, generateId(prefix))
|
||||
}
|
||||
|
||||
// Pass 2: Deep clone nodes with remapped references
|
||||
const clonedNodes = {} as Record<AnyNodeId, AnyNode>
|
||||
|
||||
for (const [oldId, node] of Object.entries(nodes)) {
|
||||
const newId = idMap.get(oldId)! as AnyNodeId
|
||||
// structuredClone to avoid shared references between original and clone
|
||||
const clonedNode = structuredClone({ ...node, id: newId }) as AnyNode
|
||||
|
||||
// Remap parentId
|
||||
if (clonedNode.parentId && typeof clonedNode.parentId === 'string') {
|
||||
clonedNode.parentId = (idMap.get(clonedNode.parentId) ?? null) as AnyNodeId | null
|
||||
}
|
||||
|
||||
// Remap children array (walls, levels, buildings, sites, items can have children)
|
||||
if ('children' in clonedNode && Array.isArray(clonedNode.children)) {
|
||||
;(clonedNode as Record<string, unknown>).children = (
|
||||
clonedNode.children as string[]
|
||||
)
|
||||
.map((childId) => idMap.get(childId))
|
||||
.filter((id): id is string => id !== undefined)
|
||||
}
|
||||
|
||||
// Remap wallId (items/doors/windows attached to walls)
|
||||
if ('wallId' in clonedNode && typeof clonedNode.wallId === 'string') {
|
||||
;(clonedNode as Record<string, unknown>).wallId = idMap.get(clonedNode.wallId) as
|
||||
| string
|
||||
| undefined
|
||||
}
|
||||
|
||||
clonedNodes[newId] = clonedNode
|
||||
}
|
||||
|
||||
// Remap root node IDs
|
||||
const clonedRootNodeIds = rootNodeIds
|
||||
.map((id) => idMap.get(id))
|
||||
.filter((id): id is string => id !== undefined) as AnyNodeId[]
|
||||
|
||||
// Clone and remap collections if present
|
||||
let clonedCollections: Record<CollectionId, Collection> | undefined
|
||||
if (collections) {
|
||||
clonedCollections = {} as Record<CollectionId, Collection>
|
||||
const collectionIdMap = new Map<string, CollectionId>()
|
||||
|
||||
// Generate new collection IDs
|
||||
for (const collectionId of Object.keys(collections)) {
|
||||
collectionIdMap.set(collectionId, generateId('collection'))
|
||||
}
|
||||
|
||||
for (const [oldCollectionId, collection] of Object.entries(collections)) {
|
||||
const newCollectionId = collectionIdMap.get(oldCollectionId)!
|
||||
clonedCollections[newCollectionId] = {
|
||||
...collection,
|
||||
id: newCollectionId,
|
||||
nodeIds: collection.nodeIds
|
||||
.map((nodeId) => idMap.get(nodeId))
|
||||
.filter((id): id is string => id !== undefined) as AnyNodeId[],
|
||||
controlNodeId: collection.controlNodeId
|
||||
? (idMap.get(collection.controlNodeId) as AnyNodeId | undefined)
|
||||
: undefined,
|
||||
}
|
||||
|
||||
// Update collectionIds on nodes that reference this collection
|
||||
for (const oldNodeId of collection.nodeIds) {
|
||||
const newNodeId = idMap.get(oldNodeId)
|
||||
if (newNodeId && clonedNodes[newNodeId as AnyNodeId]) {
|
||||
const node = clonedNodes[newNodeId as AnyNodeId] as Record<string, unknown>
|
||||
if ('collectionIds' in node && Array.isArray(node.collectionIds)) {
|
||||
const oldColIds = node.collectionIds as string[]
|
||||
node.collectionIds = oldColIds
|
||||
.map((cid) => collectionIdMap.get(cid))
|
||||
.filter((id): id is CollectionId => id !== undefined)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: clonedNodes,
|
||||
rootNodeIds: clonedRootNodeIds,
|
||||
...(clonedCollections && { collections: clonedCollections }),
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,15 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect } from 'react'
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
|
||||
import { STLExporter } from 'three/examples/jsm/exporters/STLExporter.js'
|
||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter.js'
|
||||
|
||||
export function ExportManager() {
|
||||
const scene = useThree((state) => state.scene)
|
||||
const setExportScene = useViewer((state) => state.setExportScene)
|
||||
|
||||
useEffect(() => {
|
||||
const exportFn = async () => {
|
||||
const exportFn = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
|
||||
// Find the scene renderer group by name
|
||||
const sceneGroup = scene.getObjectByName('scene-renderer')
|
||||
if (!sceneGroup) {
|
||||
@@ -18,20 +20,33 @@ export function ExportManager() {
|
||||
return
|
||||
}
|
||||
|
||||
const exporter = new GLTFExporter()
|
||||
const date = new Date().toISOString().split('T')[0]
|
||||
|
||||
if (format === 'stl') {
|
||||
const exporter = new STLExporter()
|
||||
const result = exporter.parse(sceneGroup, { binary: true })
|
||||
const blob = new Blob([result], { type: 'model/stl' })
|
||||
downloadBlob(blob, `model_${date}.stl`)
|
||||
return
|
||||
}
|
||||
|
||||
if (format === 'obj') {
|
||||
const exporter = new OBJExporter()
|
||||
const result = exporter.parse(sceneGroup)
|
||||
const blob = new Blob([result], { type: 'model/obj' })
|
||||
downloadBlob(blob, `model_${date}.obj`)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: GLB export (existing behavior)
|
||||
const exporter = new GLTFExporter()
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
exporter.parse(
|
||||
sceneGroup,
|
||||
(gltf) => {
|
||||
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = `model_${date}.glb`
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
downloadBlob(blob, `model_${date}.glb`)
|
||||
resolve()
|
||||
},
|
||||
(error) => {
|
||||
@@ -52,3 +67,12 @@ export function ExportManager() {
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, filename: string) {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
@@ -20,12 +20,14 @@ import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { NodeActionMenu } from './node-action-menu'
|
||||
|
||||
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment']
|
||||
const ALLOWED_TYPES = ['item', 'door', 'window', 'roof', 'roof-segment', 'wall', 'slab']
|
||||
const DELETE_ONLY_TYPES = ['wall', 'slab']
|
||||
|
||||
export function FloatingActionMenu() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const deleteNode = useScene((s) => s.deleteNode)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const setMode = useEditor((s) => s.setMode)
|
||||
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
@@ -46,8 +48,10 @@ export function FloatingActionMenu() {
|
||||
const box = new THREE.Box3().setFromObject(obj)
|
||||
if (!box.isEmpty()) {
|
||||
const center = box.getCenter(new THREE.Vector3())
|
||||
// Position slightly above the object
|
||||
groupRef.current.position.set(center.x, box.max.y + 0.3, center.z)
|
||||
// Position above the object, with extra offset for walls/slabs to avoid covering measurement labels
|
||||
const isDeleteOnly = node && DELETE_ONLY_TYPES.includes(node.type)
|
||||
const yOffset = isDeleteOnly ? 0.8 : 0.3
|
||||
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -151,16 +155,14 @@ export function FloatingActionMenu() {
|
||||
const handleDelete = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (!(selectedId && node)) return
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
deleteNode(selectedId as AnyNodeId)
|
||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
// Activate delete mode (sledgehammer tool) instead of deleting directly
|
||||
setSelection({ selectedIds: [] })
|
||||
setMode('delete')
|
||||
},
|
||||
[selectedId, node, deleteNode, setSelection],
|
||||
[setSelection, setMode],
|
||||
)
|
||||
|
||||
if (!(selectedId && node && isValidType && !isFloorplanHovered)) return null
|
||||
if (!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete')) return null
|
||||
|
||||
return (
|
||||
<group ref={groupRef}>
|
||||
@@ -174,8 +176,8 @@ export function FloatingActionMenu() {
|
||||
>
|
||||
<NodeActionMenu
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
onMove={handleMove}
|
||||
onDuplicate={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleDuplicate : undefined}
|
||||
onMove={node && !DELETE_ONLY_TYPES.includes(node.type) ? handleMove : undefined}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onPointerUp={(e) => e.stopPropagation()}
|
||||
/>
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { MouseEventHandler, PointerEventHandler } from 'react'
|
||||
|
||||
type NodeActionMenuProps = {
|
||||
onDelete: MouseEventHandler<HTMLButtonElement>
|
||||
onDuplicate: MouseEventHandler<HTMLButtonElement>
|
||||
onMove: MouseEventHandler<HTMLButtonElement>
|
||||
onDuplicate?: MouseEventHandler<HTMLButtonElement>
|
||||
onMove?: MouseEventHandler<HTMLButtonElement>
|
||||
onPointerDown?: PointerEventHandler<HTMLDivElement>
|
||||
onPointerUp?: PointerEventHandler<HTMLDivElement>
|
||||
onPointerEnter?: PointerEventHandler<HTMLDivElement>
|
||||
@@ -30,24 +30,28 @@ export function NodeActionMenu({
|
||||
onPointerLeave={onPointerLeave}
|
||||
onPointerUp={onPointerUp}
|
||||
>
|
||||
<button
|
||||
aria-label="Move"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onMove}
|
||||
title="Move"
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
aria-label="Duplicate"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onDuplicate}
|
||||
title="Duplicate"
|
||||
type="button"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
{onMove && (
|
||||
<button
|
||||
aria-label="Move"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onMove}
|
||||
title="Move"
|
||||
type="button"
|
||||
>
|
||||
<Move className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<button
|
||||
aria-label="Duplicate"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
onClick={onDuplicate}
|
||||
title="Duplicate"
|
||||
type="button"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Delete"
|
||||
className="tooltip-trigger rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive"
|
||||
|
||||
@@ -11,7 +11,9 @@ import {
|
||||
} from '@pascal-app/core'
|
||||
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useThree } from '@react-three/fiber'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { sfxEmitter } from '../../lib/sfx-bus'
|
||||
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
|
||||
|
||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||
@@ -264,6 +266,79 @@ export const SelectionManager = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Delete mode: click-to-delete (sledgehammer tool)
|
||||
useEffect(() => {
|
||||
if (mode !== 'delete') return
|
||||
|
||||
const onClick = (event: NodeEvent) => {
|
||||
const node = event.node
|
||||
if (!isNodeInCurrentLevel(node)) return
|
||||
|
||||
event.stopPropagation()
|
||||
|
||||
// Play appropriate SFX
|
||||
if (node.type === 'item') {
|
||||
sfxEmitter.emit('sfx:item-delete')
|
||||
} else {
|
||||
sfxEmitter.emit('sfx:structure-delete')
|
||||
}
|
||||
|
||||
useScene.getState().deleteNode(node.id as AnyNodeId)
|
||||
if (node.parentId) useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
|
||||
// Clear hover since the node is gone
|
||||
if (useViewer.getState().hoveredId === node.id) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
}
|
||||
}
|
||||
|
||||
const onEnter = (event: NodeEvent) => {
|
||||
const node = event.node
|
||||
if (!isNodeInCurrentLevel(node)) return
|
||||
if (node.type === 'building' || node.type === 'site') return
|
||||
event.stopPropagation()
|
||||
useViewer.setState({ hoveredId: node.id })
|
||||
}
|
||||
|
||||
const onLeave = (event: NodeEvent) => {
|
||||
const nodeId = event?.node?.id
|
||||
if (nodeId && useViewer.getState().hoveredId === nodeId) {
|
||||
useViewer.setState({ hoveredId: null })
|
||||
}
|
||||
}
|
||||
|
||||
const onGridClick = () => {
|
||||
// Clicking empty space in delete mode does nothing (stay in delete mode)
|
||||
}
|
||||
|
||||
const allTypes = [
|
||||
'wall',
|
||||
'item',
|
||||
'slab',
|
||||
'ceiling',
|
||||
'roof',
|
||||
'roof-segment',
|
||||
'window',
|
||||
'door',
|
||||
'zone',
|
||||
]
|
||||
allTypes.forEach((type) => {
|
||||
emitter.on(`${type}:click` as any, onClick as any)
|
||||
emitter.on(`${type}:enter` as any, onEnter as any)
|
||||
emitter.on(`${type}:leave` as any, onLeave as any)
|
||||
})
|
||||
emitter.on('grid:click', onGridClick)
|
||||
|
||||
return () => {
|
||||
allTypes.forEach((type) => {
|
||||
emitter.off(`${type}:click` as any, onClick as any)
|
||||
emitter.off(`${type}:enter` as any, onEnter as any)
|
||||
emitter.off(`${type}:leave` as any, onLeave as any)
|
||||
})
|
||||
emitter.off('grid:click', onGridClick)
|
||||
}
|
||||
}, [mode])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'select') return
|
||||
if (movingNode) return
|
||||
@@ -475,12 +550,30 @@ export const SelectionManager = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<DeleteModeCursor />
|
||||
<SelectionStateSync />
|
||||
<EditorOutlinerSync />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const DeleteModeCursor = () => {
|
||||
const mode = useEditor((s) => s.mode)
|
||||
const gl = useThree((s) => s.gl)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = gl.domElement
|
||||
if (mode === 'delete') {
|
||||
canvas.style.cursor = 'crosshair'
|
||||
return () => {
|
||||
canvas.style.cursor = ''
|
||||
}
|
||||
}
|
||||
}, [mode, gl])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const SelectionStateSync = () => {
|
||||
useEffect(() => {
|
||||
return useScene.subscribe((state) => {
|
||||
|
||||
@@ -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 { mix, positionLocal } from 'three/tsl'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
@@ -183,6 +184,7 @@ export const CeilingTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (points.length > 0) markToolCancelConsumed()
|
||||
setPoints([])
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { BufferGeometry, DoubleSide, type Group, type Line, Vector3 } from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
@@ -232,6 +233,7 @@ export const RoofTool: React.FC = () => {
|
||||
|
||||
const onCancel = () => {
|
||||
if (corner1Ref.current) {
|
||||
markToolCancelConsumed()
|
||||
corner1Ref.current = null
|
||||
outlineRef.current.visible = false
|
||||
setPreview((prev) => ({ ...prev, corner1: null }))
|
||||
|
||||
@@ -2,6 +2,7 @@ import { emitter, type GridEvent, type LevelNode, SlabNode, useScene } from '@pa
|
||||
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 { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
@@ -150,6 +151,7 @@ export const SlabTool: React.FC = () => {
|
||||
}
|
||||
|
||||
const onCancel = () => {
|
||||
if (points.length > 0) markToolCancelConsumed()
|
||||
setPoints([])
|
||||
}
|
||||
|
||||
|
||||
@@ -1,40 +1,32 @@
|
||||
import { useScene, type WallNode, WallNode as WallSchema } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
|
||||
export type WallPlanPoint = [number, number]
|
||||
|
||||
export const WALL_GRID_STEP = 0.5
|
||||
export const WALL_JOIN_SNAP_RADIUS = 0.35
|
||||
export const WALL_MIN_LENGTH = 0.01
|
||||
|
||||
export const WALL_MIN_LENGTH = 0.5
|
||||
function distanceSquared(a: WallPlanPoint, b: WallPlanPoint): number {
|
||||
const dx = a[0] - b[0]
|
||||
const dz = a[1] - b[1]
|
||||
return dx * dx + dz * dz
|
||||
}
|
||||
|
||||
function snapScalarToGrid(value: number, step = WALL_GRID_STEP): number {
|
||||
return Math.round(value / step) * step
|
||||
}
|
||||
|
||||
export function snapPointToGrid(point: WallPlanPoint, step = WALL_GRID_STEP): WallPlanPoint {
|
||||
return [snapScalarToGrid(point[0], step), snapScalarToGrid(point[1], step)]
|
||||
}
|
||||
|
||||
export function snapPointTo45Degrees(start: WallPlanPoint, cursor: WallPlanPoint): WallPlanPoint {
|
||||
const dx = cursor[0] - start[0]
|
||||
const dz = cursor[1] - start[1]
|
||||
const angle = Math.atan2(dz, dx)
|
||||
const snappedAngle = Math.round(angle / (Math.PI / 4)) * (Math.PI / 4)
|
||||
const distance = Math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
return snapPointToGrid([
|
||||
start[0] + Math.cos(snappedAngle) * distance,
|
||||
start[1] + Math.sin(snappedAngle) * distance,
|
||||
])
|
||||
}
|
||||
|
||||
function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoint | null {
|
||||
const [x1, z1] = wall.start
|
||||
const [x2, z2] = wall.end
|
||||
@@ -44,15 +36,12 @@ function projectPointOntoWall(point: WallPlanPoint, wall: WallNode): WallPlanPoi
|
||||
if (lengthSquared < 1e-9) {
|
||||
return null
|
||||
}
|
||||
|
||||
const t = ((point[0] - x1) * dx + (point[1] - z1) * dz) / lengthSquared
|
||||
if (t <= 0 || t >= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
return [x1 + dx * t, z1 + dz * t]
|
||||
}
|
||||
|
||||
export function findWallSnapTarget(
|
||||
point: WallPlanPoint,
|
||||
walls: WallNode[],
|
||||
@@ -62,12 +51,10 @@ export function findWallSnapTarget(
|
||||
const radiusSquared = (options?.radius ?? WALL_JOIN_SNAP_RADIUS) ** 2
|
||||
let bestTarget: WallPlanPoint | null = null
|
||||
let bestDistanceSquared = Number.POSITIVE_INFINITY
|
||||
|
||||
for (const wall of walls) {
|
||||
if (ignoreWallIds.has(wall.id)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidates: Array<WallPlanPoint | null> = [
|
||||
wall.start,
|
||||
wall.end,
|
||||
@@ -77,7 +64,6 @@ export function findWallSnapTarget(
|
||||
if (!candidate) {
|
||||
continue
|
||||
}
|
||||
|
||||
const candidateDistanceSquared = distanceSquared(point, candidate)
|
||||
if (
|
||||
candidateDistanceSquared > radiusSquared ||
|
||||
@@ -85,15 +71,12 @@ export function findWallSnapTarget(
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
bestTarget = candidate
|
||||
bestDistanceSquared = candidateDistanceSquared
|
||||
}
|
||||
}
|
||||
|
||||
return bestTarget
|
||||
}
|
||||
|
||||
export function snapWallDraftPoint(args: {
|
||||
point: WallPlanPoint
|
||||
walls: WallNode[]
|
||||
@@ -103,38 +86,31 @@ export function snapWallDraftPoint(args: {
|
||||
}): WallPlanPoint {
|
||||
const { point, walls, start, angleSnap = false, ignoreWallIds } = args
|
||||
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
|
||||
|
||||
return (
|
||||
findWallSnapTarget(basePoint, walls, {
|
||||
ignoreWallIds,
|
||||
}) ?? basePoint
|
||||
)
|
||||
}
|
||||
|
||||
export function isWallLongEnough(start: WallPlanPoint, end: WallPlanPoint): boolean {
|
||||
return distanceSquared(start, end) >= WALL_MIN_LENGTH * WALL_MIN_LENGTH
|
||||
}
|
||||
|
||||
export function createWallOnCurrentLevel(
|
||||
start: WallPlanPoint,
|
||||
end: WallPlanPoint,
|
||||
): WallNode | null {
|
||||
const currentLevelId = useViewer.getState().selection.levelId
|
||||
const { createNode, nodes } = useScene.getState()
|
||||
|
||||
if (!(currentLevelId && isWallLongEnough(start, end))) {
|
||||
return null
|
||||
}
|
||||
|
||||
const wallCount = Object.values(nodes).filter((node) => node.type === 'wall').length
|
||||
const wall = WallSchema.parse({
|
||||
name: `Wall ${wallCount + 1}`,
|
||||
start,
|
||||
end,
|
||||
})
|
||||
|
||||
createNode(wall, currentLevelId)
|
||||
sfxEmitter.emit('sfx:structure-build')
|
||||
|
||||
return wall
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { emitter, type GridEvent, type LevelNode, useScene, type WallNode } from
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { DoubleSide, type Group, type Mesh, Shape, ShapeGeometry, Vector3 } from 'three'
|
||||
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
|
||||
import { EDITOR_LAYER } from '../../../lib/constants'
|
||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { CursorSphere } from '../shared/cursor-sphere'
|
||||
import { createWallOnCurrentLevel, snapWallDraftPoint, type WallPlanPoint } from './wall-drafting'
|
||||
import { createWallOnCurrentLevel, snapWallDraftPoint, WALL_MIN_LENGTH, type WallPlanPoint } from './wall-drafting'
|
||||
|
||||
const WALL_HEIGHT = 2.5
|
||||
|
||||
@@ -17,7 +18,7 @@ const updateWallPreview = (mesh: Mesh, start: Vector3, end: Vector3) => {
|
||||
const direction = new Vector3(end.x - start.x, 0, end.z - start.z)
|
||||
const length = direction.length()
|
||||
|
||||
if (length < 0.01) {
|
||||
if (length < WALL_MIN_LENGTH) {
|
||||
mesh.visible = false
|
||||
return
|
||||
}
|
||||
@@ -142,7 +143,7 @@ export const WallTool: React.FC = () => {
|
||||
endingPoint.current.set(snappedEnd[0], event.position[1], snappedEnd[1])
|
||||
const dx = endingPoint.current.x - startingPoint.current.x
|
||||
const dz = endingPoint.current.z - startingPoint.current.z
|
||||
if (dx * dx + dz * dz < 0.01 * 0.01) return
|
||||
if (dx * dx + dz * dz < WALL_MIN_LENGTH * WALL_MIN_LENGTH) return
|
||||
createWallOnCurrentLevel(
|
||||
[startingPoint.current.x, startingPoint.current.z],
|
||||
[endingPoint.current.x, endingPoint.current.z],
|
||||
@@ -166,6 +167,7 @@ export const WallTool: React.FC = () => {
|
||||
|
||||
const onCancel = () => {
|
||||
if (buildingState.current === 1) {
|
||||
markToolCancelConsumed()
|
||||
buildingState.current = 0
|
||||
wallPreviewRef.current.visible = false
|
||||
}
|
||||
|
||||
@@ -20,8 +20,8 @@ export function SliderControl({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
min = 0,
|
||||
max = 100,
|
||||
min = Number.NEGATIVE_INFINITY,
|
||||
max = Number.POSITIVE_INFINITY,
|
||||
precision = 0,
|
||||
step = 1,
|
||||
className,
|
||||
@@ -32,23 +32,12 @@ export function SliderControl({
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
const [inputValue, setInputValue] = useState(value.toFixed(precision))
|
||||
|
||||
// Track the original value and bounds when dragging starts
|
||||
const [dragStartValue, setDragStartValue] = useState<number | null>(null)
|
||||
const [dragMin, setDragMin] = useState<number | null>(null)
|
||||
const [dragMax, setDragMax] = useState<number | null>(null)
|
||||
|
||||
const trackRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const dragRef = useRef<{ startX: number; startValue: number } | null>(null)
|
||||
const labelRef = useRef<HTMLDivElement>(null)
|
||||
const valueRef = useRef(value)
|
||||
valueRef.current = value
|
||||
|
||||
const clamp = useCallback(
|
||||
(val: number) => {
|
||||
return Math.min(Math.max(val, min), max)
|
||||
},
|
||||
[min, max],
|
||||
)
|
||||
const clamp = useCallback((val: number) => Math.min(Math.max(val, min), max), [min, max])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditing) {
|
||||
@@ -56,123 +45,91 @@ export function SliderControl({
|
||||
}
|
||||
}, [value, precision, isEditing])
|
||||
|
||||
// Wheel support on the label
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const el = labelRef.current
|
||||
if (!el) return
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (isEditing) return
|
||||
|
||||
e.preventDefault()
|
||||
|
||||
const direction = e.deltaY < 0 ? 1 : -1
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
let s = step
|
||||
if (e.shiftKey) s = step * 10
|
||||
else if (e.altKey) s = step * 0.1
|
||||
const newValue = clamp(valueRef.current + direction * s)
|
||||
const final = Number.parseFloat(newValue.toFixed(precision))
|
||||
if (final !== valueRef.current) onChange(final)
|
||||
}
|
||||
|
||||
container.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => container.removeEventListener('wheel', handleWheel)
|
||||
el.addEventListener('wheel', handleWheel, { passive: false })
|
||||
return () => el.removeEventListener('wheel', handleWheel)
|
||||
}, [isEditing, step, clamp, onChange, precision])
|
||||
|
||||
// Arrow key support while hovered
|
||||
useEffect(() => {
|
||||
if (!isHovered || isEditing) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
let direction = 0
|
||||
if (e.key === 'ArrowUp') direction = 1
|
||||
else if (e.key === 'ArrowDown') direction = -1
|
||||
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowRight') direction = 1
|
||||
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
|
||||
if (direction !== 0) {
|
||||
e.preventDefault()
|
||||
let scrollStep = step
|
||||
if (e.shiftKey) scrollStep = step * 10
|
||||
else if (e.altKey) scrollStep = step * 0.1
|
||||
|
||||
const newValue = clamp(valueRef.current + direction * scrollStep)
|
||||
const finalValue = Number.parseFloat(newValue.toFixed(precision))
|
||||
|
||||
if (finalValue !== valueRef.current) {
|
||||
onChange(finalValue)
|
||||
}
|
||||
let s = step
|
||||
if (e.shiftKey) s = step * 10
|
||||
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
||||
const newValue = clamp(valueRef.current + direction * s)
|
||||
const final = Number.parseFloat(newValue.toFixed(precision))
|
||||
if (final !== valueRef.current) onChange(final)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isHovered, isEditing, step, clamp, onChange, precision])
|
||||
|
||||
const handlePointerDown = useCallback(
|
||||
(e: React.PointerEvent) => {
|
||||
const handleLabelPointerDown = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (isEditing) return
|
||||
e.preventDefault()
|
||||
|
||||
const track = trackRef.current
|
||||
if (!track) return
|
||||
|
||||
e.currentTarget.setPointerCapture(e.pointerId)
|
||||
dragRef.current = { startX: e.clientX, startValue: valueRef.current }
|
||||
setIsDragging(true)
|
||||
setDragStartValue(value)
|
||||
setDragMin(min)
|
||||
setDragMax(max)
|
||||
useScene.temporal.getState().pause()
|
||||
|
||||
const rect = track.getBoundingClientRect()
|
||||
const updateValueFromEvent = (clientX: number) => {
|
||||
const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width))
|
||||
const rawValue = min + percent * (max - min)
|
||||
// snap to step
|
||||
const snapped = Math.round(rawValue / step) * step
|
||||
const finalValue = Number.parseFloat(clamp(snapped).toFixed(precision))
|
||||
onChange(finalValue)
|
||||
}
|
||||
|
||||
updateValueFromEvent(e.clientX)
|
||||
|
||||
const handlePointerMove = (moveEvent: PointerEvent) => {
|
||||
updateValueFromEvent(moveEvent.clientX)
|
||||
}
|
||||
|
||||
const handlePointerUp = (e: PointerEvent) => {
|
||||
// Only stop dragging if we didn't release on the reset button
|
||||
// Let the reset button's onPointerDown handle its own cleanup
|
||||
if ((e.target as HTMLElement).closest('button')) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDragging(false)
|
||||
const startVal = dragStartValue
|
||||
const finalVal = valueRef.current
|
||||
|
||||
setDragStartValue(null)
|
||||
setDragMin(null)
|
||||
setDragMax(null)
|
||||
document.removeEventListener('pointermove', handlePointerMove)
|
||||
document.removeEventListener('pointerup', handlePointerUp)
|
||||
|
||||
if (startVal !== null && startVal !== finalVal) {
|
||||
// Revert to start value while paused so the undo baseline is clean
|
||||
onChange(startVal)
|
||||
|
||||
useScene.temporal.getState().resume()
|
||||
|
||||
// Apply final value while recording
|
||||
onChange(finalVal)
|
||||
} else {
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handlePointerMove)
|
||||
document.addEventListener('pointerup', handlePointerUp)
|
||||
},
|
||||
[isEditing, min, max, step, precision, clamp, onChange, dragStartValue, value],
|
||||
[isEditing],
|
||||
)
|
||||
|
||||
const handleLabelPointerMove = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return
|
||||
const { startX, startValue } = dragRef.current
|
||||
const dx = e.clientX - startX
|
||||
let s = step
|
||||
if (e.shiftKey) s = step * 10
|
||||
else if (e.metaKey || e.ctrlKey) s = step * 0.1
|
||||
// 4 px per step at default sensitivity
|
||||
const newValue = clamp(Number.parseFloat((startValue + (dx / 4) * s).toFixed(precision)))
|
||||
onChange(newValue)
|
||||
},
|
||||
[step, precision, clamp, onChange],
|
||||
)
|
||||
|
||||
const handleLabelPointerUp = useCallback(
|
||||
(e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!dragRef.current) return
|
||||
const { startValue } = dragRef.current
|
||||
const finalVal = valueRef.current
|
||||
dragRef.current = null
|
||||
setIsDragging(false)
|
||||
e.currentTarget.releasePointerCapture(e.pointerId)
|
||||
|
||||
if (startValue !== finalVal) {
|
||||
onChange(startValue)
|
||||
useScene.temporal.getState().resume()
|
||||
onChange(finalVal)
|
||||
} else {
|
||||
useScene.temporal.getState().resume()
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
const handleValueClick = useCallback(() => {
|
||||
@@ -180,10 +137,6 @@ export function SliderControl({
|
||||
setInputValue(value.toFixed(precision))
|
||||
}, [value, precision])
|
||||
|
||||
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const submitValue = useCallback(() => {
|
||||
const numValue = Number.parseFloat(inputValue)
|
||||
if (Number.isNaN(numValue)) {
|
||||
@@ -194,10 +147,6 @@ export function SliderControl({
|
||||
setIsEditing(false)
|
||||
}, [inputValue, onChange, clamp, precision, value])
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
submitValue()
|
||||
}, [submitValue])
|
||||
|
||||
const handleInputKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
@@ -220,104 +169,61 @@ export function SliderControl({
|
||||
[submitValue, value, precision, step, clamp, onChange],
|
||||
)
|
||||
|
||||
const currentMin = isDragging && dragMin !== null ? dragMin : min
|
||||
const currentMax = isDragging && dragMax !== null ? dragMax : max
|
||||
|
||||
const percent = Math.max(
|
||||
0,
|
||||
Math.min(100, ((value - currentMin) / (currentMax - currentMin)) * 100),
|
||||
)
|
||||
const startPercent =
|
||||
dragStartValue !== null
|
||||
? Math.max(
|
||||
0,
|
||||
Math.min(100, ((dragStartValue - currentMin) / (currentMax - currentMin)) * 100),
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex h-12 w-full items-center rounded-lg border border-border/50 px-3 text-sm transition-colors',
|
||||
isDragging ? 'bg-[#3e3e3e]' : 'bg-[#2C2C2E] hover:bg-[#3e3e3e]',
|
||||
'group flex h-7 w-full select-none items-center rounded-lg px-2 transition-colors',
|
||||
isDragging ? 'bg-white/5' : 'hover:bg-white/5',
|
||||
className,
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
ref={containerRef}
|
||||
>
|
||||
{/* Reset button that appears when dragged away from start */}
|
||||
{isDragging && dragStartValue !== null && dragStartValue !== value && (
|
||||
<button
|
||||
className="pointer-events-auto absolute -top-10 right-0 z-50 cursor-pointer rounded-md bg-[#2C2C2E] px-2 py-1 font-medium text-[10px] text-muted-foreground shadow-sm ring-1 ring-border/50 hover:bg-[#3e3e3e] hover:text-foreground"
|
||||
onPointerDown={(e) => {
|
||||
e.stopPropagation()
|
||||
onChange(dragStartValue)
|
||||
setDragStartValue(null)
|
||||
setDragMin(null)
|
||||
setDragMax(null)
|
||||
setIsDragging(false)
|
||||
useScene.temporal.getState().resume()
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="w-[80px] shrink-0 select-none truncate text-muted-foreground">{label}</div>
|
||||
|
||||
{/* Label — drag handle */}
|
||||
<div
|
||||
className={cn(
|
||||
'relative mx-2 flex h-full flex-1 touch-none items-center justify-center',
|
||||
isDragging ? 'cursor-grabbing' : 'cursor-grab',
|
||||
'flex shrink-0 cursor-ew-resize items-center gap-1.5 text-xs transition-colors',
|
||||
isDragging ? 'text-foreground' : 'text-muted-foreground hover:text-foreground/80',
|
||||
)}
|
||||
onPointerDown={handlePointerDown}
|
||||
ref={trackRef}
|
||||
onPointerDown={handleLabelPointerDown}
|
||||
onPointerMove={handleLabelPointerMove}
|
||||
onPointerUp={handleLabelPointerUp}
|
||||
ref={labelRef}
|
||||
>
|
||||
{/* Track dots background */}
|
||||
<div className="pointer-events-none absolute inset-x-0 flex items-center justify-between px-1 opacity-30">
|
||||
{[...Array(9)].map((_, i) => (
|
||||
<div className="h-[3px] w-[3px] rounded-full bg-current" key={i} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Original Thumb Ghost */}
|
||||
{isDragging && startPercent !== null && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground/20 shadow-sm"
|
||||
style={{ left: `${startPercent}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Active Thumb */}
|
||||
{/* Grip dots — 2×3 grid */}
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-1/2 h-6 w-[3px] -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm transition',
|
||||
isDragging
|
||||
? 'scale-y-110 bg-foreground'
|
||||
: 'bg-foreground/60 group-hover:bg-foreground/80',
|
||||
'grid grid-cols-2 gap-[2.5px] transition-opacity',
|
||||
isDragging ? 'opacity-70' : 'opacity-25 group-hover:opacity-50',
|
||||
)}
|
||||
style={{ left: `${percent}%` }}
|
||||
/>
|
||||
>
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div className="h-[2px] w-[2px] rounded-full bg-current" key={i} />
|
||||
))}
|
||||
</div>
|
||||
<span className="font-medium">{label}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[50px] shrink-0 justify-end">
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* Value — click to edit */}
|
||||
<div className="flex items-center text-xs">
|
||||
{isEditing ? (
|
||||
<div className="flex items-center">
|
||||
<>
|
||||
<input
|
||||
autoFocus
|
||||
className="w-full bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
||||
onBlur={handleInputBlur}
|
||||
onChange={handleInputChange}
|
||||
className="w-14 bg-transparent p-0 text-right font-mono text-foreground outline-none selection:bg-primary/30"
|
||||
onBlur={submitValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
/>
|
||||
{unit && <span className="ml-[1px] text-muted-foreground">{unit}</span>}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
className="flex w-full cursor-text items-center justify-end text-foreground/60 transition-colors hover:text-foreground"
|
||||
className="flex cursor-text items-center text-foreground/60 transition-colors hover:text-foreground"
|
||||
onClick={handleValueClick}
|
||||
>
|
||||
<span className="font-mono tabular-nums tracking-tight">
|
||||
|
||||
@@ -190,6 +190,8 @@ export function ItemCatalog({ category }: { category: CatalogCategory }) {
|
||||
alt={item.name}
|
||||
className="rounded-lg object-cover"
|
||||
fill
|
||||
loading="eager"
|
||||
sizes="56px"
|
||||
src={resolveCdnUrl(item.thumbnail) || ''}
|
||||
/>
|
||||
{attachmentIcon && (
|
||||
|
||||
@@ -25,6 +25,29 @@ export function WallPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
// Função mágica para a Issue #191: Atualiza o comprimento via cálculo vetorial
|
||||
const handleUpdateLength = useCallback((newLength: number) => {
|
||||
if (!node || newLength <= 0) return
|
||||
|
||||
const dx = node.end[0] - node.start[0]
|
||||
const dz = node.end[1] - node.start[1]
|
||||
const currentLength = Math.sqrt(dx * dx + dz * dz)
|
||||
|
||||
if (currentLength === 0) return
|
||||
|
||||
// Calcula a direção (vetor unitário)
|
||||
const dirX = dx / currentLength
|
||||
const dirZ = dz / currentLength
|
||||
|
||||
// Define o novo ponto final baseado no novo comprimento
|
||||
const newEnd: [number, number] = [
|
||||
node.start[0] + dirX * newLength,
|
||||
node.start[1] + dirZ * newLength
|
||||
]
|
||||
|
||||
handleUpdate({ end: newEnd })
|
||||
}, [node, handleUpdate])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -46,6 +69,17 @@ export function WallPanel() {
|
||||
width={280}
|
||||
>
|
||||
<PanelSection title="Dimensions">
|
||||
{/* Adicionando o controle de Length solicitado na Issue #191 */}
|
||||
<SliderControl
|
||||
label="Length"
|
||||
max={20}
|
||||
min={0.1}
|
||||
onChange={handleUpdateLength}
|
||||
precision={2}
|
||||
step={0.01}
|
||||
unit="m"
|
||||
value={length}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={6}
|
||||
@@ -67,13 +101,6 @@ export function WallPanel() {
|
||||
value={Math.round(thickness * 1000) / 1000}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Info">
|
||||
<div className="flex items-center justify-between px-2 py-1 text-muted-foreground text-sm">
|
||||
<span>Length</span>
|
||||
<span className="font-mono text-white">{length.toFixed(2)} m</span>
|
||||
</div>
|
||||
</PanelSection>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -209,8 +209,6 @@ export function WindowPanel() {
|
||||
X<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={-10}
|
||||
onChange={(v) => handleUpdate({ position: [v, node.position[1], node.position[2]] })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
@@ -223,8 +221,6 @@ export function WindowPanel() {
|
||||
Y<sub className="ml-[1px] text-[11px] opacity-70">pos</sub>
|
||||
</>
|
||||
}
|
||||
max={10}
|
||||
min={-10}
|
||||
onChange={(v) => handleUpdate({ position: [node.position[0], v, node.position[2]] })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
@@ -244,8 +240,7 @@ export function WindowPanel() {
|
||||
<PanelSection title="Dimensions">
|
||||
<SliderControl
|
||||
label="Width"
|
||||
max={5}
|
||||
min={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ width: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
@@ -254,8 +249,7 @@ export function WindowPanel() {
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={5}
|
||||
min={0.2}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ height: v })}
|
||||
precision={2}
|
||||
step={0.1}
|
||||
@@ -267,8 +261,7 @@ export function WindowPanel() {
|
||||
<PanelSection title="Frame">
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={0.2}
|
||||
min={0.01}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ frameThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
@@ -277,8 +270,7 @@ export function WindowPanel() {
|
||||
/>
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
max={0.3}
|
||||
min={0.01}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ frameDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
@@ -390,8 +382,7 @@ export function WindowPanel() {
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<SliderControl
|
||||
label="Depth"
|
||||
max={0.5}
|
||||
min={0.01}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillDepth: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
@@ -400,8 +391,7 @@ export function WindowPanel() {
|
||||
/>
|
||||
<SliderControl
|
||||
label="Thickness"
|
||||
max={0.2}
|
||||
min={0.005}
|
||||
min={0}
|
||||
onChange={(v) => handleUpdate({ sillThickness: v })}
|
||||
precision={3}
|
||||
step={0.01}
|
||||
|
||||
@@ -202,9 +202,9 @@ export function SettingsPanel({
|
||||
|
||||
const isLocalProject = false // Props-based; only show cloud sections when projectId provided
|
||||
|
||||
const handleExport = async () => {
|
||||
const handleExport = async (format: 'glb' | 'stl' | 'obj' = 'glb') => {
|
||||
if (exportScene) {
|
||||
await exportScene()
|
||||
await exportScene(format)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,9 +318,17 @@ export function SettingsPanel({
|
||||
{/* Export Section */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-muted-foreground text-xs uppercase">Export</label>
|
||||
<Button className="w-full justify-start gap-2" onClick={handleExport} variant="outline">
|
||||
<Button className="w-full justify-start gap-2" onClick={() => handleExport('glb')} variant="outline">
|
||||
<Download className="size-4" />
|
||||
Export 3D Model
|
||||
Export as GLB
|
||||
</Button>
|
||||
<Button className="w-full justify-start gap-2" onClick={() => handleExport('stl')} variant="outline">
|
||||
<Download className="size-4" />
|
||||
Export as STL
|
||||
</Button>
|
||||
<Button className="w-full justify-start gap-2" onClick={() => handleExport('obj')} variant="outline">
|
||||
<Download className="size-4" />
|
||||
Export as OBJ
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,149 +1,187 @@
|
||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { sfxEmitter } from '../lib/sfx-bus'
|
||||
import useEditor from '../store/use-editor'
|
||||
|
||||
export const useKeyboard = () => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle shortcuts if user is typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
// In first-person mode, all shortcuts are handled by FirstPersonControls
|
||||
if (useEditor.getState().isFirstPersonMode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
emitter.emit('tool:cancel')
|
||||
|
||||
// Return to the default select tool while keeping the active building/level context.
|
||||
useEditor.getState().setEditingHole(null)
|
||||
useEditor.getState().setMode('select')
|
||||
|
||||
// Clear selections to close UI panels, but KEEP the active building and level context.
|
||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||
useEditor.getState().setSelectedReferenceId(null)
|
||||
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('site')
|
||||
useEditor.getState().setMode('select')
|
||||
} else if (e.key === '2' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setMode('select')
|
||||
} else if (e.key === '3' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('furnish')
|
||||
useEditor.getState().setMode('select')
|
||||
} else if (e.key === 's' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('furnish')
|
||||
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('zones')
|
||||
}
|
||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setMode('select')
|
||||
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setMode('build')
|
||||
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
useScene.temporal.getState().undo()
|
||||
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
useScene.temporal.getState().redo()
|
||||
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
const { buildingId, levelId } = useViewer.getState().selection
|
||||
if (buildingId) {
|
||||
const building = useScene.getState().nodes[buildingId]
|
||||
if (building && building.type === 'building' && building.children.length > 0) {
|
||||
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
||||
const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
|
||||
if (nextIdx !== -1 && nextIdx !== currentIdx) {
|
||||
useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
|
||||
} else if (currentIdx === -1) {
|
||||
useViewer.getState().setSelection({ levelId: building.children[0] as any })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
const { buildingId, levelId } = useViewer.getState().selection
|
||||
if (buildingId) {
|
||||
const building = useScene.getState().nodes[buildingId]
|
||||
if (building && building.type === 'building' && building.children.length > 0) {
|
||||
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
||||
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
|
||||
if (prevIdx !== -1 && prevIdx !== currentIdx) {
|
||||
useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
|
||||
} else if (currentIdx === -1) {
|
||||
useViewer
|
||||
.getState()
|
||||
.setSelection({ levelId: building.children[building.children.length - 1] as any })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
// Rotate selected node if it supports rotation (items, roofs, etc.)
|
||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedNodeIds.length === 1) {
|
||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||
if (node && 'rotation' in node) {
|
||||
e.preventDefault()
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
let newRotationY = 0
|
||||
|
||||
// Handle different rotation types (number for roof, array for items/windows/doors)
|
||||
if (typeof node.rotation === 'number') {
|
||||
newRotationY = node.rotation + ROTATION_STEP
|
||||
useScene.getState().updateNode(node.id, { rotation: newRotationY })
|
||||
} else if (Array.isArray(node.rotation)) {
|
||||
newRotationY = node.rotation[1] + ROTATION_STEP
|
||||
useScene.getState().updateNode(node.id, {
|
||||
rotation: [node.rotation[0], newRotationY, node.rotation[2]],
|
||||
})
|
||||
}
|
||||
sfxEmitter.emit('sfx:item-rotate') // Play a sound for feedback
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useEffect } from 'react'
|
||||
import { sfxEmitter } from '../lib/sfx-bus'
|
||||
import useEditor from '../store/use-editor'
|
||||
|
||||
// Tools call this in their onCancel handler when they have an active mid-action to cancel,
|
||||
// so that the global Escape handler knows not to also switch to select mode.
|
||||
let _toolCancelConsumed = false
|
||||
export const markToolCancelConsumed = () => {
|
||||
_toolCancelConsumed = true
|
||||
}
|
||||
|
||||
export const useKeyboard = () => {
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Don't handle shortcuts if user is typing in an input
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
|
||||
return
|
||||
}
|
||||
|
||||
// In first-person mode, all shortcuts are handled by FirstPersonControls
|
||||
if (useEditor.getState().isFirstPersonMode) {
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault()
|
||||
_toolCancelConsumed = false
|
||||
emitter.emit('tool:cancel')
|
||||
|
||||
// Only switch to select mode if no tool had an active mid-action to cancel.
|
||||
// (e.g. mid-wall draw or mid-slab polygon should only cancel the action, not exit the tool)
|
||||
if (!_toolCancelConsumed) {
|
||||
// Return to the default select tool while keeping the active building/level context.
|
||||
useEditor.getState().setEditingHole(null)
|
||||
useEditor.getState().setMode('select')
|
||||
|
||||
// Clear selections to close UI panels, but KEEP the active building and level context.
|
||||
useViewer.getState().setSelection({ selectedIds: [], zoneId: null })
|
||||
useEditor.getState().setSelectedReferenceId(null)
|
||||
}
|
||||
} else if (e.key === '1' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('site')
|
||||
useEditor.getState().setMode('select')
|
||||
} else if (e.key === '2' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setMode('select')
|
||||
} else if (e.key === '3' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('furnish')
|
||||
useEditor.getState().setMode('select')
|
||||
} else if (e.key === 's' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('elements')
|
||||
} else if (e.key === 'f' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('furnish')
|
||||
} else if (e.key === 'z' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setPhase('structure')
|
||||
useEditor.getState().setStructureLayer('zones')
|
||||
}
|
||||
if (e.key === 'v' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setMode('select')
|
||||
} else if (e.key === 'd' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
const phase = useEditor.getState().phase
|
||||
if (phase === 'structure' || phase === 'furnish') {
|
||||
useEditor.getState().setMode('delete')
|
||||
useViewer.getState().setSelection({ selectedIds: [] })
|
||||
}
|
||||
} else if (e.key === 'b' && !e.metaKey && !e.ctrlKey) {
|
||||
e.preventDefault()
|
||||
useEditor.getState().setMode('build')
|
||||
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
useScene.temporal.getState().undo()
|
||||
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
useScene.temporal.getState().redo()
|
||||
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
const { buildingId, levelId } = useViewer.getState().selection
|
||||
if (buildingId) {
|
||||
const building = useScene.getState().nodes[buildingId]
|
||||
if (building && building.type === 'building' && building.children.length > 0) {
|
||||
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
||||
const nextIdx = currentIdx < building.children.length - 1 ? currentIdx + 1 : currentIdx
|
||||
if (nextIdx !== -1 && nextIdx !== currentIdx) {
|
||||
useViewer.getState().setSelection({ levelId: building.children[nextIdx] as any })
|
||||
} else if (currentIdx === -1) {
|
||||
useViewer.getState().setSelection({ levelId: building.children[0] as any })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'ArrowDown' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault()
|
||||
const { buildingId, levelId } = useViewer.getState().selection
|
||||
if (buildingId) {
|
||||
const building = useScene.getState().nodes[buildingId]
|
||||
if (building && building.type === 'building' && building.children.length > 0) {
|
||||
const currentIdx = levelId ? building.children.indexOf(levelId as any) : -1
|
||||
const prevIdx = currentIdx > 0 ? currentIdx - 1 : currentIdx
|
||||
if (prevIdx !== -1 && prevIdx !== currentIdx) {
|
||||
useViewer.getState().setSelection({ levelId: building.children[prevIdx] as any })
|
||||
} else if (currentIdx === -1) {
|
||||
useViewer
|
||||
.getState()
|
||||
.setSelection({ levelId: building.children[building.children.length - 1] as any })
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'r' || e.key === 'R') {
|
||||
// Rotate selected node if it supports rotation (items, roofs, etc.)
|
||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedNodeIds.length === 1) {
|
||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||
if (node && 'rotation' in node) {
|
||||
e.preventDefault()
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
let newRotationY = 0
|
||||
|
||||
// Handle different rotation types (number for roof, array for items/windows/doors)
|
||||
if (typeof node.rotation === 'number') {
|
||||
newRotationY = node.rotation + ROTATION_STEP
|
||||
useScene.getState().updateNode(node.id, { rotation: newRotationY })
|
||||
} else if (Array.isArray(node.rotation)) {
|
||||
newRotationY = node.rotation[1] + ROTATION_STEP
|
||||
useScene.getState().updateNode(node.id, {
|
||||
rotation: [node.rotation[0], newRotationY, node.rotation[2]],
|
||||
})
|
||||
}
|
||||
sfxEmitter.emit('sfx:item-rotate') // Play a sound for feedback
|
||||
}
|
||||
}
|
||||
} else if (e.key === 't' || e.key === 'T') {
|
||||
// Rotate selected node counter-clockwise
|
||||
const selectedNodeIds = useViewer.getState().selection.selectedIds as AnyNodeId[]
|
||||
if (selectedNodeIds.length === 1) {
|
||||
const node = useScene.getState().nodes[selectedNodeIds[0]!]
|
||||
if (node && 'rotation' in node) {
|
||||
e.preventDefault()
|
||||
const ROTATION_STEP = Math.PI / 4
|
||||
|
||||
if (typeof node.rotation === 'number') {
|
||||
useScene.getState().updateNode(node.id, { rotation: node.rotation - ROTATION_STEP })
|
||||
} else if (Array.isArray(node.rotation)) {
|
||||
useScene.getState().updateNode(node.id, {
|
||||
rotation: [node.rotation[0], node.rotation[1] - ROTATION_STEP, node.rotation[2]],
|
||||
})
|
||||
}
|
||||
sfxEmitter.emit('sfx:item-rotate')
|
||||
}
|
||||
}
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pascal-app/viewer",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"description": "3D viewer component for Pascal building editor",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
|
||||
@@ -21,7 +21,6 @@ import { ScanSystem } from '../../systems/scan/scan-system'
|
||||
import { WallCutout } from '../../systems/wall/wall-cutout'
|
||||
import { ZoneSystem } from '../../systems/zone/zone-system'
|
||||
import { SceneRenderer } from '../renderers/scene-renderer'
|
||||
import { GroundOccluder } from './ground-occluder'
|
||||
import { Lights } from './lights'
|
||||
import { PerfMonitor } from './perf-monitor'
|
||||
import PostProcessing from './post-processing'
|
||||
@@ -110,15 +109,18 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
const renderer = new THREE.WebGPURenderer(props as any)
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
renderer.toneMappingExposure = 0.9
|
||||
// renderer.init() // Only use when using <DebugRenderer />
|
||||
return renderer
|
||||
}}
|
||||
resize={{
|
||||
debounce: 100,
|
||||
}}
|
||||
shadows={{
|
||||
type: THREE.PCFShadowMap,
|
||||
enabled: true,
|
||||
}}
|
||||
>
|
||||
{/* <AnimatedBackground isDark={theme === 'dark'} /> */}
|
||||
<GroundOccluder />
|
||||
<ViewerCamera />
|
||||
|
||||
{/* <directionalLight position={[10, 10, 5]} intensity={0.5} castShadow
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Color, Layers, UnsignedByteType } from 'three'
|
||||
import { outline } from 'three/addons/tsl/display/OutlineNode.js'
|
||||
import { ssgi } from 'three/addons/tsl/display/SSGINode.js'
|
||||
import { traa } from 'three/addons/tsl/display/TRAANode.js'
|
||||
import { denoise } from 'three/examples/jsm/tsl/display/DenoiseNode.js'
|
||||
import {
|
||||
add,
|
||||
@@ -21,7 +20,6 @@ import {
|
||||
time,
|
||||
uniform,
|
||||
vec4,
|
||||
velocity,
|
||||
} from 'three/tsl'
|
||||
import { RenderPipeline, type WebGPURenderer } from 'three/webgpu'
|
||||
import { SCENE_LAYER, ZONE_LAYER } from '../../lib/layers'
|
||||
@@ -129,67 +127,11 @@ const PostProcessingPasses = () => {
|
||||
outliner.hoveredObjects.length = 0
|
||||
|
||||
try {
|
||||
// Scene pass with MRT for SSGI
|
||||
const scenePass = pass(scene, camera)
|
||||
scenePass.setMRT(
|
||||
mrt({
|
||||
output,
|
||||
diffuseColor,
|
||||
normal: directionToColor(normalView),
|
||||
velocity,
|
||||
}),
|
||||
)
|
||||
|
||||
// Get texture outputs
|
||||
const scenePassColor = scenePass.getTextureNode('output')
|
||||
const scenePassDiffuse = scenePass.getTextureNode('diffuseColor')
|
||||
const scenePassDepth = scenePass.getTextureNode('depth')
|
||||
const scenePassNormal = scenePass.getTextureNode('normal')
|
||||
const scenePassVelocity = scenePass.getTextureNode('velocity')
|
||||
|
||||
// Optimize texture bandwidth
|
||||
const diffuseTexture = scenePass.getTexture('diffuseColor')
|
||||
diffuseTexture.type = UnsignedByteType
|
||||
|
||||
const normalTexture = scenePass.getTexture('normal')
|
||||
normalTexture.type = UnsignedByteType
|
||||
|
||||
// Extract normal from color-encoded texture
|
||||
const sceneNormal = sample((uv) => {
|
||||
return colorToDirection(scenePassNormal.sample(uv))
|
||||
})
|
||||
|
||||
const zonePass = pass(scene, camera)
|
||||
zonePass.setLayers(zoneLayers)
|
||||
// SSGI Pass (cast to PerspectiveCamera for SSGI)
|
||||
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any)
|
||||
|
||||
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
|
||||
giPass.stepCount.value = SSGI_PARAMS.stepCount
|
||||
giPass.radius.value = SSGI_PARAMS.radius
|
||||
giPass.expFactor.value = SSGI_PARAMS.expFactor
|
||||
giPass.thickness.value = SSGI_PARAMS.thickness
|
||||
giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
|
||||
giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
|
||||
giPass.giIntensity.value = SSGI_PARAMS.giIntensity
|
||||
giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
|
||||
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
||||
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
|
||||
|
||||
const giTexture = (giPass as any).getTextureNode()
|
||||
|
||||
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
|
||||
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
|
||||
// convertToTexture() inside denoise() will call rtt() on this vec4 node automatically.
|
||||
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
|
||||
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
|
||||
denoisePass.index.value = 0
|
||||
denoisePass.radius.value = 4
|
||||
|
||||
const gi = giPass.rgb
|
||||
const ao = (denoisePass as any).r
|
||||
// const gi = giPass.rgb;
|
||||
// const ao = giPass.a;
|
||||
const scenePassColor = scenePass.getTextureNode('output')
|
||||
|
||||
// Background detection via alpha: renderer clears with alpha=0 (setClearAlpha(0) in useFrame),
|
||||
// so background pixels have scenePassColor.a=0 while geometry pixels have output.a=1.
|
||||
@@ -198,11 +140,62 @@ const PostProcessingPasses = () => {
|
||||
const hasGeometry = scenePassColor.a
|
||||
const contentAlpha = hasGeometry.max(zonePass.a)
|
||||
|
||||
// Composite: scene * AO + diffuse * GI
|
||||
const compositePass = vec4(
|
||||
add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))),
|
||||
contentAlpha,
|
||||
)
|
||||
let sceneColor = scenePassColor as unknown as ReturnType<typeof vec4>
|
||||
|
||||
if (SSGI_PARAMS.enabled) {
|
||||
// MRT only needed for SSGI (diffuse for GI, normal for SSGI sampling)
|
||||
scenePass.setMRT(
|
||||
mrt({
|
||||
output,
|
||||
diffuseColor,
|
||||
normal: directionToColor(normalView),
|
||||
}),
|
||||
)
|
||||
|
||||
const scenePassDiffuse = scenePass.getTextureNode('diffuseColor')
|
||||
const scenePassDepth = scenePass.getTextureNode('depth')
|
||||
const scenePassNormal = scenePass.getTextureNode('normal')
|
||||
|
||||
// Optimize texture bandwidth
|
||||
const diffuseTexture = scenePass.getTexture('diffuseColor')
|
||||
diffuseTexture.type = UnsignedByteType
|
||||
const normalTexture = scenePass.getTexture('normal')
|
||||
normalTexture.type = UnsignedByteType
|
||||
|
||||
// Extract normal from color-encoded texture
|
||||
const sceneNormal = sample((uv) => colorToDirection(scenePassNormal.sample(uv)))
|
||||
|
||||
const giPass = ssgi(scenePassColor, scenePassDepth, sceneNormal, camera as any)
|
||||
giPass.sliceCount.value = SSGI_PARAMS.sliceCount
|
||||
giPass.stepCount.value = SSGI_PARAMS.stepCount
|
||||
giPass.radius.value = SSGI_PARAMS.radius
|
||||
giPass.expFactor.value = SSGI_PARAMS.expFactor
|
||||
giPass.thickness.value = SSGI_PARAMS.thickness
|
||||
giPass.backfaceLighting.value = SSGI_PARAMS.backfaceLighting
|
||||
giPass.aoIntensity.value = SSGI_PARAMS.aoIntensity
|
||||
giPass.giIntensity.value = SSGI_PARAMS.giIntensity
|
||||
giPass.useLinearThickness.value = SSGI_PARAMS.useLinearThickness
|
||||
giPass.useScreenSpaceSampling.value = SSGI_PARAMS.useScreenSpaceSampling
|
||||
giPass.useTemporalFiltering = SSGI_PARAMS.useTemporalFiltering
|
||||
|
||||
const giTexture = (giPass as any).getTextureNode()
|
||||
|
||||
// DenoiseNode only denoises RGB — alpha is passed through unchanged.
|
||||
// SSGI packs AO into alpha, so we remap it into RGB before denoising.
|
||||
const aoAsRgb = vec4(giTexture.a, giTexture.a, giTexture.a, float(1))
|
||||
const denoisePass = denoise(aoAsRgb, scenePassDepth, sceneNormal, camera)
|
||||
denoisePass.index.value = 0
|
||||
denoisePass.radius.value = 4
|
||||
|
||||
const gi = giPass.rgb
|
||||
const ao = (denoisePass as any).r
|
||||
|
||||
// Composite: scene * AO + diffuse * GI
|
||||
sceneColor = vec4(
|
||||
add(scenePassColor.rgb.mul(ao), add(zonePass.rgb, scenePassDiffuse.rgb.mul(gi))),
|
||||
contentAlpha,
|
||||
)
|
||||
}
|
||||
|
||||
function generateSelectedOutlinePass() {
|
||||
const edgeStrength = uniform(3)
|
||||
@@ -256,20 +249,15 @@ const PostProcessingPasses = () => {
|
||||
const selectedOutlinePass = generateSelectedOutlinePass()
|
||||
const hoverOutlinePass = generateHoverOutlinePass()
|
||||
|
||||
// Combine composite with outlines BEFORE applying TRAA
|
||||
const compositeWithOutlines = SSGI_PARAMS.enabled
|
||||
? vec4(add(compositePass.rgb, selectedOutlinePass.add(hoverOutlinePass)), compositePass.a)
|
||||
: vec4(add(scenePassColor.rgb, selectedOutlinePass.add(hoverOutlinePass)), scenePassColor.a)
|
||||
const compositeWithOutlines = vec4(
|
||||
add(sceneColor.rgb, selectedOutlinePass.add(hoverOutlinePass)),
|
||||
sceneColor.a,
|
||||
)
|
||||
|
||||
// TRAA (Temporal Reprojection Anti-Aliasing) - applied AFTER combining everything
|
||||
const traaOutput = traa(compositeWithOutlines, scenePassDepth, scenePassVelocity, camera)
|
||||
|
||||
// For zone-over-background pixels, scenePassDepth=1.0 (no scene geometry) causes TRAA
|
||||
// to output black. Use hasGeometry to blend: geometry pixels use traaRgb, all others
|
||||
// (zones over background, pure background) use compositePass.rgb directly.
|
||||
const traaRgb = (traaOutput as any).rgb
|
||||
const colorSource = mix(compositePass.rgb, traaRgb, hasGeometry)
|
||||
const finalOutput = vec4(mix(bgUniform.current, colorSource, contentAlpha), float(1))
|
||||
const finalOutput = vec4(
|
||||
mix(bgUniform.current, compositeWithOutlines.rgb, contentAlpha),
|
||||
float(1),
|
||||
)
|
||||
|
||||
const renderPipeline = new RenderPipeline(renderer as unknown as WebGPURenderer)
|
||||
renderPipeline.outputNode = finalOutput
|
||||
|
||||
+2
-2
@@ -27,8 +27,8 @@ type ViewerState = {
|
||||
setSelection: (updates: Partial<SelectionPath>) => void
|
||||
resetSelection: () => void
|
||||
outliner: Outliner
|
||||
exportScene: (() => Promise<void>) | null
|
||||
setExportScene: (fn: (() => Promise<void>) | null) => void
|
||||
exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null
|
||||
setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null) => void
|
||||
}
|
||||
declare const useViewer: import('zustand').UseBoundStore<import('zustand').StoreApi<ViewerState>>
|
||||
export default useViewer
|
||||
|
||||
@@ -61,8 +61,8 @@ type ViewerState = {
|
||||
outliner: Outliner // No setter as we will manipulate directly the arrays
|
||||
|
||||
// Export functionality
|
||||
exportScene: (() => Promise<void>) | null
|
||||
setExportScene: (fn: (() => Promise<void>) | null) => void
|
||||
exportScene: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null
|
||||
setExportScene: (fn: ((format?: 'glb' | 'stl' | 'obj') => Promise<void>) | null) => void
|
||||
|
||||
debugColors: boolean
|
||||
setDebugColors: (enabled: boolean) => void
|
||||
|
||||
Reference in New Issue
Block a user