Merge pull request #271 from sudhir9297/feat/color-picker-tool
Feat/color picker tool
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 97 KiB |
@@ -42,9 +42,11 @@ export {
|
|||||||
getCatalogMaterialById,
|
getCatalogMaterialById,
|
||||||
getLibraryMaterialIdFromRef,
|
getLibraryMaterialIdFromRef,
|
||||||
getMaterialPresetByRef,
|
getMaterialPresetByRef,
|
||||||
getMaterialsForTarget,
|
getMaterialsForCategory,
|
||||||
LIBRARY_MATERIAL_REF_PREFIX,
|
LIBRARY_MATERIAL_REF_PREFIX,
|
||||||
MATERIAL_CATALOG,
|
MATERIAL_CATALOG,
|
||||||
|
MATERIAL_CATEGORIES,
|
||||||
|
type MaterialCategory,
|
||||||
type MaterialCatalogItem,
|
type MaterialCatalogItem,
|
||||||
toLibraryMaterialRef,
|
toLibraryMaterialRef,
|
||||||
} from './material-library'
|
} from './material-library'
|
||||||
|
|||||||
@@ -1,57 +1,33 @@
|
|||||||
import {
|
import {
|
||||||
type MaterialPresetPayload,
|
type MaterialPresetPayload,
|
||||||
type MaterialTarget,
|
|
||||||
MaterialTarget as MaterialTargetSchema,
|
|
||||||
} from './schema/material'
|
} from './schema/material'
|
||||||
|
|
||||||
export type MaterialCatalogItem = {
|
export type MaterialCatalogItem = {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label: string
|
||||||
|
category: MaterialCategory
|
||||||
description?: string
|
description?: string
|
||||||
targets: MaterialTarget[]
|
|
||||||
previewThumbnailUrl?: string
|
previewThumbnailUrl?: string
|
||||||
previewColor?: string
|
previewColor?: string
|
||||||
preset: MaterialPresetPayload
|
preset: MaterialPresetPayload
|
||||||
}
|
}
|
||||||
|
|
||||||
const WALL_TARGETS: MaterialTarget[] = [
|
export const MATERIAL_CATEGORIES = [
|
||||||
MaterialTargetSchema.enum.wall,
|
'wood',
|
||||||
]
|
'wallpaper',
|
||||||
|
'parquet',
|
||||||
const SLAB_TARGETS: MaterialTarget[] = [
|
'granite',
|
||||||
MaterialTargetSchema.enum.slab,
|
'marble',
|
||||||
]
|
'other',
|
||||||
|
] as const
|
||||||
const WALL_AND_SLAB_TARGETS: MaterialTarget[] = [
|
export type MaterialCategory = (typeof MATERIAL_CATEGORIES)[number]
|
||||||
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_CATALOG: MaterialCatalogItem[] = [
|
export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
||||||
{
|
{
|
||||||
id: 'wall-wood1',
|
id: 'wall-wood1',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Warm wood finish',
|
description: 'Warm wood finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
|
previewThumbnailUrl: '/material/wood1/wood1_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -85,8 +61,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wood2',
|
id: 'wall-wood2',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Textured wood finish',
|
description: 'Textured wood finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
|
previewThumbnailUrl: '/material/wood2/wood2_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -121,8 +97,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wood3',
|
id: 'wall-wood3',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Knotted timber finish',
|
description: 'Knotted timber finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS, ...ROOF_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
|
previewThumbnailUrl: '/material/wood3/wood3_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -155,8 +131,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wood4',
|
id: 'wall-wood4',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Oak stretcher finish',
|
description: 'Oak stretcher finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
|
previewThumbnailUrl: '/material/wood4/wood4_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -189,8 +165,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wood5',
|
id: 'wall-wood5',
|
||||||
label: 'Wood',
|
label: 'Wood',
|
||||||
|
category: 'wood',
|
||||||
description: 'Rich grain wood finish',
|
description: 'Rich grain wood finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
|
previewThumbnailUrl: '/material/wood5/wood5_thumnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -225,8 +201,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-granite1',
|
id: 'wall-granite1',
|
||||||
label: 'Granite',
|
label: 'Granite',
|
||||||
|
category: 'granite',
|
||||||
description: 'Polished granite finish',
|
description: 'Polished granite finish',
|
||||||
targets: SLAB_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
|
previewThumbnailUrl: '/material/granite1/granite_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -259,8 +235,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-marble1',
|
id: 'wall-marble1',
|
||||||
label: 'Marble',
|
label: 'Marble',
|
||||||
|
category: 'marble',
|
||||||
description: 'Smooth marble finish',
|
description: 'Smooth marble finish',
|
||||||
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
|
previewThumbnailUrl: '/material/marble1/marble1_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -293,8 +269,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-marble2',
|
id: 'wall-marble2',
|
||||||
label: 'Marble',
|
label: 'Marble',
|
||||||
|
category: 'marble',
|
||||||
description: 'Soft marble finish',
|
description: 'Soft marble finish',
|
||||||
targets: [...SLAB_TARGETS, ...STAIR_AND_FENCE_TARGETS],
|
|
||||||
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
|
previewThumbnailUrl: '/material/marble2/marble2_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -327,8 +303,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-parquet1',
|
id: 'wall-parquet1',
|
||||||
label: 'Parquet',
|
label: 'Parquet',
|
||||||
|
category: 'parquet',
|
||||||
description: 'Parquet wood finish',
|
description: 'Parquet wood finish',
|
||||||
targets: SLAB_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
|
previewThumbnailUrl: '/material/parquet1/parquet_thumnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -361,8 +337,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-parquet2',
|
id: 'wall-parquet2',
|
||||||
label: 'Parquet',
|
label: 'Parquet',
|
||||||
|
category: 'parquet',
|
||||||
description: 'Soft parquet finish',
|
description: 'Soft parquet finish',
|
||||||
targets: SLAB_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
|
previewThumbnailUrl: '/material/parquet2/parquet2_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -395,8 +371,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wallpaper1',
|
id: 'wall-wallpaper1',
|
||||||
label: 'Wallpaper',
|
label: 'Wallpaper',
|
||||||
|
category: 'wallpaper',
|
||||||
description: 'Soft wallpaper finish',
|
description: 'Soft wallpaper finish',
|
||||||
targets: WALL_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
|
previewThumbnailUrl: '/material/wallpaper1/wallpaper1_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -430,8 +406,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wallpaper2',
|
id: 'wall-wallpaper2',
|
||||||
label: 'Wallpaper',
|
label: 'Wallpaper',
|
||||||
|
category: 'wallpaper',
|
||||||
description: 'Decorative wallpaper finish',
|
description: 'Decorative wallpaper finish',
|
||||||
targets: WALL_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
|
previewThumbnailUrl: '/material/wallpaper2/wallpaper2_thumnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -464,8 +440,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'wall-wallpaper3',
|
id: 'wall-wallpaper3',
|
||||||
label: 'Wallpaper',
|
label: 'Wallpaper',
|
||||||
|
category: 'wallpaper',
|
||||||
description: 'Patterned wallpaper finish',
|
description: 'Patterned wallpaper finish',
|
||||||
targets: WALL_TARGETS,
|
|
||||||
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
|
previewThumbnailUrl: '/material/wallpaper3/wallpaper3_thumbnail.webp',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {
|
maps: {
|
||||||
@@ -498,14 +474,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'preset-white',
|
id: 'preset-white',
|
||||||
label: 'White',
|
label: 'White',
|
||||||
|
category: 'other',
|
||||||
description: 'Clean painted finish',
|
description: 'Clean painted finish',
|
||||||
targets: [
|
|
||||||
...WALL_TARGETS,
|
|
||||||
...SLAB_TARGETS,
|
|
||||||
...ROOF_TARGETS,
|
|
||||||
...STAIR_AND_FENCE_TARGETS,
|
|
||||||
...CEILING_TARGETS,
|
|
||||||
],
|
|
||||||
previewColor: '#ffffff',
|
previewColor: '#ffffff',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {},
|
maps: {},
|
||||||
@@ -536,8 +506,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'preset-metal',
|
id: 'preset-metal',
|
||||||
label: 'Metal',
|
label: 'Metal',
|
||||||
|
category: 'other',
|
||||||
description: 'Brushed metal finish',
|
description: 'Brushed metal finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
|
|
||||||
previewColor: '#c0c0c0',
|
previewColor: '#c0c0c0',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {},
|
maps: {},
|
||||||
@@ -568,8 +538,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
{
|
{
|
||||||
id: 'preset-glass',
|
id: 'preset-glass',
|
||||||
label: 'Glass',
|
label: 'Glass',
|
||||||
|
category: 'other',
|
||||||
description: 'Light glass finish',
|
description: 'Light glass finish',
|
||||||
targets: [...WALL_TARGETS, ...SLAB_TARGETS],
|
|
||||||
previewColor: '#87ceeb',
|
previewColor: '#87ceeb',
|
||||||
preset: {
|
preset: {
|
||||||
maps: {},
|
maps: {},
|
||||||
@@ -599,8 +569,8 @@ export const MATERIAL_CATALOG: MaterialCatalogItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function getMaterialsForTarget(target: MaterialTarget): MaterialCatalogItem[] {
|
export function getMaterialsForCategory(category: MaterialCategory): MaterialCatalogItem[] {
|
||||||
return MATERIAL_CATALOG.filter((item) => item.targets.includes(target))
|
return MATERIAL_CATALOG.filter((item) => item.category === category)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
|
export function getCatalogMaterialById(id?: string): MaterialCatalogItem | undefined {
|
||||||
|
|||||||
@@ -158,14 +158,16 @@ function migrateStairSurfaceMaterials(node: Record<string, any>) {
|
|||||||
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
|
if (node.treadMaterial !== undefined || typeof node.treadMaterialPreset === 'string') {
|
||||||
return {
|
return {
|
||||||
material: node.treadMaterial,
|
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') {
|
if (node.sideMaterial !== undefined || typeof node.sideMaterialPreset === 'string') {
|
||||||
return {
|
return {
|
||||||
material: node.sideMaterial,
|
material: node.sideMaterial,
|
||||||
materialPreset: typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
materialPreset:
|
||||||
|
typeof node.sideMaterialPreset === 'string' ? node.sideMaterialPreset : undefined,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,16 @@ import {
|
|||||||
spatialGridManager,
|
spatialGridManager,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
import { type HoverStyles, InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||||
import { memo, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
import {
|
||||||
|
memo,
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react'
|
||||||
import { ViewerOverlay } from '../../components/viewer-overlay'
|
import { ViewerOverlay } from '../../components/viewer-overlay'
|
||||||
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
import { ViewerZoneSystem } from '../../components/viewer-zone-system'
|
||||||
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
import { type PresetsAdapter, PresetsProvider } from '../../contexts/presets-context'
|
||||||
@@ -22,8 +30,8 @@ import {
|
|||||||
} from '../../lib/scene'
|
} from '../../lib/scene'
|
||||||
import { initSFXBus } from '../../lib/sfx-bus'
|
import { initSFXBus } from '../../lib/sfx-bus'
|
||||||
import useEditor from '../../store/use-editor'
|
import useEditor from '../../store/use-editor'
|
||||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
|
||||||
import { CeilingSelectionAffordanceSystem } from '../systems/ceiling/ceiling-selection-affordance-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 { RoofEditSystem } from '../systems/roof/roof-edit-system'
|
||||||
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
import { StairEditSystem } from '../systems/stair/stair-edit-system'
|
||||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-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_COLOR = '#ef4444'
|
||||||
const DELETE_CURSOR_BADGE_OFFSET_X = 14
|
const DELETE_CURSOR_BADGE_OFFSET_X = 14
|
||||||
const DELETE_CURSOR_BADGE_OFFSET_Y = 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
|
* 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 ──
|
// ── Viewer scene content: memoized so <Viewer> doesn't re-render on mode/viewMode changes ──
|
||||||
|
|
||||||
const ViewerSceneContent = memo(function ViewerSceneContent({
|
const ViewerSceneContent = memo(function ViewerSceneContent({
|
||||||
@@ -552,31 +619,165 @@ function DeleteCursorLayer({
|
|||||||
isVersionPreviewMode: boolean
|
isVersionPreviewMode: boolean
|
||||||
}) {
|
}) {
|
||||||
const mode = useEditor((s) => s.mode)
|
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
|
const active = mode === 'delete' && !isVersionPreviewMode
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
setPosition(null)
|
if (badgeRef.current) {
|
||||||
|
badgeRef.current.style.display = 'none'
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const el = containerRef.current
|
const el = containerRef.current
|
||||||
if (!el) return
|
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 onMove = (e: PointerEvent) => {
|
||||||
const rect = el.getBoundingClientRect()
|
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('pointermove', onMove)
|
||||||
el.addEventListener('pointerleave', onLeave)
|
el.addEventListener('pointerleave', onLeave)
|
||||||
return () => {
|
return () => {
|
||||||
|
if (frame !== 0) {
|
||||||
|
window.cancelAnimationFrame(frame)
|
||||||
|
}
|
||||||
el.removeEventListener('pointermove', onMove)
|
el.removeEventListener('pointermove', onMove)
|
||||||
el.removeEventListener('pointerleave', onLeave)
|
el.removeEventListener('pointerleave', onLeave)
|
||||||
}
|
}
|
||||||
}, [active, containerRef])
|
}, [active, containerRef])
|
||||||
|
|
||||||
if (!(active && position)) return null
|
if (!active) return null
|
||||||
return <DeleteCursorBadge position={position} />
|
|
||||||
|
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 ──
|
// ── Viewer canvas: memoized, subscribes to viewMode/floorplanPaneRatio internally ──
|
||||||
@@ -684,6 +885,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
|||||||
containerRef={viewer3dRef}
|
containerRef={viewer3dRef}
|
||||||
isVersionPreviewMode={isVersionPreviewMode}
|
isVersionPreviewMode={isVersionPreviewMode}
|
||||||
/>
|
/>
|
||||||
|
<PaintCursorLayer
|
||||||
|
containerRef={viewer3dRef}
|
||||||
|
isVersionPreviewMode={isVersionPreviewMode}
|
||||||
|
/>
|
||||||
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
{!showLoader && isCameraControlsHintVisible && !isFirstPersonMode ? (
|
||||||
<ViewerCanvasControlsHint
|
<ViewerCanvasControlsHint
|
||||||
isPreviewMode={isPreviewMode}
|
isPreviewMode={isPreviewMode}
|
||||||
@@ -691,7 +896,10 @@ const ViewerCanvas = memo(function ViewerCanvas({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
<SelectionPersistenceManager enabled={hasLoadedInitialScene && !showLoader} />
|
||||||
<Viewer selectionManager={isFirstPersonMode ? 'default' : 'custom'}>
|
<Viewer
|
||||||
|
hoverStyles={EDITOR_HOVER_STYLES}
|
||||||
|
selectionManager={isFirstPersonMode ? 'default' : 'custom'}
|
||||||
|
>
|
||||||
<ViewerSceneContent
|
<ViewerSceneContent
|
||||||
isFirstPersonMode={isFirstPersonMode}
|
isFirstPersonMode={isFirstPersonMode}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
@@ -823,7 +1031,7 @@ export default function Editor({
|
|||||||
const showLoader = isLoading || isSceneLoading
|
const showLoader = isLoading || isSceneLoading
|
||||||
|
|
||||||
const previewViewerContent = (
|
const previewViewerContent = (
|
||||||
<Viewer selectionManager="default">
|
<Viewer hoverStyles={EDITOR_HOVER_STYLES} selectionManager="default">
|
||||||
<ExportManager />
|
<ExportManager />
|
||||||
<ViewerZoneSystem />
|
<ViewerZoneSystem />
|
||||||
<CeilingSystem />
|
<CeilingSystem />
|
||||||
|
|||||||
@@ -2,27 +2,56 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type BuildingNode,
|
type BuildingNode,
|
||||||
|
type CeilingNode,
|
||||||
emitter,
|
emitter,
|
||||||
|
type FenceNode,
|
||||||
|
getMaterialPresetByRef,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type NodeEvent,
|
type NodeEvent,
|
||||||
type RoofEvent,
|
type RoofEvent,
|
||||||
|
type RoofNode,
|
||||||
type RoofSegmentEvent,
|
type RoofSegmentEvent,
|
||||||
resolveLevelId,
|
resolveLevelId,
|
||||||
sceneRegistry,
|
resolveMaterial,
|
||||||
|
type SlabNode,
|
||||||
type StairEvent,
|
type StairEvent,
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairSurfaceMaterialRole,
|
|
||||||
type StairSegmentEvent,
|
type StairSegmentEvent,
|
||||||
|
type StairSurfaceMaterialRole,
|
||||||
|
sceneRegistry,
|
||||||
useScene,
|
useScene,
|
||||||
type WallEvent,
|
type WallEvent,
|
||||||
|
type WallNode,
|
||||||
type WallSurfaceSide,
|
type WallSurfaceSide,
|
||||||
} from '@pascal-app/core'
|
} 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 { 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 { 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'
|
import { boxSelectHandled } from '../tools/select/box-select-tool'
|
||||||
|
|
||||||
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
const isNodeInCurrentLevel = (node: AnyNode): boolean => {
|
||||||
@@ -52,6 +81,16 @@ type ModifierKeys = {
|
|||||||
ctrl: boolean
|
ctrl: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PaintPreviewCleanup = () => void
|
||||||
|
|
||||||
|
type PaintInteraction = {
|
||||||
|
key: string
|
||||||
|
apply: (() => void) | null
|
||||||
|
hoverMode: HoverHighlightMode
|
||||||
|
hoveredId: AnyNodeId
|
||||||
|
preview: (() => PaintPreviewCleanup | null) | null
|
||||||
|
}
|
||||||
|
|
||||||
interface SelectionStrategy {
|
interface SelectionStrategy {
|
||||||
types: SelectableNodeType[]
|
types: SelectableNodeType[]
|
||||||
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
handleSelect: (node: AnyNode, nativeEvent?: any, modifierKeys?: ModifierKeys) => void
|
||||||
@@ -175,10 +214,197 @@ function getIntersectionMaterialIndex(
|
|||||||
return group?.materialIndex
|
return group?.materialIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSelectedMaterialTargetForNode(
|
function getRegisteredNodeObject(nodeId: string): Object3D | null {
|
||||||
node: AnyNode,
|
return sceneRegistry.nodes.get(nodeId) ?? null
|
||||||
role: MaterialTargetRole | 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) {
|
if (!role) {
|
||||||
const currentTarget = useEditor.getState().selectedMaterialTarget
|
const currentTarget = useEditor.getState().selectedMaterialTarget
|
||||||
if (currentTarget?.nodeId !== node.id) {
|
if (currentTarget?.nodeId !== node.id) {
|
||||||
@@ -209,6 +435,7 @@ const HIGHLIGHT_PROFILES = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
|
type HighlightKind = keyof typeof HIGHLIGHT_PROFILES
|
||||||
|
type HoverHighlightMode = 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
|
||||||
|
|
||||||
type HighlightableMaterial = Material & {
|
type HighlightableMaterial = Material & {
|
||||||
color?: Color
|
color?: Color
|
||||||
@@ -475,13 +702,292 @@ export const SelectionManager = () => {
|
|||||||
const curvingFence = useEditor((s) => s.curvingFence)
|
const curvingFence = useEditor((s) => s.curvingFence)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setHoverHighlightMode(mode === 'delete' ? 'delete' : 'default')
|
const nextHoverMode: HoverHighlightMode = mode === 'delete' ? 'delete' : 'default'
|
||||||
|
setHoverHighlightMode(nextHoverMode)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
setHoverHighlightMode('default')
|
setHoverHighlightMode('default')
|
||||||
}
|
}
|
||||||
}, [mode, setHoverHighlightMode])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const onKeyDown = (event: KeyboardEvent) => {
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
if (event.key === 'Meta') modifierKeysRef.current.meta = true
|
||||||
@@ -597,6 +1103,14 @@ export const SelectionManager = () => {
|
|||||||
nextMaterialTargetHandled = true
|
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) {
|
if (!nextMaterialTargetHandled && useEditor.getState().selectedMaterialTarget) {
|
||||||
useEditor.getState().setSelectedMaterialTarget(null)
|
useEditor.getState().setSelectedMaterialTarget(null)
|
||||||
}
|
}
|
||||||
@@ -912,7 +1426,12 @@ const SelectionStateSync = () => {
|
|||||||
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
|
const selectedNode = useScene.getState().nodes[singleSelectedId as AnyNodeId]
|
||||||
if (
|
if (
|
||||||
!selectedNode ||
|
!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)
|
setSelectedMaterialTarget(null)
|
||||||
return
|
return
|
||||||
@@ -1022,7 +1541,8 @@ const SelectionMaterialSync = () => {
|
|||||||
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
|
}, [hoverHighlightMode, hoveredId, previewSelectedIds, selectedIds, syncSelectionMaterials])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return useScene.subscribe(() => {
|
return useScene.subscribe((state, prevState) => {
|
||||||
|
if (state.nodes === prevState.nodes) return
|
||||||
syncSelectionMaterials()
|
syncSelectionMaterials()
|
||||||
})
|
})
|
||||||
}, [syncSelectionMaterials])
|
}, [syncSelectionMaterials])
|
||||||
|
|||||||
@@ -9,7 +9,15 @@ import { cn } from './../../../lib/utils'
|
|||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { ActionButton } from './action-button'
|
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 = {
|
type ControlConfig = {
|
||||||
id: ControlId
|
id: ControlId
|
||||||
@@ -54,6 +62,14 @@ const controls: ControlConfig[] = [
|
|||||||
color: 'hover:bg-green-500/20 hover:text-green-400',
|
color: 'hover:bg-green-500/20 hover:text-green-400',
|
||||||
activeColor: 'bg-green-500/20 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',
|
id: 'furnish',
|
||||||
imageSrc: '/icons/couch.png',
|
imageSrc: '/icons/couch.png',
|
||||||
@@ -88,6 +104,7 @@ export function ControlModes() {
|
|||||||
const setPhase = useEditor((state) => state.setPhase)
|
const setPhase = useEditor((state) => state.setPhase)
|
||||||
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
const setStructureLayer = useEditor((state) => state.setStructureLayer)
|
||||||
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
const setSelectionTool = useEditor((state) => state.setFloorplanSelectionTool)
|
||||||
|
const primeMaterialPaintFromSelection = useEditor((state) => state.primeMaterialPaintFromSelection)
|
||||||
const levelId = useViewer((s) => s.selection.levelId)
|
const levelId = useViewer((s) => s.selection.levelId)
|
||||||
|
|
||||||
// Only subscribe to the primitive `level` number — when walls are added to
|
// 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 === 'site-edit') return false
|
||||||
if (id === 'build')
|
if (id === 'build')
|
||||||
return mode === 'build' && phase === 'structure' && structureLayer === 'elements'
|
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 === 'furnish') return mode === 'build' && phase === 'furnish'
|
||||||
if (id === 'zone')
|
if (id === 'zone')
|
||||||
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
return mode === 'build' && phase === 'structure' && structureLayer === 'zones'
|
||||||
@@ -155,6 +173,15 @@ export function ControlModes() {
|
|||||||
setStructureLayer('elements')
|
setStructureLayer('elements')
|
||||||
setMode('build')
|
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') {
|
} else if (id === 'furnish') {
|
||||||
if (getIsActive('furnish')) {
|
if (getIsActive('furnish')) {
|
||||||
setMode('select')
|
setMode('select')
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useScene } from '@pascal-app/core'
|
||||||
import { AnimatePresence, motion } from 'motion/react'
|
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 { TooltipProvider } from './../../../components/ui/primitives/tooltip'
|
||||||
|
import { MaterialPicker } from './../../../components/ui/controls/material-picker'
|
||||||
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
import { useReducedMotion } from './../../../hooks/use-reduced-motion'
|
||||||
|
import { resolvePaintTargetFromSelection } from './../../../lib/material-paint'
|
||||||
import { cn } from './../../../lib/utils'
|
import { cn } from './../../../lib/utils'
|
||||||
import useEditor from './../../../store/use-editor'
|
import useEditor from './../../../store/use-editor'
|
||||||
import { ItemCatalog } from '../item-catalog/item-catalog'
|
import { ItemCatalog } from '../item-catalog/item-catalog'
|
||||||
@@ -12,12 +17,49 @@ import { FurnishTools } from './furnish-tools'
|
|||||||
import { StructureTools } from './structure-tools'
|
import { StructureTools } from './structure-tools'
|
||||||
import { ViewToggles } from './view-toggles'
|
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 }) {
|
export function ActionMenu({ className }: { className?: string }) {
|
||||||
const phase = useEditor((state) => state.phase)
|
const phase = useEditor((state) => state.phase)
|
||||||
const mode = useEditor((state) => state.mode)
|
const mode = useEditor((state) => state.mode)
|
||||||
const tool = useEditor((state) => state.tool)
|
const tool = useEditor((state) => state.tool)
|
||||||
const catalogCategory = useEditor((state) => state.catalogCategory)
|
const catalogCategory = useEditor((state) => state.catalogCategory)
|
||||||
const reducedMotion = useReducedMotion()
|
const reducedMotion = useReducedMotion()
|
||||||
|
const showPaintTray = useMemo(() => mode === 'material-paint', [mode])
|
||||||
const transition = reducedMotion
|
const transition = reducedMotion
|
||||||
? { duration: 0 }
|
? { duration: 0 }
|
||||||
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
: { type: 'spring' as const, bounce: 0.2, duration: 0.4 }
|
||||||
@@ -138,6 +180,38 @@ export function ActionMenu({ className }: { className?: string }) {
|
|||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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 */}
|
{/* Control Mode Row - Always visible, centered */}
|
||||||
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
|
<div className="flex items-center justify-center gap-1 px-2 py-1.5">
|
||||||
<ControlModes />
|
<ControlModes />
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
Moon,
|
Moon,
|
||||||
MousePointer2,
|
MousePointer2,
|
||||||
Package,
|
Package,
|
||||||
|
PaintBucket,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
Plus,
|
Plus,
|
||||||
Redo2,
|
Redo2,
|
||||||
@@ -49,6 +50,7 @@ export function EditorCommands() {
|
|||||||
const setMode = useEditor((s) => s.setMode)
|
const setMode = useEditor((s) => s.setMode)
|
||||||
const setTool = useEditor((s) => s.setTool)
|
const setTool = useEditor((s) => s.setTool)
|
||||||
const setStructureLayer = useEditor((s) => s.setStructureLayer)
|
const setStructureLayer = useEditor((s) => s.setStructureLayer)
|
||||||
|
const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
|
||||||
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
const isPreviewMode = useEditor((s) => s.isPreviewMode)
|
||||||
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
const setPreviewMode = useEditor((s) => s.setPreviewMode)
|
||||||
|
|
||||||
@@ -150,6 +152,21 @@ export function EditorCommands() {
|
|||||||
useScene.getState().deleteNodes(selectedIds as any[])
|
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 ───────────────────────────────────────────────────────────
|
// ── Levels ───────────────────────────────────────────────────────────
|
||||||
{
|
{
|
||||||
@@ -355,7 +372,7 @@ export function EditorCommands() {
|
|||||||
icon: <Box className="h-4 w-4" />,
|
icon: <Box className="h-4 w-4" />,
|
||||||
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
|
||||||
execute: () => run(() => exportScene()),
|
execute: () => run(() => exportScene()),
|
||||||
} as const,
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,59 +1,117 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getMaterialsForTarget,
|
getCatalogMaterialById,
|
||||||
|
getLibraryMaterialIdFromRef,
|
||||||
|
getMaterialsForCategory,
|
||||||
|
MATERIAL_CATEGORIES,
|
||||||
toLibraryMaterialRef,
|
toLibraryMaterialRef,
|
||||||
type MaterialSchema,
|
type MaterialSchema,
|
||||||
type MaterialTarget,
|
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import useEditor from '../../../store/use-editor'
|
||||||
|
|
||||||
type MaterialPickerProps = {
|
type MaterialPickerProps = {
|
||||||
nodeType?: MaterialTarget
|
|
||||||
value?: MaterialSchema
|
value?: MaterialSchema
|
||||||
selectedMaterialPreset?: string
|
selectedMaterialPreset?: string
|
||||||
onChange?: (material: MaterialSchema) => void
|
onChange?: (material: MaterialSchema) => void
|
||||||
onSelectMaterialPreset?: (materialPreset: string) => void
|
onSelectMaterialPreset?: (materialPreset: string) => void
|
||||||
hideSideControl?: boolean
|
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MaterialPicker({
|
export function MaterialPicker({
|
||||||
nodeType,
|
|
||||||
value,
|
value,
|
||||||
selectedMaterialPreset,
|
selectedMaterialPreset,
|
||||||
onChange,
|
onChange,
|
||||||
onSelectMaterialPreset,
|
onSelectMaterialPreset,
|
||||||
hideSideControl = false,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
}: MaterialPickerProps) {
|
}: MaterialPickerProps) {
|
||||||
|
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
|
||||||
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
|
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(() => {
|
useEffect(() => {
|
||||||
setShowCustom(!!value?.properties && !selectedMaterialPreset)
|
setShowCustom(!!value?.properties && !selectedMaterialPreset)
|
||||||
}, [selectedMaterialPreset, value?.properties])
|
}, [selectedMaterialPreset, value?.properties])
|
||||||
|
|
||||||
const currentProps = value?.properties || {
|
useEffect(() => {
|
||||||
color: '#ffffff',
|
if (!selectedMaterialPreset && value?.properties) {
|
||||||
roughness: 0.5,
|
setSelectedCategory('other')
|
||||||
metalness: 0,
|
return
|
||||||
opacity: 1,
|
}
|
||||||
transparent: false,
|
|
||||||
side: 'front' as const,
|
const catalogId =
|
||||||
}
|
getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? value?.id ?? undefined
|
||||||
|
const selectedCatalogEntry = getCatalogMaterialById(catalogId)
|
||||||
|
if (selectedCatalogEntry?.category) {
|
||||||
|
setSelectedCategory(selectedCatalogEntry.category)
|
||||||
|
}
|
||||||
|
}, [selectedMaterialPreset, value?.id])
|
||||||
|
|
||||||
const selectedCatalogId =
|
const selectedCatalogId =
|
||||||
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
|
selectedMaterialPreset ?? (value?.id ? toLibraryMaterialRef(value.id) : undefined)
|
||||||
|
|
||||||
const handleCatalogSelect = (materialId: string) => {
|
const handleCatalogSelect = (materialId: string) => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
setShowCustom(false)
|
setShowCustom(false)
|
||||||
|
setPaintPanelOpen(false)
|
||||||
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
|
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 = () => {
|
const handleCustomOpen = () => {
|
||||||
if (disabled) return
|
if (disabled) return
|
||||||
setShowCustom(true)
|
setShowCustom(true)
|
||||||
|
setPaintPanelOpen(true)
|
||||||
onChange?.({
|
onChange?.({
|
||||||
preset: 'custom',
|
preset: 'custom',
|
||||||
properties: {
|
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 (
|
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) && (
|
{(catalogItems.length > 0 || onChange) && (
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 space-y-1">
|
||||||
{catalogItems.length > 0 ? (
|
<div
|
||||||
<div className="text-gray-500 text-xs uppercase tracking-[0.16em]">Library</div>
|
className="w-full max-w-full overflow-x-auto overflow-y-hidden"
|
||||||
) : null}
|
ref={categoryScrollRef}
|
||||||
<div className="flex flex-wrap gap-1.5">
|
style={{ msOverflowStyle: 'none', scrollbarWidth: 'none' }}
|
||||||
{catalogItems.map((item) => (
|
>
|
||||||
<button
|
<div className="flex min-w-max gap-1 pb-1">
|
||||||
className={`h-14 w-14 shrink-0 overflow-hidden rounded-lg border transition-all ${
|
{MATERIAL_CATEGORIES.map((category) => (
|
||||||
selectedCatalogId === toLibraryMaterialRef(item.id)
|
<button
|
||||||
? 'border-blue-500 ring-2 ring-blue-500/30'
|
className={`shrink-0 px-2 font-medium text-[11px] uppercase tracking-[0.12em] transition-all ${
|
||||||
: 'border-gray-300 hover:border-gray-400'
|
selectedCategory === category
|
||||||
}`}
|
? 'bg-transparent text-foreground'
|
||||||
key={item.id}
|
: 'bg-transparent text-muted-foreground opacity-70 hover:text-foreground hover:opacity-100'
|
||||||
onClick={() => handleCatalogSelect(item.id)}
|
}`}
|
||||||
title={item.label}
|
key={category}
|
||||||
type="button"
|
onClick={() => {
|
||||||
>
|
setSelectedCategory(category)
|
||||||
{item.previewThumbnailUrl ? (
|
if (showCustom) {
|
||||||
<img
|
setShowCustom(false)
|
||||||
alt={item.label}
|
}
|
||||||
className="h-full w-full object-cover"
|
if (category !== 'other') {
|
||||||
src={item.previewThumbnailUrl}
|
setPaintPanelOpen(false)
|
||||||
/>
|
}
|
||||||
) : item.previewColor ? (
|
}}
|
||||||
<div className="h-full w-full" style={{ backgroundColor: item.previewColor }} />
|
type="button"
|
||||||
) : (
|
>
|
||||||
<div className="h-full w-full bg-gray-100" />
|
{category.charAt(0).toUpperCase() + category.slice(1)}
|
||||||
)}
|
</button>
|
||||||
</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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client'
|
'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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
@@ -32,20 +31,6 @@ export function CeilingPanel() {
|
|||||||
[selectedId, updateNode],
|
[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(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
@@ -257,15 +242,6 @@ export function CeilingPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
|
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
nodeType="ceiling"
|
|
||||||
onChange={handleMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={node.materialPreset}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
DoorNode,
|
DoorNode,
|
||||||
emitter,
|
emitter,
|
||||||
type MaterialSchema,
|
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { MetricControl } from '../controls/metric-control'
|
import { MetricControl } from '../controls/metric-control'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
@@ -46,13 +44,6 @@ export function DoorPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -592,9 +583,6 @@ export function DoorPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
@@ -11,9 +12,11 @@ import {
|
|||||||
normalizeWallCurveOffset,
|
normalizeWallCurveOffset,
|
||||||
useScene,
|
useScene,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Move, Spline } from 'lucide-react'
|
import { Move, Spline } from 'lucide-react'
|
||||||
import { useCallback } from 'react'
|
import { useCallback } from 'react'
|
||||||
|
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
@@ -83,33 +86,9 @@ export function FencePanel() {
|
|||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [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
|
if (!(node && node.type === 'fence' && selectedId && selectedCount === 1)) return null
|
||||||
|
|
||||||
@@ -243,27 +222,6 @@ export function FencePanel() {
|
|||||||
value={node.edgeInset}
|
value={node.edgeInset}
|
||||||
/>
|
/>
|
||||||
</PanelSection>
|
</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>
|
</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 { DoorPanel } from './door-panel'
|
||||||
import { FencePanel } from './fence-panel'
|
import { FencePanel } from './fence-panel'
|
||||||
import { ItemPanel } from './item-panel'
|
import { ItemPanel } from './item-panel'
|
||||||
|
import { PaintPanel } from './paint-panel'
|
||||||
import { ReferencePanel } from './reference-panel'
|
import { ReferencePanel } from './reference-panel'
|
||||||
import { RoofPanel } from './roof-panel'
|
import { RoofPanel } from './roof-panel'
|
||||||
import { RoofSegmentPanel } from './roof-segment-panel'
|
import { RoofSegmentPanel } from './roof-segment-panel'
|
||||||
@@ -19,6 +20,9 @@ import { WindowPanel } from './window-panel'
|
|||||||
export function PanelManager() {
|
export function PanelManager() {
|
||||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||||
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
|
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
|
// Only subscribe to the *type* of the single-selected node — string primitive
|
||||||
// so we don't re-render on unrelated scene mutations.
|
// so we don't re-render on unrelated scene mutations.
|
||||||
const selectedNodeType = useScene((s) => {
|
const selectedNodeType = useScene((s) => {
|
||||||
@@ -32,6 +36,15 @@ export function PanelManager() {
|
|||||||
return <ReferencePanel />
|
return <ReferencePanel />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
isPaintPanelOpen &&
|
||||||
|
mode === 'material-paint' &&
|
||||||
|
activePaintMaterial?.material?.properties &&
|
||||||
|
!activePaintMaterial.materialPreset
|
||||||
|
) {
|
||||||
|
return <PaintPanel />
|
||||||
|
}
|
||||||
|
|
||||||
// Show appropriate panel based on selected node type
|
// Show appropriate panel based on selected node type
|
||||||
if (selectedNodeType) {
|
if (selectedNodeType) {
|
||||||
switch (selectedNodeType) {
|
switch (selectedNodeType) {
|
||||||
|
|||||||
@@ -3,10 +3,7 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getEffectiveRoofSurfaceMaterial,
|
|
||||||
type MaterialSchema,
|
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSurfaceMaterialRole,
|
|
||||||
RoofNode as RoofNodeSchema,
|
RoofNode as RoofNodeSchema,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||||
@@ -19,44 +16,16 @@ import { useShallow } from 'zustand/react/shallow'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
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() {
|
export function RoofPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const createNode = useScene((s) => s.createNode)
|
const createNode = useScene((s) => s.createNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
|
||||||
@@ -79,33 +48,6 @@ export function RoofPanel() {
|
|||||||
[selectedId, updateNode],
|
[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(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -310,22 +252,6 @@ export function RoofPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</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>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type MaterialSchema,
|
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
RoofSegmentNode as RoofSegmentNodeSchema,
|
RoofSegmentNode as RoofSegmentNodeSchema,
|
||||||
type RoofType,
|
type RoofType,
|
||||||
@@ -15,7 +14,6 @@ import { useCallback } from 'react'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
@@ -52,20 +50,6 @@ export function RoofSegmentPanel() {
|
|||||||
[selectedId, updateNode],
|
[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(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -322,15 +306,6 @@ export function RoofSegmentPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
nodeType="roof-segment"
|
|
||||||
onChange={handleMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={node.materialPreset}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client'
|
'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 { useViewer } from '@pascal-app/viewer'
|
||||||
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
import { Edit, Move, Plus, Trash2 } from 'lucide-react'
|
||||||
import { useCallback, useEffect } from 'react'
|
import { useCallback, useEffect } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
import { PanelWrapper } from './panel-wrapper'
|
||||||
@@ -32,20 +31,6 @@ export function SlabPanel() {
|
|||||||
[selectedId, updateNode],
|
[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(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
setEditingHole(null)
|
setEditingHole(null)
|
||||||
@@ -257,20 +242,9 @@ export function SlabPanel() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
<ActionGroup>
|
||||||
<MaterialPicker
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
nodeType="slab"
|
</ActionGroup>
|
||||||
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>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,9 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getEffectiveStairSurfaceMaterial,
|
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type MaterialSchema,
|
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairRailingMode,
|
type StairRailingMode,
|
||||||
type StairSurfaceMaterialRole,
|
|
||||||
type StairSlabOpeningMode,
|
type StairSlabOpeningMode,
|
||||||
type StairTopLandingMode,
|
type StairTopLandingMode,
|
||||||
type StairType,
|
type StairType,
|
||||||
@@ -25,7 +22,6 @@ import { sfxEmitter } from '../../../lib/sfx-bus'
|
|||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { MetricControl } from '../controls/metric-control'
|
import { MetricControl } from '../controls/metric-control'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
@@ -33,32 +29,6 @@ import { SliderControl } from '../controls/slider-control'
|
|||||||
import { ToggleControl } from '../controls/toggle-control'
|
import { ToggleControl } from '../controls/toggle-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
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 }[] = [
|
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
|
||||||
{ label: 'None', value: 'none' },
|
{ label: 'None', value: 'none' },
|
||||||
{ label: 'Left', value: 'left' },
|
{ label: 'Left', value: 'left' },
|
||||||
@@ -90,7 +60,6 @@ export function StairPanel() {
|
|||||||
const createNode = useScene((s) => s.createNode)
|
const createNode = useScene((s) => s.createNode)
|
||||||
const createNodes = useScene((s) => s.createNodes)
|
const createNodes = useScene((s) => s.createNodes)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
|
||||||
@@ -121,33 +90,6 @@ export function StairPanel() {
|
|||||||
[selectedId, updateNode],
|
[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(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -610,22 +552,6 @@ export function StairPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</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>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
type AttachmentSide,
|
type AttachmentSide,
|
||||||
type MaterialSchema,
|
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
StairSegmentNode as StairSegmentNodeSchema,
|
StairSegmentNode as StairSegmentNodeSchema,
|
||||||
type StairSegmentType,
|
type StairSegmentType,
|
||||||
@@ -16,7 +15,6 @@ import { useCallback } from 'react'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SegmentedControl } from '../controls/segmented-control'
|
import { SegmentedControl } from '../controls/segmented-control'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
@@ -61,20 +59,6 @@ export function StairSegmentPanel() {
|
|||||||
[selectedId, updateNode],
|
[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(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -336,15 +320,6 @@ export function StairSegmentPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker
|
|
||||||
nodeType="stair-segment"
|
|
||||||
onChange={handleMaterialChange}
|
|
||||||
onSelectMaterialPreset={handleMaterialPresetChange}
|
|
||||||
selectedMaterialPreset={node.materialPreset}
|
|
||||||
value={node.material}
|
|
||||||
/>
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,61 +3,29 @@
|
|||||||
import {
|
import {
|
||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
getEffectiveWallSurfaceMaterial,
|
|
||||||
getClampedWallCurveOffset,
|
getClampedWallCurveOffset,
|
||||||
getMaxWallCurveOffset,
|
getMaxWallCurveOffset,
|
||||||
getWallCurveLength,
|
getWallCurveLength,
|
||||||
getWallSurfaceMaterialSignature,
|
|
||||||
normalizeWallCurveOffset,
|
normalizeWallCurveOffset,
|
||||||
type MaterialSchema,
|
|
||||||
useScene,
|
useScene,
|
||||||
type WallSurfaceSide,
|
|
||||||
type WallNode,
|
type WallNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
import { useViewer } from '@pascal-app/viewer'
|
import { useViewer } from '@pascal-app/viewer'
|
||||||
import { Move, Spline } from 'lucide-react'
|
import { Move, Spline } from 'lucide-react'
|
||||||
import { useCallback, useMemo } from 'react'
|
import { useCallback } from 'react'
|
||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
import { PanelWrapper } from './panel-wrapper'
|
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() {
|
export function WallPanel() {
|
||||||
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
const selectedId = useViewer((s) => s.selection.selectedIds[0])
|
||||||
const setSelection = useViewer((s) => s.setSelection)
|
const setSelection = useViewer((s) => s.setSelection)
|
||||||
const updateNode = useScene((s) => s.updateNode)
|
const updateNode = useScene((s) => s.updateNode)
|
||||||
const setMovingNode = useEditor((s) => s.setMovingNode)
|
const setMovingNode = useEditor((s) => s.setMovingNode)
|
||||||
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
const setCurvingWall = useEditor((s) => s.setCurvingWall)
|
||||||
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
|
|
||||||
|
|
||||||
const node = useScene((s) =>
|
const node = useScene((s) =>
|
||||||
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
|
||||||
@@ -88,35 +56,6 @@ export function WallPanel() {
|
|||||||
[selectedId, updateNode],
|
[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(
|
const handleUpdateLength = useCallback(
|
||||||
(newLength: number) => {
|
(newLength: number) => {
|
||||||
if (!node || newLength <= 0) return
|
if (!node || newLength <= 0) return
|
||||||
@@ -140,22 +79,6 @@ export function WallPanel() {
|
|||||||
[node, handleUpdate],
|
[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(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -237,23 +160,6 @@ export function WallPanel() {
|
|||||||
)}
|
)}
|
||||||
</PanelSection>
|
</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">
|
<PanelSection title="Actions">
|
||||||
<ActionGroup>
|
<ActionGroup>
|
||||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
type AnyNode,
|
type AnyNode,
|
||||||
type AnyNodeId,
|
type AnyNodeId,
|
||||||
emitter,
|
emitter,
|
||||||
type MaterialSchema,
|
|
||||||
useScene,
|
useScene,
|
||||||
WindowNode,
|
WindowNode,
|
||||||
} from '@pascal-app/core'
|
} from '@pascal-app/core'
|
||||||
@@ -15,7 +14,6 @@ import { usePresetsAdapter } from '../../../contexts/presets-context'
|
|||||||
import { sfxEmitter } from '../../../lib/sfx-bus'
|
import { sfxEmitter } from '../../../lib/sfx-bus'
|
||||||
import useEditor from '../../../store/use-editor'
|
import useEditor from '../../../store/use-editor'
|
||||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||||
import { MaterialPicker } from '../controls/material-picker'
|
|
||||||
import { MetricControl } from '../controls/metric-control'
|
import { MetricControl } from '../controls/metric-control'
|
||||||
import { PanelSection } from '../controls/panel-section'
|
import { PanelSection } from '../controls/panel-section'
|
||||||
import { SliderControl } from '../controls/slider-control'
|
import { SliderControl } from '../controls/slider-control'
|
||||||
@@ -45,13 +43,6 @@ export function WindowPanel() {
|
|||||||
[selectedId, updateNode],
|
[selectedId, updateNode],
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMaterialChange = useCallback(
|
|
||||||
(material: MaterialSchema) => {
|
|
||||||
handleUpdate({ material })
|
|
||||||
},
|
|
||||||
[handleUpdate],
|
|
||||||
)
|
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
setSelection({ selectedIds: [] })
|
setSelection({ selectedIds: [] })
|
||||||
}, [setSelection])
|
}, [setSelection])
|
||||||
@@ -431,9 +422,6 @@ export function WindowPanel() {
|
|||||||
/>
|
/>
|
||||||
</ActionGroup>
|
</ActionGroup>
|
||||||
</PanelSection>
|
</PanelSection>
|
||||||
<PanelSection title="Material">
|
|
||||||
<MaterialPicker onChange={handleMaterialChange} value={node.material} />
|
|
||||||
</PanelSection>
|
|
||||||
</PanelWrapper>
|
</PanelWrapper>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,13 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
|
|||||||
if (isVersionPreviewMode) return
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
useEditor.getState().setMode('delete')
|
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)) {
|
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
|
||||||
if (isVersionPreviewMode) return
|
if (isVersionPreviewMode) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import {
|
||||||
|
type CeilingNode,
|
||||||
|
type FenceNode,
|
||||||
|
getCatalogMaterialById,
|
||||||
|
getEffectiveRoofSurfaceMaterial,
|
||||||
|
getEffectiveStairSurfaceMaterial,
|
||||||
|
getEffectiveWallSurfaceMaterial,
|
||||||
|
getLibraryMaterialIdFromRef,
|
||||||
|
type MaterialSchema,
|
||||||
|
type MaterialTarget,
|
||||||
|
type RoofNode,
|
||||||
|
type RoofSurfaceMaterialRole,
|
||||||
|
type SlabNode,
|
||||||
|
type StairNode,
|
||||||
|
type StairSurfaceMaterialRole,
|
||||||
|
type WallNode,
|
||||||
|
type WallSurfaceSide,
|
||||||
|
} from '@pascal-app/core'
|
||||||
|
|
||||||
|
export type PaintableMaterialTarget = Extract<
|
||||||
|
MaterialTarget,
|
||||||
|
'wall' | 'roof' | 'stair' | 'fence' | 'slab' | 'ceiling'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type SingleSurfaceMaterialRole = 'surface'
|
||||||
|
|
||||||
|
export type ActivePaintMaterial = {
|
||||||
|
material?: MaterialSchema
|
||||||
|
materialPreset?: string
|
||||||
|
sourceTarget: PaintableMaterialTarget
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasActivePaintMaterial(
|
||||||
|
material: ActivePaintMaterial | null | undefined,
|
||||||
|
): material is ActivePaintMaterial {
|
||||||
|
return Boolean(
|
||||||
|
material && (material.material !== undefined || material.materialPreset !== undefined),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCatalogEntryForActivePaintMaterial(material: ActivePaintMaterial | null | undefined) {
|
||||||
|
const catalogId =
|
||||||
|
getLibraryMaterialIdFromRef(material?.materialPreset) ?? material?.material?.id ?? undefined
|
||||||
|
|
||||||
|
return getCatalogMaterialById(catalogId)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActivePaintMaterialLabel(material: ActivePaintMaterial | null | undefined) {
|
||||||
|
return getCatalogEntryForActivePaintMaterial(material)?.label ?? 'Custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildWallSurfaceMaterialPatch(
|
||||||
|
node: WallNode,
|
||||||
|
targetSide: WallSurfaceSide,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<WallNode> {
|
||||||
|
const nextSurfaceMaterial = { material, materialPreset }
|
||||||
|
const nextInterior =
|
||||||
|
targetSide === 'interior'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveWallSurfaceMaterial(node, 'interior')
|
||||||
|
const nextExterior =
|
||||||
|
targetSide === 'exterior'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveWallSurfaceMaterial(node, 'exterior')
|
||||||
|
|
||||||
|
return {
|
||||||
|
interiorMaterial: nextInterior.material,
|
||||||
|
interiorMaterialPreset: nextInterior.materialPreset,
|
||||||
|
exteriorMaterial: nextExterior.material,
|
||||||
|
exteriorMaterialPreset: nextExterior.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRoofSurfaceMaterialPatch(
|
||||||
|
node: RoofNode,
|
||||||
|
targetRole: RoofSurfaceMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<RoofNode> {
|
||||||
|
const nextSurfaceMaterial = { material, materialPreset }
|
||||||
|
const nextTop =
|
||||||
|
targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
|
||||||
|
const nextEdge =
|
||||||
|
targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
|
||||||
|
const nextWall =
|
||||||
|
targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
|
||||||
|
|
||||||
|
return {
|
||||||
|
topMaterial: nextTop.material,
|
||||||
|
topMaterialPreset: nextTop.materialPreset,
|
||||||
|
edgeMaterial: nextEdge.material,
|
||||||
|
edgeMaterialPreset: nextEdge.materialPreset,
|
||||||
|
wallMaterial: nextWall.material,
|
||||||
|
wallMaterialPreset: nextWall.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStairSurfaceMaterialPatch(
|
||||||
|
node: StairNode,
|
||||||
|
targetRole: StairSurfaceMaterialRole,
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<StairNode> {
|
||||||
|
const nextSurfaceMaterial = { material, materialPreset }
|
||||||
|
const nextRailing =
|
||||||
|
targetRole === 'railing'
|
||||||
|
? nextSurfaceMaterial
|
||||||
|
: getEffectiveStairSurfaceMaterial(node, 'railing')
|
||||||
|
const nextTread =
|
||||||
|
targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
|
||||||
|
const nextSide =
|
||||||
|
targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
|
||||||
|
|
||||||
|
return {
|
||||||
|
railingMaterial: nextRailing.material,
|
||||||
|
railingMaterialPreset: nextRailing.materialPreset,
|
||||||
|
treadMaterial: nextTread.material,
|
||||||
|
treadMaterialPreset: nextTread.materialPreset,
|
||||||
|
sideMaterial: nextSide.material,
|
||||||
|
sideMaterialPreset: nextSide.materialPreset,
|
||||||
|
material: undefined,
|
||||||
|
materialPreset: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildSingleSurfaceMaterialPatch<TNode extends FenceNode | SlabNode | CeilingNode>(
|
||||||
|
material: MaterialSchema | undefined,
|
||||||
|
materialPreset: string | undefined,
|
||||||
|
): Partial<TNode> {
|
||||||
|
return {
|
||||||
|
material,
|
||||||
|
materialPreset,
|
||||||
|
} as Partial<TNode>
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveActivePaintMaterialFromSelection(params: {
|
||||||
|
nodes: Record<string, any>
|
||||||
|
selectedId: string | null
|
||||||
|
selectedMaterialTarget: {
|
||||||
|
nodeId: string
|
||||||
|
role:
|
||||||
|
| WallSurfaceSide
|
||||||
|
| StairSurfaceMaterialRole
|
||||||
|
| RoofSurfaceMaterialRole
|
||||||
|
| SingleSurfaceMaterialRole
|
||||||
|
} | null
|
||||||
|
}): ActivePaintMaterial | null {
|
||||||
|
const { nodes, selectedId, selectedMaterialTarget } = params
|
||||||
|
if (!selectedId || !selectedMaterialTarget || selectedMaterialTarget.nodeId !== selectedId)
|
||||||
|
return null
|
||||||
|
|
||||||
|
const selectedNode = nodes[selectedId]
|
||||||
|
if (!selectedNode) return null
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedNode.type === 'wall' &&
|
||||||
|
(selectedMaterialTarget.role === 'interior' || selectedMaterialTarget.role === 'exterior')
|
||||||
|
) {
|
||||||
|
const surface = getEffectiveWallSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
||||||
|
return hasActivePaintMaterial({
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'wall',
|
||||||
|
})
|
||||||
|
? {
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'wall',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedNode.type === 'roof' &&
|
||||||
|
(selectedMaterialTarget.role === 'top' ||
|
||||||
|
selectedMaterialTarget.role === 'edge' ||
|
||||||
|
selectedMaterialTarget.role === 'wall')
|
||||||
|
) {
|
||||||
|
const surface = getEffectiveRoofSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
||||||
|
return hasActivePaintMaterial({
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'roof',
|
||||||
|
})
|
||||||
|
? {
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'roof',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
selectedNode.type === 'stair' &&
|
||||||
|
(selectedMaterialTarget.role === 'railing' ||
|
||||||
|
selectedMaterialTarget.role === 'tread' ||
|
||||||
|
selectedMaterialTarget.role === 'side')
|
||||||
|
) {
|
||||||
|
const surface = getEffectiveStairSurfaceMaterial(selectedNode, selectedMaterialTarget.role)
|
||||||
|
return hasActivePaintMaterial({
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'stair',
|
||||||
|
})
|
||||||
|
? {
|
||||||
|
material: surface.material,
|
||||||
|
materialPreset: surface.materialPreset,
|
||||||
|
sourceTarget: 'stair',
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(selectedNode.type === 'fence' ||
|
||||||
|
selectedNode.type === 'slab' ||
|
||||||
|
selectedNode.type === 'ceiling') &&
|
||||||
|
selectedMaterialTarget.role === 'surface'
|
||||||
|
) {
|
||||||
|
const target = selectedNode.type
|
||||||
|
return hasActivePaintMaterial({
|
||||||
|
material: selectedNode.material,
|
||||||
|
materialPreset: selectedNode.materialPreset,
|
||||||
|
sourceTarget: target,
|
||||||
|
})
|
||||||
|
? {
|
||||||
|
material: selectedNode.material,
|
||||||
|
materialPreset: selectedNode.materialPreset,
|
||||||
|
sourceTarget: target,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolvePaintTargetFromSelection(params: {
|
||||||
|
nodes: Record<string, any>
|
||||||
|
selectedId: string | null
|
||||||
|
}): PaintableMaterialTarget | null {
|
||||||
|
const { nodes, selectedId } = params
|
||||||
|
if (!selectedId) return null
|
||||||
|
|
||||||
|
const selectedNode = nodes[selectedId]
|
||||||
|
if (!selectedNode) return null
|
||||||
|
|
||||||
|
if (selectedNode.type === 'wall') {
|
||||||
|
return 'wall'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'roof' || selectedNode.type === 'roof-segment') {
|
||||||
|
return 'roof'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'stair' || selectedNode.type === 'stair-segment') {
|
||||||
|
return 'stair'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'fence') {
|
||||||
|
return 'fence'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'slab') {
|
||||||
|
return 'slab'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedNode.type === 'ceiling') {
|
||||||
|
return 'ceiling'
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -9,14 +9,14 @@ import {
|
|||||||
type FenceNode,
|
type FenceNode,
|
||||||
type ItemNode,
|
type ItemNode,
|
||||||
type LevelNode,
|
type LevelNode,
|
||||||
type RoofSurfaceMaterialRole,
|
|
||||||
type RoofNode,
|
type RoofNode,
|
||||||
type RoofSegmentNode,
|
type RoofSegmentNode,
|
||||||
|
type RoofSurfaceMaterialRole,
|
||||||
type SlabNode,
|
type SlabNode,
|
||||||
type Space,
|
type Space,
|
||||||
type StairSurfaceMaterialRole,
|
|
||||||
type StairNode,
|
type StairNode,
|
||||||
type StairSegmentNode,
|
type StairSegmentNode,
|
||||||
|
type StairSurfaceMaterialRole,
|
||||||
useScene,
|
useScene,
|
||||||
type WallNode,
|
type WallNode,
|
||||||
type WallSurfaceSide,
|
type WallSurfaceSide,
|
||||||
@@ -26,6 +26,13 @@ import { useViewer } from '@pascal-app/viewer'
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
import { persist } from 'zustand/middleware'
|
import { persist } from 'zustand/middleware'
|
||||||
import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
|
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_ACTIVE_SIDEBAR_PANEL = 'site'
|
||||||
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
|
||||||
@@ -37,7 +44,7 @@ export type SplitOrientation = 'horizontal' | 'vertical'
|
|||||||
|
|
||||||
export type Phase = 'site' | 'structure' | 'furnish'
|
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)
|
// Structure mode tools (building elements)
|
||||||
export type StructureTool =
|
export type StructureTool =
|
||||||
@@ -89,13 +96,19 @@ export type MovingFenceEndpoint = {
|
|||||||
endpoint: 'start' | 'end'
|
endpoint: 'start' | 'end'
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
|
export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole | SingleSurfaceMaterialRole
|
||||||
|
|
||||||
export type SelectedMaterialTarget = {
|
export type SelectedMaterialTarget = {
|
||||||
nodeId: AnyNodeId
|
nodeId: AnyNodeId
|
||||||
role: MaterialTargetRole
|
role: MaterialTargetRole
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MaterialPaintSelectionSnapshot = {
|
||||||
|
selectedId: string | null
|
||||||
|
activePaintTarget: PaintableMaterialTarget
|
||||||
|
activePaintMaterial: ActivePaintMaterial | null
|
||||||
|
}
|
||||||
|
|
||||||
type EditorState = {
|
type EditorState = {
|
||||||
phase: Phase
|
phase: Phase
|
||||||
setPhase: (phase: Phase) => void
|
setPhase: (phase: Phase) => void
|
||||||
@@ -149,6 +162,15 @@ type EditorState = {
|
|||||||
setCurvingFence: (fence: FenceNode | null) => void
|
setCurvingFence: (fence: FenceNode | null) => void
|
||||||
selectedMaterialTarget: SelectedMaterialTarget | null
|
selectedMaterialTarget: SelectedMaterialTarget | null
|
||||||
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
|
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
|
selectedReferenceId: string | null
|
||||||
setSelectedReferenceId: (id: string | null) => void
|
setSelectedReferenceId: (id: string | null) => void
|
||||||
// Space detection for cutaway mode
|
// Space detection for cutaway mode
|
||||||
@@ -228,7 +250,7 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
|
|||||||
return 'select'
|
return 'select'
|
||||||
}
|
}
|
||||||
|
|
||||||
return mode === 'build' || mode === 'delete' ? mode : 'select'
|
return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFloorplanPaneRatio(value: unknown): number {
|
function normalizeFloorplanPaneRatio(value: unknown): number {
|
||||||
@@ -466,6 +488,8 @@ const useEditor = create<EditorState>()(
|
|||||||
const category = get().catalogCategory ?? 'furniture'
|
const category = get().catalogCategory ?? 'furniture'
|
||||||
set({ selectedItem: getDefaultSelectedItemForCategory(category) })
|
set({ selectedItem: getDefaultSelectedItemForCategory(category) })
|
||||||
}
|
}
|
||||||
|
} else if (mode === 'material-paint') {
|
||||||
|
get().primeMaterialPaintFromSelection()
|
||||||
}
|
}
|
||||||
// When leaving build mode, clear tool
|
// When leaving build mode, clear tool
|
||||||
else if (tool) {
|
else if (tool) {
|
||||||
@@ -530,6 +554,47 @@ const useEditor = create<EditorState>()(
|
|||||||
setCurvingFence: (fence) => set({ curvingFence: fence }),
|
setCurvingFence: (fence) => set({ curvingFence: fence }),
|
||||||
selectedMaterialTarget: null,
|
selectedMaterialTarget: null,
|
||||||
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
|
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,
|
selectedReferenceId: null,
|
||||||
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
|
||||||
spaces: {},
|
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 { useMemo, useRef } from 'react'
|
||||||
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
import { float, mix, positionWorld, smoothstep } from 'three/tsl'
|
||||||
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
import { BackSide, FrontSide, type Mesh, MeshBasicNodeMaterial } from 'three/webgpu'
|
||||||
@@ -32,6 +37,18 @@ function createCeilingMaterials(color = '#999999') {
|
|||||||
return { topMaterial, bottomMaterial }
|
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 }) => {
|
export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
|
|
||||||
@@ -42,8 +59,14 @@ export const CeilingRenderer = ({ node }: { node: CeilingNode }) => {
|
|||||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
const preset = getMaterialPresetByRef(node.materialPreset)
|
||||||
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
const props = preset?.mapProperties ?? resolveMaterial(node.material)
|
||||||
const color = props.color || '#999999'
|
const color = props.color || '#999999'
|
||||||
return createCeilingMaterials(color)
|
return getCeilingMaterials(color)
|
||||||
}, [node.materialPreset, node.material, node.material?.preset, node.material?.properties, node.material?.texture])
|
}, [
|
||||||
|
node.materialPreset,
|
||||||
|
node.material,
|
||||||
|
node.material?.preset,
|
||||||
|
node.material?.properties,
|
||||||
|
node.material?.texture,
|
||||||
|
])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh material={materials.bottomMaterial} ref={ref}>
|
<mesh material={materials.bottomMaterial} ref={ref}>
|
||||||
|
|||||||
@@ -31,7 +31,14 @@ export const FenceRenderer = ({ node }: { node: FenceNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
return (
|
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]} />
|
<boxGeometry args={[0, 0, 0]} />
|
||||||
</mesh>
|
</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 { useEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
@@ -14,8 +20,9 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
|||||||
|
|
||||||
const handlers = useNodeEvents(node, 'roof-segment')
|
const handlers = useNodeEvents(node, 'roof-segment')
|
||||||
const debugColors = useViewer((s) => s.debugColors)
|
const debugColors = useViewer((s) => s.debugColors)
|
||||||
const parentNode =
|
const parentNode = node.parentId
|
||||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined) : undefined
|
? (nodes[node.parentId as AnyNodeId] as RoofNode | undefined)
|
||||||
|
: undefined
|
||||||
const placeholderGeometry = useMemo(() => {
|
const placeholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
geometry.setAttribute('position', new THREE.Float32BufferAttribute([], 3))
|
||||||
@@ -32,24 +39,7 @@ export const RoofSegmentRenderer = ({ node }: { node: RoofSegmentNode }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return parentNode ? getRoofMaterialArray(parentNode) : null
|
return parentNode ? getRoofMaterialArray(parentNode) : null
|
||||||
}, [
|
}, [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?.topMaterial,
|
|
||||||
parentNode?.topMaterialPreset,
|
|
||||||
parentNode?.edgeMaterial,
|
|
||||||
parentNode?.edgeMaterialPreset,
|
|
||||||
parentNode?.wallMaterial,
|
|
||||||
parentNode?.wallMaterialPreset,
|
|
||||||
])
|
|
||||||
|
|
||||||
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||||
|
|
||||||
|
|||||||
@@ -24,22 +24,7 @@ export const RoofRenderer = ({ node }: { node: RoofNode }) => {
|
|||||||
return geometry
|
return geometry
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const customMaterial = useMemo(
|
const customMaterial = useMemo(() => getRoofMaterialArray(node), [node])
|
||||||
() => 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 material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
const material = debugColors ? roofDebugMaterials : customMaterial || roofMaterials
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
|
import { getMaterialPresetByRef, type SlabNode, useRegistry } from '@pascal-app/core'
|
||||||
import { useEffect, useMemo, useRef } from 'react'
|
import { useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
|
||||||
import type { Mesh } from 'three'
|
import type { Mesh } from 'three'
|
||||||
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import {
|
import {
|
||||||
applyMaterialPresetToMaterials,
|
applyMaterialPresetToMaterials,
|
||||||
@@ -9,6 +9,42 @@ import {
|
|||||||
DEFAULT_SLAB_MATERIAL,
|
DEFAULT_SLAB_MATERIAL,
|
||||||
} from '../../../lib/materials'
|
} 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 }) => {
|
export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
||||||
const ref = useRef<Mesh>(null!)
|
const ref = useRef<Mesh>(null!)
|
||||||
|
|
||||||
@@ -17,30 +53,17 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
|||||||
const handlers = useNodeEvents(node, 'slab')
|
const handlers = useNodeEvents(node, 'slab')
|
||||||
|
|
||||||
const material = useMemo(() => {
|
const material = useMemo(() => {
|
||||||
const preset = getMaterialPresetByRef(node.materialPreset)
|
const resolvedMaterial = node.material
|
||||||
const slabMaterial = preset
|
const resolvedMaterialPreset = node.materialPreset
|
||||||
? new THREE.MeshStandardMaterial()
|
const cacheKey = JSON.stringify({
|
||||||
: node.material
|
material: resolvedMaterial ?? null,
|
||||||
? createMaterial(node.material).clone()
|
materialPreset: resolvedMaterialPreset ?? null,
|
||||||
: DEFAULT_SLAB_MATERIAL.clone()
|
})
|
||||||
|
|
||||||
if (preset) {
|
return getSlabMaterial(cacheKey, {
|
||||||
// Apply the preset to the slab-owned material so async texture loads update
|
material: resolvedMaterial,
|
||||||
// the instance we actually render after refresh as well.
|
materialPreset: resolvedMaterialPreset,
|
||||||
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
|
|
||||||
}, [
|
}, [
|
||||||
node.material,
|
node.material,
|
||||||
node.material?.preset,
|
node.material?.preset,
|
||||||
@@ -49,12 +72,6 @@ export const SlabRenderer = ({ node }: { node: SlabNode }) => {
|
|||||||
node.materialPreset,
|
node.materialPreset,
|
||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
material.dispose()
|
|
||||||
}
|
|
||||||
}, [material])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<mesh
|
<mesh
|
||||||
castShadow
|
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 { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
@@ -15,29 +21,13 @@ export const StairSegmentRenderer = ({ node }: { node: StairSegmentNode }) => {
|
|||||||
}, [node.id])
|
}, [node.id])
|
||||||
|
|
||||||
const handlers = useNodeEvents(node, 'stair-segment')
|
const handlers = useNodeEvents(node, 'stair-segment')
|
||||||
const parentNode =
|
const parentNode = node.parentId
|
||||||
node.parentId ? (nodes[node.parentId as AnyNodeId] as StairNode | undefined) : undefined
|
? (nodes[node.parentId as AnyNodeId] as StairNode | undefined)
|
||||||
|
: undefined
|
||||||
const material = useMemo(() => {
|
const material = useMemo(
|
||||||
return getStraightStairSegmentBodyMaterials(node, parentNode)
|
() => getStraightStairSegmentBodyMaterials(node, parentNode),
|
||||||
}, [
|
[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 placeholderGeometry = useMemo(() => {
|
const placeholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
|
|||||||
@@ -8,10 +8,14 @@ import {
|
|||||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||||
import * as THREE from 'three'
|
import * as THREE from 'three'
|
||||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||||
import { createMaterial, createMaterialFromPresetRef, DEFAULT_STAIR_MATERIAL } from '../../../lib/materials'
|
|
||||||
import {
|
import {
|
||||||
getStairRailingMaterial,
|
createMaterial,
|
||||||
|
createMaterialFromPresetRef,
|
||||||
|
DEFAULT_STAIR_MATERIAL,
|
||||||
|
} from '../../../lib/materials'
|
||||||
|
import {
|
||||||
getStairBodyMaterials,
|
getStairBodyMaterials,
|
||||||
|
getStairRailingMaterial,
|
||||||
type StairBodyMaterials,
|
type StairBodyMaterials,
|
||||||
} from '../../../systems/stair/stair-materials'
|
} from '../../../systems/stair/stair-materials'
|
||||||
import { NodeRenderer } from '../node-renderer'
|
import { NodeRenderer } from '../node-renderer'
|
||||||
@@ -72,33 +76,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
node.material?.texture,
|
node.material?.texture,
|
||||||
])
|
])
|
||||||
|
|
||||||
const straightBodyMaterials = useMemo(
|
const straightBodyMaterials = useMemo(() => getStairBodyMaterials(node), [node])
|
||||||
() => getStairBodyMaterials(node),
|
|
||||||
[
|
|
||||||
node.material,
|
|
||||||
node.materialPreset,
|
|
||||||
node.railingMaterial,
|
|
||||||
node.railingMaterialPreset,
|
|
||||||
node.sideMaterial,
|
|
||||||
node.sideMaterialPreset,
|
|
||||||
node.treadMaterial,
|
|
||||||
node.treadMaterialPreset,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const railingMaterial = useMemo(
|
const railingMaterial = useMemo(() => getStairRailingMaterial(node), [node])
|
||||||
() => getStairRailingMaterial(node),
|
|
||||||
[
|
|
||||||
node.material,
|
|
||||||
node.materialPreset,
|
|
||||||
node.railingMaterial,
|
|
||||||
node.railingMaterialPreset,
|
|
||||||
node.sideMaterial,
|
|
||||||
node.sideMaterialPreset,
|
|
||||||
node.treadMaterial,
|
|
||||||
node.treadMaterialPreset,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
const straightPlaceholderGeometry = useMemo(() => {
|
const straightPlaceholderGeometry = useMemo(() => {
|
||||||
const geometry = new THREE.BufferGeometry()
|
const geometry = new THREE.BufferGeometry()
|
||||||
@@ -132,7 +112,9 @@ export const StairRenderer = ({ node }: { node: StairNode }) => {
|
|||||||
receiveShadow
|
receiveShadow
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{!isSegmentBasedStair ? <CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} /> : null}
|
{!isSegmentBasedStair ? (
|
||||||
|
<CurvedStairBody bodyMaterials={straightBodyMaterials} stair={node} />
|
||||||
|
) : null}
|
||||||
<StairRailings material={railingMaterial} stair={node} />
|
<StairRailings material={railingMaterial} stair={node} />
|
||||||
{isSegmentBasedStair ? (
|
{isSegmentBasedStair ? (
|
||||||
<group name="segments-wrapper" visible={false}>
|
<group name="segments-wrapper" visible={false}>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { SceneRenderer } from '../renderers/scene-renderer'
|
|||||||
import FrameLimiter from './frame-limiter'
|
import FrameLimiter from './frame-limiter'
|
||||||
import { Lights } from './lights'
|
import { Lights } from './lights'
|
||||||
import { PerfMonitor } from './perf-monitor'
|
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 { SelectionManager } from './selection-manager'
|
||||||
import { ViewerCamera } from './viewer-camera'
|
import { ViewerCamera } from './viewer-camera'
|
||||||
|
|
||||||
@@ -101,12 +101,14 @@ function GPUDeviceWatcher() {
|
|||||||
|
|
||||||
interface ViewerProps {
|
interface ViewerProps {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
hoverStyles?: HoverStyles
|
||||||
selectionManager?: 'default' | 'custom'
|
selectionManager?: 'default' | 'custom'
|
||||||
perf?: boolean
|
perf?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const Viewer: React.FC<ViewerProps> = ({
|
const Viewer: React.FC<ViewerProps> = ({
|
||||||
children,
|
children,
|
||||||
|
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||||
selectionManager = 'default',
|
selectionManager = 'default',
|
||||||
perf = false,
|
perf = false,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -165,7 +167,7 @@ const Viewer: React.FC<ViewerProps> = ({
|
|||||||
<WallSystem />
|
<WallSystem />
|
||||||
<WindowSystem />
|
<WindowSystem />
|
||||||
<ZoneSystem />
|
<ZoneSystem />
|
||||||
<PostProcessing />
|
<PostProcessing hoverStyles={hoverStyles} />
|
||||||
{/* <DebugRenderer /> */}
|
{/* <DebugRenderer /> */}
|
||||||
<GPUDeviceWatcher />
|
<GPUDeviceWatcher />
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,28 @@ const RETRY_DELAY_MS = 500
|
|||||||
const DARK_BG = '#1f2433'
|
const DARK_BG = '#1f2433'
|
||||||
const LIGHT_BG = '#ffffff'
|
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[]) {
|
function sanitizeOutlineObjects(objects: Object3D[]) {
|
||||||
let nextIndex = 0
|
let nextIndex = 0
|
||||||
|
|
||||||
@@ -62,8 +84,12 @@ function sanitizeOutlineObjects(objects: Object3D[]) {
|
|||||||
objects.length = nextIndex
|
objects.length = nextIndex
|
||||||
}
|
}
|
||||||
|
|
||||||
const PostProcessingPasses = () => {
|
const PostProcessingPasses = ({
|
||||||
const { gl: renderer, scene, camera } = useThree()
|
hoverStyles = DEFAULT_HOVER_STYLES,
|
||||||
|
}: {
|
||||||
|
hoverStyles?: HoverStyles
|
||||||
|
}) => {
|
||||||
|
const { gl: renderer, invalidate, scene, camera } = useThree()
|
||||||
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
const renderPipelineRef = useRef<RenderPipeline | null>(null)
|
||||||
const hasPipelineErrorRef = useRef(false)
|
const hasPipelineErrorRef = useRef(false)
|
||||||
const retryCountRef = useRef(0)
|
const retryCountRef = useRef(0)
|
||||||
@@ -83,6 +109,10 @@ const PostProcessingPasses = () => {
|
|||||||
return l
|
return l
|
||||||
}, [])
|
}, [])
|
||||||
const hoverHighlightMode = useViewer((s) => s.hoverHighlightMode)
|
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
|
// Subscribe to projectId so the pipeline rebuilds on project switch
|
||||||
const projectId = useViewer((s) => s.projectId)
|
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
|
// Build / rebuild the post-processing pipeline
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Intentionally touch these so React/biome treat project switches and retry bumps
|
// Intentionally touch these so React/biome treat project switches and retry bumps
|
||||||
@@ -248,18 +295,9 @@ const PostProcessingPasses = () => {
|
|||||||
.mul(selectedStrength)
|
.mul(selectedStrength)
|
||||||
|
|
||||||
// Hovered: blue visible, yellow hidden, pulsing
|
// 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 pulsePeriod = uniform(3)
|
||||||
const osc =
|
const oscillating = oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5)
|
||||||
hoverHighlightMode === 'delete'
|
const osc = mix(oscillating, float(1), hoverPulseMix)
|
||||||
? float(1)
|
|
||||||
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
|
|
||||||
const hoverOutline = outlineNode.secondaryVisibleEdge
|
const hoverOutline = outlineNode.secondaryVisibleEdge
|
||||||
.mul(hoverVisibleColor)
|
.mul(hoverVisibleColor)
|
||||||
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
.add(outlineNode.secondaryHiddenEdge.mul(hoverHiddenColor))
|
||||||
@@ -298,7 +336,18 @@ const PostProcessingPasses = () => {
|
|||||||
}
|
}
|
||||||
renderPipelineRef.current = null
|
renderPipelineRef.current = null
|
||||||
}
|
}
|
||||||
}, [renderer, scene, camera, hoverHighlightMode, zoneLayers, projectId, pipelineVersion])
|
}, [
|
||||||
|
camera,
|
||||||
|
hoverHiddenColor,
|
||||||
|
hoverPulseMix,
|
||||||
|
hoverStrength,
|
||||||
|
hoverVisibleColor,
|
||||||
|
pipelineVersion,
|
||||||
|
projectId,
|
||||||
|
renderer,
|
||||||
|
scene,
|
||||||
|
zoneLayers,
|
||||||
|
])
|
||||||
|
|
||||||
useFrame((_, delta) => {
|
useFrame((_, delta) => {
|
||||||
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
|
// Animate background colour toward the current theme target (same lerp as AnimatedBackground)
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
export { default as Viewer } from './components/viewer'
|
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 { WalkthroughControls } from './components/viewer/walkthrough-controls'
|
||||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||||
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
export { SCENE_LAYER, ZONE_LAYER } from './lib/layers'
|
||||||
export {
|
export {
|
||||||
|
applyMaterialPresetToMaterials,
|
||||||
clearMaterialCache,
|
clearMaterialCache,
|
||||||
createDefaultMaterial,
|
createDefaultMaterial,
|
||||||
createMaterial,
|
createMaterial,
|
||||||
|
createMaterialFromPresetRef,
|
||||||
DEFAULT_CEILING_MATERIAL,
|
DEFAULT_CEILING_MATERIAL,
|
||||||
DEFAULT_DOOR_MATERIAL,
|
DEFAULT_DOOR_MATERIAL,
|
||||||
DEFAULT_ROOF_MATERIAL,
|
DEFAULT_ROOF_MATERIAL,
|
||||||
@@ -19,3 +25,6 @@ export { mergedOutline } from './lib/merged-outline-node'
|
|||||||
export { default as useViewer } from './store/use-viewer'
|
export { default as useViewer } from './store/use-viewer'
|
||||||
export { InteractiveSystem } from './systems/interactive/interactive-system'
|
export { InteractiveSystem } from './systems/interactive/interactive-system'
|
||||||
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
||||||
|
export { getRoofMaterialArray } from './systems/roof/roof-materials'
|
||||||
|
export { getStairBodyMaterials, getStairRailingMaterial } from './systems/stair/stair-materials'
|
||||||
|
export { getVisibleWallMaterials } from './systems/wall/wall-materials'
|
||||||
|
|||||||
+9
-3
@@ -1,4 +1,10 @@
|
|||||||
import type { AnyNode, BaseNode, BuildingNode, LevelNode, ZoneNode } from '@pascal-app/core'
|
import type {
|
||||||
|
AnyNode,
|
||||||
|
BaseNode,
|
||||||
|
BuildingNode,
|
||||||
|
LevelNode,
|
||||||
|
ZoneNode,
|
||||||
|
} from '@pascal-app/core'
|
||||||
import type { Object3D } from 'three'
|
import type { Object3D } from 'three'
|
||||||
type SelectionPath = {
|
type SelectionPath = {
|
||||||
buildingId: BuildingNode['id'] | null
|
buildingId: BuildingNode['id'] | null
|
||||||
@@ -14,8 +20,8 @@ type ViewerState = {
|
|||||||
selection: SelectionPath
|
selection: SelectionPath
|
||||||
previewSelectedIds: BaseNode['id'][]
|
previewSelectedIds: BaseNode['id'][]
|
||||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
hoverHighlightMode: 'default' | 'delete'
|
hoverHighlightMode: string
|
||||||
setHoverHighlightMode: (mode: 'default' | 'delete') => void
|
setHoverHighlightMode: (mode: string) => void
|
||||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||||
cameraMode: 'perspective' | 'orthographic'
|
cameraMode: 'perspective' | 'orthographic'
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ type ViewerState = {
|
|||||||
selection: SelectionPath
|
selection: SelectionPath
|
||||||
previewSelectedIds: BaseNode['id'][]
|
previewSelectedIds: BaseNode['id'][]
|
||||||
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
|
||||||
hoverHighlightMode: 'default' | 'delete'
|
hoverHighlightMode: string
|
||||||
setHoverHighlightMode: (mode: 'default' | 'delete') => void
|
setHoverHighlightMode: (mode: string) => void
|
||||||
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
|
||||||
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
|
||||||
|
|
||||||
@@ -85,9 +85,10 @@ const useViewer = create<ViewerState>()(
|
|||||||
previewSelectedIds: [],
|
previewSelectedIds: [],
|
||||||
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
|
||||||
hoverHighlightMode: 'default',
|
hoverHighlightMode: 'default',
|
||||||
setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }),
|
setHoverHighlightMode: (mode) =>
|
||||||
|
set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
|
||||||
hoveredId: null,
|
hoveredId: null,
|
||||||
setHoveredId: (id) => set({ hoveredId: id }),
|
setHoveredId: (id) => set((state) => (state.hoveredId === id ? state : { hoveredId: id })),
|
||||||
|
|
||||||
cameraMode: 'perspective',
|
cameraMode: 'perspective',
|
||||||
setCameraMode: (mode) => set({ cameraMode: mode }),
|
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]
|
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(
|
function createResolvedMaterial(
|
||||||
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
|
material: RoofNode['material'] | RoofSegmentNode['material'] | undefined,
|
||||||
materialPreset: string | undefined,
|
materialPreset: string | undefined,
|
||||||
@@ -27,6 +38,14 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
|
|||||||
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
|
const top = getEffectiveRoofSurfaceMaterial(node, 'top')
|
||||||
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
|
const edge = getEffectiveRoofSurfaceMaterial(node, 'edge')
|
||||||
const wall = getEffectiveRoofSurfaceMaterial(node, 'wall')
|
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 topMaterial = createResolvedMaterial(top.material, top.materialPreset)
|
||||||
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
|
const edgeMaterial = createResolvedMaterial(edge.material, edge.materialPreset)
|
||||||
@@ -36,11 +55,13 @@ export function getRoofMaterialArray(node: RoofNode): RoofMaterialArray | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
const materialArray: RoofMaterialArray = [
|
||||||
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
edgeMaterial ?? wallMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
wallMaterial ?? edgeMaterial ?? topMaterial ?? new THREE.MeshStandardMaterial(),
|
||||||
topMaterial ?? wallMaterial ?? edgeMaterial ?? 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]
|
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(
|
function createResolvedMaterial(
|
||||||
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
|
material: StairNode['material'] | StairSegmentNode['material'] | undefined,
|
||||||
materialPreset: string | undefined,
|
materialPreset: string | undefined,
|
||||||
@@ -30,16 +42,32 @@ function createResolvedMaterial(
|
|||||||
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
|
export function getStairBodyMaterials(stair: StairNode): StairBodyMaterials {
|
||||||
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
|
const tread = getEffectiveStairSurfaceMaterial(stair, 'tread')
|
||||||
const side = getEffectiveStairSurfaceMaterial(stair, 'side')
|
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(tread.material, tread.materialPreset),
|
||||||
createResolvedMaterial(side.material, side.materialPreset),
|
createResolvedMaterial(side.material, side.materialPreset),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
stairBodyMaterialCache.set(cacheKey, materials)
|
||||||
|
return materials
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
|
export function getStairRailingMaterial(stair: StairNode): THREE.Material {
|
||||||
const railing = getEffectiveStairSurfaceMaterial(stair, 'railing')
|
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(
|
export function getStraightStairSegmentBodyMaterials(
|
||||||
|
|||||||
Reference in New Issue
Block a user