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' '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 { useViewer } from '@pascal-app/viewer'
import { Eraser, RotateCcw } from 'lucide-react' import { Eraser, RotateCcw } from 'lucide-react'
import { useEffect } from 'react' import { useEffect, useState } from 'react'
import { import {
buildResetSurfaceMaterialUpdates, buildResetSurfaceMaterialUpdates,
resolvePaintTargetFromSelection, resolvePaintTargetFromSelection,
@@ -27,6 +33,8 @@ export function MaterialPaintPanel() {
const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget) const setActivePaintTarget = useEditor((state) => state.setActivePaintTarget)
const paintEraser = useEditor((state) => state.paintEraser) const paintEraser = useEditor((state) => state.paintEraser)
const setPaintEraser = useEditor((state) => state.setPaintEraser) 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 selectedIds = useViewer((state) => state.selection.selectedIds)
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
const materialCount = useScene((state) => Object.keys(state.materials).length) const materialCount = useScene((state) => Object.keys(state.materials).length)
@@ -73,7 +81,18 @@ export function MaterialPaintPanel() {
</div> </div>
<MaterialPicker <MaterialPicker
onChange={(material) => { 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) => { onSelectMaterialPreset={(materialPreset) => {
setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget }) setActivePaintMaterial({ materialPreset, sourceTarget: activePaintTarget })
@@ -83,7 +102,7 @@ export function MaterialPaintPanel() {
/> />
{materialCount > 0 ? ( {materialCount > 0 ? (
<PanelSection title="Scene materials"> <PanelSection title="Scene materials">
<SceneMaterialList /> <SceneMaterialList autoEditId={autoEditMaterialId} />
</PanelSection> </PanelSection>
) : null} ) : null}
</div> </div>
@@ -11,7 +11,6 @@ import {
} from '@pascal-app/core' } from '@pascal-app/core'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { triggerSFX } from '../../../lib/sfx-bus' import { triggerSFX } from '../../../lib/sfx-bus'
import useEditor from '../../../store/use-editor'
type MaterialPickerProps = { type MaterialPickerProps = {
value?: MaterialSchema value?: MaterialSchema
@@ -34,7 +33,6 @@ export function MaterialPicker({
onSelectMaterialPreset, onSelectMaterialPreset,
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 [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>( const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
MATERIAL_CATEGORIES[0], MATERIAL_CATEGORIES[0],
@@ -70,14 +68,14 @@ export function MaterialPicker({
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))
} }
// 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 = () => { const handleCustomOpen = () => {
if (disabled) return if (disabled) return
setShowCustom(true)
setPaintPanelOpen(true)
const forkColor = selectedMaterialPreset const forkColor = selectedMaterialPreset
? (selectedCatalogEntry?.previewColor ?? '#ffffff') ? (selectedCatalogEntry?.previewColor ?? '#ffffff')
: '#ffffff' : '#ffffff'
@@ -112,9 +110,6 @@ export function MaterialPicker({
if (showCustom) { if (showCustom) {
setShowCustom(false) setShowCustom(false)
} }
if (category !== 'colors') {
setPaintPanelOpen(false)
}
}} }}
type="button" type="button"
> >
@@ -2,6 +2,7 @@
import type { MaterialProperties, MaterialSchema } from '@pascal-app/core' import type { MaterialProperties, MaterialSchema } from '@pascal-app/core'
import { Input } from '../primitives/input' import { Input } from '../primitives/input'
import { SliderControl } from './slider-control'
const DEFAULT_MATERIAL_PROPERTIES: MaterialProperties = { const DEFAULT_MATERIAL_PROPERTIES: MaterialProperties = {
color: '#ffffff', color: '#ffffff',
@@ -44,7 +45,7 @@ export function MaterialPropertiesEditor({
</label> </label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <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 })} onChange={(e) => updateMaterial({ color: e.target.value })}
type="color" type="color"
value={currentProps.color} value={currentProps.color}
@@ -56,68 +57,35 @@ export function MaterialPropertiesEditor({
</div> </div>
</div> </div>
<div className="space-y-2"> <SliderControl
<div className="flex items-center justify-between"> label="Roughness"
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]"> max={1}
Roughness min={0}
</label> onChange={(value) => updateMaterial({ roughness: value })}
<span className="font-mono text-muted-foreground text-xs"> precision={2}
{currentProps.roughness.toFixed(2)} step={0.01}
</span> value={currentProps.roughness}
</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>
<div className="space-y-2"> <SliderControl
<div className="flex items-center justify-between"> label="Metalness"
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]"> max={1}
Metalness min={0}
</label> onChange={(value) => updateMaterial({ metalness: value })}
<span className="font-mono text-muted-foreground text-xs"> precision={2}
{currentProps.metalness.toFixed(2)} step={0.01}
</span> value={currentProps.metalness}
</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>
<div className="space-y-2"> <SliderControl
<div className="flex items-center justify-between"> label="Opacity"
<label className="font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]"> max={1}
Opacity min={0}
</label> onChange={(value) => updateMaterial({ opacity: value }, value < 1 || currentProps.transparent)}
<span className="font-mono text-muted-foreground text-xs"> precision={2}
{currentProps.opacity.toFixed(2)} step={0.01}
</span> value={currentProps.opacity}
</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>
<div className="space-y-2"> <div className="space-y-2">
<label className="block font-medium text-muted-foreground text-xs uppercase tracking-[0.12em]"> <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 useEditor from '../../../store/use-editor'
import { Button } from '../primitives/button' import { Button } from '../primitives/button'
import { Input } from '../primitives/input' import { Input } from '../primitives/input'
import { Tooltip, TooltipContent, TooltipTrigger } from '../primitives/tooltip'
import { MaterialPropertiesEditor } from './material-properties-editor' import { MaterialPropertiesEditor } from './material-properties-editor'
type SlotRecord = Record<string, string | undefined> type SlotRecord = Record<string, string | undefined>
@@ -24,7 +25,7 @@ function getSlotRecord(node: unknown): SlotRecord | null {
return slots as SlotRecord return slots as SlotRecord
} }
export function SceneMaterialList() { export function SceneMaterialList({ autoEditId }: { autoEditId?: SceneMaterialId | null }) {
const materials = useScene((state) => state.materials) const materials = useScene((state) => state.materials)
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
const addSceneMaterial = useScene((state) => state.addSceneMaterial) const addSceneMaterial = useScene((state) => state.addSceneMaterial)
@@ -68,6 +69,7 @@ export function SceneMaterialList() {
<SceneMaterialRow <SceneMaterialRow
addSceneMaterial={addSceneMaterial} addSceneMaterial={addSceneMaterial}
activePaintTarget={activePaintTarget} activePaintTarget={activePaintTarget}
autoEdit={autoEditId === id}
id={id} id={id}
key={id} key={id}
removeSceneMaterial={removeSceneMaterial} removeSceneMaterial={removeSceneMaterial}
@@ -86,6 +88,7 @@ function SceneMaterialRow({
sceneMaterial, sceneMaterial,
usageCount, usageCount,
activePaintTarget, activePaintTarget,
autoEdit,
addSceneMaterial, addSceneMaterial,
updateSceneMaterial, updateSceneMaterial,
removeSceneMaterial, removeSceneMaterial,
@@ -95,12 +98,14 @@ function SceneMaterialRow({
sceneMaterial: SceneMaterial sceneMaterial: SceneMaterial
usageCount: number usageCount: number
activePaintTarget: ReturnType<typeof useEditor.getState>['activePaintTarget'] activePaintTarget: ReturnType<typeof useEditor.getState>['activePaintTarget']
autoEdit: boolean
addSceneMaterial: ReturnType<typeof useScene.getState>['addSceneMaterial'] addSceneMaterial: ReturnType<typeof useScene.getState>['addSceneMaterial']
updateSceneMaterial: ReturnType<typeof useScene.getState>['updateSceneMaterial'] updateSceneMaterial: ReturnType<typeof useScene.getState>['updateSceneMaterial']
removeSceneMaterial: ReturnType<typeof useScene.getState>['removeSceneMaterial'] removeSceneMaterial: ReturnType<typeof useScene.getState>['removeSceneMaterial']
setActivePaintMaterial: ReturnType<typeof useEditor.getState>['setActivePaintMaterial'] 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 [draftName, setDraftName] = useState(sceneMaterial.name)
const swatchColor = sceneMaterial.material.properties?.color ?? '#ffffff' const swatchColor = sceneMaterial.material.properties?.color ?? '#ffffff'
@@ -156,52 +161,68 @@ function SceneMaterialRow({
Used by {usageCount} {usageCount === 1 ? 'part' : 'parts'} Used by {usageCount} {usageCount === 1 ? 'part' : 'parts'}
</span> </span>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Tooltip>
aria-label="Paint with" <TooltipTrigger asChild>
onClick={() => <Button
setActivePaintMaterial({ aria-label="Paint with"
material: sceneMaterial.material, onClick={() =>
sourceTarget: activePaintTarget, setActivePaintMaterial({
}) materialPreset: toSceneMaterialRef(id),
} sourceTarget: activePaintTarget,
size="icon-sm" })
title="Paint with" }
type="button" size="icon-sm"
variant="outline" type="button"
> variant="outline"
<Paintbrush /> >
</Button> <Paintbrush />
<Button </Button>
aria-label="Edit" </TooltipTrigger>
aria-pressed={isEditingMaterial} <TooltipContent>Paint with</TooltipContent>
onClick={() => setIsEditingMaterial((value) => !value)} </Tooltip>
size="icon-sm" <Tooltip>
title="Edit" <TooltipTrigger asChild>
type="button" <Button
variant={isEditingMaterial ? 'default' : 'outline'} aria-label="Edit"
> aria-pressed={isEditingMaterial}
<Pencil /> onClick={() => setIsEditingMaterial((value) => !value)}
</Button> size="icon-sm"
<Button type="button"
aria-label="Duplicate" variant={isEditingMaterial ? 'default' : 'outline'}
onClick={duplicateMaterial} >
size="icon-sm" <Pencil />
title="Duplicate" </Button>
type="button" </TooltipTrigger>
variant="outline" <TooltipContent>Edit</TooltipContent>
> </Tooltip>
<Copy /> <Tooltip>
</Button> <TooltipTrigger asChild>
<Button <Button
aria-label="Delete" aria-label="Duplicate"
onClick={() => removeSceneMaterial(id)} onClick={duplicateMaterial}
size="icon-sm" size="icon-sm"
title="Delete" type="button"
type="button" variant="outline"
variant="outline" >
> <Copy />
<Trash2 /> </Button>
</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>
</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 { MobilePanelSheet } from './mobile-panel-sheet'
import { MobileSelectionBar } from './mobile-selection-bar' import { MobileSelectionBar } from './mobile-selection-bar'
import { getNodeDisplay } from './node-display' import { getNodeDisplay } from './node-display'
import { PaintPanel } from './paint-panel'
import { ParametricInspector } from './parametric-inspector' import { ParametricInspector } from './parametric-inspector'
import { ReferencePanel } from './reference-panel' import { ReferencePanel } from './reference-panel'
@@ -174,9 +173,6 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
const selectedZoneId = useViewer((s) => s.selection.zoneId) const selectedZoneId = useViewer((s) => s.selection.zoneId)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
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) => {
@@ -208,15 +204,6 @@ export function PanelManager({ inspectorFooter }: { inspectorFooter?: React.Reac
return <ReferencePanel /> return <ReferencePanel />
} }
if (
isPaintPanelOpen &&
mode === 'material-paint' &&
activePaintMaterial?.material?.properties &&
!activePaintMaterial.materialPreset
) {
return <PaintPanel />
}
if (selectedZoneId && selectedIds.length === 0) { if (selectedZoneId && selectedIds.length === 0) {
return ( return (
<ParametricInspector <ParametricInspector
-4
View File
@@ -309,8 +309,6 @@ type EditorState = {
primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot primeMaterialPaintFromSelection: () => MaterialPaintSelectionSnapshot
hoveredPaintTarget: PaintableMaterialTarget | null hoveredPaintTarget: PaintableMaterialTarget | null
setHoveredPaintTarget: (target: PaintableMaterialTarget | null) => void 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
guideUi: Record<string, GuideUiState> guideUi: Record<string, GuideUiState>
@@ -854,8 +852,6 @@ const useEditor = create<EditorState>()(
set((state) => set((state) =>
state.hoveredPaintTarget === target ? state : { hoveredPaintTarget: target }, 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 }),
guideUi: {}, guideUi: {},
+8 -1
View File
@@ -169,7 +169,14 @@ function buildPreviewMaterial(
materialPreset: string | undefined, materialPreset: string | undefined,
): Material | null { ): Material | null {
const shading = useViewer.getState().shading 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) if (material) return createMaterial(material, shading)
return null return null
} }
+8 -1
View File
@@ -134,7 +134,14 @@ export function buildSlotPreviewMaterial(
materialPreset: string | undefined, materialPreset: string | undefined,
): Material | null { ): Material | null {
const shading = useViewer.getState().shading 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) if (material) return createMaterial(material, shading)
return null return null
} }