diff --git a/packages/editor/src/components/ui/command-palette/editor-commands.tsx b/packages/editor/src/components/ui/command-palette/editor-commands.tsx
index 42d4bc9f..d9b43252 100644
--- a/packages/editor/src/components/ui/command-palette/editor-commands.tsx
+++ b/packages/editor/src/components/ui/command-palette/editor-commands.tsx
@@ -23,6 +23,7 @@ import {
Moon,
MousePointer2,
Package,
+ PaintBucket,
PencilLine,
Plus,
Redo2,
@@ -48,6 +49,7 @@ export function EditorCommands() {
const setMode = useEditor((s) => s.setMode)
const setTool = useEditor((s) => s.setTool)
const setStructureLayer = useEditor((s) => s.setStructureLayer)
+ const primeMaterialPaintFromSelection = useEditor((s) => s.primeMaterialPaintFromSelection)
const isPreviewMode = useEditor((s) => s.isPreviewMode)
const setPreviewMode = useEditor((s) => s.setPreviewMode)
@@ -149,6 +151,21 @@ export function EditorCommands() {
useScene.getState().deleteNodes(selectedIds as any[])
}),
},
+ {
+ id: 'editor.mode.material-paint',
+ label: 'Material Paint',
+ group: 'Scene',
+ icon:
,
+ keywords: ['paint', 'material', 'texture', 'bucket', 'surface'],
+ shortcut: ['P'],
+ execute: () =>
+ run(() => {
+ primeMaterialPaintFromSelection()
+ setPhase('structure')
+ setStructureLayer('elements')
+ setMode('material-paint')
+ }),
+ },
// ── Levels ───────────────────────────────────────────────────────────
{
@@ -354,7 +371,7 @@ export function EditorCommands() {
icon:
,
keywords: ['export', 'glb', 'gltf', '3d', 'model', 'download'],
execute: () => run(() => exportScene()),
- } as const,
+ },
]
: []),
{
diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx
index 72b1b5e9..e189276e 100755
--- a/packages/editor/src/components/ui/controls/material-picker.tsx
+++ b/packages/editor/src/components/ui/controls/material-picker.tsx
@@ -6,7 +6,7 @@ import {
type MaterialSchema,
type MaterialTarget,
} from '@pascal-app/core'
-import { useEffect, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
type MaterialPickerProps = {
nodeType?: MaterialTarget
@@ -28,6 +28,7 @@ export function MaterialPicker({
disabled = false,
}: MaterialPickerProps) {
const [showCustom, setShowCustom] = useState
(!!value?.properties)
+ const catalogScrollRef = useRef(null)
const catalogItems = nodeType ? getMaterialsForTarget(nodeType) : []
useEffect(() => {
@@ -51,6 +52,27 @@ export function MaterialPicker({
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])
+
const handleCustomOpen = () => {
if (disabled) return
setShowCustom(true)
@@ -82,52 +104,59 @@ export function MaterialPicker({
}
return (
-
+
{(catalogItems.length > 0 || onChange) && (
-
+
{catalogItems.length > 0 ? (
Library
) : null}
-
- {catalogItems.map((item) => (
-
- ))}
- {onChange ? (
-
- ) : null}
+
+
+ {catalogItems.map((item) => (
+
+ ))}
+ {onChange ? (
+
+ ) : null}
+
)}
diff --git a/packages/editor/src/components/ui/panels/roof-panel.tsx b/packages/editor/src/components/ui/panels/roof-panel.tsx
index 69cdba15..f546a8f3 100755
--- a/packages/editor/src/components/ui/panels/roof-panel.tsx
+++ b/packages/editor/src/components/ui/panels/roof-panel.tsx
@@ -6,7 +6,6 @@ import {
getEffectiveRoofSurfaceMaterial,
type MaterialSchema,
type RoofNode,
- type RoofSurfaceMaterialRole,
RoofNode as RoofNodeSchema,
type RoofSegmentNode,
RoofSegmentNode as RoofSegmentNodeSchema,
@@ -16,6 +15,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
+import { buildRoofSurfaceMaterialPatch } from '../../../lib/material-paint'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
@@ -24,32 +24,6 @@ import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
-function buildRoofSurfaceMaterialPatch(
- node: RoofNode,
- targetRole: RoofSurfaceMaterialRole,
- material: MaterialSchema | undefined,
- materialPreset: string | undefined,
-): Partial
{
- const nextSurfaceMaterial = { material, materialPreset }
- const nextTop =
- targetRole === 'top' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'top')
- const nextEdge =
- targetRole === 'edge' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'edge')
- const nextWall =
- targetRole === 'wall' ? nextSurfaceMaterial : getEffectiveRoofSurfaceMaterial(node, 'wall')
-
- return {
- topMaterial: nextTop.material,
- topMaterialPreset: nextTop.materialPreset,
- edgeMaterial: nextEdge.material,
- edgeMaterialPreset: nextEdge.materialPreset,
- wallMaterial: nextWall.material,
- wallMaterialPreset: nextWall.materialPreset,
- material: undefined,
- materialPreset: undefined,
- }
-}
-
export function RoofPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
@@ -57,6 +31,8 @@ export function RoofPanel() {
const createNode = useScene((s) => s.createNode)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
+ const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial)
+ const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as RoofNode | undefined) : undefined,
@@ -93,17 +69,21 @@ export function RoofPanel() {
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!node || !materialTargetRole) return
+ setActivePaintTarget('roof')
+ setActivePaintMaterial({ material, sourceTarget: 'roof' })
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
- [handleUpdate, materialTargetRole, node],
+ [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!node || !materialTargetRole) return
+ setActivePaintTarget('roof')
+ setActivePaintMaterial({ materialPreset, sourceTarget: 'roof' })
handleUpdate(buildRoofSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
},
- [handleUpdate, materialTargetRole, node],
+ [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget],
)
const handleClose = useCallback(() => {
diff --git a/packages/editor/src/components/ui/panels/stair-panel.tsx b/packages/editor/src/components/ui/panels/stair-panel.tsx
index 8756cc0b..ea3f1d26 100644
--- a/packages/editor/src/components/ui/panels/stair-panel.tsx
+++ b/packages/editor/src/components/ui/panels/stair-panel.tsx
@@ -8,7 +8,6 @@ import {
type MaterialSchema,
type StairNode,
type StairRailingMode,
- type StairSurfaceMaterialRole,
type StairSlabOpeningMode,
type StairTopLandingMode,
type StairType,
@@ -21,6 +20,7 @@ import { useViewer } from '@pascal-app/viewer'
import { Copy, Move, Plus, Trash2 } from 'lucide-react'
import { useCallback } from 'react'
import { useShallow } from 'zustand/react/shallow'
+import { buildStairSurfaceMaterialPatch } from '../../../lib/material-paint'
import { sfxEmitter } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
import { DEFAULT_SPIRAL_STAIR_SWEEP_ANGLE } from '../../tools/stair/stair-defaults'
@@ -33,32 +33,6 @@ import { SliderControl } from '../controls/slider-control'
import { ToggleControl } from '../controls/toggle-control'
import { PanelWrapper } from './panel-wrapper'
-function buildStairSurfaceMaterialPatch(
- node: StairNode,
- targetRole: StairSurfaceMaterialRole,
- material: MaterialSchema | undefined,
- materialPreset: string | undefined,
-): Partial {
- const nextSurfaceMaterial = { material, materialPreset }
- const nextRailing =
- targetRole === 'railing' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'railing')
- const nextTread =
- targetRole === 'tread' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'tread')
- const nextSide =
- targetRole === 'side' ? nextSurfaceMaterial : getEffectiveStairSurfaceMaterial(node, 'side')
-
- return {
- railingMaterial: nextRailing.material,
- railingMaterialPreset: nextRailing.materialPreset,
- treadMaterial: nextTread.material,
- treadMaterialPreset: nextTread.materialPreset,
- sideMaterial: nextSide.material,
- sideMaterialPreset: nextSide.materialPreset,
- material: undefined,
- materialPreset: undefined,
- }
-}
-
const RAILING_MODE_OPTIONS: { label: string; value: StairRailingMode }[] = [
{ label: 'None', value: 'none' },
{ label: 'Left', value: 'left' },
@@ -91,6 +65,8 @@ export function StairPanel() {
const createNodes = useScene((s) => s.createNodes)
const setMovingNode = useEditor((s) => s.setMovingNode)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
+ const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial)
+ const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as StairNode | undefined) : undefined,
@@ -135,17 +111,21 @@ export function StairPanel() {
const handleTargetedMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!node || !materialTargetRole) return
+ setActivePaintTarget('stair')
+ setActivePaintMaterial({ material, sourceTarget: 'stair' })
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, material, undefined))
},
- [handleUpdate, materialTargetRole, node],
+ [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget],
)
const handleTargetedMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!node || !materialTargetRole) return
+ setActivePaintTarget('stair')
+ setActivePaintMaterial({ materialPreset, sourceTarget: 'stair' })
handleUpdate(buildStairSurfaceMaterialPatch(node, materialTargetRole, undefined, materialPreset))
},
- [handleUpdate, materialTargetRole, node],
+ [handleUpdate, materialTargetRole, node, setActivePaintMaterial, setActivePaintTarget],
)
const handleClose = useCallback(() => {
diff --git a/packages/editor/src/components/ui/panels/wall-panel.tsx b/packages/editor/src/components/ui/panels/wall-panel.tsx
index 35bd8e5f..899c92fc 100755
--- a/packages/editor/src/components/ui/panels/wall-panel.tsx
+++ b/packages/editor/src/components/ui/panels/wall-panel.tsx
@@ -11,13 +11,13 @@ import {
normalizeWallCurveOffset,
type MaterialSchema,
useScene,
- type WallSurfaceSide,
type WallNode,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Move, Spline } from 'lucide-react'
import { useCallback, useMemo } from 'react'
import { sfxEmitter } from '../../../lib/sfx-bus'
+import { buildWallSurfaceMaterialPatch } from '../../../lib/material-paint'
import useEditor from '../../../store/use-editor'
import { ActionButton, ActionGroup } from '../controls/action-button'
import { MaterialPicker } from '../controls/material-picker'
@@ -25,32 +25,6 @@ import { PanelSection } from '../controls/panel-section'
import { SliderControl } from '../controls/slider-control'
import { PanelWrapper } from './panel-wrapper'
-function buildWallSurfaceMaterialPatch(
- node: WallNode,
- targetSide: WallSurfaceSide | null,
- material: MaterialSchema | undefined,
- materialPreset: string | undefined,
-): Partial {
- const nextSurfaceMaterial = { material, materialPreset }
- const nextInterior =
- targetSide === null || targetSide === 'interior'
- ? nextSurfaceMaterial
- : getEffectiveWallSurfaceMaterial(node, 'interior')
- const nextExterior =
- targetSide === null || targetSide === 'exterior'
- ? nextSurfaceMaterial
- : getEffectiveWallSurfaceMaterial(node, 'exterior')
-
- return {
- interiorMaterial: nextInterior.material,
- interiorMaterialPreset: nextInterior.materialPreset,
- exteriorMaterial: nextExterior.material,
- exteriorMaterialPreset: nextExterior.materialPreset,
- material: undefined,
- materialPreset: undefined,
- }
-}
-
export function WallPanel() {
const selectedId = useViewer((s) => s.selection.selectedIds[0])
const setSelection = useViewer((s) => s.setSelection)
@@ -58,6 +32,8 @@ export function WallPanel() {
const setMovingNode = useEditor((s) => s.setMovingNode)
const setCurvingWall = useEditor((s) => s.setCurvingWall)
const selectedMaterialTarget = useEditor((s) => s.selectedMaterialTarget)
+ const setActivePaintMaterial = useEditor((s) => s.setActivePaintMaterial)
+ const setActivePaintTarget = useEditor((s) => s.setActivePaintTarget)
const node = useScene((s) =>
selectedId ? (s.nodes[selectedId as AnyNode['id']] as WallNode | undefined) : undefined,
@@ -143,17 +119,21 @@ export function WallPanel() {
const handleMaterialPresetChange = useCallback(
(materialPreset: string) => {
if (!node || !materialTargetSide) return
+ setActivePaintTarget('wall')
+ setActivePaintMaterial({ materialPreset, sourceTarget: 'wall' })
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, undefined, materialPreset))
},
- [handleUpdate, materialTargetSide, node],
+ [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget],
)
const handleCustomMaterialChange = useCallback(
(material: MaterialSchema) => {
if (!node || !materialTargetSide) return
+ setActivePaintTarget('wall')
+ setActivePaintMaterial({ material, sourceTarget: 'wall' })
handleUpdate(buildWallSurfaceMaterialPatch(node, materialTargetSide, material, undefined))
},
- [handleUpdate, materialTargetSide, node],
+ [handleUpdate, materialTargetSide, node, setActivePaintMaterial, setActivePaintTarget],
)
const handleClose = useCallback(() => {
diff --git a/packages/editor/src/hooks/use-keyboard.ts b/packages/editor/src/hooks/use-keyboard.ts
index 511075d9..24b53bed 100755
--- a/packages/editor/src/hooks/use-keyboard.ts
+++ b/packages/editor/src/hooks/use-keyboard.ts
@@ -88,6 +88,13 @@ export const useKeyboard = ({ isVersionPreviewMode = false } = {}) => {
if (isVersionPreviewMode) return
e.preventDefault()
useEditor.getState().setMode('delete')
+ } else if (e.key === 'p' && !e.metaKey && !e.ctrlKey) {
+ if (isVersionPreviewMode) return
+ e.preventDefault()
+ useEditor.getState().primeMaterialPaintFromSelection()
+ useEditor.getState().setPhase('structure')
+ useEditor.getState().setStructureLayer('elements')
+ useEditor.getState().setMode('material-paint')
} else if (e.key === 'z' && (e.metaKey || e.ctrlKey)) {
if (isVersionPreviewMode) return
e.preventDefault()
diff --git a/packages/editor/src/lib/material-paint.ts b/packages/editor/src/lib/material-paint.ts
new file mode 100644
index 00000000..7c7fed68
--- /dev/null
+++ b/packages/editor/src/lib/material-paint.ts
@@ -0,0 +1,237 @@
+'use client'
+
+import {
+ getCatalogMaterialById,
+ getEffectiveRoofSurfaceMaterial,
+ getEffectiveStairSurfaceMaterial,
+ getEffectiveWallSurfaceMaterial,
+ getLibraryMaterialIdFromRef,
+ type MaterialSchema,
+ type MaterialTarget,
+ type RoofNode,
+ type RoofSurfaceMaterialRole,
+ type StairNode,
+ type StairSurfaceMaterialRole,
+ type WallNode,
+ type WallSurfaceSide,
+} from '@pascal-app/core'
+
+export type PaintableMaterialTarget = Extract
+
+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 isActivePaintMaterialCompatible(
+ material: ActivePaintMaterial | null | undefined,
+ target: PaintableMaterialTarget,
+) {
+ if (!hasActivePaintMaterial(material)) return false
+
+ const catalogEntry = getCatalogEntryForActivePaintMaterial(material)
+ if (!catalogEntry) return true
+
+ return catalogEntry.targets.includes(target)
+}
+
+export function buildWallSurfaceMaterialPatch(
+ node: WallNode,
+ targetSide: WallSurfaceSide,
+ material: MaterialSchema | undefined,
+ materialPreset: string | undefined,
+): Partial {
+ 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 {
+ 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 {
+ 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 resolveActivePaintMaterialFromSelection(params: {
+ nodes: Record
+ selectedId: string | null
+ selectedMaterialTarget: {
+ nodeId: string
+ role: WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
+ } | 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
+ }
+
+ return null
+}
+
+export function resolvePaintTargetFromSelection(params: {
+ nodes: Record
+ 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'
+ }
+
+ return null
+}
diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx
index 03d8a0e9..d323f12f 100644
--- a/packages/editor/src/store/use-editor.tsx
+++ b/packages/editor/src/store/use-editor.tsx
@@ -9,14 +9,14 @@ import {
type FenceNode,
type ItemNode,
type LevelNode,
- type RoofSurfaceMaterialRole,
type RoofNode,
type RoofSegmentNode,
+ type RoofSurfaceMaterialRole,
type SlabNode,
type Space,
- type StairSurfaceMaterialRole,
type StairNode,
type StairSegmentNode,
+ type StairSurfaceMaterialRole,
useScene,
type WallNode,
type WallSurfaceSide,
@@ -26,6 +26,12 @@ import { useViewer } from '@pascal-app/viewer'
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { getDefaultCatalogItem } from '../components/ui/item-catalog/catalog-items'
+import {
+ type ActivePaintMaterial,
+ type PaintableMaterialTarget,
+ resolveActivePaintMaterialFromSelection,
+ resolvePaintTargetFromSelection,
+} from '../lib/material-paint'
const DEFAULT_ACTIVE_SIDEBAR_PANEL = 'site'
const DEFAULT_FLOORPLAN_PANE_RATIO = 0.5
@@ -37,7 +43,7 @@ export type SplitOrientation = 'horizontal' | 'vertical'
export type Phase = 'site' | 'structure' | 'furnish'
-export type Mode = 'select' | 'edit' | 'delete' | 'build'
+export type Mode = 'select' | 'edit' | 'delete' | 'build' | 'material-paint'
// Structure mode tools (building elements)
export type StructureTool =
@@ -84,13 +90,22 @@ export type MovingWallEndpoint = {
endpoint: 'start' | 'end'
}
-export type MaterialTargetRole = WallSurfaceSide | StairSurfaceMaterialRole | RoofSurfaceMaterialRole
+export type MaterialTargetRole =
+ | WallSurfaceSide
+ | StairSurfaceMaterialRole
+ | RoofSurfaceMaterialRole
export type SelectedMaterialTarget = {
nodeId: AnyNodeId
role: MaterialTargetRole
}
+type MaterialPaintSelectionSnapshot = {
+ selectedId: string | null
+ activePaintTarget: PaintableMaterialTarget
+ activePaintMaterial: ActivePaintMaterial | null
+}
+
type EditorState = {
phase: Phase
setPhase: (phase: Phase) => void
@@ -140,6 +155,15 @@ type EditorState = {
setCurvingWall: (wall: WallNode | null) => void
selectedMaterialTarget: SelectedMaterialTarget | null
setSelectedMaterialTarget: (target: SelectedMaterialTarget | null) => void
+ activePaintMaterial: ActivePaintMaterial | null
+ setActivePaintMaterial: (material: ActivePaintMaterial | null) => void
+ activePaintTarget: PaintableMaterialTarget
+ setActivePaintTarget: (target: PaintableMaterialTarget) => void
+ primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
+ paintDisabledFeedbackTick: number
+ triggerPaintDisabledFeedback: () => void
+ hoveredPaintTarget: PaintableMaterialTarget | null
+ setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
// Space detection for cutaway mode
@@ -219,7 +243,7 @@ function normalizeModeForPhase(phase: Phase, mode: Mode | undefined): Mode {
return 'select'
}
- return mode === 'build' || mode === 'delete' ? mode : 'select'
+ return mode === 'build' || mode === 'delete' || mode === 'material-paint' ? mode : 'select'
}
function normalizeFloorplanPaneRatio(value: unknown): number {
@@ -457,6 +481,8 @@ const useEditor = create()(
const category = get().catalogCategory ?? 'furniture'
set({ selectedItem: getDefaultSelectedItemForCategory(category) })
}
+ } else if (mode === 'material-paint') {
+ get().primeMaterialPaintFromSelection()
}
// When leaving build mode, clear tool
else if (tool) {
@@ -517,6 +543,48 @@ const useEditor = create()(
setCurvingWall: (wall) => set({ curvingWall: wall }),
selectedMaterialTarget: null,
setSelectedMaterialTarget: (target) => set({ selectedMaterialTarget: target }),
+ activePaintMaterial: null,
+ setActivePaintMaterial: (material) => set({ activePaintMaterial: material }),
+ activePaintTarget: 'wall',
+ setActivePaintTarget: (target) =>
+ set((state) =>
+ state.activePaintTarget === target ? state : { activePaintTarget: target },
+ ),
+ primeMaterialPaintFromSelection: () => {
+ const selectedId =
+ useViewer.getState().selection.selectedIds.length === 1
+ ? (useViewer.getState().selection.selectedIds[0] ?? null)
+ : null
+ const activePaintTarget =
+ resolvePaintTargetFromSelection({
+ nodes: useScene.getState().nodes,
+ selectedId,
+ }) ?? get().activePaintTarget
+ const activePaintMaterial = resolveActivePaintMaterialFromSelection({
+ nodes: useScene.getState().nodes,
+ selectedId,
+ selectedMaterialTarget: get().selectedMaterialTarget,
+ })
+
+ set({
+ activePaintTarget,
+ ...(activePaintMaterial ? { activePaintMaterial } : {}),
+ })
+
+ return {
+ selectedId,
+ activePaintTarget,
+ activePaintMaterial: activePaintMaterial ?? get().activePaintMaterial,
+ }
+ },
+ paintDisabledFeedbackTick: 0,
+ triggerPaintDisabledFeedback: () =>
+ set((state) => ({ paintDisabledFeedbackTick: state.paintDisabledFeedbackTick + 1 })),
+ hoveredPaintTarget: null,
+ setHoveredPaintTarget: (target) =>
+ set((state) =>
+ state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
+ ),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
spaces: {},
diff --git a/packages/viewer/src/components/viewer/post-processing.tsx b/packages/viewer/src/components/viewer/post-processing.tsx
index a0b3022e..63217975 100644
--- a/packages/viewer/src/components/viewer/post-processing.tsx
+++ b/packages/viewer/src/components/viewer/post-processing.tsx
@@ -249,15 +249,33 @@ const PostProcessingPasses = () => {
// Hovered: blue visible, yellow hidden, pulsing
const hoverVisibleColor = uniform(
- new Color(hoverHighlightMode === 'delete' ? 0xef_44_44 : 0x00_aa_ff),
+ new Color(
+ hoverHighlightMode === 'delete'
+ ? 0xef_44_44
+ : hoverHighlightMode === 'paint-ready'
+ ? 0xf59e_0b
+ : hoverHighlightMode === 'paint-disabled'
+ ? 0x94_a3_b8
+ : 0x00_aa_ff,
+ ),
)
const hoverHiddenColor = uniform(
- new Color(hoverHighlightMode === 'delete' ? 0x99_1b_1b : 0xf3_ff_47),
+ new Color(
+ hoverHighlightMode === 'delete'
+ ? 0x99_1b_1b
+ : hoverHighlightMode === 'paint-ready'
+ ? 0xfde0_68
+ : hoverHighlightMode === 'paint-disabled'
+ ? 0x47_55_69
+ : 0xf3_ff_47,
+ ),
+ )
+ const hoverStrength = uniform(
+ hoverHighlightMode === 'delete' ? 6 : hoverHighlightMode === 'paint-disabled' ? 4 : 5,
)
- const hoverStrength = uniform(hoverHighlightMode === 'delete' ? 6 : 5)
const pulsePeriod = uniform(3)
const osc =
- hoverHighlightMode === 'delete'
+ hoverHighlightMode === 'delete' || hoverHighlightMode === 'paint-disabled'
? float(1)
: oscSine(time.div(pulsePeriod).mul(2)).mul(0.5).add(0.5) // [ 0.5, 1.0 ]
const hoverOutline = outlineNode.secondaryVisibleEdge
diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts
index 070ec71d..f390edd6 100644
--- a/packages/viewer/src/store/use-viewer.ts
+++ b/packages/viewer/src/store/use-viewer.ts
@@ -22,8 +22,8 @@ type ViewerState = {
selection: SelectionPath
previewSelectedIds: BaseNode['id'][]
setPreviewSelectedIds: (ids: BaseNode['id'][]) => void
- hoverHighlightMode: 'default' | 'delete'
- setHoverHighlightMode: (mode: 'default' | 'delete') => void
+ hoverHighlightMode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled'
+ setHoverHighlightMode: (mode: 'default' | 'delete' | 'paint-ready' | 'paint-disabled') => void
hoveredId: AnyNode['id'] | ZoneNode['id'] | null
setHoveredId: (id: AnyNode['id'] | ZoneNode['id'] | null) => void
@@ -85,9 +85,10 @@ const useViewer = create()(
previewSelectedIds: [],
setPreviewSelectedIds: (ids) => set({ previewSelectedIds: ids }),
hoverHighlightMode: 'default',
- setHoverHighlightMode: (mode) => set({ hoverHighlightMode: mode }),
+ setHoverHighlightMode: (mode) =>
+ set((state) => (state.hoverHighlightMode === mode ? state : { hoverHighlightMode: mode })),
hoveredId: null,
- setHoveredId: (id) => set({ hoveredId: id }),
+ setHoveredId: (id) => set((state) => (state.hoveredId === id ? state : { hoveredId: id })),
cameraMode: 'perspective',
setCameraMode: (mode) => set({ cameraMode: mode }),