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:
co-authored by
Claude Opus 4.8
parent
c6423e7c73
commit
6b67ab6949
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user