Merge remote-tracking branch 'origin/main' into feat/mcp-server

This commit is contained in:
Aymeric Rabot
2026-04-27 17:22:14 -04:00
82 changed files with 4729 additions and 1063 deletions
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "@pascal-app/editor",
"version": "0.5.1",
"version": "0.6.0",
"description": "Pascal building editor component",
"type": "module",
"exports": {
@@ -11,8 +11,8 @@
"check-types": "tsc --noEmit"
},
"peerDependencies": {
"@pascal-app/core": "^0.5.1",
"@pascal-app/viewer": "^0.5.1",
"@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.6.0",
"@react-three/drei": "^10",
"@react-three/fiber": "^9",
"next": ">=15",
@@ -50,8 +50,8 @@
"zustand": "^5.0.11"
},
"devDependencies": {
"@pascal-app/core": "^0.5.1",
"@pascal-app/viewer": "^0.5.1",
"@pascal-app/core": "^0.6.0",
"@pascal-app/viewer": "^0.6.0",
"@pascal/typescript-config": "*",
"@types/howler": "^2.2.12",
"@types/node": "^22.19.12",
@@ -49,9 +49,13 @@ export function FloatingActionMenu() {
const mode = useEditor((s) => s.mode)
const isFloorplanHovered = useEditor((s) => s.isFloorplanHovered)
const movingWallEndpoint = useEditor((s) => s.movingWallEndpoint)
const movingFenceEndpoint = useEditor((s) => s.movingFenceEndpoint)
const curvingFence = useEditor((s) => s.curvingFence)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setMovingWallEndpoint = useEditor((s) => s.setMovingWallEndpoint)
const setMovingFenceEndpoint = useEditor((s) => s.setMovingFenceEndpoint)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const setSelection = useViewer((s) => s.setSelection)
const setEditingHole = useEditor((s) => s.setEditingHole)
@@ -128,12 +132,26 @@ export function FloatingActionMenu() {
groupRef.current.position.set(center.x, box.max.y + yOffset, center.z)
}
if (node?.type === 'wall') {
const wall = node as WallNode
const wallLength = Math.hypot(wall.end[0] - wall.start[0], wall.end[1] - wall.start[1])
if (node?.type === 'wall' || node?.type === 'fence') {
const segment = node as WallNode | FenceNode
const endpointYOffset = 0.35
const startWorld = obj.localToWorld(new THREE.Vector3(0, 0, 0))
const endWorld = obj.localToWorld(new THREE.Vector3(wallLength, 0, 0))
const startWorld =
node.type === 'wall'
? obj.localToWorld(new THREE.Vector3(0, 0, 0))
: obj.localToWorld(new THREE.Vector3(segment.start[0], 0, segment.start[1]))
const endWorld =
node.type === 'wall'
? obj.localToWorld(
new THREE.Vector3(
Math.hypot(
segment.end[0] - segment.start[0],
segment.end[1] - segment.start[1],
),
0,
0,
),
)
: obj.localToWorld(new THREE.Vector3(segment.end[0], 0, segment.end[1]))
if (startEndpointGroupRef.current) {
startEndpointGroupRef.current.position.set(
@@ -180,22 +198,35 @@ export function FloatingActionMenu() {
const handleCurve = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
if (!canCurveSelectedWall || !node || node.type !== 'wall') return
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setCurvingWall(node)
if (node.type === 'wall') {
if (!canCurveSelectedWall) return
setCurvingWall(node)
} else if (node.type === 'fence') {
setCurvingFence(node)
} else {
return
}
setSelection({ selectedIds: [] })
},
[canCurveSelectedWall, node, setCurvingWall, setSelection],
[canCurveSelectedWall, node, setCurvingFence, setCurvingWall, setSelection],
)
const handleEndpointMove = useCallback(
(endpoint: 'start' | 'end', e: React.MouseEvent) => {
e.stopPropagation()
if (!(node && node.type === 'wall')) return
if (!node) return
sfxEmitter.emit('sfx:item-pick')
setMovingWallEndpoint({ wall: node, endpoint })
if (node.type === 'wall') {
setMovingWallEndpoint({ wall: node, endpoint })
} else if (node.type === 'fence') {
setMovingFenceEndpoint({ fence: node, endpoint })
} else {
return
}
setSelection({ selectedIds: [] })
},
[node, setMovingWallEndpoint, setSelection],
[node, setMovingFenceEndpoint, setMovingWallEndpoint, setSelection],
)
const handleDuplicate = useCallback(
@@ -396,7 +427,9 @@ export function FloatingActionMenu() {
if (
!(selectedId && node && isValidType && !isFloorplanHovered && mode !== 'delete') ||
movingWallEndpoint
movingWallEndpoint ||
movingFenceEndpoint ||
curvingFence
)
return null
@@ -413,7 +446,11 @@ export function FloatingActionMenu() {
>
<NodeActionMenu
onAddHole={node && HOLE_TYPES.includes(node.type) ? handleAddHole : undefined}
onCurve={canCurveSelectedWall ? handleCurve : undefined}
onCurve={
node?.type === 'fence' || (node?.type === 'wall' && canCurveSelectedWall)
? handleCurve
: undefined
}
onDelete={handleDelete}
onDuplicate={
node && !DELETE_ONLY_TYPES.includes(node.type) && !HOLE_TYPES.includes(node.type)
@@ -426,7 +463,7 @@ export function FloatingActionMenu() {
/>
</Html>
</group>
{node?.type === 'wall' && (
{(node?.type === 'wall' || node?.type === 'fence') && (
<>
<group ref={startEndpointGroupRef}>
<Html
@@ -435,7 +472,7 @@ export function FloatingActionMenu() {
zIndexRange={[100, 0]}
>
<button
aria-label="Move wall start"
aria-label={node.type === 'wall' ? 'Move wall start' : 'Move fence start'}
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
@@ -443,7 +480,11 @@ export function FloatingActionMenu() {
}`}
onClick={(e) => handleEndpointMove('start', e)}
onPointerDown={(e) => e.stopPropagation()}
title="Move wall start (Alt to detach)"
title={
node.type === 'wall'
? 'Move wall start (Alt to detach)'
: 'Move fence start (Alt to detach)'
}
type="button"
>
<Move className="h-4 w-4" />
@@ -457,7 +498,7 @@ export function FloatingActionMenu() {
zIndexRange={[100, 0]}
>
<button
aria-label="Move wall end"
aria-label={node.type === 'wall' ? 'Move wall end' : 'Move fence end'}
className={`pointer-events-auto flex h-8 w-8 items-center justify-center rounded-full border bg-background/95 shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/80 bg-amber-500/15 text-amber-100 hover:bg-amber-500/20 hover:text-white'
@@ -465,7 +506,11 @@ export function FloatingActionMenu() {
}`}
onClick={(e) => handleEndpointMove('end', e)}
onPointerDown={(e) => e.stopPropagation()}
title="Move wall end (Alt to detach)"
title={
node.type === 'wall'
? 'Move wall end (Alt to detach)'
: 'Move fence end (Alt to detach)'
}
type="button"
>
<Move className="h-4 w-4" />
@@ -159,6 +159,12 @@ type FloorplanViewport = {
width: number
}
function floorplanViewportEquals(a: FloorplanViewport | null, b: FloorplanViewport | null) {
if (a === b) return true
if (!(a && b)) return false
return a.centerX === b.centerX && a.centerY === b.centerY && a.width === b.width
}
type SvgPoint = {
x: number
y: number
@@ -4772,8 +4778,9 @@ const FloorplanActionMenuLayer = memo(function FloorplanActionMenuLayer({
const isFloorplanHovered = useEditor((state) => state.isFloorplanHovered)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
if (!isFloorplanHovered || movingNode || curvingWall) {
if (!isFloorplanHovered || movingNode || curvingWall || curvingFence) {
return null
}
@@ -4976,6 +4983,7 @@ export function FloorplanPanel() {
const setMode = useEditor((state) => state.setMode)
const movingNode = useEditor((state) => state.movingNode)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const setPhase = useEditor((state) => state.setPhase)
@@ -5650,6 +5658,7 @@ export function FloorplanPanel() {
const isCeilingMoveActive = movingNode?.type === 'ceiling'
const isWallMoveActive = movingNode?.type === 'wall'
const isWallCurveActive = curvingWall?.type === 'wall'
const isFenceCurveActive = curvingFence?.type === 'fence'
const isItemPlacementPreviewActive =
(mode === 'build' && tool === 'item') || movingNode?.type === 'item'
const isFloorItemBuildActive = mode === 'build' && tool === 'item' && !selectedItem?.attachTo
@@ -5661,6 +5670,7 @@ export function FloorplanPanel() {
isCeilingMoveActive ||
isWallMoveActive ||
isWallCurveActive ||
isFenceCurveActive ||
isFloorItemBuildActive ||
isFloorItemMoveActive
const floorplanPreviewStairSegment = useMemo(
@@ -6155,12 +6165,12 @@ export function FloorplanPanel() {
if (levelChanged) {
previousLevelIdRef.current = levelId ?? null
hasUserAdjustedViewportRef.current = false
setViewport(fittedViewport)
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
return
}
if (!hasUserAdjustedViewportRef.current) {
setViewport(fittedViewport)
setViewport((current) => (floorplanViewportEquals(current, fittedViewport) ? current : fittedViewport))
}
}, [fittedViewport, levelId])
+218 -10
View File
@@ -7,8 +7,16 @@ import {
spatialGridManager,
useScene,
} from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import {
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
@@ -64,6 +72,21 @@ const CAMERA_CONTROLS_HINT_DISMISSED_STORAGE_KEY = 'editor-camera-controls-hint-
const DELETE_CURSOR_BADGE_COLOR = '#ef4444'
const DELETE_CURSOR_BADGE_OFFSET_X = 14
const DELETE_CURSOR_BADGE_OFFSET_Y = 14
const PAINT_CURSOR_BADGE_COLOR = '#f59e0b'
const PAINT_CURSOR_BADGE_DISABLED_COLOR = '#94a3b8'
const PAINT_CURSOR_BADGE_OFFSET_X = 14
const PAINT_CURSOR_BADGE_OFFSET_Y = 14
const EDITOR_HOVER_STYLES: HoverStyles = {
default: { visibleColor: 0x00_aaff, hiddenColor: 0xf3_ff47, strength: 5, pulse: true },
delete: { visibleColor: 0xef_4444, hiddenColor: 0x99_1b1b, strength: 6, pulse: false },
'paint-ready': { visibleColor: 0xf5_9e0b, hiddenColor: 0xfd_e068, strength: 5, pulse: true },
'paint-disabled': {
visibleColor: 0x94_a3b8,
hiddenColor: 0x47_5569,
strength: 4,
pulse: false,
},
}
/**
* Wire up module-level singletons (spatial grid, space detection, SFX) for
@@ -502,6 +525,50 @@ function DeleteCursorBadge({ position }: { position: { x: number; y: number } })
)
}
function PaintCursorBadge({
position,
label,
disabled,
icon,
}: {
position: { x: number; y: number }
label: string
disabled: boolean
icon: string
}) {
const accentColor = disabled ? PAINT_CURSOR_BADGE_DISABLED_COLOR : PAINT_CURSOR_BADGE_COLOR
return (
<div
aria-hidden="true"
className="pointer-events-none absolute z-40"
style={{
left: position.x + PAINT_CURSOR_BADGE_OFFSET_X,
top: position.y + PAINT_CURSOR_BADGE_OFFSET_Y,
}}
>
<div
className="flex items-center gap-2 rounded-xl border border-white/5 bg-zinc-900/95 px-3 py-2 shadow-[0_8px_16px_-4px_rgba(0,0,0,0.3),0_4px_8px_-4px_rgba(0,0,0,0.2)]"
style={{
boxShadow: `0 8px 16px -4px rgba(0,0,0,0.3), 0 4px 8px -4px rgba(0,0,0,0.2), 0 0 18px ${accentColor}22`,
}}
>
<Icon
aria-hidden="true"
className="drop-shadow-[0_2px_4px_rgba(0,0,0,0.5)]"
color={accentColor}
height={16}
icon={icon}
width={16}
/>
<span className="font-medium text-[11px]" style={{ color: accentColor }}>
{label}
</span>
</div>
</div>
)
}
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
const ViewerSceneContent = memo(function ViewerSceneContent({
@@ -553,31 +620,165 @@ function DeleteCursorLayer({
isVersionPreviewMode: boolean
}) {
const mode = useEditor((s) => s.mode)
const [position, setPosition] = useState<{ x: number; y: number } | null>(null)
const badgeRef = useRef<HTMLDivElement>(null)
const active = mode === 'delete' && !isVersionPreviewMode
useEffect(() => {
if (!active) {
setPosition(null)
if (badgeRef.current) {
badgeRef.current.style.display = 'none'
}
return
}
const el = containerRef.current
if (!el) return
let frame = 0
let nextX = 0
let nextY = 0
const badge = badgeRef.current
const flushPosition = () => {
frame = 0
if (!badge) return
badge.style.display = 'block'
badge.style.transform = `translate(${nextX + DELETE_CURSOR_BADGE_OFFSET_X}px, ${nextY + DELETE_CURSOR_BADGE_OFFSET_Y}px)`
}
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect()
setPosition({ x: e.clientX - rect.left, y: e.clientY - rect.top })
nextX = e.clientX - rect.left
nextY = e.clientY - rect.top
if (frame === 0) {
frame = window.requestAnimationFrame(flushPosition)
}
}
const onLeave = () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
frame = 0
}
if (badge) {
badge.style.display = 'none'
}
}
const onLeave = () => setPosition(null)
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerleave', onLeave)
return () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
}
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerleave', onLeave)
}
}, [active, containerRef])
if (!(active && position)) return null
return <DeleteCursorBadge position={position} />
if (!active) return null
return (
<div
className="pointer-events-none"
ref={badgeRef}
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
>
<DeleteCursorBadge position={{ x: 0, y: 0 }} />
</div>
)
}
function PaintCursorLayer({
containerRef,
isVersionPreviewMode,
}: {
containerRef: React.RefObject<HTMLDivElement | null>
isVersionPreviewMode: boolean
}) {
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
const activePaintTarget = useEditor((s) => s.activePaintTarget)
const badgeRef = useRef<HTMLDivElement>(null)
const active = mode === 'material-paint' && !isVersionPreviewMode
useEffect(() => {
if (!active) {
if (badgeRef.current) {
badgeRef.current.style.display = 'none'
}
return
}
const el = containerRef.current
if (!el) return
let frame = 0
let nextX = 0
let nextY = 0
const badge = badgeRef.current
const flushPosition = () => {
frame = 0
if (!badge) return
badge.style.display = 'block'
badge.style.transform = `translate(${nextX + PAINT_CURSOR_BADGE_OFFSET_X}px, ${nextY + PAINT_CURSOR_BADGE_OFFSET_Y}px)`
}
const onMove = (e: PointerEvent) => {
const rect = el.getBoundingClientRect()
nextX = e.clientX - rect.left
nextY = e.clientY - rect.top
if (frame === 0) {
frame = window.requestAnimationFrame(flushPosition)
}
}
const onLeave = () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
frame = 0
}
if (badge) {
badge.style.display = 'none'
}
}
el.addEventListener('pointermove', onMove)
el.addEventListener('pointerleave', onLeave)
return () => {
if (frame !== 0) {
window.cancelAnimationFrame(frame)
}
el.removeEventListener('pointermove', onMove)
el.removeEventListener('pointerleave', onLeave)
}
}, [active, containerRef])
const hasMaterial = Boolean(
activePaintMaterial &&
(activePaintMaterial.material !== undefined ||
activePaintMaterial.materialPreset !== undefined),
)
const label = !hasMaterial ? 'Choose material' : `Paint ${activePaintTarget}`
const icon = 'mdi:format-color-fill'
useLayoutEffect(() => {
if (!active && badgeRef.current) {
badgeRef.current.style.display = 'none'
}
}, [active])
if (!active) return null
return (
<div
className="pointer-events-none"
ref={badgeRef}
style={{ display: 'none', position: 'absolute', left: 0, top: 0 }}
>
<PaintCursorBadge
disabled={!hasMaterial}
icon={icon}
label={label}
position={{ x: 0, y: 0 }}
/>
</div>
)
}
// ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
@@ -685,6 +886,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
containerRef={viewer3dRef}
isVersionPreviewMode={isVersionPreviewMode}
/>
<PaintCursorLayer
containerRef={viewer3dRef}
isVersionPreviewMode={isVersionPreviewMode}
/>
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
<ViewerCanvasControlsHint
isPreviewMode={isPreviewMode}
@@ -692,7 +897,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
/>
) : null}
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
<Viewer
hoverStyles={EDITOR_HOVER_STYLES}
selectionManager={isFirstPersonMode ? 'default' : 'custom'}
>
<ViewerSceneContent
isFirstPersonMode={isFirstPersonMode}
isLoading={isLoading}
@@ -825,7 +1033,7 @@ export default function Editor({
const showLoader = isLoading || isSceneLoading
const previewViewerContent = (
<Viewer selectionManager="default">
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
<ExportManager />
<ViewerZoneSystem />
<CeilingSystem />
@@ -2,19 +2,56 @@ import {
type AnyNode,
type AnyNodeId,
type BuildingNode,
type CeilingNode,
emitter,
type FenceNode,
getMaterialPresetByRef,
type ItemNode,
type NodeEvent,
type RoofEvent,
type RoofNode,
type RoofSegmentEvent,
resolveLevelId,
resolveMaterial,
type SlabNode,
type StairEvent,
type StairNode,
type StairSegmentEvent,
type StairSurfaceMaterialRole,
sceneRegistry,
useScene,
type WallEvent,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import {
applyMaterialPresetToMaterials,
createMaterial,
createMaterialFromPresetRef,
getRoofMaterialArray,
getStairBodyMaterials,
getStairRailingMaterial,
getVisibleWallMaterials,
useViewer,
} from '@pascal-app/viewer'
import { useCallback, useEffect, useRef } from 'react'
import { Color, type Material, type Mesh, type Object3D } from 'three'
import { type BufferGeometry, Color, type Material, type Mesh, type Object3D } from 'three'
import {
type ActivePaintMaterial,
buildRoofSurfaceMaterialPatch,
buildSingleSurfaceMaterialPatch,
buildStairSurfaceMaterialPatch,
buildWallSurfaceMaterialPatch,
hasActivePaintMaterial,
resolveActivePaintMaterialFromSelection,
} from '../../lib/material-paint'
import { sfxEmitter } from '../../lib/sfx-bus'
import useEditor, { type Phase, type StructureLayer } from './../../store/use-editor'
import useEditor, {
type MaterialTargetRole,
type Phase,
type StructureLayer,
} from './../../store/use-editor'
import { boxSelectHandled } from '../tools/select/box-select-tool'
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
@@ -44,6 +81,16 @@ type ModifierKeys = {
ctrl: boolean
}
type PaintPreviewCleanup = () => void
type PaintInteraction = {
key: string
apply: (() => void) | null
hoverMode: HoverHighlightMode
hoveredId: AnyNodeId
preview: (() => PaintPreviewCleanup | null) | null
}
interface SelectionStrategy {
types: SelectableNodeType[]
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
@@ -68,6 +115,310 @@ export const resolveBuildingId = (
return null
}
function resolveWallMaterialTarget(event: WallEvent): WallSurfaceSide | null {
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (materialIndex === 1) return 'interior'
if (materialIndex === 2) return 'exterior'
const normalZ = event.normal?.[2]
const localZ = event.localPosition[2]
const thickness = event.node.thickness ?? 0.1
if (
normalZ === undefined ||
Math.abs(normalZ) < 0.65 ||
Math.abs(localZ) < Math.max(thickness * 0.2, 0.01)
) {
return null
}
const hitFace = localZ >= 0 ? 'front' : 'back'
const semantic = hitFace === 'front' ? event.node.frontSide : event.node.backSide
if (semantic === 'interior' || semantic === 'exterior') {
return semantic
}
return hitFace === 'front' ? 'interior' : 'exterior'
}
function resolveStairMaterialTarget(
event: StairEvent | StairSegmentEvent,
): StairSurfaceMaterialRole | null {
const hitObjectName = event.nativeEvent.object?.name ?? ''
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (hitObjectName.startsWith('stair-railing')) {
return 'railing'
}
if (hitObjectName.startsWith('stair-side')) {
return 'side'
}
if (materialIndex === 0) {
return 'tread'
}
if (materialIndex === 1) {
return 'side'
}
const normalY = event.normal?.[1]
if (normalY !== undefined && normalY > 0.75) {
return 'tread'
}
if (normalY !== undefined && Math.abs(normalY) <= 0.75) {
return 'side'
}
return null
}
function resolveRoofMaterialTarget(
event: RoofEvent | RoofSegmentEvent,
): 'top' | 'edge' | 'wall' | null {
const materialIndex = getIntersectionMaterialIndex(getEventObject(event), event.faceIndex)
if (materialIndex === 3) return 'top'
if (materialIndex === 0) return 'edge'
if (materialIndex === 1 || materialIndex === 2) return 'wall'
const normalY = event.normal?.[1]
if (normalY !== undefined && normalY > 0.35) return 'top'
if (normalY !== undefined && Math.abs(normalY) <= 0.35) return 'edge'
if (normalY !== undefined && normalY < -0.35) return 'wall'
return null
}
function getEventObject(event: NodeEvent): Object3D {
const eventWithObject = event as NodeEvent & { object?: Object3D }
return eventWithObject.object ?? event.nativeEvent.object
}
function getIntersectionMaterialIndex(
object: Object3D,
faceIndex: number | undefined,
): number | undefined {
if (faceIndex === undefined) return undefined
const geometry = (object as Mesh).geometry as BufferGeometry | undefined
if (!geometry || geometry.groups.length === 0) return undefined
const triangleStart = faceIndex * 3
const group = geometry.groups.find(
(entry) => triangleStart >= entry.start && triangleStart < entry.start + entry.count,
)
return group?.materialIndex
}
function getRegisteredNodeObject(nodeId: string): Object3D | null {
return sceneRegistry.nodes.get(nodeId) ?? null
}
function getRegisteredMesh(nodeId: string): Mesh | null {
const object = getRegisteredNodeObject(nodeId)
return object && (object as Mesh).isMesh ? (object as Mesh) : null
}
function previewMeshMaterial(mesh: Mesh, material: Material | Material[]): PaintPreviewCleanup {
const previousMaterial = mesh.material
mesh.material = material
return () => {
mesh.material = previousMaterial
}
}
function previewCursor(cursor: string): PaintPreviewCleanup {
const previousCursor = document.body.style.cursor
document.body.style.cursor = cursor
return () => {
document.body.style.cursor = previousCursor
}
}
function getSingleSurfacePreviewMaterial(material: ActivePaintMaterial): Material | null {
if (material.materialPreset) {
return createMaterialFromPresetRef(material.materialPreset)
}
if (material.material) {
return createMaterial(material.material)
}
return null
}
function applyWallPaintPreview(
node: WallNode,
role: WallSurfaceSide,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const mesh = getRegisteredMesh(node.id)
if (!mesh) return null
const previewNode = {
...node,
...buildWallSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
return previewMeshMaterial(mesh, getVisibleWallMaterials(previewNode))
}
function applyRoofPaintPreview(
node: RoofNode,
role: 'top' | 'edge' | 'wall',
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const root = getRegisteredNodeObject(node.id)
const mesh = root?.getObjectByName('merged-roof') as Mesh | undefined
if (!mesh) return null
const previewNode = {
...node,
...buildRoofSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
const previewMaterial = getRoofMaterialArray(previewNode)
if (!previewMaterial) return null
return previewMeshMaterial(mesh, previewMaterial)
}
function applyStairPaintPreview(
node: StairNode,
role: StairSurfaceMaterialRole,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
const root = getRegisteredNodeObject(node.id)
if (!root) return null
const previewNode = {
...node,
...buildStairSurfaceMaterialPatch(node, role, material.material, material.materialPreset),
}
const bodyMaterials = getStairBodyMaterials(previewNode)
const railingMaterial = getStairRailingMaterial(previewNode)
const restores: PaintPreviewCleanup[] = []
root.traverse((object) => {
if (!(object as Mesh).isMesh) return
const mesh = object as Mesh
if (mesh.name.startsWith('stair-railing')) {
restores.push(previewMeshMaterial(mesh, railingMaterial))
return
}
if (Array.isArray(mesh.material) && mesh.material.length === 2) {
restores.push(previewMeshMaterial(mesh, bodyMaterials))
return
}
if (mesh.name === 'merged-stair') {
restores.push(previewMeshMaterial(mesh, bodyMaterials))
return
}
if (mesh.name.startsWith('stair-side')) {
restores.push(previewMeshMaterial(mesh, bodyMaterials[1]))
}
})
if (restores.length === 0) return null
return () => {
for (let index = restores.length - 1; index >= 0; index -= 1) {
restores[index]?.()
}
}
}
function applySingleSurfacePaintPreview(
node: FenceNode | SlabNode | CeilingNode,
material: ActivePaintMaterial,
): PaintPreviewCleanup | null {
if (node.type === 'ceiling') {
const root = getRegisteredMesh(node.id)
const overlay = root?.getObjectByName('ceiling-grid') as Mesh | undefined
if (!root || !overlay) return null
const previewColor =
getMaterialPresetByRef(material.materialPreset)?.mapProperties.color ??
resolveMaterial(material.material).color ??
'#999999'
const previousRootMaterial = root.material
const previousOverlayMaterial = overlay.material
const rootPreviewMaterial = Array.isArray(previousRootMaterial)
? previousRootMaterial.map((entry) => entry.clone())
: previousRootMaterial.clone()
const overlayPreviewMaterial = Array.isArray(previousOverlayMaterial)
? previousOverlayMaterial.map((entry) => entry.clone())
: previousOverlayMaterial.clone()
const applyColor = (input: Material | Material[]) => {
const materials = Array.isArray(input) ? input : [input]
for (const entry of materials) {
const materialWithColor = entry as Material & { color?: Color; needsUpdate?: boolean }
if (materialWithColor.color instanceof Color) {
materialWithColor.color = new Color(previewColor)
}
materialWithColor.needsUpdate = true
}
}
applyColor(rootPreviewMaterial)
applyColor(overlayPreviewMaterial)
root.material = rootPreviewMaterial
overlay.material = overlayPreviewMaterial
return () => {
root.material = previousRootMaterial
overlay.material = previousOverlayMaterial
}
}
const mesh = getRegisteredMesh(node.id)
if (!mesh) return null
const previewMaterial = getSingleSurfacePreviewMaterial(material)
if (!previewMaterial) return null
if (node.type === 'slab') {
const slabMaterial = previewMaterial.clone()
applyMaterialPresetToMaterials(slabMaterial, getMaterialPresetByRef(material.materialPreset))
const previewMeshMaterialInput = slabMaterial as Material & {
alphaMap?: unknown
depthWrite?: boolean
needsUpdate?: boolean
opacity?: number
side?: number
transparent?: boolean
}
previewMeshMaterialInput.transparent = false
previewMeshMaterialInput.opacity = 1
previewMeshMaterialInput.alphaMap = null
previewMeshMaterialInput.depthWrite = true
previewMeshMaterialInput.needsUpdate = true
return previewMeshMaterial(mesh, slabMaterial)
}
return previewMeshMaterial(mesh, previewMaterial)
}
function setSelectedMaterialTargetForNode(node: AnyNode, role: MaterialTargetRole | null) {
if (!role) {
const currentTarget = useEditor.getState().selectedMaterialTarget
if (currentTarget?.nodeId !== node.id) {
useEditor.getState().setSelectedMaterialTarget(null)
}
return
}
useEditor.getState().setSelectedMaterialTarget({
nodeId: node.id as AnyNodeId,
role,
})
}
const HIGHLIGHT_PROFILES = {
delete: {
color: new Color('#dc2626'),
@@ -84,6 +435,7 @@ const HIGHLIGHT_PROFILES = {
} as const
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
type HighlightableMaterial = Material & {
color?: Color
@@ -347,15 +699,295 @@ export const SelectionManager = () => {
const movingNode = useEditor((s) => s.movingNode)
const curvingWall = useEditor((s) => s.curvingWall)
const curvingFence = useEditor((s) => s.curvingFence)
useEffect(() => {
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
setHoverHighlightMode(nextHoverMode)
return () => {
setHoverHighlightMode('default')
}
}, [mode, setHoverHighlightMode])
useEffect(() => {
if (mode !== 'material-paint') return
if (movingNode || curvingWall) return
let activePreview: { key: string; restore: PaintPreviewCleanup } | null = null
const clearActivePreview = () => {
activePreview?.restore()
activePreview = null
}
const resolveActivePaintMaterial = () =>
useEditor.getState().activePaintMaterial ??
resolveActivePaintMaterialFromSelection({
nodes: useScene.getState().nodes,
selectedId:
useViewer.getState().selection.selectedIds.length === 1
? (useViewer.getState().selection.selectedIds[0] ?? null)
: null,
selectedMaterialTarget: useEditor.getState().selectedMaterialTarget,
})
const getPaintInteraction = (event: NodeEvent): PaintInteraction | null => {
const activePaintMaterial = resolveActivePaintMaterial()
const node = event.node
if (!isNodeInCurrentLevel(node)) return null
if (node.type === 'wall') {
const role = resolveWallMaterialTarget(event as WallEvent)
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
return {
key: `wall:${node.id}:${role ?? 'unsupported'}`,
hoveredId: node.id as AnyNodeId,
hoverMode:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? 'paint-ready'
: 'paint-disabled',
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
useScene
.getState()
.updateNode(
node.id as AnyNodeId,
buildWallSurfaceMaterialPatch(
node as WallNode,
role!,
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
)
}
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? () => applyWallPaintPreview(node as WallNode, role, activePaintMaterial)
: () => previewCursor('not-allowed'),
}
}
if (node.type === 'roof' || node.type === 'roof-segment') {
const roofNode =
node.type === 'roof'
? node
: node.parentId
? useScene.getState().nodes[node.parentId as AnyNodeId]
: null
if (!roofNode || roofNode.type !== 'roof') return null
const role = resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent)
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
return {
key: `roof:${roofNode.id}:${role ?? 'unsupported'}`,
hoveredId: roofNode.id as AnyNodeId,
hoverMode:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? 'paint-ready'
: 'paint-disabled',
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
useScene
.getState()
.updateNode(
roofNode.id as AnyNodeId,
buildRoofSurfaceMaterialPatch(
roofNode as RoofNode,
role!,
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
)
}
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? () => applyRoofPaintPreview(roofNode as RoofNode, role, activePaintMaterial)
: () => previewCursor('not-allowed'),
}
}
if (node.type === 'stair' || node.type === 'stair-segment') {
const stairNode =
node.type === 'stair'
? node
: node.parentId
? useScene.getState().nodes[node.parentId as AnyNodeId]
: null
if (!stairNode || stairNode.type !== 'stair') return null
const role = resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent)
const compatible = role !== null && hasActivePaintMaterial(activePaintMaterial)
return {
key: `stair:${stairNode.id}:${role ?? 'unsupported'}`,
hoveredId: stairNode.id as AnyNodeId,
hoverMode:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? 'paint-ready'
: 'paint-disabled',
apply:
compatible && hasActivePaintMaterial(activePaintMaterial)
? () => {
useScene
.getState()
.updateNode(
stairNode.id as AnyNodeId,
buildStairSurfaceMaterialPatch(
stairNode as StairNode,
role!,
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
)
}
: null,
preview:
compatible && hasActivePaintMaterial(activePaintMaterial) && role
? () => applyStairPaintPreview(stairNode as StairNode, role, activePaintMaterial)
: () => previewCursor('not-allowed'),
}
}
if (node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') {
const compatible = hasActivePaintMaterial(activePaintMaterial)
return {
key: `${node.type}:${node.id}:surface`,
hoveredId: node.id as AnyNodeId,
hoverMode: compatible ? 'paint-ready' : 'paint-disabled',
apply: compatible
? () => {
useScene
.getState()
.updateNode(
node.id as AnyNodeId,
buildSingleSurfaceMaterialPatch<FenceNode | SlabNode | CeilingNode>(
activePaintMaterial.material,
activePaintMaterial.materialPreset,
),
)
}
: null,
preview: compatible
? () =>
applySingleSurfacePaintPreview(
node as FenceNode | SlabNode | CeilingNode,
activePaintMaterial,
)
: () => previewCursor('not-allowed'),
}
}
const disabledNodeTypes = ['item', 'window', 'door', 'zone']
if (disabledNodeTypes.includes(node.type)) {
return {
key: `${node.type}:${node.id}:unsupported`,
hoveredId: node.id as AnyNodeId,
hoverMode: 'paint-disabled',
apply: null,
preview: () => previewCursor('not-allowed'),
}
}
return null
}
const onEnter = (event: NodeEvent) => {
if (boxSelectHandled) return
const interaction = getPaintInteraction(event)
if (!interaction) return
event.stopPropagation()
if (activePreview?.key === interaction.key) {
return
}
clearActivePreview()
useViewer.setState({ hoveredId: interaction.hoveredId })
setHoverHighlightMode(interaction.hoverMode)
const restore = interaction.preview?.()
if (restore) {
activePreview = { key: interaction.key, restore }
}
}
const onLeave = (event: NodeEvent) => {
const interaction = getPaintInteraction(event)
if (!interaction) return
if (activePreview?.key !== interaction.key) {
return
}
clearActivePreview()
if (useViewer.getState().hoveredId === interaction.hoveredId) {
useViewer.setState({ hoveredId: null })
}
setHoverHighlightMode('default')
}
const onClick = (event: NodeEvent) => {
if (boxSelectHandled) return
const interaction = getPaintInteraction(event)
if (!interaction) return
event.stopPropagation()
if (!interaction.apply) {
return
}
interaction.apply()
if (activePreview?.key === interaction.key) {
activePreview = null
} else {
clearActivePreview()
}
setHoverHighlightMode(interaction.hoverMode)
}
const allTypes = [
'wall',
'fence',
'item',
'slab',
'ceiling',
'roof',
'roof-segment',
'stair',
'stair-segment',
'window',
'door',
'zone',
] as const
for (const type of allTypes) {
emitter.on(`${type}:enter` as any, onEnter as any)
emitter.on(`${type}:leave` as any, onLeave as any)
emitter.on(`${type}:click` as any, onClick as any)
}
return () => {
for (const type of allTypes) {
emitter.off(`${type}:enter` as any, onEnter as any)
emitter.off(`${type}:leave` as any, onLeave as any)
emitter.off(`${type}:click` as any, onClick as any)
}
clearActivePreview()
useViewer.setState({ hoveredId: null })
setHoverHighlightMode('default')
}
}, [curvingWall, mode, movingNode, setHoverHighlightMode])
useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Meta') modifierKeysRef.current.meta = true
@@ -385,7 +1017,7 @@ export const SelectionManager = () => {
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall) return
if (movingNode || curvingWall || curvingFence) return
const onClick = (event: NodeEvent) => {
// Skip if box-select just completed (drag ended over a node)
@@ -439,6 +1071,50 @@ export const SelectionManager = () => {
activeStrategy.handleSelect(nodeToSelect, event.nativeEvent, modifierKeysRef.current)
let nextMaterialTargetHandled = false
if (node.type === 'wall' && nodeToSelect.type === 'wall') {
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveWallMaterialTarget(event as WallEvent),
)
nextMaterialTargetHandled = true
}
if (
(node.type === 'stair' || node.type === 'stair-segment') &&
nodeToSelect.type === 'stair'
) {
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveStairMaterialTarget(event as StairEvent | StairSegmentEvent),
)
nextMaterialTargetHandled = true
}
if (
(node.type === 'roof' || node.type === 'roof-segment') &&
nodeToSelect.type === 'roof'
) {
setSelectedMaterialTargetForNode(
nodeToSelect,
resolveRoofMaterialTarget(event as RoofEvent | RoofSegmentEvent),
)
nextMaterialTargetHandled = true
}
if (
(node.type === 'fence' || node.type === 'slab' || node.type === 'ceiling') &&
nodeToSelect.type === node.type
) {
setSelectedMaterialTargetForNode(nodeToSelect, 'surface')
nextMaterialTargetHandled = true
}
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
useEditor.getState().setSelectedMaterialTarget(null)
}
// Reset the handled flag after a short delay to allow grid:click to be ignored
setTimeout(() => {
clickHandledRef.current = false
@@ -471,6 +1147,7 @@ export const SelectionManager = () => {
const { phase, structureLayer } = useEditor.getState()
const activeStrategy = SELECTION_STRATEGIES[phase]
if (activeStrategy) activeStrategy.handleDeselect()
useEditor.getState().setSelectedMaterialTarget(null)
// When deselecting from zone mode, return to structure select
if (phase === 'structure' && structureLayer === 'zones') {
@@ -486,12 +1163,12 @@ export const SelectionManager = () => {
})
emitter.off('grid:click', onGridClick)
}
}, [curvingWall, mode, movingNode])
}, [curvingFence, curvingWall, mode, movingNode])
// Global double-click handler for auto-switching phases and cross-phase hover
useEffect(() => {
if (mode !== 'select') return
if (movingNode || curvingWall) return
if (movingNode || curvingWall || curvingFence) return
const onEnter = (event: NodeEvent) => {
const node = event.node
@@ -620,7 +1297,7 @@ export const SelectionManager = () => {
emitter.off(`${type}:double-click` as any, onDoubleClick as any)
})
}
}, [curvingWall, mode, movingNode])
}, [curvingFence, curvingWall, mode, movingNode])
// Delete mode: click-to-delete (sledgehammer tool)
useEffect(() => {
@@ -704,6 +1381,12 @@ export const SelectionManager = () => {
}
const SelectionStateSync = () => {
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
const setSelectedMaterialTarget = useEditor((s) => s.setSelectedMaterialTarget)
const singleSelectedId = useViewer((s) =>
s.selection.selectedIds.length === 1 ? s.selection.selectedIds[0] : null,
)
useEffect(() => {
return useScene.subscribe((state) => {
const { buildingId, levelId, zoneId, selectedIds } = useViewer.getState().selection
@@ -732,6 +1415,33 @@ const SelectionStateSync = () => {
})
}, [])
useEffect(() => {
if (!selectedMaterialTarget) return
if (!singleSelectedId) {
setSelectedMaterialTarget(null)
return
}
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
if (
!selectedNode ||
(selectedNode.type !== 'wall' &&
selectedNode.type !== 'fence' &&
selectedNode.type !== 'slab' &&
selectedNode.type !== 'ceiling' &&
selectedNode.type !== 'stair' &&
selectedNode.type !== 'roof')
) {
setSelectedMaterialTarget(null)
return
}
if (selectedMaterialTarget.nodeId !== selectedNode.id) {
setSelectedMaterialTarget(null)
}
}, [selectedMaterialTarget, setSelectedMaterialTarget, singleSelectedId])
return null
}
@@ -831,7 +1541,8 @@ const SelectionMaterialSync = () => {
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
useEffect(() => {
return useScene.subscribe(() => {
return useScene.subscribe((state, prevState) => {
if (state.nodes === prevState.nodes) return
syncSelectionMaterials()
})
}, [syncSelectionMaterials])
@@ -6,7 +6,7 @@ import { useEffect, useRef } from 'react'
* Imperatively toggles the Three.js visibility of roof objects based on the
* editor selection — without causing React re-renders in RoofRenderer.
*
* When a roof (or one of its segments) is selected:
* When a roof-segment is selected:
* - merged-roof mesh is hidden
* - segments-wrapper group is shown (individual segments visible for editing)
* - all children are marked dirty so RoofSystem rebuilds their geometry
@@ -22,14 +22,14 @@ export const RoofEditSystem = () => {
useEffect(() => {
const nodes = useScene.getState().nodes
// Collect which roof nodes should be in "edit mode"
// Collect which roof nodes should be in "edit mode".
// Selecting the roof itself should keep the merged visual intact so
// material appearance does not jump between merged and per-segment meshes.
const activeRoofIds = new Set<string>()
for (const id of selectedIds) {
const node = nodes[id as AnyNodeId]
if (!node) continue
if (node.type === 'roof') {
activeRoofIds.add(id)
} else if (node.type === 'roof-segment' && node.parentId) {
if (node.type === 'roof-segment' && node.parentId) {
activeRoofIds.add(node.parentId)
}
}
@@ -0,0 +1,179 @@
'use client'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallChordFrame,
getWallMidpointHandlePoint,
normalizeWallCurveOffset,
pauseSceneHistory,
resumeSceneHistory,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { getWallGridStep, snapScalarToGrid } from '../wall/wall-drafting'
export const CurveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const activatedAtRef = useRef<number>(Date.now())
const originalCurveOffsetRef = useRef(getClampedWallCurveOffset(node))
const previousCurveOffsetRef = useRef<number | null>(null)
const shiftPressedRef = useRef(false)
const previewOffsetRef = useRef<number>(originalCurveOffsetRef.current)
const initialHandle = getWallMidpointHandlePoint(node)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>([
initialHandle.x,
0,
initialHandle.y,
])
const exitCurveMode = useCallback(() => {
useEditor.getState().setCurvingFence(null)
}, [])
useEffect(() => {
const nodeId = node.id
const originalCurveOffset = originalCurveOffsetRef.current
const chord = getWallChordFrame(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyPreview = (curveOffset: number) => {
if (previewOffsetRef.current === curveOffset) {
return
}
previewOffsetRef.current = curveOffset
const nextNode = {
...node,
curveOffset,
}
const handlePoint = getWallMidpointHandlePoint(nextNode)
setCursorLocalPos([handlePoint.x, 0, handlePoint.y])
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const restoreOriginal = () => {
if (previewOffsetRef.current === originalCurveOffset) {
return
}
previewOffsetRef.current = originalCurveOffset
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
}
const onGridMove = (event: GridEvent) => {
const snapStep = getWallGridStep()
const localX = shiftPressedRef.current
? event.localPosition[0]
: snapScalarToGrid(event.localPosition[0], snapStep)
const localZ = shiftPressedRef.current
? event.localPosition[2]
: snapScalarToGrid(event.localPosition[2], snapStep)
const offsetFromMidpoint =
-(
(localX - chord.midpoint.x) * chord.normal.x +
(localZ - chord.midpoint.y) * chord.normal.y
)
const snappedOffset = shiftPressedRef.current
? offsetFromMidpoint
: snapScalarToGrid(offsetFromMidpoint, snapStep)
const nextCurveOffset = normalizeWallCurveOffset(
node,
Math.max(-maxCurveOffset, Math.min(maxCurveOffset, snappedOffset)),
)
if (
previousCurveOffsetRef.current !== null &&
nextCurveOffset !== previousCurveOffsetRef.current
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousCurveOffsetRef.current = nextCurveOffset
applyPreview(nextCurveOffset)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const curveOffset = previewOffsetRef.current
wasCommitted = true
if (curveOffset !== originalCurveOffset) {
useScene.getState().updateNode(nodeId, { curveOffset: originalCurveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
resumeSceneHistory(useScene)
useScene.getState().updateNode(nodeId, { curveOffset })
useScene.getState().markDirty(nodeId as AnyNodeId)
pauseSceneHistory(useScene)
}
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitCurveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitCurveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
}
}, [exitCurveMode, node])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
</group>
)
}
@@ -1,7 +1,9 @@
import { FenceNode, useScene, type WallNode } from '@pascal-app/core'
import { FenceNode, getWallCurveFrameAt, getWallCurveLength, isCurvedWall, useScene, type WallNode } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { sfxEmitter } from '../../../lib/sfx-bus'
import {
getWallAngleSnapStep,
getWallGridStep,
type WallPlanPoint,
findWallSnapTarget,
isWallLongEnough,
@@ -58,11 +60,16 @@ function findFenceSnapTarget(
continue
}
const candidates: Array<FencePlanPoint | null> = [
fence.start,
fence.end,
projectPointOntoSegment(point, fence),
]
const candidates: Array<FencePlanPoint | null> = [fence.start, fence.end]
if (isCurvedWall(fence)) {
const sampleCount = Math.max(8, Math.ceil(getWallCurveLength(fence) / 0.3))
for (let index = 0; index <= sampleCount; index += 1) {
const frame = getWallCurveFrameAt(fence, index / sampleCount)
candidates.push([frame.point.x, frame.point.y])
}
} else {
candidates.push(projectPointOntoSegment(point, fence))
}
for (const candidate of candidates) {
if (!candidate) {
@@ -94,7 +101,12 @@ export function snapFenceDraftPoint(args: {
ignoreFenceIds?: string[]
}): FencePlanPoint {
const { point, walls, fences, start, angleSnap = false, ignoreFenceIds } = args
const basePoint = start && angleSnap ? snapPointTo45Degrees(start, point) : snapPointToGrid(point)
const gridStep = getWallGridStep()
const angleStep = getWallAngleSnapStep(gridStep)
const basePoint =
start && angleSnap
? snapPointTo45Degrees(start, point, gridStep, angleStep)
: snapPointToGrid(point, gridStep)
const fenceSnapTarget = findFenceSnapTarget(basePoint, fences, ignoreFenceIds)
return fenceSnapTarget ?? findWallSnapTarget(basePoint, walls) ?? basePoint
@@ -0,0 +1,327 @@
'use client'
import {
type AnyNodeId,
type FenceNode,
type WallNode,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
} from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor, { type MovingFenceEndpoint } from '../../../store/use-editor'
import { CursorSphere } from '../shared/cursor-sphere'
import { snapFenceDraftPoint, type FencePlanPoint } from './fence-drafting'
import { isWallLongEnough } from '../wall/wall-drafting'
function samePoint(a: FencePlanPoint, b: FencePlanPoint) {
return a[0] === b[0] && a[1] === b[1]
}
type LinkedFenceSnapshot = {
id: FenceNode['id']
start: FencePlanPoint
end: FencePlanPoint
}
function getLinkedFenceSnapshots(args: {
fenceId: FenceNode['id']
fenceParentId: string | null
originalStart: FencePlanPoint
originalEnd: FencePlanPoint
}) {
const { fenceId, fenceParentId, originalStart, originalEnd } = args
const { nodes } = useScene.getState()
const snapshots: LinkedFenceSnapshot[] = []
for (const node of Object.values(nodes)) {
if (!(node?.type === 'fence' && node.id !== fenceId)) {
continue
}
if ((node.parentId ?? null) !== fenceParentId) {
continue
}
if (
!samePoint(node.start, originalStart) &&
!samePoint(node.start, originalEnd) &&
!samePoint(node.end, originalStart) &&
!samePoint(node.end, originalEnd)
) {
continue
}
snapshots.push({
id: node.id,
start: [...node.start] as FencePlanPoint,
end: [...node.end] as FencePlanPoint,
})
}
return snapshots
}
function getLinkedFenceUpdates(
linkedFences: LinkedFenceSnapshot[],
originalStart: FencePlanPoint,
originalEnd: FencePlanPoint,
nextStart: FencePlanPoint,
nextEnd: FencePlanPoint,
) {
return linkedFences.map((fence) => ({
id: fence.id,
start: samePoint(fence.start, originalStart)
? nextStart
: samePoint(fence.start, originalEnd)
? nextEnd
: fence.start,
end: samePoint(fence.end, originalStart)
? nextStart
: samePoint(fence.end, originalEnd)
? nextEnd
: fence.end,
}))
}
export const MoveFenceEndpointTool: React.FC<{ target: MovingFenceEndpoint }> = ({ target }) => {
const activatedAtRef = useRef<number>(Date.now())
const previousGridPosRef = useRef<FencePlanPoint | null>(null)
const shiftPressedRef = useRef(false)
const altPressedRef = useRef(false)
const nodeIdRef = useRef(target.fence.id)
const originalStartRef = useRef<FencePlanPoint>([...target.fence.start] as FencePlanPoint)
const originalEndRef = useRef<FencePlanPoint>([...target.fence.end] as FencePlanPoint)
const fixedPointRef = useRef<FencePlanPoint>(
target.endpoint === 'start'
? ([...target.fence.end] as FencePlanPoint)
: ([...target.fence.start] as FencePlanPoint),
)
const linkedOriginalsRef = useRef(
getLinkedFenceSnapshots({
fenceId: target.fence.id,
fenceParentId: target.fence.parentId ?? null,
originalStart: target.fence.start,
originalEnd: target.fence.end,
}),
)
const previewRef = useRef<{ start: FencePlanPoint; end: FencePlanPoint } | null>(null)
const [cursorLocalPos, setCursorLocalPos] = useState<[number, number, number]>(() => {
const point = target.endpoint === 'start' ? target.fence.start : target.fence.end
return [point[0], 0, point[1]]
})
const [altPressed, setAltPressed] = useState(false)
const exitMoveMode = useCallback(() => {
useEditor.getState().setMovingFenceEndpoint(null)
}, [])
useEffect(() => {
const nodeId = nodeIdRef.current
const originalStart = originalStartRef.current
const originalEnd = originalEndRef.current
const fixedPoint = fixedPointRef.current
const siblings = Object.values(useScene.getState().nodes)
const levelWalls = siblings.filter(
(node): node is WallNode =>
node?.type === 'wall' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
const levelFences = siblings.filter(
(node): node is FenceNode =>
node?.type === 'fence' && (node.parentId ?? null) === (target.fence.parentId ?? null),
)
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
updates: Array<{ id: FenceNode['id']; start: FencePlanPoint; end: FencePlanPoint }>,
) => {
useScene.getState().updateNodes(
updates.map((entry) => ({
id: entry.id as AnyNodeId,
data: { start: entry.start, end: entry.end },
})),
)
for (const entry of updates) {
useScene.getState().markDirty(entry.id as AnyNodeId)
}
}
const applyPreview = (movingPoint: FencePlanPoint, detachLinkedFences = false) => {
const nextStart = target.endpoint === 'start' ? movingPoint : fixedPoint
const nextEnd = target.endpoint === 'end' ? movingPoint : fixedPoint
previewRef.current = { start: nextStart, end: nextEnd }
setCursorLocalPos([movingPoint[0], 0, movingPoint[1]])
applyNodePreview([
{ id: nodeId, start: nextStart, end: nextEnd },
...(detachLinkedFences
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
nextStart,
nextEnd,
)),
])
}
const restoreOriginal = () => {
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
}
const onGridMove = (event: GridEvent) => {
const planPoint: FencePlanPoint = [event.localPosition[0], event.localPosition[2]]
const snappedPoint = snapFenceDraftPoint({
point: planPoint,
walls: levelWalls,
fences: levelFences,
start: fixedPoint,
angleSnap: !shiftPressedRef.current,
ignoreFenceIds: [nodeId],
})
if (
previousGridPosRef.current &&
(snappedPoint[0] !== previousGridPosRef.current[0] ||
snappedPoint[1] !== previousGridPosRef.current[1])
) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = snappedPoint
applyPreview(snappedPoint, event.nativeEvent.altKey)
}
const onGridClick = (event: GridEvent) => {
if (Date.now() - activatedAtRef.current < 150) {
event.nativeEvent?.stopPropagation?.()
return
}
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
const hasChanged =
!samePoint(preview.start, originalStart) || !samePoint(preview.end, originalEnd)
if (hasChanged && isWallLongEnough(preview.start, preview.end)) {
wasCommitted = true
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
resumeSceneHistory(useScene)
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
? []
: getLinkedFenceUpdates(
linkedOriginalsRef.current,
originalStart,
originalEnd,
preview.start,
preview.end,
)),
])
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
}
useViewer.getState().setSelection({ selectedIds: [nodeId] })
exitMoveMode()
event.nativeEvent?.stopPropagation?.()
}
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitMoveMode()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) {
return
}
if (event.key === 'Shift') {
shiftPressedRef.current = true
}
if (event.key === 'Alt') {
altPressedRef.current = true
setAltPressed(true)
}
}
const onKeyUp = (event: KeyboardEvent) => {
if (event.key === 'Shift') {
shiftPressedRef.current = false
}
if (event.key === 'Alt') {
altPressedRef.current = false
setAltPressed(false)
}
}
const onWindowBlur = () => {
shiftPressedRef.current = false
altPressedRef.current = false
setAltPressed(false)
}
emitter.on('grid:move', onGridMove)
emitter.on('grid:click', onGridClick)
emitter.on('tool:cancel', onCancel)
window.addEventListener('keydown', onKeyDown)
window.addEventListener('keyup', onKeyUp)
window.addEventListener('blur', onWindowBlur)
return () => {
if (!wasCommitted) {
restoreOriginal()
}
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('keyup', onKeyUp)
window.removeEventListener('blur', onWindowBlur)
}
}, [exitMoveMode, target])
return (
<group>
<CursorSphere position={cursorLocalPos} showTooltip={false} />
<Html
position={[cursorLocalPos[0], 0, cursorLocalPos[2]]}
style={{ pointerEvents: 'none', touchAction: 'none' }}
zIndexRange={[100, 0]}
>
<div className="translate-y-10">
<div
className={`whitespace-nowrap rounded-full border px-2 py-1 text-[11px] font-medium shadow-lg backdrop-blur-md transition-colors ${
altPressed
? 'border-amber-500/70 bg-amber-500/15 text-amber-100'
: 'border-border/70 bg-background/90 text-foreground/80'
}`}
>
{altPressed ? 'Detach endpoint' : 'Drag endpoint'}
</div>
</div>
</Html>
</group>
)
}
@@ -167,6 +167,14 @@ export const MoveFenceTool: React.FC<{ node: FenceNode }> = ({ node }) => {
const preview = previewRef.current ?? { start: originalStart, end: originalEnd }
wasCommitted = true
// Restore original baseline while paused so the next resume+update
// registers as a single tracked change (undo reverts to original).
applyNodePreview([
{ id: nodeId, start: originalStart, end: originalEnd },
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume()
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
@@ -1,7 +1,9 @@
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
type RoofNode,
type RoofSegmentNode,
type StairNode,
@@ -9,13 +11,16 @@ import {
sceneRegistry,
useLiveTransforms,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import * as THREE from 'three'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere'
import type { WallPlanPoint } from '../wall/wall-drafting'
export const MoveRoofTool: React.FC<{
node: RoofNode | RoofSegmentNode | StairNode | StairSegmentNode
@@ -118,6 +123,46 @@ export const MoveRoofTool: React.FC<{
}
}
const resolveLevelId = () => {
if (movingNode.type === 'roof' || movingNode.type === 'stair') {
return movingNode.parentId ?? null
}
if (
(movingNode.type === 'roof-segment' || movingNode.type === 'stair-segment') &&
movingNode.parentId
) {
const parentNode = useScene.getState().nodes[movingNode.parentId as AnyNodeId]
return parentNode && 'parentId' in parentNode ? (parentNode.parentId ?? null) : null
}
return null
}
const levelId = resolveLevelId()
const levelNode =
levelId && useScene.getState().nodes[levelId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[levelId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is WallNode => node?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((node): node is FenceNode => node?.type === 'fence')
const buildingId = useViewer.getState().selection.buildingId
const buildingObj = buildingId ? sceneRegistry.nodes.get(buildingId as AnyNodeId) : null
const localToWorldPoint = (localPoint: WallPlanPoint, y: number): [number, number, number] => {
if (buildingObj) {
const worldPoint = buildingObj.localToWorld(new THREE.Vector3(localPoint[0], y, localPoint[1]))
return [worldPoint.x, worldPoint.y, worldPoint.z]
}
return [localPoint[0], y, localPoint[1]]
}
const computeLocal = (
gridX: number,
gridZ: number,
@@ -155,21 +200,21 @@ export const MoveRoofTool: React.FC<{
}
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
if (
previousGridPosRef.current &&
(gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])
) {
const snappedLocal = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
if (previousGridPosRef.current && (gridX !== previousGridPosRef.current[0] || gridZ !== previousGridPosRef.current[1])) {
sfxEmitter.emit('sfx:grid-snap')
}
previousGridPosRef.current = [gridX, gridZ]
// Cursor is inside the building-local ToolManager group — use local position
const lx = Math.round(event.localPosition[0] * 2) / 2
const lz = Math.round(event.localPosition[2] * 2) / 2
const [lx, lz] = snappedLocal
setCursorWorldPos([lx, event.localPosition[1], lz])
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
@@ -189,11 +234,14 @@ export const MoveRoofTool: React.FC<{
}
const onGridClick = (event: GridEvent) => {
const gridX = Math.round(event.position[0] * 2) / 2 // world, for computeLocal
const gridZ = Math.round(event.position[2] * 2) / 2
const y = event.position[1]
const lx = Math.round(event.localPosition[0] * 2) / 2
const lz = Math.round(event.localPosition[2] * 2) / 2
const snappedLocal = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
const [gridX, , gridZ] = localToWorldPoint(snappedLocal, y)
const [lx, lz] = snappedLocal
const [localX, localZ] = computeLocal(gridX, gridZ, y, lx, lz)
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { BufferGeometry, Float32BufferAttribute, type Line, type Object3D } from 'three'
import { EDITOR_LAYER } from '../../../lib/constants'
import { sfxEmitter } from '../../../lib/sfx-bus'
import { snapToHalf } from '../item/placement-math'
const Y_OFFSET = 0.02
@@ -187,8 +188,8 @@ export const PolygonEditor: React.FC<PolygonEditorProps> = ({
// Listen to grid:move events to track cursor position
useEffect(() => {
const onGridMove = (event: GridEvent) => {
const gridX = Math.round(event.localPosition[0] * 2) / 2
const gridZ = Math.round(event.localPosition[2] * 2) / 2
const gridX = snapToHalf(event.localPosition[0])
const gridZ = snapToHalf(event.localPosition[2])
const newPosition: [number, number] = [gridX, gridZ]
// Play snap sound when cursor moves to a new grid cell during drag
@@ -1,17 +1,23 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type SlabNode } from '@pascal-app/core'
import {
type AnyNodeId,
emitter,
type FenceNode,
type GridEvent,
type LevelNode,
type SlabNode,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { snapFenceDraftPoint } from '../fence/fence-drafting'
import { CursorSphere } from '../shared/cursor-sphere'
function snap(value: number) {
return Math.round(value * 2) / 2
}
function translatePolygon(
polygon: Array<[number, number]>,
deltaX: number,
@@ -56,6 +62,17 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
useEffect(() => {
const originalPolygon = originalPolygonRef.current
const originalHoles = originalHolesRef.current
const levelNode =
node.parentId && useScene.getState().nodes[node.parentId as AnyNodeId]?.type === 'level'
? (useScene.getState().nodes[node.parentId as AnyNodeId] as LevelNode)
: null
const levelChildren = levelNode?.children ?? []
const levelWalls = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is WallNode => child?.type === 'wall')
const levelFences = levelChildren
.map((childId) => useScene.getState().nodes[childId as AnyNodeId])
.filter((child): child is FenceNode => child?.type === 'fence')
useScene.temporal.getState().pause()
let wasCommitted = false
@@ -80,8 +97,11 @@ export const MoveSlabTool: React.FC<{ node: SlabNode }> = ({ node }) => {
}
const onGridMove = (event: GridEvent) => {
const localX = snap(event.localPosition[0])
const localZ = snap(event.localPosition[2])
const [localX, localZ] = snapFenceDraftPoint({
point: [event.localPosition[0], event.localPosition[2]],
walls: levelWalls,
fences: levelFences,
})
if (
previousGridPosRef.current &&
@@ -11,7 +11,9 @@ import { CeilingBoundaryEditor } from './ceiling/ceiling-boundary-editor'
import { CeilingHoleEditor } from './ceiling/ceiling-hole-editor'
import { CeilingTool } from './ceiling/ceiling-tool'
import { DoorTool } from './door/door-tool'
import { CurveFenceTool } from './fence/curve-fence-tool'
import { FenceTool } from './fence/fence-tool'
import { MoveFenceEndpointTool } from './fence/move-fence-endpoint-tool'
import { ItemTool } from './item/item-tool'
import { MoveTool } from './item/move-tool'
import { RoofTool } from './roof/roof-tool'
@@ -54,7 +56,9 @@ export const ToolManager: React.FC = () => {
const tool = useEditor((state) => state.tool)
const movingNode = useEditor((state) => state.movingNode)
const movingWallEndpoint = useEditor((state) => state.movingWallEndpoint)
const movingFenceEndpoint = useEditor((state) => state.movingFenceEndpoint)
const curvingWall = useEditor((state) => state.curvingWall)
const curvingFence = useEditor((state) => state.curvingFence)
const editingHole = useEditor((state) => state.editingHole)
const selectedZoneId = useViewer((state) => state.selection.zoneId)
const buildingId = useViewer((state) => state.selection.buildingId)
@@ -145,7 +149,9 @@ export const ToolManager: React.FC = () => {
<CeilingHoleEditor ceilingId={selectedCeilingId} holeIndex={editingHole.holeIndex} />
)}
{movingWallEndpoint && <MoveWallEndpointTool target={movingWallEndpoint} />}
{movingFenceEndpoint && <MoveFenceEndpointTool target={movingFenceEndpoint} />}
{curvingWall && <CurveWallTool node={curvingWall} />}
{curvingFence && <CurveFenceTool node={curvingFence} />}
{movingNode && movingNode.type !== 'building' && <MoveTool />}
{!movingNode && BuildToolComponent && <BuildToolComponent />}
</group>
@@ -1,6 +1,14 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core'
import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { Html } from '@react-three/drei'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
@@ -127,7 +135,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
node?.type === 'wall' && (node.parentId ?? null) === (target.wall.parentId ?? null),
)
useScene.temporal.getState().pause()
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
@@ -209,7 +217,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume()
resumeSceneHistory(useScene)
applyNodePreview([
{ id: nodeId, start: preview.start, end: preview.end },
...(altPressedRef.current
@@ -222,7 +230,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
preview.end,
)),
])
useScene.temporal.getState().pause()
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
}
@@ -234,7 +242,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitMoveMode()
}
@@ -279,7 +287,7 @@ export const MoveWallEndpointTool: React.FC<{ target: MovingWallEndpoint }> = ({
if (!wasCommitted) {
restoreOriginal()
}
useScene.temporal.getState().resume()
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
@@ -1,6 +1,14 @@
'use client'
import { type AnyNodeId, emitter, type GridEvent, useScene, type WallNode } from '@pascal-app/core'
import {
type AnyNodeId,
emitter,
type GridEvent,
pauseSceneHistory,
resumeSceneHistory,
useScene,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useCallback, useEffect, useRef, useState } from 'react'
import { markToolCancelConsumed } from '../../../hooks/use-keyboard'
@@ -24,9 +32,9 @@ function stripWallIsNewMetadata(meta: WallNode['metadata']): WallNode['metadata'
return meta
}
const nextMeta = { ...(meta as Record<string, unknown>) }
const nextMeta = { ...(meta as Record<string, unknown>) } as Record<string, unknown>
delete nextMeta.isNew
return nextMeta
return nextMeta as WallNode['metadata']
}
type LinkedWallSnapshot = {
@@ -146,7 +154,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const originalCenter = originalCenterRef.current
const originalHalfVector = originalHalfVectorRef.current
useScene.temporal.getState().pause()
pauseSceneHistory(useScene)
let wasCommitted = false
const applyNodePreview = (
@@ -237,7 +245,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
...linkedOriginalsRef.current,
])
useScene.temporal.getState().resume()
resumeSceneHistory(useScene)
const commitUpdates = [
{
@@ -266,7 +274,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
useScene.getState().markDirty(id)
}
useScene.temporal.getState().pause()
pauseSceneHistory(useScene)
sfxEmitter.emit('sfx:item-place')
useViewer.getState().setSelection({ selectedIds: [nodeId] })
@@ -315,7 +323,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
const onCancel = () => {
restoreOriginal()
useViewer.getState().setSelection({ selectedIds: [nodeId] })
useScene.temporal.getState().resume()
resumeSceneHistory(useScene)
markToolCancelConsumed()
exitMoveMode()
}
@@ -331,7 +339,7 @@ export const MoveWallTool: React.FC<{ node: WallNode }> = ({ node }) => {
restoreOriginal()
}
shiftPressedRef.current = false
useScene.temporal.getState().resume()
resumeSceneHistory(useScene)
emitter.off('grid:move', onGridMove)
emitter.off('grid:click', onGridClick)
emitter.off('tool:cancel', onCancel)
@@ -9,7 +9,15 @@ import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor'
import { ActionButton } from './action-button'
type ControlId = 'select' | 'box-select' | 'site-edit' | 'build' | 'furnish' | 'zone' | 'delete'
type ControlId =
| 'select'
| 'box-select'
| 'site-edit'
| 'build'
| 'material-paint'
| 'furnish'
| 'zone'
| 'delete'
type ControlConfig = {
id: ControlId
@@ -54,6 +62,14 @@ const controls: ControlConfig[] = [
color: 'hover:bg-green-500/20 hover:text-green-400',
activeColor: 'bg-green-500/20 text-green-400',
},
{
id: 'material-paint',
imageSrc: '/icons/paint.png',
label: 'Material Paint',
shortcut: 'P',
color: 'hover:bg-amber-500/20 hover:text-amber-400',
activeColor: 'bg-amber-500/20 text-amber-400',
},
{
id: 'furnish',
imageSrc: '/icons/couch.png',
@@ -88,6 +104,7 @@ export function ControlModes() {
const setPhase = useEditor((state) => state.setPhase)
const setStructureLayer = useEditor((state) => state.setStructureLayer)
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection)
const levelId = useViewer((s) => s.selection.levelId)
// Only subscribe to the primitive `level` number — when walls are added to
@@ -112,6 +129,7 @@ export function ControlModes() {
if (id === 'site-edit') return false
if (id === 'build')
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
if (id === 'material-paint') return mode === 'material-paint'
if (id === 'furnish') return mode === 'build' && phase === 'furnish'
if (id === 'zone')
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
@@ -155,6 +173,15 @@ export function ControlModes() {
setStructureLayer('elements')
setMode('build')
}
} else if (id === 'material-paint') {
if (getIsActive('material-paint')) {
setMode('select')
} else {
primeMaterialPaintFromSelection()
setPhase('structure')
setStructureLayer('elements')
setMode('material-paint')
}
} else if (id === 'furnish') {
if (getIsActive('furnish')) {
setMode('select')
@@ -1,8 +1,13 @@
'use client'
import { useScene } from '@pascal-app/core'
import { AnimatePresence, motion } from 'motion/react'
import { useEffect, useMemo } from 'react'
import { useViewer } from '@pascal-app/viewer'
import { TooltipProvider } from './../../../components/ui/primitives/tooltip'
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
import { cn } from './../../../lib/utils'
import useEditor from './../../../store/use-editor'
import { ItemCatalog } from '../item-catalog/item-catalog'
@@ -12,12 +17,49 @@ import { FurnishTools } from './furnish-tools'
import { StructureTools } from './structure-tools'
import { ViewToggles } from './view-toggles'
function PaintMaterialTray() {
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
const activePaintTarget = useEditor((state) => state.activePaintTarget)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes)
const selectedId = selectedIds.length === 1 ? (selectedIds[0] ?? null) : null
useEffect(() => {
const selectedPaintTarget = resolvePaintTargetFromSelection({
nodes,
selectedId,
})
if (selectedPaintTarget) {
setActivePaintTarget(selectedPaintTarget)
}
}, [nodes, selectedId, setActivePaintTarget])
return (
<div className="w-[42rem] max-w-[calc(100vw-2rem)]">
<MaterialPicker
onChange={(material) => {
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
}}
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
}}
selectedMaterialPreset={activePaintMaterial?.materialPreset}
value={activePaintMaterial?.material}
/>
</div>
)
}
export function ActionMenu({ className }: { className?: string }) {
const phase = useEditor((state) => state.phase)
const mode = useEditor((state) => state.mode)
const tool = useEditor((state) => state.tool)
const catalogCategory = useEditor((state) => state.catalogCategory)
const reducedMotion = useReducedMotion()
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
const transition = reducedMotion
? { duration: 0 }
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
@@ -138,6 +180,38 @@ export function ActionMenu({ className }: { className?: string }) {
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showPaintTray && (
<motion.div
animate={{
opacity: 1,
maxHeight: 96,
paddingTop: 8,
paddingBottom: 8,
borderBottomWidth: 1,
}}
className={cn('overflow-hidden border-border border-b px-3')}
exit={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
initial={{
opacity: 0,
maxHeight: 0,
paddingTop: 0,
paddingBottom: 0,
borderBottomWidth: 0,
}}
transition={transition}
>
<PaintMaterialTray />
</motion.div>
)}
</AnimatePresence>
{/* Control Mode Row - Always visible, centered */}
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
<ControlModes />
@@ -23,6 +23,7 @@ import {
Moon,
MousePointer2,
Package,
PaintBucket,
PencilLine,
Plus,
Redo2,
@@ -35,6 +36,7 @@ import {
} from 'lucide-react'
import { useEffect } from 'react'
import { deleteLevelWithFallbackSelection } from '../../../lib/level-selection'
import { runRedo, runUndo } from '../../../lib/history'
import { useCommandRegistry } from '../../../store/use-command-registry'
import type { StructureTool } from '../../../store/use-editor'
import useEditor from '../../../store/use-editor'
@@ -48,6 +50,7 @@ export function EditorCommands() {
const setMode = useEditor((s) => s.setMode)
const setTool = useEditor((s) => s.setTool)
const setStructureLayer = useEditor((s) => s.setStructureLayer)
const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const setPreviewMode = useEditor((s) => s.setPreviewMode)
@@ -149,6 +152,21 @@ export function EditorCommands() {
useScene.getState().deleteNodes(selectedIds as any[])
}),
},
{
id: 'editor.mode.material-paint',
label: 'Material Paint',
group: 'Scene',
icon: <PaintBucket className="h-4 w-4" />,
keywords: ['paint', 'material', 'texture', 'bucket', 'surface'],
shortcut: ['P'],
execute: () =>
run(() => {
primeMaterialPaintFromSelection()
setPhase('structure')
setStructureLayer('elements')
setMode('material-paint')
}),
},
// ── Levels ───────────────────────────────────────────────────────────
{
@@ -313,7 +331,7 @@ export function EditorCommands() {
group: 'History',
icon: <Undo2 className="h-4 w-4" />,
keywords: ['undo', 'revert', 'back'],
execute: () => run(() => useScene.temporal.getState().undo()),
execute: () => run(() => runUndo()),
},
{
id: 'editor.history.redo',
@@ -321,7 +339,7 @@ export function EditorCommands() {
group: 'History',
icon: <Redo2 className="h-4 w-4" />,
keywords: ['redo', 'forward', 'repeat'],
execute: () => run(() => useScene.temporal.getState().redo()),
execute: () => run(() => runRedo()),
},
// ── Export & Share ───────────────────────────────────────────────────
@@ -354,7 +372,7 @@ export function EditorCommands() {
icon: <Box className="h-4 w-4" />,
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
execute: () => run(() => exportScene()),
} as const,
},
]
: []),
{
@@ -1,49 +1,117 @@
'use client'
import {
getMaterialsForTarget,
getCatalogMaterialById,
getLibraryMaterialIdFromRef,
getMaterialsForCategory,
MATERIAL_CATEGORIES,
toLibraryMaterialRef,
type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core'
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import useEditor from '../../../store/use-editor'
type MaterialPickerProps = {
nodeType?: MaterialTarget
value?: MaterialSchema
selectedMaterialPreset?: string
onChange?: (material: MaterialSchema) => void
onSelectMaterialPreset?: (materialPreset: string) => void
disabled?: boolean
}
export function MaterialPicker({
nodeType,
value,
selectedMaterialPreset,
onChange,
onSelectMaterialPreset,
disabled = false,
}: MaterialPickerProps) {
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
MATERIAL_CATEGORIES[0],
)
const catalogScrollRef = useRef<HTMLDivElement>(null)
const categoryScrollRef = useRef<HTMLDivElement>(null)
const catalogItems =
selectedCategory === 'other'
? getMaterialsForCategory('other')
: getMaterialsForCategory(selectedCategory)
useEffect(() => {
setShowCustom(!!value?.properties && !selectedMaterialPreset)
}, [selectedMaterialPreset, value?.properties])
useEffect(() => {
if (!selectedMaterialPreset && value?.properties) {
setSelectedCategory('other')
return
}
const catalogId =
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
const selectedCatalogEntry = getCatalogMaterialById(catalogId)
if (selectedCatalogEntry?.category) {
setSelectedCategory(selectedCatalogEntry.category)
}
}, [selectedMaterialPreset, value?.id])
const currentProps = value?.properties || {
color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
}
const selectedCatalogId =
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
const handleCatalogSelect = (materialId: string) => {
if (disabled) return
setShowCustom(false)
setPaintPanelOpen(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
}
useEffect(() => {
const container = catalogScrollRef.current
if (!container) return
const handleWheel = (event: WheelEvent) => {
const deltaX = event.deltaX
const deltaY = event.deltaY
const nextScrollLeft = container.scrollLeft + deltaX + deltaY
if (nextScrollLeft === container.scrollLeft) return
event.preventDefault()
container.scrollLeft = nextScrollLeft
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => {
container.removeEventListener('wheel', handleWheel)
}
}, [catalogItems.length, onChange, showCustom])
useEffect(() => {
const container = categoryScrollRef.current
if (!container) return
const handleWheel = (event: WheelEvent) => {
const deltaX = event.deltaX
const deltaY = event.deltaY
const nextScrollLeft = container.scrollLeft + deltaX + deltaY
if (nextScrollLeft === container.scrollLeft) return
event.preventDefault()
container.scrollLeft = nextScrollLeft
}
container.addEventListener('wheel', handleWheel, { passive: false })
return () => {
container.removeEventListener('wheel', handleWheel)
}
}, [])
const handleCustomOpen = () => {
if (disabled) return
setShowCustom(true)
setPaintPanelOpen(true)
onChange?.({
preset: 'custom',
properties: {
@@ -57,155 +125,87 @@ export function MaterialPicker({
})
}
const handlePropertyChange = (
prop: keyof typeof currentProps,
val: (typeof currentProps)[keyof typeof currentProps],
) => {
onChange?.({
preset: 'custom',
properties: {
...currentProps,
[prop]: val,
},
})
}
return (
<div className="space-y-3">
<div className={`min-w-0 space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
{(catalogItems.length > 0 || onChange) && (
<div className="space-y-2">
{catalogItems.length > 0 ? (
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
) : null}
<div className="flex flex-wrap gap-1.5">
{catalogItems.map((item) => (
<button
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
selectedCatalogId === toLibraryMaterialRef(item.id)
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400'
}`}
key={item.id}
onClick={() => handleCatalogSelect(item.id)}
title={item.label}
type="button"
>
{item.previewThumbnailUrl ? (
<img
alt={item.label}
className="h-full w-full object-cover"
src={item.previewThumbnailUrl}
/>
) : item.previewColor ? (
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
) : (
<div className="h-full w-full bg-gray-100" />
)}
</button>
))}
{onChange ? (
<button
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
showCustom
? 'border-blue-500 bg-blue-50 text-blue-700 ring-2 ring-blue-500/30'
: 'border-gray-300 bg-white text-gray-500 hover:border-gray-400'
}`}
onClick={handleCustomOpen}
title="Custom"
type="button"
>
Custom
</button>
) : null}
<div className="min-w-0 space-y-1">
<div
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
ref={categoryScrollRef}
style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
>
<div className="flex min-w-max gap-1 pb-1">
{MATERIAL_CATEGORIES.map((category) => (
<button
className={`shrink-0 px-2 font-medium text-[11px] uppercase tracking-[0.12em] transition-all ${
selectedCategory === category
? 'bg-transparent text-foreground'
: 'bg-transparent text-muted-foreground opacity-70 hover:text-foreground hover:opacity-100'
}`}
key={category}
onClick={() => {
setSelectedCategory(category)
if (showCustom) {
setShowCustom(false)
}
if (category !== 'other') {
setPaintPanelOpen(false)
}
}}
type="button"
>
{category.charAt(0).toUpperCase() + category.slice(1)}
</button>
))}
</div>
</div>
</div>
)}
{showCustom && onChange && (
<div className="space-y-2 pt-2">
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Color</label>
<input
className="h-7 w-12 cursor-pointer rounded border border-gray-300"
onChange={(e) => handlePropertyChange('color', e.target.value)}
type="color"
value={currentProps.color}
/>
<input
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) => handlePropertyChange('color', e.target.value)}
type="text"
value={currentProps.color}
/>
</div>
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Roughness</label>
<input
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1}
min={0}
onChange={(e) => handlePropertyChange('roughness', Number.parseFloat(e.target.value))}
step={0.01}
type="range"
value={currentProps.roughness}
/>
<span className="w-8 text-right text-gray-400 text-xs">
{currentProps.roughness.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Metalness</label>
<input
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1}
min={0}
onChange={(e) => handlePropertyChange('metalness', Number.parseFloat(e.target.value))}
step={0.01}
type="range"
value={currentProps.metalness}
/>
<span className="w-8 text-right text-gray-400 text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Opacity</label>
<input
className="h-1.5 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-200"
max={1}
min={0}
onChange={(e) => {
const opacity = Number.parseFloat(e.target.value)
handlePropertyChange('opacity', opacity)
if (opacity < 1 && !currentProps.transparent) {
handlePropertyChange('transparent', true)
}
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
<span className="w-8 text-right text-gray-400 text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<div className="flex items-center gap-2">
<label className="w-16 text-gray-500 text-xs">Side</label>
<select
className="h-7 flex-1 rounded border border-gray-300 px-2 text-xs"
onChange={(e) =>
handlePropertyChange('side', e.target.value as 'front' | 'back' | 'double')
}
value={currentProps.side}
>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="double">Double</option>
</select>
<div
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
ref={catalogScrollRef}
style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
>
<div className="flex min-w-max gap-1.5 pb-1">
{catalogItems.map((item) => (
<button
className={`relative h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
selectedCatalogId === toLibraryMaterialRef(item.id)
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400'
}`}
key={item.id}
onClick={() => handleCatalogSelect(item.id)}
title={item.label}
type="button"
>
<div className="pointer-events-none absolute inset-0 rounded-[inherit] ring-1 ring-inset ring-white/12" />
{item.previewThumbnailUrl ? (
<img
alt={item.label}
className="h-full w-full object-cover"
src={item.previewThumbnailUrl}
/>
) : item.previewColor ? (
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
) : (
<div className="h-full w-full bg-gray-100" />
)}
</button>
))}
{selectedCategory === 'other' && onChange ? (
<button
className={`flex h-14 w-14 shrink-0 items-center justify-center rounded-lg border text-[10px] font-medium transition-all ${
showCustom
? 'border-blue-500 ring-2 ring-blue-500/30'
: 'border-gray-300 hover:border-gray-400'
}`}
onClick={handleCustomOpen}
title="Custom"
type="button"
>
Custom
</button>
) : null}
</div>
</div>
</div>
)}
@@ -21,6 +21,20 @@ function stepPrecision(s: number): number {
return Math.max(0, Math.ceil(-Math.log10(s)))
}
function getAdjustedStep(
baseStep: number,
modifiers: {
shiftKey?: boolean
metaKey?: boolean
ctrlKey?: boolean
altKey?: boolean
},
): number {
if (modifiers.shiftKey) return baseStep * 10
if (modifiers.metaKey || modifiers.ctrlKey || modifiers.altKey) return baseStep * 0.1
return baseStep
}
export function SliderControl({
label,
value,
@@ -58,16 +72,14 @@ export function SliderControl({
if (isEditing) return
e.preventDefault()
const direction = e.deltaY < 0 ? 1 : -1
let s = step
if (e.shiftKey) s = step * 10
else if (e.altKey) s = step * 0.1
const s = getAdjustedStep(step, e)
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
}
el.addEventListener('wheel', handleWheel, { passive: false })
return () => el.removeEventListener('wheel', handleWheel)
}, [isEditing, step, clamp, onChange, precision])
}, [isEditing, step, clamp, onChange])
// Arrow key support while hovered
useEffect(() => {
@@ -78,9 +90,7 @@ export function SliderControl({
else if (e.key === 'ArrowDown' || e.key === 'ArrowLeft') direction = -1
if (direction !== 0) {
e.preventDefault()
let s = step
if (e.shiftKey) s = step * 10
else if (e.metaKey || e.ctrlKey) s = step * 0.1
const s = getAdjustedStep(step, e)
const newValue = clamp(valueRef.current + direction * s)
const final = Number.parseFloat(newValue.toFixed(stepPrecision(s)))
if (final !== valueRef.current) onChange(final)
@@ -88,7 +98,7 @@ export function SliderControl({
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isHovered, isEditing, step, clamp, onChange, precision])
}, [isHovered, isEditing, step, clamp, onChange])
const handleLabelPointerDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
@@ -107,16 +117,14 @@ export function SliderControl({
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
const s = getAdjustedStep(step, e)
// 4 px per step at default sensitivity
const newValue = clamp(
Number.parseFloat((startValue + (dx / 4) * s).toFixed(stepPrecision(s))),
)
onChange(newValue)
},
[step, precision, clamp, onChange],
[step, clamp, onChange],
)
const handleLabelPointerUp = useCallback(
@@ -163,12 +171,18 @@ export function SliderControl({
setIsEditing(false)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
const newV = clamp(value + step)
const adjustedStep = getAdjustedStep(step, e)
const newV = clamp(
Number.parseFloat((value + adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV)
setInputValue(newV.toFixed(precision))
} else if (e.key === 'ArrowDown') {
e.preventDefault()
const newV = clamp(value - step)
const adjustedStep = getAdjustedStep(step, e)
const newV = clamp(
Number.parseFloat((value - adjustedStep).toFixed(stepPrecision(adjustedStep))),
)
onChange(newV)
setInputValue(newV.toFixed(precision))
}
@@ -1,13 +1,12 @@
'use client'
import { type AnyNode, type CeilingNode, type MaterialSchema, useScene } from '@pascal-app/core'
import { type AnyNode, type CeilingNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
@@ -32,20 +31,6 @@ export function CeilingPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
@@ -257,15 +242,6 @@ export function CeilingPanel() {
</div>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="ceiling"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
@@ -5,7 +5,6 @@ import {
type AnyNodeId,
DoorNode,
emitter,
type MaterialSchema,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
@@ -46,13 +44,6 @@ export function DoorPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -592,9 +583,6 @@ export function DoorPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
}
@@ -1,8 +1,25 @@
'use client'
import { type AnyNode, type AnyNodeId, type FenceNode, type MaterialSchema, useScene } from '@pascal-app/core'
import {
type AnyNode,
type AnyNodeId,
type FenceNode,
getClampedWallCurveOffset,
getMaxWallCurveOffset,
getWallCurveLength,
type MaterialSchema,
normalizeWallCurveOffset,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
@@ -28,6 +45,8 @@ export function FencePanel() {
const selectedCount = useViewer((s) => s.selection.selectedIds.length)
const setSelection = useViewer((s) => s.setSelection)
const updateNode = useScene((s) => s.updateNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingFence = useEditor((s) => s.setCurvingFence)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as FenceNode | undefined) : undefined,
@@ -67,25 +86,15 @@ export function FencePanel() {
setSelection({ selectedIds: [] })
}, [setSelection])
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
const dx = node.end[0] - node.start[0]
const dz = node.end[1] - node.start[1]
const length = Math.sqrt(dx * dx + dz * dz)
const length = getWallCurveLength(node)
const curveOffset = getClampedWallCurveOffset(node)
const maxCurveOffset = getMaxWallCurveOffset(node)
return (
<PanelWrapper
@@ -119,6 +128,16 @@ export function FencePanel() {
unit="m"
value={length}
/>
<SliderControl
label="Curve"
max={Math.max(0.01, maxCurveOffset)}
min={-Math.max(0.01, maxCurveOffset)}
onChange={(value) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, value) })}
precision={2}
step={0.1}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
<SliderControl
label="Height"
max={4}
@@ -203,16 +222,6 @@ export function FencePanel() {
value={node.edgeInset}
/>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="fence"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -0,0 +1,163 @@
'use client'
import useEditor from '../../../store/use-editor'
import { Input } from '../primitives/input'
import { PanelSection } from '../controls/panel-section'
import { PanelWrapper } from './panel-wrapper'
function buildDefaultCustomMaterial() {
return {
preset: 'custom' as const,
properties: {
color: '#ffffff',
roughness: 0.5,
metalness: 0,
opacity: 1,
transparent: false,
side: 'front' as const,
},
}
}
export function PaintPanel() {
const activePaintMaterial = useEditor((state) => state.activePaintMaterial)
const activePaintTarget = useEditor((state) => state.activePaintTarget)
const setActivePaintMaterial = useEditor((state) => state.setActivePaintMaterial)
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
const customMaterial =
activePaintMaterial?.material?.properties && !activePaintMaterial.materialPreset
? activePaintMaterial.material
: null
if (!customMaterial) return null
const currentProps = customMaterial.properties ?? buildDefaultCustomMaterial().properties
const updateCustomMaterial = (
updates: Partial<typeof currentProps>,
nextTransparent = currentProps.transparent,
) => {
setActivePaintMaterial({
material: {
preset: 'custom',
properties: {
...currentProps,
...updates,
transparent: nextTransparent,
},
},
sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget,
})
}
return (
<PanelWrapper
onClose={() => setPaintPanelOpen(false)}
title="Material"
width={320}
>
<PanelSection title="Custom Material">
<div className="space-y-3">
<div className="space-y-2">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Color
</label>
<div className="flex items-center gap-2">
<input
className="h-10 w-14 cursor-pointer rounded-md border border-input bg-transparent"
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
type="color"
value={currentProps.color}
/>
<Input
onChange={(e) => updateCustomMaterial({ color: e.target.value })}
value={currentProps.color}
/>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Roughness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.roughness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => updateCustomMaterial({ roughness: Number.parseFloat(e.target.value) })}
step={0.01}
type="range"
value={currentProps.roughness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Metalness
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.metalness.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => updateCustomMaterial({ metalness: Number.parseFloat(e.target.value) })}
step={0.01}
type="range"
value={currentProps.metalness}
/>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Opacity
</label>
<span className="font-mono text-muted-foreground text-xs">
{currentProps.opacity.toFixed(2)}
</span>
</div>
<input
className="h-2 w-full cursor-pointer appearance-none rounded-full bg-accent"
max={1}
min={0}
onChange={(e) => {
const opacity = Number.parseFloat(e.target.value)
updateCustomMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
</div>
<div className="space-y-2">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
Side
</label>
<select
className="h-9 w-full rounded-md border border-input bg-transparent px-3 text-sm shadow-xs outline-none transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30"
onChange={(e) =>
updateCustomMaterial({ side: e.target.value as 'front' | 'back' | 'double' })
}
value={currentProps.side}
>
<option value="front">Front</option>
<option value="back">Back</option>
<option value="double">Double</option>
</select>
</div>
</div>
</PanelSection>
</PanelWrapper>
)
}
@@ -7,6 +7,7 @@ import { CeilingPanel } from './ceiling-panel'
import { DoorPanel } from './door-panel'
import { FencePanel } from './fence-panel'
import { ItemPanel } from './item-panel'
import { PaintPanel } from './paint-panel'
import { ReferencePanel } from './reference-panel'
import { RoofPanel } from './roof-panel'
import { RoofSegmentPanel } from './roof-segment-panel'
@@ -19,6 +20,9 @@ import { WindowPanel } from './window-panel'
export function PanelManager() {
const selectedIds = useViewer((s) => s.selection.selectedIds)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
// Only subscribe to the *type* of the single-selected node — string primitive
// so we don't re-render on unrelated scene mutations.
const selectedNodeType = useScene((s) => {
@@ -32,6 +36,15 @@ export function PanelManager() {
return <ReferencePanel />
}
if (
isPaintPanelOpen &&
mode === 'material-paint' &&
activePaintMaterial?.material?.properties &&
!activePaintMaterial.materialPreset
) {
return <PaintPanel />
}
// Show appropriate panel based on selected node type
if (selectedNodeType) {
switch (selectedNodeType) {
@@ -3,7 +3,6 @@
import {
type AnyNode,
type AnyNodeId,
type MaterialSchema,
type RoofNode,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
@@ -17,7 +16,6 @@ import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
@@ -50,20 +48,6 @@ export function RoofPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -170,11 +154,13 @@ export function RoofPanel() {
</button>
))}
</div>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Segment"
onClick={handleAddSegment}
/>
<ActionGroup>
<ActionButton
icon={<Plus className="h-3.5 w-3.5" />}
label="Add Segment"
onClick={handleAddSegment}
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Position">
@@ -266,15 +252,6 @@ export function RoofPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="roof"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -3,7 +3,6 @@
import {
type AnyNode,
type AnyNodeId,
type MaterialSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
type RoofType,
@@ -15,7 +14,6 @@ import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -52,20 +50,6 @@ export function RoofSegmentPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -322,15 +306,6 @@ export function RoofSegmentPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="roof-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -1,13 +1,12 @@
'use client'
import { type AnyNode, type MaterialSchema, type SlabNode, useScene } from '@pascal-app/core'
import { type AnyNode, type SlabNode, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback, useEffect } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
@@ -32,20 +31,6 @@ export function SlabPanel() {
[selectedId, updateNode],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
setEditingHole(null)
@@ -257,15 +242,6 @@ export function SlabPanel() {
/>
</div>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="slab"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
</ActionGroup>
@@ -4,7 +4,6 @@ import {
type AnyNode,
type AnyNodeId,
type LevelNode,
type MaterialSchema,
type StairNode,
type StairRailingMode,
type StairSlabOpeningMode,
@@ -23,7 +22,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
@@ -92,20 +90,6 @@ export function StairPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -568,15 +552,6 @@ export function StairPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="stair"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -4,7 +4,6 @@ import {
type AnyNode,
type AnyNodeId,
type AttachmentSide,
type MaterialSchema,
type StairSegmentNode,
StairSegmentNode as StairSegmentNodeSchema,
type StairSegmentType,
@@ -16,7 +15,6 @@ import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SegmentedControl } from '../controls/segmented-control'
import { SliderControl } from '../controls/slider-control'
@@ -61,20 +59,6 @@ export function StairSegmentPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -336,15 +320,6 @@ export function StairSegmentPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="stair-segment"
onChange={handleMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -7,7 +7,6 @@ import {
getMaxWallCurveOffset,
getWallCurveLength,
normalizeWallCurveOffset,
type MaterialSchema,
useScene,
type WallNode,
} from '@pascal-app/core'
@@ -17,7 +16,6 @@ import { useCallback } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
@@ -81,20 +79,6 @@ export function WallPanel() {
[node, handleUpdate],
)
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
handleUpdate({ materialPreset, material: undefined })
},
[handleUpdate],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material, materialPreset: undefined })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -169,33 +153,25 @@ export function WallPanel() {
min={-Math.max(0.01, maxCurveOffset)}
onChange={(v) => handleUpdate({ curveOffset: normalizeWallCurveOffset(node, v) })}
precision={2}
step={0.01}
step={0.1}
unit="m"
value={Math.round(curveOffset * 100) / 100}
/>
)}
</PanelSection>
<PanelSection title="Material">
<MaterialPicker
nodeType="wall"
onChange={handleCustomMaterialChange}
onSelectMaterialPreset={handleMaterialPresetChange}
selectedMaterialPreset={node.materialPreset}
value={node.material}
/>
<PanelSection title="Actions">
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
</PanelSection>
<ActionGroup>
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
{!hasWallChildrenBlockingCurve && (
<ActionButton
icon={<Spline className="h-3.5 w-3.5" />}
label="Curve"
onClick={handleCurve}
/>
)}
</ActionGroup>
</PanelWrapper>
)
}
@@ -4,7 +4,6 @@ import {
type AnyNode,
type AnyNodeId,
emitter,
type MaterialSchema,
useScene,
WindowNode,
} from '@pascal-app/core'
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { MetricControl } from '../controls/metric-control'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
@@ -45,13 +43,6 @@ export function WindowPanel() {
[selectedId, updateNode],
)
const handleMaterialChange = useCallback(
(material: MaterialSchema) => {
handleUpdate({ material })
},
[handleUpdate],
)
const handleClose = useCallback(() => {
setSelection({ selectedIds: [] })
}, [setSelection])
@@ -431,9 +422,6 @@ export function WindowPanel() {
/>
</ActionGroup>
</PanelSection>
<PanelSection title="Material">
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
</PanelSection>
</PanelWrapper>
)
}
+10 -2
View File
@@ -1,6 +1,7 @@
import { type AnyNodeId, emitter, useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { runRedo, runUndo } from '../lib/history'
import { sfxEmitter } from '../lib/sfx-bus'
import useEditor from '../store/use-editor'
@@ -88,14 +89,21 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
if (isVersionPreviewMode) return
e.preventDefault()
useEditor.getState().setMode('delete')
} else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
if (isVersionPreviewMode) return
e.preventDefault()
useEditor.getState().primeMaterialPaintFromSelection()
useEditor.getState().setPhase('structure')
useEditor.getState().setStructureLayer('elements')
useEditor.getState().setMode('material-paint')
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
useScene.temporal.getState().undo()
runUndo()
} else if (e.key === 'Z' && e.shiftKey && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
useScene.temporal.getState().redo()
runRedo()
} else if (e.key === 'ArrowUp' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
const { buildingId, levelId } = useViewer.getState().selection
+20
View File
@@ -0,0 +1,20 @@
import { useLiveTransforms, useScene } from '@pascal-app/core'
function refreshSceneAfterHistoryJump() {
useLiveTransforms.getState().clearAll()
const state = useScene.getState()
for (const node of Object.values(state.nodes)) {
state.markDirty(node.id)
}
}
export function runUndo() {
useScene.temporal.getState().undo()
refreshSceneAfterHistoryJump()
}
export function runRedo() {
useScene.temporal.getState().redo()
refreshSceneAfterHistoryJump()
}
+279
View File
@@ -0,0 +1,279 @@
'use client'
import {
type CeilingNode,
type FenceNode,
getCatalogMaterialById,
getEffectiveRoofSurfaceMaterial,
getEffectiveStairSurfaceMaterial,
getEffectiveWallSurfaceMaterial,
getLibraryMaterialIdFromRef,
type MaterialSchema,
type MaterialTarget,
type RoofNode,
type RoofSurfaceMaterialRole,
type SlabNode,
type StairNode,
type StairSurfaceMaterialRole,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core'
export type PaintableMaterialTarget = Extract<
MaterialTarget,
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
>
export type SingleSurfaceMaterialRole = 'surface'
export type ActivePaintMaterial = {
material?: MaterialSchema
materialPreset?: string
sourceTarget: PaintableMaterialTarget
}
export function hasActivePaintMaterial(
material: ActivePaintMaterial | null | undefined,
): material is ActivePaintMaterial {
return Boolean(
material && (material.material !== undefined || material.materialPreset !== undefined),
)
}
function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | null | undefined) {
const catalogId =
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
return getCatalogMaterialById(catalogId)
}
export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) {
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
}
export function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function buildRoofSurfaceMaterialPatch(
node: RoofNode,
targetRole: RoofSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<RoofNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextTop =
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
const nextEdge =
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
const nextWall =
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
return {
topMaterial: nextTop.material,
topMaterialPreset: nextTop.materialPreset,
edgeMaterial: nextEdge.material,
edgeMaterialPreset: nextEdge.materialPreset,
wallMaterial: nextWall.material,
wallMaterialPreset: nextWall.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function buildStairSurfaceMaterialPatch(
node: StairNode,
targetRole: StairSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<StairNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextRailing =
targetRole === 'railing'
? nextSurfaceMaterial
: getEffectiveStairSurfaceMaterial(node, 'railing')
const nextTread =
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
const nextSide =
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
return {
railingMaterial: nextRailing.material,
railingMaterialPreset: nextRailing.materialPreset,
treadMaterial: nextTread.material,
treadMaterialPreset: nextTread.materialPreset,
sideMaterial: nextSide.material,
sideMaterialPreset: nextSide.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<TNode> {
return {
material,
materialPreset,
} as Partial<TNode>
}
export function resolveActivePaintMaterialFromSelection(params: {
nodes: Record<string, any>
selectedId: string | null
selectedMaterialTarget: {
nodeId: string
role:
| WallSurfaceSide
| StairSurfaceMaterialRole
| RoofSurfaceMaterialRole
| SingleSurfaceMaterialRole
} | null
}): ActivePaintMaterial | null {
const { nodes, selectedId, selectedMaterialTarget } = params
if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId)
return null
const selectedNode = nodes[selectedId]
if (!selectedNode) return null
if (
selectedNode.type === 'wall' &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
) {
const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
})
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
}
: null
}
if (
selectedNode.type === 'roof' &&
(selectedMaterialTarget.role === 'top' ||
selectedMaterialTarget.role === 'edge' ||
selectedMaterialTarget.role === 'wall')
) {
const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
})
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
}
: null
}
if (
selectedNode.type === 'stair' &&
(selectedMaterialTarget.role === 'railing' ||
selectedMaterialTarget.role === 'tread' ||
selectedMaterialTarget.role === 'side')
) {
const surface = getEffectiveStairSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
})
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
}
: null
}
if (
(selectedNode.type === 'fence' ||
selectedNode.type === 'slab' ||
selectedNode.type === 'ceiling') &&
selectedMaterialTarget.role === 'surface'
) {
const target = selectedNode.type
return hasActivePaintMaterial({
material: selectedNode.material,
materialPreset: selectedNode.materialPreset,
sourceTarget: target,
})
? {
material: selectedNode.material,
materialPreset: selectedNode.materialPreset,
sourceTarget: target,
}
: null
}
return null
}
export function resolvePaintTargetFromSelection(params: {
nodes: Record<string, any>
selectedId: string | null
}): PaintableMaterialTarget | null {
const { nodes, selectedId } = params
if (!selectedId) return null
const selectedNode = nodes[selectedId]
if (!selectedNode) return null
if (selectedNode.type === 'wall') {
return 'wall'
}
if (selectedNode.type === 'roof' || selectedNode.type === 'roof-segment') {
return 'roof'
}
if (selectedNode.type === 'stair' || selectedNode.type === 'stair-segment') {
return 'stair'
}
if (selectedNode.type === 'fence') {
return 'fence'
}
if (selectedNode.type === 'slab') {
return 'slab'
}
if (selectedNode.type === 'ceiling') {
return 'ceiling'
}
return null
}
+96 -3
View File
@@ -1,7 +1,8 @@
'use client'
import type { AssetInput } from '@pascal-app/core'
import {
type AnyNodeId,
type AssetInput,
type BuildingNode,
type CeilingNode,
type DoorNode,
@@ -10,18 +11,28 @@ import {
type LevelNode,
type RoofNode,
type RoofSegmentNode,
type RoofSurfaceMaterialRole,
type SlabNode,
type Space,
type StairNode,
type StairSegmentNode,
type StairSurfaceMaterialRole,
useScene,
type WallNode,
type WallSurfaceSide,
type WindowNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
import {
type ActivePaintMaterial,
type PaintableMaterialTarget,
resolveActivePaintMaterialFromSelection,
resolvePaintTargetFromSelection,
type SingleSurfaceMaterialRole,
} from '../lib/material-paint'
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
@@ -33,7 +44,7 @@ export type SplitOrientation = 'horizontal' | 'vertical'
export type Phase = 'site' | 'structure' | 'furnish'
export type Mode = 'select' | 'edit' | 'delete' | 'build'
export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint'
// Structure mode tools (building elements)
export type StructureTool =
@@ -80,6 +91,24 @@ export type MovingWallEndpoint = {
endpoint: 'start' | 'end'
}
export type MovingFenceEndpoint = {
fence: FenceNode
endpoint: 'start' | 'end'
}
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole
export type SelectedMaterialTarget = {
nodeId: AnyNodeId
role: MaterialTargetRole
}
type MaterialPaintSelectionSnapshot = {
selectedId: string | null
activePaintTarget: PaintableMaterialTarget
activePaintMaterial: ActivePaintMaterial | null
}
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
@@ -125,8 +154,23 @@ type EditorState = {
) => void
movingWallEndpoint: MovingWallEndpoint | null
setMovingWallEndpoint: (value: MovingWallEndpoint | null) => void
movingFenceEndpoint: MovingFenceEndpoint | null
setMovingFenceEndpoint: (value: MovingFenceEndpoint | null) => void
curvingWall: WallNode | null
setCurvingWall: (wall: WallNode | null) => void
curvingFence: FenceNode | null
setCurvingFence: (fence: FenceNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
activePaintMaterial: ActivePaintMaterial | null
setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
activePaintTarget: PaintableMaterialTarget
setActivePaintTarget: (target: PaintableMaterialTarget) => void
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
isPaintPanelOpen: boolean
setPaintPanelOpen: (open: boolean) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
@@ -206,7 +250,7 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
return 'select'
}
return mode === 'build' || mode === 'delete' ? mode : 'select'
return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
}
function normalizeFloorplanPaneRatio(value: unknown): number {
@@ -444,6 +488,8 @@ const useEditor = create<EditorState>()(
const category = get().catalogCategory ?? 'furniture'
set({ selectedItem: getDefaultSelectedItemForCategory(category) })
}
} else if (mode === 'material-paint') {
get().primeMaterialPaintFromSelection()
}
// When leaving build mode, clear tool
else if (tool) {
@@ -500,8 +546,55 @@ const useEditor = create<EditorState>()(
setMovingNode: (node) => set({ movingNode: node }),
movingWallEndpoint: null,
setMovingWallEndpoint: (value) => set({ movingWallEndpoint: value }),
movingFenceEndpoint: null,
setMovingFenceEndpoint: (value) => set({ movingFenceEndpoint: value }),
curvingWall: null,
setCurvingWall: (wall) => set({ curvingWall: wall }),
curvingFence: null,
setCurvingFence: (fence) => set({ curvingFence: fence }),
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
activePaintMaterial: null,
setActivePaintMaterial: (material) => set({ activePaintMaterial: material }),
activePaintTarget: 'wall',
setActivePaintTarget: (target) =>
set((state) =>
state.activePaintTarget === target ? state : { activePaintTarget: target },
),
primeMaterialPaintFromSelection: () => {
const selectedId =
useViewer.getState().selection.selectedIds.length === 1
? (useViewer.getState().selection.selectedIds[0] ?? null)
: null
const activePaintTarget =
resolvePaintTargetFromSelection({
nodes: useScene.getState().nodes,
selectedId,
}) ?? get().activePaintTarget
const activePaintMaterial = resolveActivePaintMaterialFromSelection({
nodes: useScene.getState().nodes,
selectedId,
selectedMaterialTarget: get().selectedMaterialTarget,
})
set({
activePaintTarget,
...(activePaintMaterial ? { activePaintMaterial } : {}),
})
return {
selectedId,
activePaintTarget,
activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
}
},
hoveredPaintTarget: null,
setHoveredPaintTarget: (target) =>
set((state) =>
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
),
isPaintPanelOpen: false,
setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {},