* Remove .env.local guard; add portless dep
Remove the check that blocked a local .env.local in apps/editor's dev script and simplify the dev startup to source the root .env. Add portless@^0.4.2 to the project dependencies in package.json. Lockfile updated accordingly.
* Improve tool cursors, previews, and contextual tools
Revamp editor tool visuals and behaviour: replace mesh cursors with a new CursorSphere Group (adds ground marker, vertical indicator and optional tool icon tooltip), unify preview colors to #818cf8, add ground-level previews and ghost lines for ceiling and roof tools, and update slab/zone/wall visuals to match. Add automatic build-mode switching and contextual highlighting for structure tools via a new useContextualTools hook, and relax action-menu visibility checks so tool rows are shown even when not in build mode. Minor typing/import adjustments and small opacity/placement tweaks across multiple tool components.
* Keep tools active and refine action-menu styles
Stop auto-deactivating tools in RoofTool and ZoneTool by removing setTool(null) (and setMode('select') in roof). Add hasActiveTool checks and small layout padding to FurnishTools and StructureTools, and update button classes to improve visual emphasis: smoother transitions, stronger active rings/scale, and dimming/grayscale of non-active tools when another tool is active. These changes improve UX by keeping the selected tool active and giving clearer visual feedback in the action menu.
* Improve selection behavior and refresh UI styling
SelectionManager: add global enter/leave/double-click handlers to support cross-phase hovering and auto-switching into structure/furnish on double-click; normalize click handling (explicitly coalesce shiftKey). Remove per-strategy enter/leave listeners.
UI panels & controls: unify and modernize styles across many panels (ceiling, door, item, reference, roof, slab, wall, window) and action buttons — rounded-md, neutral borders, subtle shadows, translucent headers and consistent hover/focus states. Update view toggles icons and toggle styling.
NumberInput: integrate @number-flow/react NumberFlow for formatted value display, add visual drag progress indicator and refine input/label interactions and styling.
Sidebar: replace rename-popover with inline-rename-input and update tree nodes to use inline editing. Add slider-demo and tweak slider implementation. Update package.json accordingly.
* Use Barlow font across editor UI
Add the Google Barlow font and expose it as a CSS variable, update theme font variables in globals.css, and apply the new font across the editor UI. Imports: add Barlow in app/layout.tsx and include its variable on <html>, set body to use font-sans. Apply font-barlow or font-mono classes to headings, labels, numeric readouts and buttons across multiple panels and primitives to standardize typography. Also update button base variant to include font-barlow. Remove the PhaseSwitcher component and its import/usage from the action menu.
* Replace button wrapper with div in LevelItem
Replace the outer <button> around the LevelItem content with a <div> to avoid nesting interactive button elements (and the resulting invalid HTML/interaction issues). Preserves the original classes and onClick/onDoubleClick handlers so behavior remains the same and allows the PopoverTrigger/button children to function correctly.
* Refine site panel list styles and spacing
Update the SitePanel and TreeNode UI to use compact, border-bottom list rows instead of rounded, card-like items. Simplified active/hover states to use bg-accent variants and reduced shadow/ring styles for a cleaner look. Adjusted paddings (pl/px/py) and container layouts (many sections switched to flex-col) and increased tree node indent/paddingRight for consistent alignment. These changes unify spacing and visual hierarchy across levels, buildings, zones and tree nodes.
* Add auto-scroll to selected items and refine icon styles
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
399 lines
13 KiB
TypeScript
399 lines
13 KiB
TypeScript
import { CeilingNode, emitter, type GridEvent, type LevelNode, useScene } from '@pascal-app/core'
|
|
import { useViewer } from '@pascal-app/viewer'
|
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } from 'three'
|
|
import { mix, positionLocal } from 'three/tsl'
|
|
import { sfxEmitter } from '@/lib/sfx-bus'
|
|
import { CursorSphere } from '../shared/cursor-sphere'
|
|
|
|
const CEILING_HEIGHT = 2.52
|
|
const GRID_OFFSET = 0.02
|
|
|
|
/**
|
|
* Snaps a point to the nearest axis-aligned or 45-degree diagonal from the last point
|
|
*/
|
|
const calculateSnapPoint = (
|
|
lastPoint: [number, number],
|
|
currentPoint: [number, number],
|
|
): [number, number] => {
|
|
const [x1, y1] = lastPoint
|
|
const [x, y] = currentPoint
|
|
|
|
const dx = x - x1
|
|
const dy = y - y1
|
|
const absDx = Math.abs(dx)
|
|
const absDy = Math.abs(dy)
|
|
|
|
// Calculate distances to horizontal, vertical, and diagonal lines
|
|
const horizontalDist = absDy
|
|
const verticalDist = absDx
|
|
const diagonalDist = Math.abs(absDx - absDy)
|
|
|
|
// Find the minimum distance to determine which axis to snap to
|
|
const minDist = Math.min(horizontalDist, verticalDist, diagonalDist)
|
|
|
|
if (minDist === diagonalDist) {
|
|
// Snap to 45° diagonal
|
|
const diagonalLength = Math.min(absDx, absDy)
|
|
return [x1 + Math.sign(dx) * diagonalLength, y1 + Math.sign(dy) * diagonalLength]
|
|
} else if (minDist === horizontalDist) {
|
|
// Snap to horizontal
|
|
return [x, y1]
|
|
} else {
|
|
// Snap to vertical
|
|
return [x1, y]
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Creates a ceiling with the given polygon points and returns its ID
|
|
*/
|
|
const commitCeilingDrawing = (levelId: LevelNode['id'], points: Array<[number, number]>): string => {
|
|
const { createNode, nodes } = useScene.getState()
|
|
|
|
// Count existing ceilings for naming
|
|
const ceilingCount = Object.values(nodes).filter((n) => n.type === 'ceiling').length
|
|
const name = `Ceiling ${ceilingCount + 1}`
|
|
|
|
const ceiling = CeilingNode.parse({
|
|
name,
|
|
polygon: points,
|
|
})
|
|
|
|
createNode(ceiling, levelId)
|
|
sfxEmitter.emit('sfx:structure-build')
|
|
return ceiling.id
|
|
}
|
|
|
|
export const CeilingTool: React.FC = () => {
|
|
const cursorRef = useRef<Group>(null)
|
|
const gridCursorRef = useRef<Group>(null)
|
|
const mainLineRef = useRef<Line>(null!)
|
|
const closingLineRef = useRef<Line>(null!)
|
|
const groundMainLineRef = useRef<Line>(null!)
|
|
const groundClosingLineRef = useRef<Line>(null!)
|
|
const verticalLineRef = useRef<Line>(null!)
|
|
const currentLevelId = useViewer((state) => state.selection.levelId)
|
|
const setSelection = useViewer((state) => state.setSelection)
|
|
|
|
const [points, setPoints] = useState<Array<[number, number]>>([])
|
|
const [cursorPosition, setCursorPosition] = useState<[number, number]>([0, 0])
|
|
const [snappedCursorPosition, setSnappedCursorPosition] = useState<[number, number]>([0, 0])
|
|
const [levelY, setLevelY] = useState(0)
|
|
const previousSnappedPointRef = useRef<[number, number] | null>(null)
|
|
const shiftPressed = useRef(false)
|
|
|
|
// Static geometry: local y goes 0 (grid) → H (ceiling), mesh is positioned at gridY
|
|
const verticalGeo = useMemo(
|
|
() => new BufferGeometry().setFromPoints([new Vector3(0, 0, 0), new Vector3(0, CEILING_HEIGHT - GRID_OFFSET, 0)]),
|
|
[],
|
|
)
|
|
|
|
// opacityNode: positionLocal.y is 0 at grid, H at ceiling → fade from 0.6 to 0
|
|
const gradientOpacityNode = useMemo(
|
|
() => mix(0.6, 0.0, positionLocal.y.div(CEILING_HEIGHT - GRID_OFFSET).clamp()),
|
|
[],
|
|
)
|
|
|
|
// Update cursor position and lines on grid move
|
|
useEffect(() => {
|
|
if (!currentLevelId) return
|
|
|
|
const onGridMove = (event: GridEvent) => {
|
|
if (!cursorRef.current || !gridCursorRef.current) return
|
|
|
|
const gridX = Math.round(event.position[0] * 2) / 2
|
|
const gridZ = Math.round(event.position[2] * 2) / 2
|
|
const gridPosition: [number, number] = [gridX, gridZ]
|
|
|
|
setCursorPosition(gridPosition)
|
|
setLevelY(event.position[1])
|
|
|
|
const ceilingY = event.position[1] + CEILING_HEIGHT
|
|
const gridY = event.position[1] + GRID_OFFSET
|
|
|
|
// Calculate snapped display position (bypass snap when Shift is held)
|
|
const lastPoint = points[points.length - 1]
|
|
const displayPoint =
|
|
shiftPressed.current || !lastPoint
|
|
? gridPosition
|
|
: calculateSnapPoint(lastPoint, gridPosition)
|
|
setSnappedCursorPosition(displayPoint)
|
|
|
|
// Play snap sound when the snapped position actually changes (only when drawing)
|
|
if (
|
|
points.length > 0 &&
|
|
previousSnappedPointRef.current &&
|
|
(displayPoint[0] !== previousSnappedPointRef.current[0] ||
|
|
displayPoint[1] !== previousSnappedPointRef.current[1])
|
|
) {
|
|
sfxEmitter.emit('sfx:grid-snap')
|
|
}
|
|
|
|
previousSnappedPointRef.current = displayPoint
|
|
cursorRef.current.position.set(displayPoint[0], ceilingY, displayPoint[1])
|
|
gridCursorRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
|
|
|
if (verticalLineRef.current) {
|
|
verticalLineRef.current.position.set(displayPoint[0], gridY, displayPoint[1])
|
|
}
|
|
}
|
|
|
|
const onGridClick = (_event: GridEvent) => {
|
|
if (!currentLevelId) return
|
|
|
|
// Use the last displayed snapped position (respects Shift state from onGridMove)
|
|
const clickPoint = previousSnappedPointRef.current ?? cursorPosition
|
|
|
|
// Check if clicking on the first point to close the shape
|
|
const firstPoint = points[0]
|
|
if (
|
|
points.length >= 3 &&
|
|
firstPoint &&
|
|
Math.abs(clickPoint[0] - firstPoint[0]) < 0.25 &&
|
|
Math.abs(clickPoint[1] - firstPoint[1]) < 0.25
|
|
) {
|
|
// Create the ceiling and select it
|
|
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
|
setSelection({ selectedIds: [ceilingId] })
|
|
setPoints([])
|
|
} else {
|
|
// Add point to polygon
|
|
setPoints([...points, clickPoint])
|
|
}
|
|
}
|
|
|
|
const onGridDoubleClick = (_event: GridEvent) => {
|
|
if (!currentLevelId) return
|
|
|
|
// Need at least 3 points to form a polygon
|
|
if (points.length >= 3) {
|
|
const ceilingId = commitCeilingDrawing(currentLevelId, points)
|
|
setSelection({ selectedIds: [ceilingId] })
|
|
setPoints([])
|
|
}
|
|
}
|
|
|
|
const onCancel = () => {
|
|
setPoints([])
|
|
}
|
|
|
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Shift') shiftPressed.current = true
|
|
}
|
|
const onKeyUp = (e: KeyboardEvent) => {
|
|
if (e.key === 'Shift') shiftPressed.current = false
|
|
}
|
|
document.addEventListener('keydown', onKeyDown)
|
|
document.addEventListener('keyup', onKeyUp)
|
|
|
|
emitter.on('grid:move', onGridMove)
|
|
emitter.on('grid:click', onGridClick)
|
|
emitter.on('grid:double-click', onGridDoubleClick)
|
|
emitter.on('tool:cancel', onCancel)
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', onKeyDown)
|
|
document.removeEventListener('keyup', onKeyUp)
|
|
emitter.off('grid:move', onGridMove)
|
|
emitter.off('grid:click', onGridClick)
|
|
emitter.off('grid:double-click', onGridDoubleClick)
|
|
emitter.off('tool:cancel', onCancel)
|
|
}
|
|
}, [currentLevelId, points, cursorPosition, setSelection])
|
|
|
|
// Update line geometries when points change
|
|
useEffect(() => {
|
|
if (!mainLineRef.current || !closingLineRef.current) return
|
|
|
|
if (points.length === 0) {
|
|
mainLineRef.current.visible = false
|
|
closingLineRef.current.visible = false
|
|
return
|
|
}
|
|
|
|
const ceilingY = levelY + CEILING_HEIGHT
|
|
const snappedCursor = snappedCursorPosition
|
|
|
|
// Build main line points
|
|
const linePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, ceilingY, z))
|
|
linePoints.push(new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]))
|
|
|
|
const gridY = levelY + GRID_OFFSET
|
|
const groundLinePoints: Vector3[] = points.map(([x, z]) => new Vector3(x, gridY, z))
|
|
groundLinePoints.push(new Vector3(snappedCursor[0], gridY, snappedCursor[1]))
|
|
|
|
// Update main line
|
|
if (linePoints.length >= 2) {
|
|
mainLineRef.current.geometry.dispose()
|
|
mainLineRef.current.geometry = new BufferGeometry().setFromPoints(linePoints)
|
|
mainLineRef.current.visible = true
|
|
|
|
groundMainLineRef.current.geometry.dispose()
|
|
groundMainLineRef.current.geometry = new BufferGeometry().setFromPoints(groundLinePoints)
|
|
groundMainLineRef.current.visible = true
|
|
} else {
|
|
mainLineRef.current.visible = false
|
|
groundMainLineRef.current.visible = false
|
|
}
|
|
|
|
// Update closing line (from cursor back to first point)
|
|
const firstPoint = points[0]
|
|
if (points.length >= 2 && firstPoint) {
|
|
const closingPoints = [
|
|
new Vector3(snappedCursor[0], ceilingY, snappedCursor[1]),
|
|
new Vector3(firstPoint[0], ceilingY, firstPoint[1]),
|
|
]
|
|
closingLineRef.current.geometry.dispose()
|
|
closingLineRef.current.geometry = new BufferGeometry().setFromPoints(closingPoints)
|
|
closingLineRef.current.visible = true
|
|
|
|
const groundClosingPoints = [
|
|
new Vector3(snappedCursor[0], gridY, snappedCursor[1]),
|
|
new Vector3(firstPoint[0], gridY, firstPoint[1]),
|
|
]
|
|
groundClosingLineRef.current.geometry.dispose()
|
|
groundClosingLineRef.current.geometry = new BufferGeometry().setFromPoints(groundClosingPoints)
|
|
groundClosingLineRef.current.visible = true
|
|
} else {
|
|
closingLineRef.current.visible = false
|
|
groundClosingLineRef.current.visible = false
|
|
}
|
|
}, [points, snappedCursorPosition, levelY])
|
|
|
|
// Create preview shape when we have 3+ points
|
|
const previewShape = useMemo(() => {
|
|
if (points.length < 3) return null
|
|
|
|
const snappedCursor = snappedCursorPosition
|
|
|
|
const allPoints = [...points, snappedCursor]
|
|
|
|
// THREE.Shape is in X-Y plane. After rotation of -PI/2 around X:
|
|
// - Shape X -> World X
|
|
// - Shape Y -> World -Z (so we negate Z to get correct orientation)
|
|
const firstPt = allPoints[0]
|
|
if (!firstPt) return null
|
|
|
|
const shape = new Shape()
|
|
shape.moveTo(firstPt[0], -firstPt[1])
|
|
|
|
for (let i = 1; i < allPoints.length; i++) {
|
|
const pt = allPoints[i]
|
|
if (pt) {
|
|
shape.lineTo(pt[0], -pt[1])
|
|
}
|
|
}
|
|
shape.closePath()
|
|
|
|
return shape
|
|
}, [points, snappedCursorPosition])
|
|
|
|
return (
|
|
<group>
|
|
{/* Cursor at ceiling height */}
|
|
<CursorSphere ref={cursorRef} />
|
|
|
|
{/* Grid-level cursor indicator */}
|
|
<mesh ref={gridCursorRef} rotation={[-Math.PI / 2, 0, 0]} renderOrder={2}>
|
|
<ringGeometry args={[0.15, 0.2, 32]} />
|
|
<meshBasicMaterial color="#818cf8" side={DoubleSide} depthTest={false} depthWrite={true} opacity={0.5} transparent />
|
|
</mesh>
|
|
|
|
{/* Vertical connector: local y=0 at grid, y=H at ceiling; position.y set to gridY on move */}
|
|
{/* @ts-ignore */}
|
|
<line ref={verticalLineRef} geometry={verticalGeo} renderOrder={1}>
|
|
<lineBasicNodeMaterial color="#818cf8" opacityNode={gradientOpacityNode} depthTest={false} depthWrite={false} transparent />
|
|
</line>
|
|
|
|
{/* Preview fill (Top) */}
|
|
{previewShape && (
|
|
<mesh
|
|
frustumCulled={false}
|
|
position={[0, levelY + CEILING_HEIGHT, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
>
|
|
<shapeGeometry args={[previewShape]} />
|
|
<meshBasicMaterial
|
|
color="#818cf8"
|
|
depthTest={false}
|
|
opacity={0.15}
|
|
side={DoubleSide}
|
|
transparent
|
|
/>
|
|
</mesh>
|
|
)}
|
|
|
|
{/* Preview fill (Ground) */}
|
|
{previewShape && (
|
|
<mesh
|
|
frustumCulled={false}
|
|
position={[0, levelY + GRID_OFFSET, 0]}
|
|
rotation={[-Math.PI / 2, 0, 0]}
|
|
>
|
|
<shapeGeometry args={[previewShape]} />
|
|
<meshBasicMaterial
|
|
color="#818cf8"
|
|
depthTest={false}
|
|
opacity={0.1}
|
|
side={DoubleSide}
|
|
transparent
|
|
/>
|
|
</mesh>
|
|
)}
|
|
|
|
{/* Main line */}
|
|
{/* @ts-ignore */}
|
|
<line ref={mainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
|
<bufferGeometry />
|
|
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} />
|
|
</line>
|
|
|
|
{/* Closing line */}
|
|
{/* @ts-ignore */}
|
|
<line ref={closingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
|
<bufferGeometry />
|
|
<lineBasicNodeMaterial
|
|
color="#818cf8"
|
|
linewidth={2}
|
|
depthTest={false}
|
|
depthWrite={false}
|
|
opacity={0.5}
|
|
transparent
|
|
/>
|
|
</line>
|
|
|
|
{/* Ground main line */}
|
|
{/* @ts-ignore */}
|
|
<line ref={groundMainLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
|
<bufferGeometry />
|
|
<lineBasicNodeMaterial color="#818cf8" linewidth={3} depthTest={false} depthWrite={false} opacity={0.3} transparent />
|
|
</line>
|
|
|
|
{/* Ground closing line */}
|
|
{/* @ts-ignore */}
|
|
<line ref={groundClosingLineRef} frustumCulled={false} renderOrder={1} visible={false}>
|
|
<bufferGeometry />
|
|
<lineBasicNodeMaterial
|
|
color="#818cf8"
|
|
linewidth={2}
|
|
depthTest={false}
|
|
depthWrite={false}
|
|
opacity={0.15}
|
|
transparent
|
|
/>
|
|
</line>
|
|
|
|
{/* Point markers */}
|
|
{points.map(([x, z], index) => (
|
|
<CursorSphere
|
|
key={index}
|
|
position={[x, levelY + CEILING_HEIGHT + 0.01, z]}
|
|
color="#818cf8"
|
|
showTooltip={false}
|
|
/>
|
|
))}
|
|
</group>
|
|
)
|
|
}
|