Merge remote-tracking branch 'origin/main' into feat/mcp-server
This commit is contained in:
@@ -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])
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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: {},
|
||||
|
||||
Reference in New Issue
Block a user