Merge pull request #271 from sudhir9297/feat/color-picker-tool

Feat/color picker tool
This commit is contained in:
Wassim SAMAD
2026-04-24 14:54:33 -04:00
committed by GitHub
39 changed files with 1863 additions and 827 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

+3 -1
View File
@@ -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'
+28 -58
View File
@@ -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 {
+4 -2
View File
@@ -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,
}
}
+219 -11
View File
@@ -7,8 +7,16 @@ import {
spatialGridManager,
useScene,
} from '@pascal-app/core'
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
import {
memo,
type ReactNode,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react'
import { ViewerOverlay } from '../../components/viewer-overlay'
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
@@ -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,7 @@
import {
type AnyNode,
type AnyNodeId,
getEffectiveRoofSurfaceMaterial,
type MaterialSchema,
type RoofNode,
type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
@@ -19,44 +16,16 @@ import { useShallow } from 'zustand/react/shallow'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
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 +48,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])
@@ -310,22 +252,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' },
@@ -90,7 +60,6 @@ export function StairPanel() {
const createNode = useScene((s) => s.createNode)
const createNodes = useScene((s) => s.createNodes)
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,
@@ -121,33 +90,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])
@@ -610,22 +552,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()
+279
View File
@@ -0,0 +1,279 @@
'use client'
import {
type CeilingNode,
type FenceNode,
getCatalogMaterialById,
getEffectiveRoofSurfaceMaterial,
getEffectiveStairSurfaceMaterial,
getEffectiveWallSurfaceMaterial,
getLibraryMaterialIdFromRef,
type MaterialSchema,
type MaterialTarget,
type RoofNode,
type RoofSurfaceMaterialRole,
type SlabNode,
type StairNode,
type StairSurfaceMaterialRole,
type WallNode,
type WallSurfaceSide,
} from '@pascal-app/core'
export type PaintableMaterialTarget = Extract<
MaterialTarget,
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
>
export type SingleSurfaceMaterialRole = 'surface'
export type ActivePaintMaterial = {
material?: MaterialSchema
materialPreset?: string
sourceTarget: PaintableMaterialTarget
}
export function hasActivePaintMaterial(
material: ActivePaintMaterial | null | undefined,
): material is ActivePaintMaterial {
return Boolean(
material && (material.material !== undefined || material.materialPreset !== undefined),
)
}
function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | null | undefined) {
const catalogId =
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
return getCatalogMaterialById(catalogId)
}
export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) {
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
}
export function buildWallSurfaceMaterialPatch(
node: WallNode,
targetSide: WallSurfaceSide,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<WallNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextInterior =
targetSide === 'interior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'interior')
const nextExterior =
targetSide === 'exterior'
? nextSurfaceMaterial
: getEffectiveWallSurfaceMaterial(node, 'exterior')
return {
interiorMaterial: nextInterior.material,
interiorMaterialPreset: nextInterior.materialPreset,
exteriorMaterial: nextExterior.material,
exteriorMaterialPreset: nextExterior.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function buildRoofSurfaceMaterialPatch(
node: RoofNode,
targetRole: RoofSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<RoofNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextTop =
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
const nextEdge =
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
const nextWall =
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
return {
topMaterial: nextTop.material,
topMaterialPreset: nextTop.materialPreset,
edgeMaterial: nextEdge.material,
edgeMaterialPreset: nextEdge.materialPreset,
wallMaterial: nextWall.material,
wallMaterialPreset: nextWall.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function buildStairSurfaceMaterialPatch(
node: StairNode,
targetRole: StairSurfaceMaterialRole,
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<StairNode> {
const nextSurfaceMaterial = { material, materialPreset }
const nextRailing =
targetRole === 'railing'
? nextSurfaceMaterial
: getEffectiveStairSurfaceMaterial(node, 'railing')
const nextTread =
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
const nextSide =
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
return {
railingMaterial: nextRailing.material,
railingMaterialPreset: nextRailing.materialPreset,
treadMaterial: nextTread.material,
treadMaterialPreset: nextTread.materialPreset,
sideMaterial: nextSide.material,
sideMaterialPreset: nextSide.materialPreset,
material: undefined,
materialPreset: undefined,
}
}
export function buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
material: MaterialSchema | undefined,
materialPreset: string | undefined,
): Partial<TNode> {
return {
material,
materialPreset,
} as Partial<TNode>
}
export function resolveActivePaintMaterialFromSelection(params: {
nodes: Record<string, any>
selectedId: string | null
selectedMaterialTarget: {
nodeId: string
role:
| WallSurfaceSide
| StairSurfaceMaterialRole
| RoofSurfaceMaterialRole
| SingleSurfaceMaterialRole
} | null
}): ActivePaintMaterial | null {
const { nodes, selectedId, selectedMaterialTarget } = params
if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId)
return null
const selectedNode = nodes[selectedId]
if (!selectedNode) return null
if (
selectedNode.type === 'wall' &&
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
) {
const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
})
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'wall',
}
: null
}
if (
selectedNode.type === 'roof' &&
(selectedMaterialTarget.role === 'top' ||
selectedMaterialTarget.role === 'edge' ||
selectedMaterialTarget.role === 'wall')
) {
const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
})
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'roof',
}
: null
}
if (
selectedNode.type === 'stair' &&
(selectedMaterialTarget.role === 'railing' ||
selectedMaterialTarget.role === 'tread' ||
selectedMaterialTarget.role === 'side')
) {
const surface = getEffectiveStairSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
return hasActivePaintMaterial({
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
})
? {
material: surface.material,
materialPreset: surface.materialPreset,
sourceTarget: 'stair',
}
: null
}
if (
(selectedNode.type === 'fence' ||
selectedNode.type === 'slab' ||
selectedNode.type === 'ceiling') &&
selectedMaterialTarget.role === 'surface'
) {
const target = selectedNode.type
return hasActivePaintMaterial({
material: selectedNode.material,
materialPreset: selectedNode.materialPreset,
sourceTarget: target,
})
? {
material: selectedNode.material,
materialPreset: selectedNode.materialPreset,
sourceTarget: target,
}
: null
}
return null
}
export function resolvePaintTargetFromSelection(params: {
nodes: Record<string, any>
selectedId: string | null
}): PaintableMaterialTarget | null {
const { nodes, selectedId } = params
if (!selectedId) return null
const selectedNode = nodes[selectedId]
if (!selectedNode) return null
if (selectedNode.type === 'wall') {
return 'wall'
}
if (selectedNode.type === 'roof' || selectedNode.type === 'roof-segment') {
return 'roof'
}
if (selectedNode.type === 'stair' || selectedNode.type === 'stair-segment') {
return 'stair'
}
if (selectedNode.type === 'fence') {
return 'fence'
}
if (selectedNode.type === 'slab') {
return 'slab'
}
if (selectedNode.type === 'ceiling') {
return 'ceiling'
}
return null
}
+70 -5
View File
@@ -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)
+10 -1
View File
@@ -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
View File
@@ -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'
+5 -4
View File
@@ -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(