Merge branch 'main' into feat/2d-editor
This commit is contained in:
@@ -42,9 +42,11 @@ export {
|
||||
getCatalogMaterialById,
|
||||
getLibraryMaterialIdFromRef,
|
||||
getMaterialPresetByRef,
|
||||
getMaterialsForTarget,
|
||||
getMaterialsForCategory,
|
||||
LIBRARY_MATERIAL_REF_PREFIX,
|
||||
MATERIAL_CATALOG,
|
||||
MATERIAL_CATEGORIES,
|
||||
type MaterialCategory,
|
||||
type MaterialCatalogItem,
|
||||
toLibraryMaterialRef,
|
||||
} from './material-library'
|
||||
|
||||
@@ -1,57 +1,33 @@
|
||||
import {
|
||||
type MaterialPresetPayload,
|
||||
type MaterialTarget,
|
||||
MaterialTarget as MaterialTargetSchema,
|
||||
} from './schema/material'
|
||||
|
||||
export type MaterialCatalogItem = {
|
||||
id: string
|
||||
label: string
|
||||
category: MaterialCategory
|
||||
description?: string
|
||||
targets: MaterialTarget[]
|
||||
previewThumbnailUrl?: string
|
||||
previewColor?: string
|
||||
preset: MaterialPresetPayload
|
||||
}
|
||||
|
||||
const WALL_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.wall,
|
||||
]
|
||||
|
||||
const SLAB_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.slab,
|
||||
]
|
||||
|
||||
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.wall,
|
||||
MaterialTargetSchema.enum.slab,
|
||||
]
|
||||
|
||||
const STAIR_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.stair,
|
||||
MaterialTargetSchema.enum['stair-segment'],
|
||||
]
|
||||
|
||||
const STAIR_AND_FENCE_TARGETS: MaterialTarget[] = [
|
||||
...STAIR_TARGETS,
|
||||
MaterialTargetSchema.enum.fence,
|
||||
]
|
||||
|
||||
const ROOF_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.roof,
|
||||
MaterialTargetSchema.enum['roof-segment'],
|
||||
]
|
||||
|
||||
const CEILING_TARGETS: MaterialTarget[] = [
|
||||
MaterialTargetSchema.enum.ceiling,
|
||||
]
|
||||
export const MATERIAL_CATEGORIES = [
|
||||
'wood',
|
||||
'wallpaper',
|
||||
'parquet',
|
||||
'granite',
|
||||
'marble',
|
||||
'other',
|
||||
] as const
|
||||
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number]
|
||||
|
||||
export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood1',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Warm wood finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -85,8 +61,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood2',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Textured wood finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -121,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood3',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Knotted timber finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -155,8 +131,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood4',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Oak stretcher finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -189,8 +165,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wood5',
|
||||
label: 'Wood',
|
||||
category: 'wood',
|
||||
description: 'Rich grain wood finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -225,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-granite1',
|
||||
label: 'Granite',
|
||||
category: 'granite',
|
||||
description: 'Polished granite finish',
|
||||
targets: SLAB_TARGETS,
|
||||
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -259,8 +235,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-marble1',
|
||||
label: 'Marble',
|
||||
category: 'marble',
|
||||
description: 'Smooth marble finish',
|
||||
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -293,8 +269,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-marble2',
|
||||
label: 'Marble',
|
||||
category: 'marble',
|
||||
description: 'Soft marble finish',
|
||||
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
||||
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -327,8 +303,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-parquet1',
|
||||
label: 'Parquet',
|
||||
category: 'parquet',
|
||||
description: 'Parquet wood finish',
|
||||
targets: SLAB_TARGETS,
|
||||
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -361,8 +337,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-parquet2',
|
||||
label: 'Parquet',
|
||||
category: 'parquet',
|
||||
description: 'Soft parquet finish',
|
||||
targets: SLAB_TARGETS,
|
||||
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -395,8 +371,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wallpaper1',
|
||||
label: 'Wallpaper',
|
||||
category: 'wallpaper',
|
||||
description: 'Soft wallpaper finish',
|
||||
targets: WALL_TARGETS,
|
||||
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -430,8 +406,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wallpaper2',
|
||||
label: 'Wallpaper',
|
||||
category: 'wallpaper',
|
||||
description: 'Decorative wallpaper finish',
|
||||
targets: WALL_TARGETS,
|
||||
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -464,8 +440,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'wall-wallpaper3',
|
||||
label: 'Wallpaper',
|
||||
category: 'wallpaper',
|
||||
description: 'Patterned wallpaper finish',
|
||||
targets: WALL_TARGETS,
|
||||
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
|
||||
preset: {
|
||||
maps: {
|
||||
@@ -498,14 +474,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'preset-white',
|
||||
label: 'White',
|
||||
category: 'other',
|
||||
description: 'Clean painted finish',
|
||||
targets: [
|
||||
...WALL_TARGETS,
|
||||
...SLAB_TARGETS,
|
||||
...ROOF_TARGETS,
|
||||
...STAIR_AND_FENCE_TARGETS,
|
||||
...CEILING_TARGETS,
|
||||
],
|
||||
previewColor: '#ffffff',
|
||||
preset: {
|
||||
maps: {},
|
||||
@@ -536,8 +506,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'preset-metal',
|
||||
label: 'Metal',
|
||||
category: 'other',
|
||||
description: 'Brushed metal finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
|
||||
previewColor: '#c0c0c0',
|
||||
preset: {
|
||||
maps: {},
|
||||
@@ -568,8 +538,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
{
|
||||
id: 'preset-glass',
|
||||
label: 'Glass',
|
||||
category: 'other',
|
||||
description: 'Light glass finish',
|
||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
|
||||
previewColor: '#87ceeb',
|
||||
preset: {
|
||||
maps: {},
|
||||
@@ -599,8 +569,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] {
|
||||
return MATERIAL_CATALOG.filter((item) => item.targets.includes(target))
|
||||
export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
|
||||
return MATERIAL_CATALOG.filter((item) => item.category === category)
|
||||
}
|
||||
|
||||
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
|
||||
|
||||
@@ -158,14 +158,16 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
||||
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
|
||||
return {
|
||||
material: node.treadMaterial,
|
||||
materialPreset: typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||
materialPreset:
|
||||
typeof node.treadMaterialPreset === 'string' ? node.treadMaterialPreset : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
|
||||
return {
|
||||
material: node.sideMaterial,
|
||||
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
||||
materialPreset:
|
||||
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
@@ -22,8 +30,8 @@ import {
|
||||
} from '../../lib/scene'
|
||||
import { initSFXBus } from '../../lib/sfx-bus'
|
||||
import useEditor from '../../store/use-editor'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-system'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
@@ -63,6 +71,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
|
||||
@@ -501,6 +524,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({
|
||||
@@ -552,31 +619,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 ──
|
||||
@@ -684,6 +885,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
||||
containerRef={viewer3dRef}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
/>
|
||||
<PaintCursorLayer
|
||||
containerRef={viewer3dRef}
|
||||
isVersionPreviewMode={isVersionPreviewMode}
|
||||
/>
|
||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||
<ViewerCanvasControlsHint
|
||||
isPreviewMode={isPreviewMode}
|
||||
@@ -691,7 +896,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}
|
||||
@@ -823,7 +1031,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,27 +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,
|
||||
sceneRegistry,
|
||||
resolveMaterial,
|
||||
type SlabNode,
|
||||
type StairEvent,
|
||||
type StairNode,
|
||||
type StairSurfaceMaterialRole,
|
||||
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 BufferGeometry, 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 MaterialTargetRole, 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 => {
|
||||
@@ -52,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
|
||||
@@ -175,10 +214,197 @@ function getIntersectionMaterialIndex(
|
||||
return group?.materialIndex
|
||||
}
|
||||
|
||||
function setSelectedMaterialTargetForNode(
|
||||
node: AnyNode,
|
||||
role: MaterialTargetRole | null,
|
||||
) {
|
||||
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) {
|
||||
@@ -209,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
|
||||
@@ -475,13 +702,292 @@ export const SelectionManager = () => {
|
||||
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
|
||||
@@ -597,6 +1103,14 @@ export const SelectionManager = () => {
|
||||
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)
|
||||
}
|
||||
@@ -912,7 +1426,12 @@ const SelectionStateSync = () => {
|
||||
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
|
||||
if (
|
||||
!selectedNode ||
|
||||
(selectedNode.type !== 'wall' && selectedNode.type !== 'stair' && selectedNode.type !== 'roof')
|
||||
(selectedNode.type !== 'wall' &&
|
||||
selectedNode.type !== 'fence' &&
|
||||
selectedNode.type !== 'slab' &&
|
||||
selectedNode.type !== 'ceiling' &&
|
||||
selectedNode.type !== 'stair' &&
|
||||
selectedNode.type !== 'roof')
|
||||
) {
|
||||
setSelectedMaterialTarget(null)
|
||||
return
|
||||
@@ -1022,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])
|
||||
|
||||
@@ -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,
|
||||
@@ -49,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)
|
||||
|
||||
@@ -150,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 ───────────────────────────────────────────────────────────
|
||||
{
|
||||
@@ -355,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,59 +1,117 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
getMaterialsForTarget,
|
||||
getCatalogMaterialById,
|
||||
getLibraryMaterialIdFromRef,
|
||||
getMaterialsForCategory,
|
||||
MATERIAL_CATEGORIES,
|
||||
toLibraryMaterialRef,
|
||||
type MaterialSchema,
|
||||
type MaterialTarget,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, 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
|
||||
hideSideControl?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function MaterialPicker({
|
||||
nodeType,
|
||||
value,
|
||||
selectedMaterialPreset,
|
||||
onChange,
|
||||
onSelectMaterialPreset,
|
||||
hideSideControl = false,
|
||||
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])
|
||||
|
||||
const currentProps = value?.properties || {
|
||||
color: '#ffffff',
|
||||
roughness: 0.5,
|
||||
metalness: 0,
|
||||
opacity: 1,
|
||||
transparent: false,
|
||||
side: 'front' as const,
|
||||
}
|
||||
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 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: {
|
||||
@@ -67,159 +125,88 @@ export function MaterialPicker({
|
||||
})
|
||||
}
|
||||
|
||||
const handlePropertyChange = (
|
||||
prop: keyof typeof currentProps,
|
||||
val: (typeof currentProps)[keyof typeof currentProps],
|
||||
) => {
|
||||
if (disabled) return
|
||||
onChange?.({
|
||||
preset: 'custom',
|
||||
properties: {
|
||||
...currentProps,
|
||||
[prop]: val,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`space-y-3 ${disabled ? 'pointer-events-none opacity-50' : ''}`}>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{!hideSideControl && (
|
||||
<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="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
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
@@ -11,9 +12,11 @@ import {
|
||||
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'
|
||||
@@ -83,33 +86,9 @@ export function FencePanel() {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
|
||||
const handleMove = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setMovingNode(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setMovingNode, setSelection])
|
||||
|
||||
const handleCurve = useCallback(() => {
|
||||
if (!node) return
|
||||
sfxEmitter.emit('sfx:item-pick')
|
||||
setCurvingFence(node)
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [node, setCurvingFence, 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
|
||||
|
||||
@@ -243,27 +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>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
<ActionButton
|
||||
icon={<Spline className="h-3.5 w-3.5" />}
|
||||
label="Curve"
|
||||
onClick={handleCurve}
|
||||
/>
|
||||
</ActionGroup>
|
||||
</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,10 +3,9 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveRoofSurfaceMaterial,
|
||||
type MaterialSchema,
|
||||
type RoofNode,
|
||||
type RoofSurfaceMaterialRole,
|
||||
RoofNode as RoofNodeSchema,
|
||||
type RoofSegmentNode,
|
||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||
useScene,
|
||||
@@ -19,44 +18,16 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||
import { duplicateRoofSubtree } from '../../../lib/roof-duplication'
|
||||
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'
|
||||
|
||||
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 RoofPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
||||
@@ -79,33 +50,6 @@ export function RoofPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const materialTargetRole =
|
||||
selectedMaterialTarget &&
|
||||
selectedMaterialTarget.nodeId === node?.id &&
|
||||
(selectedMaterialTarget.role === 'top' ||
|
||||
selectedMaterialTarget.role === 'edge' ||
|
||||
selectedMaterialTarget.role === 'wall')
|
||||
? selectedMaterialTarget.role
|
||||
: null
|
||||
const materialPickerValue =
|
||||
node && materialTargetRole ? getEffectiveRoofSurfaceMaterial(node, materialTargetRole) : {}
|
||||
|
||||
const handleTargetedMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
if (!node || !materialTargetRole) return
|
||||
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleTargetedMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
if (!node || !materialTargetRole) return
|
||||
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -281,22 +225,6 @@ export function RoofPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
{!materialTargetRole ? (
|
||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Click the roof surface you want to edit. Materials apply to one target at a time.
|
||||
</div>
|
||||
) : null}
|
||||
<MaterialPicker
|
||||
disabled={!materialTargetRole}
|
||||
hideSideControl
|
||||
nodeType="roof"
|
||||
onChange={handleTargetedMaterialChange}
|
||||
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.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,20 +242,9 @@ export function SlabPanel() {
|
||||
/>
|
||||
</div>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
<MaterialPicker
|
||||
nodeType="slab"
|
||||
onChange={handleCustomMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={node.materialPreset}
|
||||
value={node.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
</ActionGroup>
|
||||
</PanelWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,9 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveStairSurfaceMaterial,
|
||||
type LevelNode,
|
||||
type MaterialSchema,
|
||||
type StairNode,
|
||||
type StairRailingMode,
|
||||
type StairSurfaceMaterialRole,
|
||||
type StairSlabOpeningMode,
|
||||
type StairTopLandingMode,
|
||||
type StairType,
|
||||
@@ -25,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'
|
||||
@@ -33,32 +29,6 @@ import { SliderControl } from '../controls/slider-control'
|
||||
import { ToggleControl } from '../controls/toggle-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Left', value: 'left' },
|
||||
@@ -89,7 +59,6 @@ export function StairPanel() {
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const createNode = useScene((s) => s.createNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
||||
@@ -120,33 +89,6 @@ export function StairPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const materialTargetRole =
|
||||
selectedMaterialTarget &&
|
||||
selectedMaterialTarget.nodeId === node?.id &&
|
||||
(selectedMaterialTarget.role === 'railing' ||
|
||||
selectedMaterialTarget.role === 'tread' ||
|
||||
selectedMaterialTarget.role === 'side')
|
||||
? selectedMaterialTarget.role
|
||||
: null
|
||||
const materialPickerValue =
|
||||
node && materialTargetRole ? getEffectiveStairSurfaceMaterial(node, materialTargetRole) : {}
|
||||
|
||||
const handleTargetedMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
if (!node || !materialTargetRole) return
|
||||
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleTargetedMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
if (!node || !materialTargetRole) return
|
||||
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
|
||||
},
|
||||
[handleUpdate, materialTargetRole, node],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -578,22 +520,6 @@ export function StairPanel() {
|
||||
/>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
<PanelSection title="Material">
|
||||
{!materialTargetRole ? (
|
||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Click the stair surface you want to edit. Materials apply to one target at a time.
|
||||
</div>
|
||||
) : null}
|
||||
<MaterialPicker
|
||||
disabled={!materialTargetRole}
|
||||
hideSideControl
|
||||
nodeType="stair"
|
||||
onChange={handleTargetedMaterialChange}
|
||||
onSelectMaterialPreset={handleTargetedMaterialPresetChange}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,61 +3,29 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
type AnyNodeId,
|
||||
getEffectiveWallSurfaceMaterial,
|
||||
getClampedWallCurveOffset,
|
||||
getMaxWallCurveOffset,
|
||||
getWallCurveLength,
|
||||
getWallSurfaceMaterialSignature,
|
||||
normalizeWallCurveOffset,
|
||||
type MaterialSchema,
|
||||
useScene,
|
||||
type WallSurfaceSide,
|
||||
type WallNode,
|
||||
} from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Move, Spline } from 'lucide-react'
|
||||
import { useCallback, useMemo } from '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 { SliderControl } from '../controls/slider-control'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
|
||||
function buildWallSurfaceMaterialPatch(
|
||||
node: WallNode,
|
||||
targetSide: WallSurfaceSide | null,
|
||||
material: MaterialSchema | undefined,
|
||||
materialPreset: string | undefined,
|
||||
): Partial<WallNode> {
|
||||
const nextSurfaceMaterial = { material, materialPreset }
|
||||
const nextInterior =
|
||||
targetSide === null || targetSide === 'interior'
|
||||
? nextSurfaceMaterial
|
||||
: getEffectiveWallSurfaceMaterial(node, 'interior')
|
||||
const nextExterior =
|
||||
targetSide === null || 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 WallPanel() {
|
||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
||||
|
||||
const node = useScene((s) =>
|
||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||
@@ -88,35 +56,6 @@ export function WallPanel() {
|
||||
[selectedId, updateNode],
|
||||
)
|
||||
|
||||
const effectiveInteriorMaterial = useMemo(
|
||||
() => (node ? getEffectiveWallSurfaceMaterial(node, 'interior') : {}),
|
||||
[node],
|
||||
)
|
||||
const effectiveExteriorMaterial = useMemo(
|
||||
() => (node ? getEffectiveWallSurfaceMaterial(node, 'exterior') : {}),
|
||||
[node],
|
||||
)
|
||||
const surfaceMaterialsMatch = useMemo(
|
||||
() =>
|
||||
getWallSurfaceMaterialSignature(effectiveInteriorMaterial) ===
|
||||
getWallSurfaceMaterialSignature(effectiveExteriorMaterial),
|
||||
[effectiveExteriorMaterial, effectiveInteriorMaterial],
|
||||
)
|
||||
const materialTargetSide =
|
||||
selectedMaterialTarget &&
|
||||
selectedMaterialTarget.nodeId === node?.id &&
|
||||
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
|
||||
? selectedMaterialTarget.role
|
||||
: null
|
||||
const materialPickerValue =
|
||||
materialTargetSide === 'interior'
|
||||
? effectiveInteriorMaterial
|
||||
: materialTargetSide === 'exterior'
|
||||
? effectiveExteriorMaterial
|
||||
: surfaceMaterialsMatch
|
||||
? effectiveInteriorMaterial
|
||||
: {}
|
||||
|
||||
const handleUpdateLength = useCallback(
|
||||
(newLength: number) => {
|
||||
if (!node || newLength <= 0) return
|
||||
@@ -140,22 +79,6 @@ export function WallPanel() {
|
||||
[node, handleUpdate],
|
||||
)
|
||||
|
||||
const handleMaterialPresetChange = useCallback(
|
||||
(materialPreset: string) => {
|
||||
if (!node || !materialTargetSide) return
|
||||
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
|
||||
},
|
||||
[handleUpdate, materialTargetSide, node],
|
||||
)
|
||||
|
||||
const handleCustomMaterialChange = useCallback(
|
||||
(material: MaterialSchema) => {
|
||||
if (!node || !materialTargetSide) return
|
||||
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
|
||||
},
|
||||
[handleUpdate, materialTargetSide, node],
|
||||
)
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelection({ selectedIds: [] })
|
||||
}, [setSelection])
|
||||
@@ -237,23 +160,6 @@ export function WallPanel() {
|
||||
)}
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Material">
|
||||
{!materialTargetSide ? (
|
||||
<div className="mb-3 rounded-lg border border-border/50 bg-[#2C2C2E] px-3 py-2 text-[11px] text-muted-foreground">
|
||||
Click the wall face you want to edit. Materials now apply to one side at a time.
|
||||
</div>
|
||||
) : null}
|
||||
<MaterialPicker
|
||||
disabled={!materialTargetSide}
|
||||
hideSideControl
|
||||
nodeType="wall"
|
||||
onChange={handleCustomMaterialChange}
|
||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
||||
selectedMaterialPreset={materialPickerValue.materialPreset}
|
||||
value={materialPickerValue.material}
|
||||
/>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,6 +89,13 @@ 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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -9,14 +9,14 @@ import {
|
||||
type FenceNode,
|
||||
type ItemNode,
|
||||
type LevelNode,
|
||||
type RoofSurfaceMaterialRole,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
type RoofSurfaceMaterialRole,
|
||||
type SlabNode,
|
||||
type Space,
|
||||
type StairSurfaceMaterialRole,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
type StairSurfaceMaterialRole,
|
||||
useScene,
|
||||
type WallNode,
|
||||
type WallSurfaceSide,
|
||||
@@ -26,6 +26,13 @@ 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
|
||||
@@ -37,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 =
|
||||
@@ -89,13 +96,19 @@ export type MovingFenceEndpoint = {
|
||||
endpoint: 'start' | 'end'
|
||||
}
|
||||
|
||||
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
|
||||
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
|
||||
@@ -149,6 +162,15 @@ type EditorState = {
|
||||
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
|
||||
@@ -228,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 {
|
||||
@@ -466,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) {
|
||||
@@ -530,6 +554,47 @@ const useEditor = create<EditorState>()(
|
||||
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: {},
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { type CeilingNode, getMaterialPresetByRef, resolveMaterial, useRegistry } from '@pascal-app/core'
|
||||
import {
|
||||
type CeilingNode,
|
||||
getMaterialPresetByRef,
|
||||
resolveMaterial,
|
||||
useRegistry,
|
||||
} from '@pascal-app/core'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||
@@ -32,6 +37,18 @@ function createCeilingMaterials(color = '#999999') {
|
||||
return { topMaterial, bottomMaterial }
|
||||
}
|
||||
|
||||
const ceilingMaterialCache = new Map<string, ReturnType<typeof createCeilingMaterials>>()
|
||||
|
||||
function getCeilingMaterials(color = '#999999') {
|
||||
const cacheKey = color
|
||||
const cached = ceilingMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const materials = createCeilingMaterials(color)
|
||||
ceilingMaterialCache.set(cacheKey, materials)
|
||||
return materials
|
||||
}
|
||||
|
||||
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
|
||||
@@ -42,8 +59,14 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||
const color = props.color || '#999999'
|
||||
return createCeilingMaterials(color)
|
||||
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
||||
return getCeilingMaterials(color)
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
])
|
||||
|
||||
return (
|
||||
<mesh material={materials.bottomMaterial} ref={ref}>
|
||||
|
||||
@@ -31,7 +31,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
||||
}, [node.id])
|
||||
|
||||
return (
|
||||
<mesh castShadow material={material} receiveShadow ref={ref} visible={node.visible} {...handlers}>
|
||||
<mesh
|
||||
castShadow
|
||||
material={material}
|
||||
receiveShadow
|
||||
ref={ref}
|
||||
visible={node.visible}
|
||||
{...handlers}
|
||||
>
|
||||
<boxGeometry args={[0, 0, 0]} />
|
||||
</mesh>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type AnyNodeId, type RoofNode, type RoofSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type RoofNode,
|
||||
type RoofSegmentNode,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
@@ -14,8 +20,9 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
|
||||
const handlers = useNodeEvents(node, 'roof-segment')
|
||||
const debugColors = useViewer((s) => s.debugColors)
|
||||
const parentNode =
|
||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
|
||||
const parentNode = node.parentId
|
||||
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
|
||||
: undefined
|
||||
const placeholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||
@@ -32,24 +39,7 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
||||
}
|
||||
|
||||
return parentNode ? getRoofMaterialArray(parentNode) : null
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
parentNode?.materialPreset,
|
||||
parentNode?.material,
|
||||
parentNode?.material?.preset,
|
||||
parentNode?.material?.properties,
|
||||
parentNode?.material?.texture,
|
||||
parentNode?.topMaterial,
|
||||
parentNode?.topMaterialPreset,
|
||||
parentNode?.edgeMaterial,
|
||||
parentNode?.edgeMaterialPreset,
|
||||
parentNode?.wallMaterial,
|
||||
parentNode?.wallMaterialPreset,
|
||||
])
|
||||
}, [node, parentNode])
|
||||
|
||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||
|
||||
|
||||
@@ -24,22 +24,7 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
||||
return geometry
|
||||
}, [])
|
||||
|
||||
const customMaterial = useMemo(
|
||||
() => getRoofMaterialArray(node),
|
||||
[
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
node.topMaterial,
|
||||
node.topMaterialPreset,
|
||||
node.edgeMaterial,
|
||||
node.edgeMaterialPreset,
|
||||
node.wallMaterial,
|
||||
node.wallMaterialPreset,
|
||||
],
|
||||
)
|
||||
const customMaterial = useMemo(() => getRoofMaterialArray(node), [node])
|
||||
|
||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import type { Mesh } from 'three'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import {
|
||||
applyMaterialPresetToMaterials,
|
||||
@@ -9,6 +9,42 @@ import {
|
||||
DEFAULT_SLAB_MATERIAL,
|
||||
} from '../../../lib/materials'
|
||||
|
||||
const slabMaterialCache = new Map<string, THREE.MeshStandardMaterial>()
|
||||
|
||||
function getSlabMaterial(
|
||||
cacheKey: string,
|
||||
params: { material?: SlabNode['material']; materialPreset?: string },
|
||||
) {
|
||||
const cached = slabMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const preset = getMaterialPresetByRef(params.materialPreset)
|
||||
const slabMaterial = preset
|
||||
? new THREE.MeshStandardMaterial()
|
||||
: params.material
|
||||
? createMaterial(params.material).clone()
|
||||
: DEFAULT_SLAB_MATERIAL.clone()
|
||||
|
||||
if (preset) {
|
||||
// Apply the preset to the slab-owned material so async texture loads update
|
||||
// the instance we actually render after refresh as well.
|
||||
applyMaterialPresetToMaterials(slabMaterial, preset)
|
||||
}
|
||||
|
||||
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
|
||||
// pipeline variants that can fail when geometry is regenerated while a
|
||||
// transparent/custom material is attached.
|
||||
slabMaterial.transparent = false
|
||||
slabMaterial.opacity = 1
|
||||
slabMaterial.alphaMap = null
|
||||
slabMaterial.side = THREE.DoubleSide
|
||||
slabMaterial.depthWrite = true
|
||||
slabMaterial.needsUpdate = true
|
||||
|
||||
slabMaterialCache.set(cacheKey, slabMaterial)
|
||||
return slabMaterial
|
||||
}
|
||||
|
||||
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const ref = useRef<Mesh>(null!)
|
||||
|
||||
@@ -17,30 +53,17 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
const handlers = useNodeEvents(node, 'slab')
|
||||
|
||||
const material = useMemo(() => {
|
||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||
const slabMaterial = preset
|
||||
? new THREE.MeshStandardMaterial()
|
||||
: node.material
|
||||
? createMaterial(node.material).clone()
|
||||
: DEFAULT_SLAB_MATERIAL.clone()
|
||||
const resolvedMaterial = node.material
|
||||
const resolvedMaterialPreset = node.materialPreset
|
||||
const cacheKey = JSON.stringify({
|
||||
material: resolvedMaterial ?? null,
|
||||
materialPreset: resolvedMaterialPreset ?? null,
|
||||
})
|
||||
|
||||
if (preset) {
|
||||
// Apply the preset to the slab-owned material so async texture loads update
|
||||
// the instance we actually render after refresh as well.
|
||||
applyMaterialPresetToMaterials(slabMaterial, preset)
|
||||
}
|
||||
|
||||
// Slabs participate in the WebGPU MRT scene pass. Keeping them opaque avoids
|
||||
// pipeline variants that can fail when geometry is regenerated while a
|
||||
// transparent/custom material is attached.
|
||||
slabMaterial.transparent = false
|
||||
slabMaterial.opacity = 1
|
||||
slabMaterial.alphaMap = null
|
||||
slabMaterial.side = THREE.DoubleSide
|
||||
slabMaterial.depthWrite = true
|
||||
slabMaterial.needsUpdate = true
|
||||
|
||||
return slabMaterial
|
||||
return getSlabMaterial(cacheKey, {
|
||||
material: resolvedMaterial,
|
||||
materialPreset: resolvedMaterialPreset,
|
||||
})
|
||||
}, [
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
@@ -49,12 +72,6 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||
node.materialPreset,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
material.dispose()
|
||||
}
|
||||
}, [material])
|
||||
|
||||
return (
|
||||
<mesh
|
||||
castShadow
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { type AnyNodeId, type StairNode, type StairSegmentNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type StairNode,
|
||||
type StairSegmentNode,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
@@ -15,29 +21,13 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
||||
}, [node.id])
|
||||
|
||||
const handlers = useNodeEvents(node, 'stair-segment')
|
||||
const parentNode =
|
||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
|
||||
|
||||
const material = useMemo(() => {
|
||||
return getStraightStairSegmentBodyMaterials(node, parentNode)
|
||||
}, [
|
||||
node.materialPreset,
|
||||
node.material,
|
||||
node.material?.preset,
|
||||
node.material?.properties,
|
||||
node.material?.texture,
|
||||
parentNode?.materialPreset,
|
||||
parentNode?.material,
|
||||
parentNode?.material?.preset,
|
||||
parentNode?.material?.properties,
|
||||
parentNode?.material?.texture,
|
||||
parentNode?.railingMaterialPreset,
|
||||
parentNode?.railingMaterial,
|
||||
parentNode?.sideMaterialPreset,
|
||||
parentNode?.sideMaterial,
|
||||
parentNode?.treadMaterialPreset,
|
||||
parentNode?.treadMaterial,
|
||||
])
|
||||
const parentNode = node.parentId
|
||||
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
|
||||
: undefined
|
||||
const material = useMemo(
|
||||
() => getStraightStairSegmentBodyMaterials(node, parentNode),
|
||||
[node, parentNode],
|
||||
)
|
||||
|
||||
const placeholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
|
||||
@@ -8,10 +8,14 @@ import {
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import * as THREE from 'three'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
||||
import {
|
||||
getStairRailingMaterial,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
DEFAULT_STAIR_MATERIAL,
|
||||
} from '../../../lib/materials'
|
||||
import {
|
||||
getStairBodyMaterials,
|
||||
getStairRailingMaterial,
|
||||
type StairBodyMaterials,
|
||||
} from '../../../systems/stair/stair-materials'
|
||||
import { NodeRenderer } from '../node-renderer'
|
||||
@@ -72,33 +76,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
||||
node.material?.texture,
|
||||
])
|
||||
|
||||
const straightBodyMaterials = useMemo(
|
||||
() => getStairBodyMaterials(node),
|
||||
[
|
||||
node.material,
|
||||
node.materialPreset,
|
||||
node.railingMaterial,
|
||||
node.railingMaterialPreset,
|
||||
node.sideMaterial,
|
||||
node.sideMaterialPreset,
|
||||
node.treadMaterial,
|
||||
node.treadMaterialPreset,
|
||||
],
|
||||
)
|
||||
const straightBodyMaterials = useMemo(() => getStairBodyMaterials(node), [node])
|
||||
|
||||
const railingMaterial = useMemo(
|
||||
() => getStairRailingMaterial(node),
|
||||
[
|
||||
node.material,
|
||||
node.materialPreset,
|
||||
node.railingMaterial,
|
||||
node.railingMaterialPreset,
|
||||
node.sideMaterial,
|
||||
node.sideMaterialPreset,
|
||||
node.treadMaterial,
|
||||
node.treadMaterialPreset,
|
||||
],
|
||||
)
|
||||
const railingMaterial = useMemo(() => getStairRailingMaterial(node), [node])
|
||||
|
||||
const straightPlaceholderGeometry = useMemo(() => {
|
||||
const geometry = new THREE.BufferGeometry()
|
||||
@@ -132,7 +112,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
||||
receiveShadow
|
||||
/>
|
||||
) : null}
|
||||
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
|
||||
{!isSegmentBasedStair ? (
|
||||
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
|
||||
) : null}
|
||||
<StairRailings material={railingMaterial} stair={node} />
|
||||
{isSegmentBasedStair ? (
|
||||
<group name="segments-wrapper" visible={false}>
|
||||
|
||||
@@ -26,7 +26,7 @@ import { SceneRenderer } from '../renderers/scene-renderer'
|
||||
import FrameLimiter from './frame-limiter'
|
||||
import { Lights } from './lights'
|
||||
import { PerfMonitor } from './perf-monitor'
|
||||
import PostProcessing from './post-processing'
|
||||
import PostProcessing, { DEFAULT_HOVER_STYLES, type HoverStyles } from './post-processing'
|
||||
import { SelectionManager } from './selection-manager'
|
||||
import { ViewerCamera } from './viewer-camera'
|
||||
|
||||
@@ -101,12 +101,14 @@ function GPUDeviceWatcher() {
|
||||
|
||||
interface ViewerProps {
|
||||
children?: React.ReactNode
|
||||
hoverStyles?: HoverStyles
|
||||
selectionManager?: 'default' | 'custom'
|
||||
perf?: boolean
|
||||
}
|
||||
|
||||
const Viewer: React.FC<ViewerProps> = ({
|
||||
children,
|
||||
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||
selectionManager = 'default',
|
||||
perf = false,
|
||||
}) => {
|
||||
@@ -165,7 +167,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
||||
<WallSystem />
|
||||
<WindowSystem />
|
||||
<ZoneSystem />
|
||||
<PostProcessing />
|
||||
<PostProcessing hoverStyles={hoverStyles} />
|
||||
{/* <DebugRenderer /> */}
|
||||
<GPUDeviceWatcher />
|
||||
|
||||
|
||||
@@ -47,6 +47,28 @@ const RETRY_DELAY_MS = 500
|
||||
const DARK_BG = '#1f2433'
|
||||
const LIGHT_BG = '#ffffff'
|
||||
|
||||
export type HoverStyle = {
|
||||
visibleColor: number
|
||||
hiddenColor: number
|
||||
strength: number
|
||||
pulse: boolean
|
||||
}
|
||||
|
||||
export type HoverStyles = {
|
||||
default: HoverStyle
|
||||
} & Record<string, HoverStyle>
|
||||
|
||||
const DEFAULT_HOVER_STYLE: HoverStyle = {
|
||||
visibleColor: 0x00_aa_ff,
|
||||
hiddenColor: 0xf3_ff_47,
|
||||
strength: 5,
|
||||
pulse: true,
|
||||
}
|
||||
|
||||
export const DEFAULT_HOVER_STYLES: HoverStyles = {
|
||||
default: DEFAULT_HOVER_STYLE,
|
||||
}
|
||||
|
||||
function sanitizeOutlineObjects(objects: Object3D[]) {
|
||||
let nextIndex = 0
|
||||
|
||||
@@ -62,8 +84,12 @@ function sanitizeOutlineObjects(objects: Object3D[]) {
|
||||
objects.length = nextIndex
|
||||
}
|
||||
|
||||
const PostProcessingPasses = () => {
|
||||
const { gl: renderer, scene, camera } = useThree()
|
||||
const PostProcessingPasses = ({
|
||||
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||
}: {
|
||||
hoverStyles?: HoverStyles
|
||||
}) => {
|
||||
const { gl: renderer, invalidate, scene, camera } = useThree()
|
||||
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
||||
const hasPipelineErrorRef = useRef(false)
|
||||
const retryCountRef = useRef(0)
|
||||
@@ -83,6 +109,10 @@ const PostProcessingPasses = () => {
|
||||
return l
|
||||
}, [])
|
||||
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
||||
const hoverVisibleColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.visibleColor)), [])
|
||||
const hoverHiddenColor = useMemo(() => uniform(new Color(DEFAULT_HOVER_STYLE.hiddenColor)), [])
|
||||
const hoverStrength = useMemo(() => uniform(DEFAULT_HOVER_STYLE.strength), [])
|
||||
const hoverPulseMix = useMemo(() => uniform(DEFAULT_HOVER_STYLE.pulse ? 0 : 1), [])
|
||||
|
||||
// Subscribe to projectId so the pipeline rebuilds on project switch
|
||||
const projectId = useViewer((s) => s.projectId)
|
||||
@@ -119,6 +149,23 @@ const PostProcessingPasses = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const style = hoverStyles[hoverHighlightMode] ?? hoverStyles.default
|
||||
hoverVisibleColor.value.setHex(style.visibleColor)
|
||||
hoverHiddenColor.value.setHex(style.hiddenColor)
|
||||
hoverStrength.value = style.strength
|
||||
hoverPulseMix.value = style.pulse ? 0 : 1
|
||||
invalidate()
|
||||
}, [
|
||||
hoverHiddenColor,
|
||||
hoverHighlightMode,
|
||||
hoverPulseMix,
|
||||
hoverStrength,
|
||||
hoverStyles,
|
||||
hoverVisibleColor,
|
||||
invalidate,
|
||||
])
|
||||
|
||||
// Build / rebuild the post-processing pipeline
|
||||
useEffect(() => {
|
||||
// Intentionally touch these so React/biome treat project switches and retry bumps
|
||||
@@ -248,18 +295,9 @@ const PostProcessingPasses = () => {
|
||||
.mul(selectedStrength)
|
||||
|
||||
// Hovered: blue visible, yellow hidden, pulsing
|
||||
const hoverVisibleColor = uniform(
|
||||
new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff),
|
||||
)
|
||||
const hoverHiddenColor = uniform(
|
||||
new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47),
|
||||
)
|
||||
const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5)
|
||||
const pulsePeriod = uniform(3)
|
||||
const osc =
|
||||
hoverHighlightMode === 'delete'
|
||||
? float(1)
|
||||
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
|
||||
const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
|
||||
const osc = mix(oscillating, float(1), hoverPulseMix)
|
||||
const hoverOutline = outlineNode.secondaryVisibleEdge
|
||||
.mul(hoverVisibleColor)
|
||||
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
||||
@@ -298,7 +336,18 @@ const PostProcessingPasses = () => {
|
||||
}
|
||||
renderPipelineRef.current = null
|
||||
}
|
||||
}, [renderer, scene, camera, hoverHighlightMode, zoneLayers, projectId, pipelineVersion])
|
||||
}, [
|
||||
camera,
|
||||
hoverHiddenColor,
|
||||
hoverPulseMix,
|
||||
hoverStrength,
|
||||
hoverVisibleColor,
|
||||
pipelineVersion,
|
||||
projectId,
|
||||
renderer,
|
||||
scene,
|
||||
zoneLayers,
|
||||
])
|
||||
|
||||
useFrame((_, delta) => {
|
||||
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
export { default as Viewer } from './components/viewer'
|
||||
export { SSGI_PARAMS } from './components/viewer/post-processing'
|
||||
export type { HoverStyle, HoverStyles } from './components/viewer/post-processing'
|
||||
export {
|
||||
DEFAULT_HOVER_STYLES,
|
||||
SSGI_PARAMS,
|
||||
} from './components/viewer/post-processing'
|
||||
export { WalkthroughControls } from './components/viewer/walkthrough-controls'
|
||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
||||
export {
|
||||
applyMaterialPresetToMaterials,
|
||||
clearMaterialCache,
|
||||
createDefaultMaterial,
|
||||
createMaterial,
|
||||
createMaterialFromPresetRef,
|
||||
DEFAULT_CEILING_MATERIAL,
|
||||
DEFAULT_DOOR_MATERIAL,
|
||||
DEFAULT_ROOF_MATERIAL,
|
||||
@@ -19,3 +25,6 @@ export { mergedOutline } from './lib/merged-outline-node'
|
||||
export { default as useViewer } from './store/use-viewer'
|
||||
export { InteractiveSystem } from './systems/interactive/interactive-system'
|
||||
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
||||
export { getRoofMaterialArray } from './systems/roof/roof-materials'
|
||||
export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials'
|
||||
export { getVisibleWallMaterials } from './systems/wall/wall-materials'
|
||||
|
||||
+9
-3
@@ -1,4 +1,10 @@
|
||||
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'
|
||||
import type {
|
||||
AnyNode,
|
||||
BaseNode,
|
||||
BuildingNode,
|
||||
LevelNode,
|
||||
ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import type { Object3D } from 'three'
|
||||
type SelectionPath = {
|
||||
buildingId: BuildingNode['id'] | null
|
||||
@@ -14,8 +20,8 @@ type ViewerState = {
|
||||
selection: SelectionPath
|
||||
previewSelectedIds: BaseNode['id'][]
|
||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||
hoverHighlightMode: 'default' | 'delete'
|
||||
setHoverHighlightMode: (mode: 'default' | 'delete') => void
|
||||
hoverHighlightMode: string
|
||||
setHoverHighlightMode: (mode: string) => void
|
||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||
cameraMode: 'perspective' | 'orthographic'
|
||||
|
||||
@@ -22,8 +22,8 @@ type ViewerState = {
|
||||
selection: SelectionPath
|
||||
previewSelectedIds: BaseNode['id'][]
|
||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||
hoverHighlightMode: 'default' | 'delete'
|
||||
setHoverHighlightMode: (mode: 'default' | 'delete') => void
|
||||
hoverHighlightMode: string
|
||||
setHoverHighlightMode: (mode: string) => void
|
||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||
|
||||
@@ -85,9 +85,10 @@ const useViewer = create<ViewerState>()(
|
||||
previewSelectedIds: [],
|
||||
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
||||
hoverHighlightMode: 'default',
|
||||
setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }),
|
||||
setHoverHighlightMode: (mode) =>
|
||||
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
|
||||
hoveredId: null,
|
||||
setHoveredId: (id) => set({ hoveredId: id }),
|
||||
setHoveredId: (id) => set((state) => (state.hoveredId === id ? state : { hoveredId: id })),
|
||||
|
||||
cameraMode: 'perspective',
|
||||
setCameraMode: (mode) => set({ cameraMode: mode }),
|
||||
|
||||
@@ -8,6 +8,17 @@ import { createMaterial, createMaterialFromPresetRef } from '../../lib/materials
|
||||
|
||||
export type RoofMaterialArray = [THREE.Material, THREE.Material, THREE.Material, THREE.Material]
|
||||
|
||||
const roofMaterialArrayCache = new Map<string, RoofMaterialArray>()
|
||||
|
||||
function getSurfaceMaterialSignature(
|
||||
spec: ReturnType<typeof getEffectiveRoofSurfaceMaterial>,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
material: spec.material ?? null,
|
||||
materialPreset: spec.materialPreset ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
function createResolvedMaterial(
|
||||
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
|
||||
materialPreset: string | undefined,
|
||||
@@ -27,6 +38,14 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
|
||||
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
|
||||
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
|
||||
const wall = getEffectiveRoofSurfaceMaterial(node, 'wall')
|
||||
const cacheKey = JSON.stringify({
|
||||
top: getSurfaceMaterialSignature(top),
|
||||
edge: getSurfaceMaterialSignature(edge),
|
||||
wall: getSurfaceMaterialSignature(wall),
|
||||
})
|
||||
|
||||
const cached = roofMaterialArrayCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const topMaterial = createResolvedMaterial(top.material, top.materialPreset)
|
||||
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
|
||||
@@ -36,11 +55,13 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
|
||||
return null
|
||||
}
|
||||
|
||||
return [
|
||||
const materialArray: RoofMaterialArray = [
|
||||
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||
topMaterial ?? wallMaterial ?? edgeMaterial ?? new THREE.MeshStandardMaterial(),
|
||||
]
|
||||
}
|
||||
|
||||
roofMaterialArrayCache.set(cacheKey, materialArray)
|
||||
return materialArray
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ import {
|
||||
|
||||
export type StairBodyMaterials = [THREE.Material, THREE.Material]
|
||||
|
||||
const stairBodyMaterialCache = new Map<string, StairBodyMaterials>()
|
||||
const stairRailingMaterialCache = new Map<string, THREE.Material>()
|
||||
|
||||
function getSurfaceMaterialSignature(
|
||||
spec: ReturnType<typeof getEffectiveStairSurfaceMaterial>,
|
||||
): string {
|
||||
return JSON.stringify({
|
||||
material: spec.material ?? null,
|
||||
materialPreset: spec.materialPreset ?? null,
|
||||
})
|
||||
}
|
||||
|
||||
function createResolvedMaterial(
|
||||
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
|
||||
materialPreset: string | undefined,
|
||||
@@ -30,16 +42,32 @@ function createResolvedMaterial(
|
||||
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
|
||||
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
|
||||
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
|
||||
const cacheKey = JSON.stringify({
|
||||
tread: getSurfaceMaterialSignature(tread),
|
||||
side: getSurfaceMaterialSignature(side),
|
||||
})
|
||||
|
||||
return [
|
||||
const cached = stairBodyMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const materials: StairBodyMaterials = [
|
||||
createResolvedMaterial(tread.material, tread.materialPreset),
|
||||
createResolvedMaterial(side.material, side.materialPreset),
|
||||
]
|
||||
|
||||
stairBodyMaterialCache.set(cacheKey, materials)
|
||||
return materials
|
||||
}
|
||||
|
||||
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
|
||||
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
|
||||
return createResolvedMaterial(railing.material, railing.materialPreset)
|
||||
const cacheKey = getSurfaceMaterialSignature(railing)
|
||||
const cached = stairRailingMaterialCache.get(cacheKey)
|
||||
if (cached) return cached
|
||||
|
||||
const material = createResolvedMaterial(railing.material, railing.materialPreset)
|
||||
stairRailingMaterialCache.set(cacheKey, material)
|
||||
return material
|
||||
}
|
||||
|
||||
export function getStraightStairSegmentBodyMaterials(
|
||||
|
||||
Reference in New Issue
Block a user