feat(paint-slots): create-in-place scene materials + paint-panel polish

Custom-create now pre-creates a scene material and opens its inline editor
in the build pane (no separate right-side PaintPanel, which is removed);
the brush + "Paint with" use a scene: ref so painting stores the ref and
edits propagate everywhere. Slot preview (shared + item) resolves scene
refs so hover shows the real material.

Material properties editor uses the shared SliderControl for roughness/
metalness/opacity; row action buttons use Tooltip instead of title; the
color input renders as a clean filled swatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-06-18 11:46:45 -04:00
co-authored by Claude Opus 4.8
parent c6423e7c73
commit 6b67ab6949
9 changed files with 140 additions and 176 deletions
@@ -1,9 +1,15 @@
'use client'
import { type AnyNodeId, useScene } from '@pascal-app/core'
import {
type AnyNodeId,
generateSceneMaterialId,
type SceneMaterialId,
toSceneMaterialRef,
useScene,
} from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { Eraser, RotateCcw } from 'lucide-react'
import { useEffect } from 'react'
import { useEffect, useState } from 'react'
import {
buildResetSurfaceMaterialUpdates,
resolvePaintTargetFromSelection,
@@ -27,6 +33,8 @@ export function MaterialPaintPanel() {
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
const paintEraser = useEditor((state) => state.paintEraser)
const setPaintEraser = useEditor((state) => state.setPaintEraser)
// Id of a just-created scene material whose inline editor should open on mount.
const [autoEditMaterialId, setAutoEditMaterialId] = useState<SceneMaterialId | null>(null)
const selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes)
const materialCount = useScene((state) => Object.keys(state.materials).length)
@@ -73,7 +81,18 @@ export function MaterialPaintPanel() {
</div>
<MaterialPicker
onChange={(material) => {
setActivePaintMaterial({ material, sourceTarget: activePaintTarget })
// Custom-create: pre-create a scene material and select it as the
// brush via a `scene:` ref so painting stores the ref and edits to
// it propagate everywhere. The user edits it inline in the scene-
// material list below (auto-opened) — no separate right-side pane.
const id = generateSceneMaterialId()
const count = Object.keys(useScene.getState().materials).length
useScene.getState().addSceneMaterial({ id, name: `Material ${count + 1}`, material })
setActivePaintMaterial({
materialPreset: toSceneMaterialRef(id),
sourceTarget: activePaintTarget,
})
setAutoEditMaterialId(id)
}}
onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
@@ -83,7 +102,7 @@ export function MaterialPaintPanel() {
/>
{materialCount > 0 ? (
<PanelSection title="Scene materials">
<SceneMaterialList />
<SceneMaterialList autoEditId={autoEditMaterialId} />
</PanelSection>
) : null}
</div>
@@ -11,7 +11,6 @@ import {
} from '@pascal-app/core'
import { useEffect, useState } from 'react'
import { triggerSFX } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
type MaterialPickerProps = {
value?: MaterialSchema
@@ -34,7 +33,6 @@ export function MaterialPicker({
onSelectMaterialPreset,
disabled = false,
}: MaterialPickerProps) {
const setPaintPanelOpen = useEditor((state) => state.setPaintPanelOpen)
const [showCustom, setShowCustom] = useState<boolean>(!!value?.properties)
const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
MATERIAL_CATEGORIES[0],
@@ -70,14 +68,14 @@ export function MaterialPicker({
const handleCatalogSelect = (materialId: string) => {
if (disabled) return
setShowCustom(false)
setPaintPanelOpen(false)
onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
}
// Seed a new custom material from the current/forked colour and hand it to
// the host (MaterialPaintPanel), which pre-creates a scene material the user
// edits inline in the build pane — no separate right-side editor pane.
const handleCustomOpen = () => {
if (disabled) return
setShowCustom(true)
setPaintPanelOpen(true)
const forkColor = selectedMaterialPreset
? (selectedCatalogEntry?.previewColor ?? '#ffffff')
: '#ffffff'
@@ -112,9 +110,6 @@ export function MaterialPicker({
if (showCustom) {
setShowCustom(false)
}
if (category !== 'colors') {
setPaintPanelOpen(false)
}
}}
type="button"
>
@@ -2,6 +2,7 @@
import type { MaterialProperties, MaterialSchema } from '@pascal-app/core'
import { Input } from '../primitives/input'
import { SliderControl } from './slider-control'
const DEFAULT_MATERIAL_PROPERTIES: MaterialProperties = {
color: '#ffffff',
@@ -44,7 +45,7 @@ export function MaterialPropertiesEditor({
</label>
<div className="flex items-center gap-2">
<input
className="h-10 w-14 cursor-pointer rounded-md border border-input bg-transparent"
className="h-9 w-9 shrink-0 cursor-pointer rounded-md border border-input bg-transparent p-0 [&::-moz-color-swatch]:rounded-[5px] [&::-moz-color-swatch]:border-none [&::-webkit-color-swatch-wrapper]:p-0 [&::-webkit-color-swatch]:rounded-[5px] [&::-webkit-color-swatch]:border-none"
onChange={(e) => updateMaterial({ color: e.target.value })}
type="color"
value={currentProps.color}
@@ -56,68 +57,35 @@ export function MaterialPropertiesEditor({
</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) => updateMaterial({ roughness: Number.parseFloat(e.target.value) })}
step={0.01}
type="range"
value={currentProps.roughness}
/>
</div>
<SliderControl
label="Roughness"
max={1}
min={0}
onChange={(value) => updateMaterial({ roughness: value })}
precision={2}
step={0.01}
value={currentProps.roughness}
/>
<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) => updateMaterial({ metalness: Number.parseFloat(e.target.value) })}
step={0.01}
type="range"
value={currentProps.metalness}
/>
</div>
<SliderControl
label="Metalness"
max={1}
min={0}
onChange={(value) => updateMaterial({ metalness: value })}
precision={2}
step={0.01}
value={currentProps.metalness}
/>
<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)
updateMaterial({ opacity }, opacity < 1 || currentProps.transparent)
}}
step={0.01}
type="range"
value={currentProps.opacity}
/>
</div>
<SliderControl
label="Opacity"
max={1}
min={0}
onChange={(value) => updateMaterial({ opacity: value }, value < 1 || currentProps.transparent)}
precision={2}
step={0.01}
value={currentProps.opacity}
/>
<div className="space-y-2">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]">
@@ -13,6 +13,7 @@ import { useEffect, useMemo, useState } from 'react'
import useEditor from '../../../store/use-editor'
import { Button } from '../primitives/button'
import { Input } from '../primitives/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
import { MaterialPropertiesEditor } from './material-properties-editor'
type SlotRecord = Record<string, string | undefined>
@@ -24,7 +25,7 @@ function getSlotRecord(node: unknown): SlotRecord | null {
return slots as SlotRecord
}
export function SceneMaterialList() {
export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId | null }) {
const materials = useScene((state) => state.materials)
const nodes = useScene((state) => state.nodes)
const addSceneMaterial = useScene((state) => state.addSceneMaterial)
@@ -68,6 +69,7 @@ export function SceneMaterialList() {
<SceneMaterialRow
addSceneMaterial={addSceneMaterial}
activePaintTarget={activePaintTarget}
autoEdit={autoEditId === id}
id={id}
key={id}
removeSceneMaterial={removeSceneMaterial}
@@ -86,6 +88,7 @@ function SceneMaterialRow({
sceneMaterial,
usageCount,
activePaintTarget,
autoEdit,
addSceneMaterial,
updateSceneMaterial,
removeSceneMaterial,
@@ -95,12 +98,14 @@ function SceneMaterialRow({
sceneMaterial: SceneMaterial
usageCount: number
activePaintTarget: ReturnType<typeof useEditor.getState>['activePaintTarget']
autoEdit: boolean
addSceneMaterial: ReturnType<typeof useScene.getState>['addSceneMaterial']
updateSceneMaterial: ReturnType<typeof useScene.getState>['updateSceneMaterial']
removeSceneMaterial: ReturnType<typeof useScene.getState>['removeSceneMaterial']
setActivePaintMaterial: ReturnType<typeof useEditor.getState>['setActivePaintMaterial']
}) {
const [isEditingMaterial, setIsEditingMaterial] = useState(false)
// A freshly-created material (via "+ Custom") mounts with its editor open.
const [isEditingMaterial, setIsEditingMaterial] = useState(autoEdit)
const [draftName, setDraftName] = useState(sceneMaterial.name)
const swatchColor = sceneMaterial.material.properties?.color ?? '#ffffff'
@@ -156,52 +161,68 @@ function SceneMaterialRow({
Used by {usageCount} {usageCount === 1 ? 'part' : 'parts'}
</span>
<div className="flex items-center gap-1">
<Button
aria-label="Paint with"
onClick={() =>
setActivePaintMaterial({
material: sceneMaterial.material,
sourceTarget: activePaintTarget,
})
}
size="icon-sm"
title="Paint with"
type="button"
variant="outline"
>
<Paintbrush />
</Button>
<Button
aria-label="Edit"
aria-pressed={isEditingMaterial}
onClick={() => setIsEditingMaterial((value) => !value)}
size="icon-sm"
title="Edit"
type="button"
variant={isEditingMaterial ? 'default' : 'outline'}
>
<Pencil />
</Button>
<Button
aria-label="Duplicate"
onClick={duplicateMaterial}
size="icon-sm"
title="Duplicate"
type="button"
variant="outline"
>
<Copy />
</Button>
<Button
aria-label="Delete"
onClick={() => removeSceneMaterial(id)}
size="icon-sm"
title="Delete"
type="button"
variant="outline"
>
<Trash2 />
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Paint with"
onClick={() =>
setActivePaintMaterial({
materialPreset: toSceneMaterialRef(id),
sourceTarget: activePaintTarget,
})
}
size="icon-sm"
type="button"
variant="outline"
>
<Paintbrush />
</Button>
</TooltipTrigger>
<TooltipContent>Paint with</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Edit"
aria-pressed={isEditingMaterial}
onClick={() => setIsEditingMaterial((value) => !value)}
size="icon-sm"
type="button"
variant={isEditingMaterial ? 'default' : 'outline'}
>
<Pencil />
</Button>
</TooltipTrigger>
<TooltipContent>Edit</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Duplicate"
onClick={duplicateMaterial}
size="icon-sm"
type="button"
variant="outline"
>
<Copy />
</Button>
</TooltipTrigger>
<TooltipContent>Duplicate</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
aria-label="Delete"
onClick={() => removeSceneMaterial(id)}
size="icon-sm"
type="button"
variant="outline"
>
<Trash2 />
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
</div>
@@ -1,36 +0,0 @@
'use client'
import useEditor from '../../../store/use-editor'
import { MaterialPropertiesEditor } from '../controls/material-properties-editor'
import { PanelSection } from '../controls/panel-section'
import { PanelWrapper } from './panel-wrapper'
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
return (
<PanelWrapper onClose={() => setPaintPanelOpen(false)} title="Material" width={320}>
<PanelSection title="Custom material">
<MaterialPropertiesEditor
onChange={(material) =>
setActivePaintMaterial({
material,
sourceTarget: activePaintMaterial?.sourceTarget ?? activePaintTarget,
})
}
value={customMaterial}
/>
</PanelSection>
</PanelWrapper>
)
}
@@ -29,7 +29,6 @@ import useEditor from '../../../store/use-editor'
import { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display'
import { PaintPanel } from './paint-panel'
import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel'
@@ -174,9 +173,6 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
const selectedZoneId = useViewer((s) => s.selection.zoneId)
const setSelection = useViewer((s) => s.setSelection)
const selectedReferenceId = useEditor((s) => s.selectedReferenceId)
const isPaintPanelOpen = useEditor((s) => s.isPaintPanelOpen)
const mode = useEditor((s) => s.mode)
const activePaintMaterial = useEditor((s) => s.activePaintMaterial)
// Only subscribe to the *type* of the single-selected node — string primitive
// so we don't re-render on unrelated scene mutations.
const selectedNodeType = useScene((s) => {
@@ -208,15 +204,6 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
return <ReferencePanel />
}
if (
isPaintPanelOpen &&
mode === 'material-paint' &&
activePaintMaterial?.material?.properties &&
!activePaintMaterial.materialPreset
) {
return <PaintPanel />
}
if (selectedZoneId && selectedIds.length === 0) {
return (
<ParametricInspector
-4
View File
@@ -309,8 +309,6 @@ type EditorState = {
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void
isPaintPanelOpen: boolean
setPaintPanelOpen: (open: boolean) => void
selectedReferenceId: string | null
setSelectedReferenceId: (id: string | null) => void
guideUi: Record<string, GuideUiState>
@@ -854,8 +852,6 @@ const useEditor = create<EditorState>()(
set((state) =>
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target },
),
isPaintPanelOpen: false,
setPaintPanelOpen: (open) => set({ isPaintPanelOpen: open }),
selectedReferenceId: null,
setSelectedReferenceId: (id) => set({ selectedReferenceId: id }),
guideUi: {},
+8 -1
View File
@@ -169,7 +169,14 @@ function buildPreviewMaterial(
materialPreset: string | undefined,
): Material | null {
const shading = useViewer.getState().shading
if (materialPreset) return createMaterialFromPresetRef(materialPreset, shading)
if (materialPreset) {
const parsed = parseMaterialRef(materialPreset)
if (parsed?.kind === 'scene') {
const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
return sceneMaterial ? createMaterial(sceneMaterial.material, shading) : null
}
return createMaterialFromPresetRef(materialPreset, shading)
}
if (material) return createMaterial(material, shading)
return null
}
+8 -1
View File
@@ -134,7 +134,14 @@ export function buildSlotPreviewMaterial(
materialPreset: string | undefined,
): Material | null {
const shading = useViewer.getState().shading
if (materialPreset) return createMaterialFromPresetRef(materialPreset, shading)
if (materialPreset) {
const parsed = parseMaterialRef(materialPreset)
if (parsed?.kind === 'scene') {
const sceneMaterial = useScene.getState().materials[parsed.id as SceneMaterialId]
return sceneMaterial ? createMaterial(sceneMaterial.material, shading) : null
}
return createMaterialFromPresetRef(materialPreset, shading)
}
if (material) return createMaterial(material, shading)
return null
}