@@ -117,9 +58,7 @@ export function MaterialPaintPanel({ onCreateMaterialRequest }: MaterialPaintPan
{
- armMaterialPaint({ materialPreset, sourceTarget: activePaintTarget })
- }}
+ onSelectMaterialPreset={selectMaterial}
selectedMaterialPreset={activePaintMaterial?.materialPreset}
/>
diff --git a/packages/editor/src/components/ui/controls/material-picker.tsx b/packages/editor/src/components/ui/controls/material-picker.tsx
index 39f2019db2..a80c22f46a 100644
--- a/packages/editor/src/components/ui/controls/material-picker.tsx
+++ b/packages/editor/src/components/ui/controls/material-picker.tsx
@@ -1,23 +1,11 @@
'use client'
-import {
- getCatalogMaterialById,
- getDynamicLibraryMaterials,
- getLibraryMaterialIdFromRef,
- getLibraryMaterialsVersion,
- getMaterialsForCategory,
- MATERIAL_CATEGORIES,
- type MaterialCatalogItem,
- type MaterialSource,
- type MaterialTarget,
- subscribeLibraryMaterials,
- toLibraryMaterialRef,
-} from '@pascal-app/core'
+import { type MaterialTarget, toLibraryMaterialRef } from '@pascal-app/core'
import { Plus } from 'lucide-react'
-import { useEffect, useMemo, useState, useSyncExternalStore } from 'react'
+import { useMaterialCatalogModel, type MaterialSourceFilter } from '../../../lib/material-catalog-model'
import { triggerSFX } from '../../../lib/sfx-bus'
-export type MaterialSourceFilter = MaterialSource
+export type { MaterialSourceFilter } from '../../../lib/material-catalog-model'
export type MaterialPickerProps = {
selectedMaterialPreset?: string
@@ -28,23 +16,10 @@ export type MaterialPickerProps = {
onCreateMaterialRequest?: () => void
}
-// No 'All': the browse surfaces (Items / Rooms / Build) dropped it and default
-// to the Pascal library — the combined list buried the curated set.
-const SOURCE_FILTERS: { id: MaterialSourceFilter; label: string }[] = [
- { id: 'pascal', label: 'Pascal' },
- { id: 'mine', label: 'Mine' },
- { id: 'workspace', label: 'Workspace' },
- { id: 'community', label: 'Community' },
-]
-
-function getCategoryLabel(category: (typeof MATERIAL_CATEGORIES)[number]) {
+function getCategoryLabel(category: string) {
return category.charAt(0).toUpperCase() + category.slice(1)
}
-function filterBySource(items: MaterialCatalogItem[], filter: MaterialSourceFilter) {
- return items.filter((item) => (item.source ?? 'pascal') === filter)
-}
-
/**
* Catalog material picker: a fixed row of category tabs and a source filter row
* over a scrollable grid of swatches. Scene-material creation lives in the
@@ -57,40 +32,7 @@ export function MaterialPicker({
disabled = false,
onCreateMaterialRequest,
}: MaterialPickerProps) {
- const [selectedCategory, setSelectedCategory] = useState<(typeof MATERIAL_CATEGORIES)[number]>(
- MATERIAL_CATEGORIES[0],
- )
- const [sourceFilter, setSourceFilter] = useState
('pascal')
- // Version counter so host registrations/unregistrations re-render the picker.
- const libraryVersion = useSyncExternalStore(
- subscribeLibraryMaterials,
- getLibraryMaterialsVersion,
- getLibraryMaterialsVersion,
- )
- const hasWorkspaceMaterials = useMemo(
- () => getDynamicLibraryMaterials().some((item) => item.source === 'workspace'),
- [libraryVersion],
- )
- const visibleSourceFilters = SOURCE_FILTERS.filter(
- (filter) => filter.id !== 'workspace' || hasWorkspaceMaterials,
- )
- const availableCategories = MATERIAL_CATEGORIES.filter(
- (category) => getMaterialsForCategory(category).length > 0,
- )
- const catalogItems = filterBySource(getMaterialsForCategory(selectedCategory), sourceFilter)
-
- // Keep the visible category in sync with the externally-selected catalog
- // material (a `scene:` ref matches no catalog entry, so the tab stays put).
- useEffect(() => {
- const catalogId = getLibraryMaterialIdFromRef(selectedMaterialPreset) ?? undefined
- const entry = getCatalogMaterialById(catalogId)
- if (entry?.category) setSelectedCategory(entry.category)
- }, [selectedMaterialPreset])
-
- const handleCatalogSelect = (materialId: string) => {
- if (disabled) return
- onSelectMaterialPreset?.(toLibraryMaterialRef(materialId))
- }
+ const { selectedCategory, setSelectedCategory, sourceFilter, setSourceFilter, visibleSourceFilters, availableCategories, catalogItems, select: handleCatalogSelect } = useMaterialCatalogModel(selectedMaterialPreset, onSelectMaterialPreset, disabled)
return (
{
setSelectedCategory(category)
- // Auto-select the first material in the category so the brush is
- // immediately ready (and the swatch shows as selected).
- const first = filterBySource(getMaterialsForCategory(category), sourceFilter)[0]
- if (first) handleCatalogSelect(first.id)
}}
type="button"
>
diff --git a/packages/editor/src/components/ui/helpers/helper-manager.tsx b/packages/editor/src/components/ui/helpers/helper-manager.tsx
index 2d5cc3f100..730f4b453d 100644
--- a/packages/editor/src/components/ui/helpers/helper-manager.tsx
+++ b/packages/editor/src/components/ui/helpers/helper-manager.tsx
@@ -85,6 +85,7 @@ function terrainSculptHints(verb: TerrainVerb, sampling: boolean): ContextualSho
}
type ActiveModifierKeys = {
+ alt: boolean
command: boolean
shift: boolean
}
@@ -94,6 +95,7 @@ const NO_CONTEXTUAL_HELP_SUBSCRIPTION = () => () => {}
function useActiveModifierKeys(): ActiveModifierKeys {
const [modifiers, setModifiers] = useState
({
+ alt: false,
command: false,
shift: false,
})
@@ -102,6 +104,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
const updateModifiers = (event: KeyboardEvent) => {
const isKeyDown = event.type === 'keydown'
setModifiers({
+ alt: event.altKey || (isKeyDown && event.key === 'Alt'),
command:
event.metaKey ||
event.ctrlKey ||
@@ -110,7 +113,7 @@ function useActiveModifierKeys(): ActiveModifierKeys {
})
}
const clearModifiers = () => {
- setModifiers({ command: false, shift: false })
+ setModifiers({ alt: false, command: false, shift: false })
}
window.addEventListener('keydown', updateModifiers)
@@ -213,15 +216,16 @@ export function HelperManager() {
// tools — editor shortcut hints would only mislead there.
if (workspaceMode === 'studio') return null
- // Rotating a node (or a multi-selection group) via its in-world gizmo:
- // advertise Shift = free rotation, the same angle-step bypass wall drafting
- // exposes. Takes priority over the idle select-mode hints since a handle
- // drag is the active interaction.
if (
activeHandleDrag?.label === ROTATE_HANDLE_DRAG_LABEL ||
activeHandleDrag?.label === GROUP_ROTATE_DRAG_LABEL
) {
- return
+ return (
+
+ )
}
// Group-move drag / pick-up: the drag resolves to the 'item' snap context
diff --git a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx
index 85483fa208..b77460177a 100644
--- a/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx
+++ b/packages/editor/src/components/ui/helpers/registered-tool-helper.tsx
@@ -1,8 +1,7 @@
import type { ToolHint } from '@pascal-app/core'
-import { useMemo, useSyncExternalStore } from 'react'
import type { ContinuationContext } from '../../../lib/continuation'
import type { SnapContext } from '../../../lib/snapping-mode'
-import useEditor from '../../../store/use-editor'
+import { useVisibleToolHints } from '../../../lib/panel-tool-options'
import { ContextualHelperPanel } from './contextual-helper-panel'
/**
@@ -25,36 +24,7 @@ export function RegisteredToolHelper({
snapContext?: SnapContext | null
continuationContext?: ContinuationContext | null
}) {
- // Live vertex count of an in-progress polygon draft, so hints gated on a
- // minimum (e.g. "Finish" at ≥ 3) only appear once they're actually possible.
- const draftVertexCount = useEditor((s) => s.draftVertexCount)
- const visibilityStore = useMemo(
- () => ({
- subscribe: (onChange: () => void) => {
- const unsubscribers = hints.flatMap((hint) =>
- hint.visible ? [hint.visible.subscribe(onChange)] : [],
- )
- return () => {
- for (const unsubscribe of unsubscribers) unsubscribe()
- }
- },
- getSnapshot: () => hints.map((hint) => (hint.visible?.value() === false ? '0' : '1')).join(''),
- }),
- [hints],
- )
- useSyncExternalStore(
- visibilityStore.subscribe,
- visibilityStore.getSnapshot,
- visibilityStore.getSnapshot,
- )
- // Some hints are replaced by live contextual chips, so keep the generic
- // registry renderer from duplicating stale/static versions.
- const visible = hints.filter(
- (hint) =>
- !(hint.key === 'Shift' && hint.label === 'Cycle snapping mode') &&
- hint.visible?.value() !== false &&
- (hint.minDraftVertices == null || draftVertexCount >= hint.minDraftVertices),
- )
+ const visible = useVisibleToolHints(hints)
if (visible.length === 0 && !snapContext && !continuationContext) return null
// Hints carrying a live-state `chip` render as mode chips next to the
// snapping / continuation rows; the rest stay static key rows.
diff --git a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx
index b111e62404..010545666b 100644
--- a/packages/editor/src/components/ui/item-catalog/item-catalog.tsx
+++ b/packages/editor/src/components/ui/item-catalog/item-catalog.tsx
@@ -1,13 +1,13 @@
'use client'
import type { AssetInput } from '@pascal-app/core'
-import { resolveCdnUrl, useViewer } from '@pascal-app/viewer'
+import { resolveCdnUrl } from '@pascal-app/viewer'
import { useEffect } from 'react'
import { triggerSFX } from './../../../lib/sfx-bus'
import { cn } from './../../../lib/utils'
import useEditor, { type CatalogCategory } from './../../../store/use-editor'
import { resolveAssetSnapTarget, SnapTargetBadge } from '../snap-target-badge'
-import { CATALOG_ITEMS, type CatalogItem } from './catalog-items'
+import { activateCatalogItem, filterCatalogItems, isCatalogItemSelected } from '../../../lib/catalog-panel-model'
export function ItemCatalog({
category,
@@ -32,26 +32,7 @@ export function ItemCatalog({
emptyState?: React.ReactNode
}) {
const selectedItem = useEditor((state) => state.selectedItem)
- const setSelectedItem = useEditor((state) => state.setSelectedItem)
- const setMode = useEditor((state) => state.setMode)
- const setTool = useEditor((state) => state.setTool)
-
- const sourceItems: CatalogItem[] = itemsOverride ?? CATALOG_ITEMS
- // Server-provided results bypass all local filtering; otherwise filter by category/search/tags
- const filteredItems: CatalogItem[] =
- overrideItems ??
- (() => {
- const categoryItems = search
- ? sourceItems
- : sourceItems.filter((item) => item.category === category)
- return categoryItems.filter((item) => {
- const tags = item.tags ?? []
- if (activePlacementTag && !tags.includes(activePlacementTag)) return false
- if (activeFunctionalTag && !tags.includes(activeFunctionalTag)) return false
- if (search && !item.name.toLowerCase().includes(search.toLowerCase())) return false
- return true
- })
- })()
+ const filteredItems = filterCatalogItems({ category, items: itemsOverride, overrideItems, search, activePlacementTag, activeFunctionalTag })
if (filteredItems.length === 0 && emptyState) {
return <>{emptyState}>
@@ -64,7 +45,7 @@ export function ItemCatalog({
>
{leadingTile}
{filteredItems.map((item, index) => {
- const isSelected = selectedItem?.src === item?.src
+ const isSelected = isCatalogItemSelected(item, selectedItem)
const snapTarget = resolveAssetSnapTarget(item?.attachTo)
return (