Merge pull request #136 from pascalorg/feat/items-interactivity
Feat/items interactivity
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type CollectionId,
|
||||
type Control,
|
||||
type ControlValue,
|
||||
type ItemNode,
|
||||
useInteractive,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ─── Shared control derivation ───────────────────────────────────────────────
|
||||
|
||||
type ItemControlRef = { itemId: AnyNodeId; controlIndex: number }
|
||||
|
||||
type SharedControlDef = {
|
||||
kind: 'toggle' | 'slider' | 'temperature'
|
||||
label?: string
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
unit?: string
|
||||
refs: ItemControlRef[]
|
||||
}
|
||||
|
||||
function deriveSharedControls(items: ItemNode[]): SharedControlDef[] {
|
||||
if (items.length === 0) return []
|
||||
const result: SharedControlDef[] = []
|
||||
|
||||
for (const kind of ['toggle', 'slider', 'temperature'] as const) {
|
||||
const refs: ItemControlRef[] = []
|
||||
let ref: Control | null = null
|
||||
let allHave = true
|
||||
|
||||
for (const item of items) {
|
||||
const idx = item.asset.interactive!.controls.findIndex((c) => c.kind === kind)
|
||||
if (idx === -1) { allHave = false; break }
|
||||
refs.push({ itemId: item.id, controlIndex: idx })
|
||||
if (!ref) ref = item.asset.interactive!.controls[idx]!
|
||||
}
|
||||
|
||||
if (!allHave || !ref) continue
|
||||
|
||||
const def: SharedControlDef = { kind, label: ref.label, refs }
|
||||
if ('min' in ref) { def.min = ref.min; def.max = ref.max }
|
||||
if ('step' in ref) def.step = (ref as { step?: number }).step
|
||||
if ('unit' in ref) def.unit = (ref as { unit?: string }).unit
|
||||
result.push(def)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ─── Shared control widget ───────────────────────────────────────────────────
|
||||
|
||||
function SharedWidget({ def, value, onChange }: { def: SharedControlDef; value: ControlValue; onChange: (v: ControlValue) => void }) {
|
||||
if (def.kind === 'toggle') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!value)}
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center justify-center rounded-md px-3 text-xs font-medium transition-colors',
|
||||
value ? 'bg-green-500/20 text-green-400' : 'bg-white/10 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{def.label ?? (value ? 'On' : 'Off')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{def.label ?? def.kind}</span>
|
||||
<span>{value}{def.kind === 'temperature' ? '°' : ''}{def.unit ? ` ${def.unit}` : ''}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={def.min}
|
||||
max={def.max}
|
||||
step={def.step ?? 1}
|
||||
value={value as number}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="w-full accent-white"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Individual item control widget ──────────────────────────────────────────
|
||||
|
||||
function ItemWidget({ control, value, onChange }: { control: Control; value: ControlValue; onChange: (v: ControlValue) => void }) {
|
||||
if (control.kind === 'toggle') {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(!value)}
|
||||
className={cn(
|
||||
'flex h-6 w-full items-center justify-center rounded px-2 text-[10px] font-medium transition-colors',
|
||||
value ? 'bg-green-500/20 text-green-400' : 'bg-white/5 text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{control.label ?? (value ? 'On' : 'Off')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center justify-between text-[10px] text-muted-foreground">
|
||||
<span>{control.label ?? control.kind}</span>
|
||||
<span>{value}{control.kind === 'temperature' ? '°' : ''}{'unit' in control && control.unit ? ` ${control.unit}` : ''}</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={'min' in control ? control.min : 0}
|
||||
max={'max' in control ? control.max : 100}
|
||||
step={'step' in control ? (control as { step?: number }).step ?? 1 : 1}
|
||||
value={value as number}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="w-full accent-white"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Collection row ───────────────────────────────────────────────────────────
|
||||
|
||||
function CollectionRow({ collectionId }: { collectionId: CollectionId }) {
|
||||
const collection = useScene((s) => s.collections[collectionId])
|
||||
|
||||
const interactiveItems = useScene(
|
||||
useShallow((s) =>
|
||||
(collection?.nodeIds ?? [])
|
||||
.map((id) => s.nodes[id])
|
||||
.filter((n): n is ItemNode => n?.type === 'item' && !!n.asset.interactive)
|
||||
),
|
||||
)
|
||||
|
||||
const allItems = useInteractive((s) => s.items)
|
||||
const controlValuesByItem = useMemo(
|
||||
() => Object.fromEntries(interactiveItems.map((n) => [n.id, allItems[n.id]?.controlValues ?? []])),
|
||||
[allItems, interactiveItems],
|
||||
)
|
||||
|
||||
const setControlValue = useInteractive((s) => s.setControlValue)
|
||||
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const [expandedItemIds, setExpandedItemIds] = useState<Set<AnyNodeId>>(new Set())
|
||||
|
||||
if (!collection) return null
|
||||
|
||||
const sharedControls = deriveSharedControls(interactiveItems)
|
||||
|
||||
const getSharedValue = (def: SharedControlDef): ControlValue => {
|
||||
if (def.kind === 'toggle') {
|
||||
return def.refs.every(({ itemId, controlIndex }) => Boolean(controlValuesByItem[itemId]?.[controlIndex]))
|
||||
}
|
||||
const first = def.refs[0]!
|
||||
return controlValuesByItem[first.itemId]?.[first.controlIndex] ?? 0
|
||||
}
|
||||
|
||||
const setSharedValue = (def: SharedControlDef, value: ControlValue) => {
|
||||
for (const { itemId, controlIndex } of def.refs) {
|
||||
setControlValue(itemId, controlIndex, value)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleItemExpand = (id: AnyNodeId) => {
|
||||
setExpandedItemIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex w-full items-center gap-2 px-3 py-2.5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
<span
|
||||
className="h-2.5 w-2.5 shrink-0 rounded-sm border border-white/20"
|
||||
style={{ backgroundColor: collection.color ?? '#6366f1' }}
|
||||
/>
|
||||
<span className="flex-1 min-w-0 text-xs font-medium text-foreground truncate text-left">
|
||||
{collection.name}
|
||||
</span>
|
||||
{interactiveItems.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0">
|
||||
{interactiveItems.length}
|
||||
</span>
|
||||
)}
|
||||
{expanded
|
||||
? <ChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />}
|
||||
</button>
|
||||
|
||||
{/* Expanded */}
|
||||
{expanded && (
|
||||
<div className="pb-1">
|
||||
{interactiveItems.length === 0 ? (
|
||||
<p className="px-3 pb-2 text-[11px] text-muted-foreground">No interactive items.</p>
|
||||
) : (
|
||||
<>
|
||||
{/* Shared controls */}
|
||||
{sharedControls.length > 0 && (
|
||||
<div className="px-3 pt-0.5 pb-2.5 border-b border-border/30">
|
||||
<p className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground mb-2">All</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{sharedControls.map((def, i) => (
|
||||
<SharedWidget
|
||||
key={i}
|
||||
def={def}
|
||||
value={getSharedValue(def)}
|
||||
onChange={(v) => setSharedValue(def, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Individual items */}
|
||||
{interactiveItems.map((item) => {
|
||||
const isItemExpanded = expandedItemIds.has(item.id)
|
||||
const controls = item.asset.interactive!.controls
|
||||
const values = controlValuesByItem[item.id] ?? []
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleItemExpand(item.id)}
|
||||
className="flex w-full items-center gap-1.5 px-3 py-1.5 hover:bg-white/5 transition-colors"
|
||||
>
|
||||
{isItemExpanded
|
||||
? <ChevronDown className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />
|
||||
: <ChevronRight className="h-2.5 w-2.5 shrink-0 text-muted-foreground/50" />}
|
||||
<span className="flex-1 min-w-0 text-[11px] text-muted-foreground truncate text-left">
|
||||
{item.name || item.asset.name}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{isItemExpanded && (
|
||||
<div className="px-3 pb-2 flex flex-col gap-1.5">
|
||||
{controls.map((control, i) => (
|
||||
<ItemWidget
|
||||
key={i}
|
||||
control={control}
|
||||
value={values[i] ?? false}
|
||||
onChange={(v) => setControlValue(item.id, i, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main panel ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function CollectionsPanel() {
|
||||
const collectionIds = useScene(
|
||||
useShallow((s) => Object.keys(s.collections) as CollectionId[]),
|
||||
)
|
||||
|
||||
if (collectionIds.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="pointer-events-auto flex flex-col rounded-2xl border border-border/40 bg-background/95 shadow-lg backdrop-blur-xl overflow-hidden w-56">
|
||||
<div className="px-3 py-2 border-b border-border/40 shrink-0">
|
||||
<span className="text-[10px] font-medium text-muted-foreground uppercase tracking-wider">Collections</span>
|
||||
</div>
|
||||
<div className="overflow-y-auto max-h-[70vh] no-scrollbar divide-y divide-border/30">
|
||||
{collectionIds.map((id) => (
|
||||
<CollectionRow key={id} collectionId={id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { initSpatialGridSync, useScene } from '@pascal-app/core'
|
||||
import { useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import { InteractiveSystem, useViewer, Viewer } from '@pascal-app/viewer'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
@@ -180,6 +180,7 @@ export default function ViewerPage() {
|
||||
<Viewer>
|
||||
<ViewerCameraControls />
|
||||
<ViewerZoneSystem />
|
||||
<InteractiveSystem />
|
||||
</Viewer>
|
||||
</ErrorBoundary>
|
||||
</>
|
||||
|
||||
@@ -26,6 +26,7 @@ import type { ProjectOwner } from '@/features/community/lib/projects/types'
|
||||
import { ActionButton } from '@/components/ui/action-menu/action-button'
|
||||
import { TooltipProvider } from '@/components/ui/primitives/tooltip'
|
||||
import { emitter } from '@pascal-app/core'
|
||||
import { CollectionsPanel } from './collections-panel'
|
||||
|
||||
const levelModeLabels: Record<'stacked' | 'exploded' | 'solo', string> = {
|
||||
stacked: 'Stacked',
|
||||
@@ -246,6 +247,11 @@ export const ViewerOverlay = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collections Panel - Top Right */}
|
||||
<div className="absolute top-4 right-4 z-20 flex flex-col gap-3 dark text-foreground">
|
||||
<CollectionsPanel />
|
||||
</div>
|
||||
|
||||
{/* Controls Panel - Bottom Center */}
|
||||
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-20 dark text-foreground">
|
||||
<TooltipProvider delayDuration={0}>
|
||||
|
||||
@@ -11,6 +11,7 @@ import useEditor from '@/store/use-editor'
|
||||
import { FeedbackDialog } from '../feedback-dialog'
|
||||
import { PascalRadio } from '../pascal-radio'
|
||||
import { CeilingSystem } from '../systems/ceiling/ceiling-system'
|
||||
import { ZoneLabelEditorSystem } from '../systems/zone/zone-label-editor-system'
|
||||
import { ZoneSystem } from '../systems/zone/zone-system'
|
||||
import { ToolManager } from '../tools/tool-manager'
|
||||
import { ActionMenu } from '../ui/action-menu'
|
||||
@@ -153,6 +154,7 @@ export default function Editor({ projectId }: EditorProps) {
|
||||
<PresetThumbnailGenerator />
|
||||
<SiteEdgeLabels />
|
||||
</Viewer>
|
||||
<ZoneLabelEditorSystem />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import { useScene, type ZoneNode } from '@pascal-app/core'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { Check, Pencil } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useEditor from '@/store/use-editor'
|
||||
|
||||
// ─── Per-zone label editor ────────────────────────────────────────────────────
|
||||
|
||||
function ZoneLabelEditor({ zoneId }: { zoneId: ZoneNode['id'] }) {
|
||||
const zone = useScene((s) => s.nodes[zoneId] as ZoneNode | undefined)
|
||||
const updateNode = useScene((s) => s.updateNode)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [value, setValue] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const [labelEl, setLabelEl] = useState<HTMLElement | null>(null)
|
||||
|
||||
// Keep a ref so the click handler never has a stale zone name
|
||||
const zoneNameRef = useRef(zone?.name ?? '')
|
||||
useEffect(() => { zoneNameRef.current = zone?.name ?? '' }, [zone?.name])
|
||||
|
||||
// Setup: find the label element, enable pointer events, and hide the
|
||||
// zone-renderer's own text node (children[0]) — we replace it via portal.
|
||||
useEffect(() => {
|
||||
const el = document.getElementById(`${zoneId}-label`)
|
||||
if (!el) return
|
||||
setLabelEl(el)
|
||||
el.style.pointerEvents = 'auto'
|
||||
|
||||
const textEl = el.children[0] as HTMLElement | undefined
|
||||
if (textEl) textEl.style.display = 'none'
|
||||
|
||||
return () => {
|
||||
el.style.pointerEvents = ''
|
||||
if (textEl) textEl.style.display = ''
|
||||
}
|
||||
}, [zoneId])
|
||||
|
||||
// Focus + select-all when entering edit mode
|
||||
useEffect(() => {
|
||||
if (editing) {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}, [editing])
|
||||
|
||||
const save = useCallback(() => {
|
||||
const trimmed = value.trim()
|
||||
if (trimmed !== (zone?.name ?? '')) {
|
||||
updateNode(zoneId, { name: trimmed || undefined })
|
||||
}
|
||||
setEditing(false)
|
||||
}, [value, zone?.name, updateNode, zoneId])
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setValue(zone?.name ?? '')
|
||||
setEditing(false)
|
||||
}, [zone?.name])
|
||||
|
||||
if (!labelEl) return null
|
||||
|
||||
const shadowColor = zone?.color ?? '#6366f1'
|
||||
const textShadow = [
|
||||
`-1px -1px 0 ${shadowColor}`,
|
||||
` 1px -1px 0 ${shadowColor}`,
|
||||
`-1px 1px 0 ${shadowColor}`,
|
||||
` 1px 1px 0 ${shadowColor}`,
|
||||
].join(',')
|
||||
|
||||
// order: -1 puts this flex item before children[0] (hidden) and children[1] (pin)
|
||||
const sharedStyle: React.CSSProperties = {
|
||||
order: -1,
|
||||
color: 'white',
|
||||
textShadow,
|
||||
fontSize: 14,
|
||||
fontFamily: 'sans-serif',
|
||||
userSelect: 'none',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
editing ? (
|
||||
<div
|
||||
style={sharedStyle}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
e.stopPropagation()
|
||||
if (e.key === 'Enter') { e.preventDefault(); save() }
|
||||
if (e.key === 'Escape') { e.preventDefault(); cancel() }
|
||||
}}
|
||||
onBlur={save}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
width: `${Math.max((value || zone?.name || '').length + 1, 4)}ch`,
|
||||
border: 'none',
|
||||
borderBottom: `1px solid ${shadowColor}`,
|
||||
background: 'transparent',
|
||||
color: 'white',
|
||||
textShadow,
|
||||
outline: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
fontFamily: 'inherit',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); save() }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Check size={12} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...sharedStyle, background: 'none', border: 'none', cursor: 'text', padding: 0 }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setSelection({ zoneId })
|
||||
setValue(zoneNameRef.current)
|
||||
setEditing(true)
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<span>{zone?.name}</span>
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', opacity: 0.55 }}>
|
||||
<Pencil size={10} />
|
||||
</span>
|
||||
</button>
|
||||
),
|
||||
labelEl,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── System: rendered in the main React tree (outside Canvas) ─────────────────
|
||||
|
||||
export function ZoneLabelEditorSystem() {
|
||||
const zoneIds = useScene(
|
||||
useShallow((s) =>
|
||||
Object.values(s.nodes)
|
||||
.filter((n) => n.type === 'zone')
|
||||
.map((n) => n.id as ZoneNode['id']),
|
||||
),
|
||||
)
|
||||
const structureLayer = useEditor((s) => s.structureLayer)
|
||||
const mode = useEditor((s) => s.mode)
|
||||
|
||||
if (structureLayer !== 'zones' || mode !== 'select') return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{zoneIds.map((id) => (
|
||||
<ZoneLabelEditor key={id} zoneId={id} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,18 +5,7 @@ import { BufferGeometry, DoubleSide, type Line, type Group, Shape, Vector3 } fro
|
||||
import { EDITOR_LAYER } from "@/lib/constants";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { CursorSphere } from "../shared/cursor-sphere";
|
||||
|
||||
// Zone colors for cycling through
|
||||
const ZONE_COLORS = [
|
||||
"#3b82f6", // blue
|
||||
"#ef4444", // red
|
||||
"#22c55e", // green
|
||||
"#f59e0b", // amber
|
||||
"#8b5cf6", // violet
|
||||
"#06b6d4", // cyan
|
||||
"#ec4899", // pink
|
||||
"#84cc16", // lime
|
||||
];
|
||||
import { PALETTE_COLORS } from "@/components/ui/primitives/color-dot";
|
||||
|
||||
const Y_OFFSET = 0.02;
|
||||
|
||||
@@ -73,7 +62,7 @@ const commitZoneDrawing = (
|
||||
const name = `Zone ${zoneCount + 1}`;
|
||||
|
||||
// Cycle through colors
|
||||
const color = ZONE_COLORS[zoneCount % ZONE_COLORS.length];
|
||||
const color = PALETTE_COLORS[zoneCount % PALETTE_COLORS.length];
|
||||
|
||||
const zone = ZoneNode.parse({
|
||||
name,
|
||||
|
||||
@@ -1,5 +1,62 @@
|
||||
import { AssetInput, ItemNode } from "@pascal-app/core";
|
||||
export const CATALOG_ITEMS: AssetInput[] = [
|
||||
{
|
||||
"id": "tesla",
|
||||
"category": "outdoor",
|
||||
tags: ["floor", "garage"],
|
||||
"name": "Tesla",
|
||||
"thumbnail": "/items/tesla/thumbnail.webp",
|
||||
"src": "/items/tesla/model.glb",
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"offset": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"dimensions": [
|
||||
2,
|
||||
1.7,
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ev-wall-charger",
|
||||
"category": "appliance",
|
||||
tags: ["wall", "garage"],
|
||||
"name": "Ev-wall-charger",
|
||||
"thumbnail": "/items/ev-wall-charger/thumbnail.webp",
|
||||
"src": "/items/ev-wall-charger/model.glb",
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"offset": [
|
||||
-0.07,
|
||||
0.4,
|
||||
0.15
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"dimensions": [
|
||||
0.5,
|
||||
0.8,
|
||||
0.5
|
||||
],
|
||||
"attachTo": "wall"
|
||||
},
|
||||
{
|
||||
id: "pillar",
|
||||
category: "outdoor",
|
||||
@@ -612,6 +669,21 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
||||
rotation: [0, 0, 0],
|
||||
dimensions: [1, 0.5, 1.5],
|
||||
attachTo: "ceiling",
|
||||
interactive: {
|
||||
effects: [
|
||||
{
|
||||
kind: 'animation',
|
||||
clips: {
|
||||
on: 'On',
|
||||
},
|
||||
}
|
||||
],
|
||||
controls: [
|
||||
{
|
||||
kind: 'toggle',
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -1143,19 +1215,19 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
||||
dimensions: [1.5, 2.5, 3.5],
|
||||
},
|
||||
|
||||
{
|
||||
id: "suspended-fireplace",
|
||||
category: "furniture",
|
||||
tags: ["ceiling", "decor"],
|
||||
name: "Suspended Fireplace",
|
||||
thumbnail: "/items/suspended-fireplace/thumbnail.webp",
|
||||
src: "/items/suspended-fireplace/model.glb",
|
||||
scale: [1, 1, 1],
|
||||
offset: [0, 0.45, 0],
|
||||
rotation: [0, 0, 0],
|
||||
dimensions: [0.5, 0.5, 0.5],
|
||||
attachTo: "ceiling",
|
||||
},
|
||||
// {
|
||||
// id: "suspended-fireplace",
|
||||
// category: "furniture",
|
||||
// tags: ["ceiling", "decor"],
|
||||
// name: "Suspended Fireplace",
|
||||
// thumbnail: "/items/suspended-fireplace/thumbnail.webp",
|
||||
// src: "/items/suspended-fireplace/model.glb",
|
||||
// scale: [1, 1, 1],
|
||||
// offset: [0, 0.45, 0],
|
||||
// rotation: [0, 0, 0],
|
||||
// dimensions: [0.5, 0.5, 0.5],
|
||||
// attachTo: "ceiling",
|
||||
// },
|
||||
|
||||
{
|
||||
id: "tv-stand",
|
||||
@@ -1214,6 +1286,73 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
||||
rotation: [0, 0, 0],
|
||||
dimensions: [1, 1, 1],
|
||||
attachTo: "ceiling",
|
||||
interactive: {
|
||||
controls: [
|
||||
{
|
||||
kind: 'toggle',
|
||||
},
|
||||
{
|
||||
kind: 'slider', label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial',
|
||||
default: 100
|
||||
}
|
||||
],
|
||||
effects: [
|
||||
{
|
||||
kind: 'light',
|
||||
intensityRange: [0, 2],
|
||||
color: '#ffffff',
|
||||
offset: [0, -0.5, 0],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "recessed-light",
|
||||
category: "furniture",
|
||||
"tags": ["ceiling", "lighting"],
|
||||
"name": "Recessed Light",
|
||||
"thumbnail": "/items/recessed-light/thumbnail.webp",
|
||||
"src": "/items/recessed-light/model.glb",
|
||||
"scale": [
|
||||
1,
|
||||
1,
|
||||
1
|
||||
],
|
||||
"offset": [
|
||||
0,
|
||||
0.094,
|
||||
0
|
||||
],
|
||||
"rotation": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"dimensions": [
|
||||
0.5,
|
||||
0.1,
|
||||
0.5
|
||||
],
|
||||
"attachTo": "ceiling",
|
||||
interactive: {
|
||||
controls: [
|
||||
{
|
||||
kind: 'toggle',
|
||||
},
|
||||
{
|
||||
kind: 'slider', label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial',
|
||||
default: 100
|
||||
}
|
||||
],
|
||||
effects: [
|
||||
{
|
||||
kind: 'light',
|
||||
intensityRange: [0, 2],
|
||||
color: '#ffffff',
|
||||
offset: [0, -0.1, 0],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
@@ -1227,6 +1366,25 @@ export const CATALOG_ITEMS: AssetInput[] = [
|
||||
offset: [0.04, 0, 0.02],
|
||||
rotation: [0, 0, 0],
|
||||
dimensions: [1, 1.9, 1],
|
||||
interactive: {
|
||||
controls: [
|
||||
{
|
||||
kind: 'toggle',
|
||||
},
|
||||
{
|
||||
kind: 'slider', label: 'Intensity', min: 0, max: 100, unit: '%', displayMode: 'dial' ,
|
||||
default: 100
|
||||
}
|
||||
],
|
||||
effects: [
|
||||
{
|
||||
kind: 'light',
|
||||
intensityRange: [0, 2],
|
||||
color: '#ffffff',
|
||||
offset: [0, 1.4, 0],
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
'use client'
|
||||
|
||||
import type { AnyNodeId, Collection, CollectionId } from '@pascal-app/core'
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { Check, ChevronDown, ChevronRight, Layers, MoreHorizontal, Pencil, Plus, Trash2, X } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/primitives/dropdown-menu'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/primitives/popover'
|
||||
import { ColorDot } from '@/components/ui/primitives/color-dot'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CollectionsPopoverProps {
|
||||
nodeId: AnyNodeId
|
||||
collectionIds?: CollectionId[]
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export function CollectionsPopover({ nodeId, collectionIds, children }: CollectionsPopoverProps) {
|
||||
const collections = useScene((s) => s.collections)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const createCollection = useScene((s) => s.createCollection)
|
||||
const deleteCollection = useScene((s) => s.deleteCollection)
|
||||
const updateCollection = useScene((s) => s.updateCollection)
|
||||
const addToCollection = useScene((s) => s.addToCollection)
|
||||
const removeFromCollection = useScene((s) => s.removeFromCollection)
|
||||
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showCreateInput, setShowCreateInput] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
|
||||
const [renamingId, setRenamingId] = useState<CollectionId | null>(null)
|
||||
const [renameValue, setRenameValue] = useState('')
|
||||
const [renameColor, setRenameColor] = useState('')
|
||||
|
||||
const [deletingId, setDeletingId] = useState<CollectionId | null>(null)
|
||||
const [expandedIds, setExpandedIds] = useState<Set<CollectionId>>(new Set())
|
||||
|
||||
const memberIds = collectionIds ?? []
|
||||
const allCollections = Object.values(collections)
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!createName.trim()) return
|
||||
createCollection(createName.trim(), [nodeId])
|
||||
setCreateName('')
|
||||
setShowCreateInput(false)
|
||||
}
|
||||
|
||||
const handleRenameConfirm = (id: CollectionId) => {
|
||||
if (!renameValue.trim()) return
|
||||
updateCollection(id, { name: renameValue.trim(), color: renameColor || undefined })
|
||||
setRenamingId(null)
|
||||
}
|
||||
|
||||
const toggleMembership = (collectionId: CollectionId) => {
|
||||
if (memberIds.includes(collectionId)) {
|
||||
removeFromCollection(collectionId, nodeId)
|
||||
} else {
|
||||
addToCollection(collectionId, nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleExpand = (collectionId: CollectionId) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(collectionId)) next.delete(collectionId)
|
||||
else next.add(collectionId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>{children}</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="left"
|
||||
align="start"
|
||||
sideOffset={8}
|
||||
className="w-72 p-0 border-border/50 bg-sidebar/95 backdrop-blur-xl shadow-2xl rounded-xl overflow-hidden"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2.5 border-b border-border/50">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Layers className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-semibold text-foreground tracking-tight">Collections</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowCreateInput((v) => !v); setCreateName('') }}
|
||||
className="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create input */}
|
||||
{showCreateInput && (
|
||||
<div className="flex items-center gap-1.5 px-3 py-2 border-b border-border/50 bg-white/5">
|
||||
<input
|
||||
autoFocus
|
||||
value={createName}
|
||||
onChange={(e) => setCreateName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleCreate()
|
||||
if (e.key === 'Escape') { setShowCreateInput(false); setCreateName('') }
|
||||
}}
|
||||
placeholder="Collection name…"
|
||||
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!createName.trim()}
|
||||
onClick={handleCreate}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowCreateInput(false); setCreateName('') }}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collections list */}
|
||||
<div className="max-h-72 overflow-y-auto no-scrollbar">
|
||||
{allCollections.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center px-4">
|
||||
<Layers className="h-6 w-6 text-muted-foreground/40" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No collections yet. Create one to group items together.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/30">
|
||||
{allCollections.map((collection) => {
|
||||
const isIn = memberIds.includes(collection.id)
|
||||
const isExpanded = expandedIds.has(collection.id)
|
||||
const isRenaming = renamingId === collection.id
|
||||
const isDeleting = deletingId === collection.id
|
||||
|
||||
if (isDeleting) {
|
||||
return (
|
||||
<li key={collection.id} className="flex items-center justify-between gap-2 px-3 py-2.5 bg-red-500/10">
|
||||
<span className="text-xs text-foreground/80 truncate">Delete "{collection.name}"?</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { deleteCollection(collection.id); setDeletingId(null) }}
|
||||
className="rounded-md px-2 py-0.5 text-[11px] font-medium bg-red-500/20 text-red-400 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeletingId(null)}
|
||||
className="rounded-md px-2 py-0.5 text-[11px] font-medium hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
if (isRenaming) {
|
||||
return (
|
||||
<li key={collection.id} className="flex items-center gap-1.5 px-3 py-2">
|
||||
<ColorDot color={renameColor || '#6366f1'} onChange={setRenameColor} />
|
||||
<input
|
||||
autoFocus
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleRenameConfirm(collection.id)
|
||||
if (e.key === 'Escape') setRenamingId(null)
|
||||
}}
|
||||
className="flex-1 min-w-0 rounded-md border border-border/50 bg-background/50 px-2 py-1 text-xs text-foreground outline-none focus:border-ring focus:ring-1 focus:ring-ring/30"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRenameConfirm(collection.id)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/20 hover:bg-primary/30 text-primary transition-colors"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenamingId(null)}
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md hover:bg-white/10 text-muted-foreground transition-colors"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<li key={collection.id}>
|
||||
<div className="group flex items-center gap-2 px-3 py-2 hover:bg-white/5 transition-colors">
|
||||
{/* Color dot — click to pick color */}
|
||||
<ColorDot
|
||||
color={collection.color ?? '#6366f1'}
|
||||
onChange={(c) => updateCollection(collection.id, { color: c })}
|
||||
/>
|
||||
|
||||
{/* Name + count — clicking toggles membership */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleMembership(collection.id)}
|
||||
className="flex-1 min-w-0 flex items-center gap-1.5 text-left"
|
||||
>
|
||||
<span className={cn('truncate text-xs font-medium', isIn ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{collection.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground/60">
|
||||
{collection.nodeIds.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Membership check */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors pointer-events-none',
|
||||
isIn ? 'border-primary bg-primary/20 text-primary' : 'border-border/50',
|
||||
)}
|
||||
>
|
||||
{isIn && <Check className="h-2.5 w-2.5" />}
|
||||
</div>
|
||||
|
||||
{/* Expand toggle (only if has members) */}
|
||||
{collection.nodeIds.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleExpand(collection.id)}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded
|
||||
? <ChevronDown className="h-3 w-3" />
|
||||
: <ChevronRight className="h-3 w-3" />}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* More dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-white/10 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<MoreHorizontal className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent side="left" align="start" className="min-w-40">
|
||||
<DropdownMenuItem onClick={() => { setRenamingId(collection.id); setRenameValue(collection.name); setRenameColor(collection.color ?? '') }}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem variant="destructive" onClick={() => setDeletingId(collection.id)}>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Expanded member list */}
|
||||
{isExpanded && (
|
||||
<ul className="pb-1 pl-6 pr-3 flex flex-col gap-0.5">
|
||||
{collection.nodeIds.map((nid) => {
|
||||
const n = nodes[nid]
|
||||
return (
|
||||
<li key={nid} className="flex items-center gap-1.5 py-0.5">
|
||||
<span className="h-1 w-1 rounded-full bg-muted-foreground/40 shrink-0" />
|
||||
<span className={cn('truncate text-[11px]', nid === nodeId ? 'text-foreground font-medium' : 'text-muted-foreground')}>
|
||||
{n?.name ?? nid}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import { cn } from '@/lib/utils'
|
||||
import { PanelWrapper } from './panel-wrapper'
|
||||
import { PanelSection } from '../controls/panel-section'
|
||||
import { SliderControl } from '../controls/slider-control'
|
||||
import { MetricControl } from '../controls/metric-control'
|
||||
|
||||
import { ActionButton, ActionGroup } from '../controls/action-button'
|
||||
import { CollectionsPopover } from './collections/collections-popover'
|
||||
|
||||
export function ItemPanel() {
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
@@ -231,6 +232,14 @@ export function ItemPanel() {
|
||||
</div>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Collections">
|
||||
<ActionGroup>
|
||||
<CollectionsPopover nodeId={selectedId as AnyNode['id']} collectionIds={node.collectionIds}>
|
||||
<ActionButton label="Manage collections…" />
|
||||
</CollectionsPopover>
|
||||
</ActionGroup>
|
||||
</PanelSection>
|
||||
|
||||
<PanelSection title="Actions">
|
||||
<ActionGroup>
|
||||
<ActionButton icon={<Move className="h-3.5 w-3.5" />} label="Move" onClick={handleMove} />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export const PALETTE_COLORS = [
|
||||
'#ef4444', // Red 0°
|
||||
'#f97316', // Orange 30°
|
||||
'#f59e0b', // Amber 45°
|
||||
'#84cc16', // Lime 85°
|
||||
'#22c55e', // Green 142°
|
||||
'#10b981', // Emerald 160°
|
||||
'#06b6d4', // Cyan 190°
|
||||
'#3b82f6', // Blue 217°
|
||||
'#6366f1', // Indigo 239°
|
||||
'#a855f7', // Violet 270°
|
||||
'#64748b', // Dark gray
|
||||
'#cccccc', // Light gray
|
||||
]
|
||||
|
||||
interface ColorDotProps {
|
||||
color: string
|
||||
onChange: (color: string) => void
|
||||
}
|
||||
|
||||
export function ColorDot({ color, onChange }: ColorDotProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="relative shrink-0 h-3 w-3 rounded-sm border border-border/50 cursor-pointer hover:ring-1 hover:ring-ring/50 transition-all"
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="left" align="center" sideOffset={6} className="w-auto p-1.5">
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{PALETTE_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
className={cn(
|
||||
'h-5 w-5 rounded-sm border transition-transform hover:scale-110',
|
||||
c === color ? 'border-foreground/50 ring-1 ring-ring/50' : 'border-border/30',
|
||||
)}
|
||||
style={{ backgroundColor: c }}
|
||||
onClick={() => { onChange(c); setOpen(false) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -41,18 +41,7 @@ import {
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/primitives/popover";
|
||||
import { motion, AnimatePresence, LayoutGroup } from "motion/react";
|
||||
|
||||
// Preset colors for zones
|
||||
const PRESET_COLORS = [
|
||||
"#3b82f6", // blue
|
||||
"#22c55e", // green
|
||||
"#eab308", // yellow
|
||||
"#f97316", // orange
|
||||
"#ef4444", // red
|
||||
"#a855f7", // purple
|
||||
"#ec4899", // pink
|
||||
"#06b6d4", // cyan
|
||||
];
|
||||
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
||||
|
||||
// ============================================================================
|
||||
// PROPERTY LINE SECTION
|
||||
@@ -953,37 +942,9 @@ function ZoneItem({ zone, isLast }: { zone: ZoneNode, isLast?: boolean }) {
|
||||
{/* Horizontal branch line */}
|
||||
<div className="absolute top-1/2 h-px bg-border/50 pointer-events-none" style={{ left: 8, width: 4 }} />
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
"mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-all hover:scale-110 cursor-pointer",
|
||||
!isSelected && "opacity-40"
|
||||
)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ backgroundColor: zone.color }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-auto p-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
className={cn(
|
||||
"size-6 rounded-sm border transition-transform hover:scale-110 cursor-pointer",
|
||||
color === zone.color ? "ring-2 ring-primary ring-offset-1" : ""
|
||||
)}
|
||||
key={color}
|
||||
onClick={() => handleColorChange(color)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className={cn("mr-2", !isSelected && "opacity-40")}>
|
||||
<ColorDot color={zone.color} onChange={handleColorChange} />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0 pr-1">
|
||||
<InlineRenameInput
|
||||
node={zone}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ZoneNode } from "@pascal-app/core";
|
||||
import { ZoneNode, useScene } from "@pascal-app/core";
|
||||
import { useViewer } from "@pascal-app/viewer";
|
||||
import { useState } from "react";
|
||||
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
||||
import { InlineRenameInput } from "./inline-rename-input";
|
||||
import { TreeNodeWrapper } from "./tree-node";
|
||||
import { TreeNodeActions } from "./tree-node-actions";
|
||||
@@ -13,6 +14,7 @@ interface ZoneTreeNodeProps {
|
||||
|
||||
export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const updateNode = useScene((state) => state.updateNode);
|
||||
const isSelected = useViewer((state) => state.selection.zoneId === node.id);
|
||||
const isHovered = useViewer((state) => state.hoveredId === node.id);
|
||||
const setSelection = useViewer((state) => state.setSelection);
|
||||
@@ -41,9 +43,9 @@ export function ZoneTreeNode({ node, depth, isLast }: ZoneTreeNodeProps) {
|
||||
return (
|
||||
<TreeNodeWrapper
|
||||
icon={
|
||||
<div
|
||||
className="w-3 h-3 rounded-sm border border-border/50"
|
||||
style={{ backgroundColor: node.color }}
|
||||
<ColorDot
|
||||
color={node.color}
|
||||
onChange={(color) => updateNode(node.id, { color })}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
|
||||
@@ -4,23 +4,8 @@ import { Camera, Hexagon, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/primitives/popover";
|
||||
|
||||
// Preset colors for zones
|
||||
const PRESET_COLORS = [
|
||||
"#3b82f6", // blue
|
||||
"#22c55e", // green
|
||||
"#eab308", // yellow
|
||||
"#f97316", // orange
|
||||
"#ef4444", // red
|
||||
"#a855f7", // purple
|
||||
"#ec4899", // pink
|
||||
"#06b6d4", // cyan
|
||||
];
|
||||
import { ColorDot } from "@/components/ui/primitives/color-dot";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/primitives/popover";
|
||||
|
||||
function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
const [cameraPopoverOpen, setCameraPopoverOpen] = useState(false);
|
||||
@@ -57,34 +42,9 @@ function ZoneItem({ zone }: { zone: ZoneNode }) {
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="mr-2 size-3 shrink-0 rounded-sm border border-border/50 transition-transform hover:scale-110 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{ backgroundColor: zone.color }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-auto p-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="grid grid-cols-4 gap-1">
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
className={cn(
|
||||
"size-6 rounded-sm border transition-transform hover:scale-110 cursor-pointer",
|
||||
color === zone.color ? "ring-2 ring-primary ring-offset-1" : ""
|
||||
)}
|
||||
key={color}
|
||||
onClick={() => handleColorChange(color)}
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span className="mr-2">
|
||||
<ColorDot color={zone.color} onChange={handleColorChange} />
|
||||
</span>
|
||||
<Hexagon className="w-3.5 h-3.5 mr-1.5 shrink-0" />
|
||||
<span className="truncate flex-1">{zone.name}</span>
|
||||
{/* Camera snapshot button */}
|
||||
|
||||
@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
},
|
||||
images: {
|
||||
unoptimized: process.env.NEXT_PUBLIC_ASSETS_CDN_URL?.startsWith('http://localhost') ?? false,
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'https',
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.7 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -86,8 +86,23 @@ const useEditor = create<EditorState>()((set, get) => ({
|
||||
|
||||
set({ phase })
|
||||
|
||||
const { mode, structureLayer } = get()
|
||||
|
||||
if (mode === 'build') {
|
||||
// Stay in build mode, select the first tool for the new phase
|
||||
if (phase === 'site') {
|
||||
set({ tool: 'property-line', catalogCategory: null })
|
||||
} else if (phase === 'structure' && structureLayer === 'zones') {
|
||||
set({ tool: 'zone', catalogCategory: null })
|
||||
} else if (phase === 'structure') {
|
||||
set({ tool: 'wall', catalogCategory: null })
|
||||
} else if (phase === 'furnish') {
|
||||
set({ tool: 'item', catalogCategory: 'furniture' })
|
||||
}
|
||||
} else {
|
||||
// Reset to select mode and clear tool/catalog when switching phases
|
||||
set({ mode: 'select', tool: null, catalogCategory: null })
|
||||
}
|
||||
|
||||
const viewer = useViewer.getState()
|
||||
const scene = useScene.getState()
|
||||
@@ -177,7 +192,14 @@ const useEditor = create<EditorState>()((set, get) => ({
|
||||
setTool: (tool) => set({ tool }),
|
||||
structureLayer: 'elements',
|
||||
setStructureLayer: (layer) => {
|
||||
const { mode } = get()
|
||||
|
||||
if (mode === 'build') {
|
||||
const tool = layer === 'zones' ? 'zone' : 'wall'
|
||||
set({ structureLayer: layer, tool })
|
||||
} else {
|
||||
set({ structureLayer: layer, mode: 'select', tool: null })
|
||||
}
|
||||
|
||||
const viewer = useViewer.getState()
|
||||
viewer.setSelection({
|
||||
|
||||
@@ -41,6 +41,7 @@ export {
|
||||
} from './lib/space-detection'
|
||||
// Schema
|
||||
export * from './schema'
|
||||
export { useInteractive, type ControlValue, type ItemInteractiveState } from './store/use-interactive'
|
||||
export { default as useScene } from './store/use-scene'
|
||||
// Systems
|
||||
export { CeilingSystem } from './systems/ceiling/ceiling-system'
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { generateId } from './base'
|
||||
import type { AnyNodeId } from './types'
|
||||
|
||||
export type CollectionId = `collection_${string}`
|
||||
|
||||
export type Collection = {
|
||||
id: CollectionId
|
||||
name: string
|
||||
color?: string
|
||||
nodeIds: AnyNodeId[]
|
||||
controlNodeId?: AnyNodeId
|
||||
}
|
||||
|
||||
export const generateCollectionId = (): CollectionId => generateId('collection')
|
||||
@@ -1,8 +1,10 @@
|
||||
// Base
|
||||
export { BaseNode, generateId, Material, nodeType, objectId } from './base'
|
||||
// Collections
|
||||
export { generateCollectionId, type Collection, type CollectionId } from './collections'
|
||||
// Camera
|
||||
export { CameraSchema } from './camera'
|
||||
export type { AssetInput } from './nodes/item'
|
||||
export type { AnimationEffect, Asset, AssetInput, Control, Effect, Interactive, LightEffect, SliderControl, TemperatureControl, ToggleControl } from './nodes/item'
|
||||
export { getScaledDimensions, ItemNode } from './nodes/item'
|
||||
export { LevelNode } from './nodes/level'
|
||||
// Nodes
|
||||
|
||||
@@ -1,6 +1,81 @@
|
||||
import dedent from 'dedent'
|
||||
import { z } from 'zod'
|
||||
import { BaseNode, nodeType, objectId } from '../base'
|
||||
import type { CollectionId } from '../collections'
|
||||
|
||||
// --- Control descriptors ---
|
||||
|
||||
const toggleControlSchema = z.object({
|
||||
kind: z.literal('toggle'),
|
||||
label: z.string().optional(),
|
||||
default: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const sliderControlSchema = z.object({
|
||||
kind: z.literal('slider'),
|
||||
label: z.string(),
|
||||
min: z.number(),
|
||||
max: z.number(),
|
||||
step: z.number().default(1),
|
||||
unit: z.string().optional(),
|
||||
displayMode: z.enum(['slider', 'stepper', 'dial']).default('slider'),
|
||||
default: z.number().optional(),
|
||||
})
|
||||
|
||||
const temperatureControlSchema = z.object({
|
||||
kind: z.literal('temperature'),
|
||||
label: z.string().default('Temperature'),
|
||||
min: z.number().default(16),
|
||||
max: z.number().default(30),
|
||||
unit: z.enum(['C', 'F']).default('C'),
|
||||
default: z.number().optional(),
|
||||
})
|
||||
|
||||
const controlSchema = z.discriminatedUnion('kind', [
|
||||
toggleControlSchema,
|
||||
sliderControlSchema,
|
||||
temperatureControlSchema,
|
||||
])
|
||||
|
||||
// --- Effect descriptors ---
|
||||
|
||||
const animationEffectSchema = z.object({
|
||||
kind: z.literal('animation'),
|
||||
clips: z.object({
|
||||
on: z.string().optional(),
|
||||
off: z.string().optional(),
|
||||
loop: z.string().optional(),
|
||||
}),
|
||||
})
|
||||
|
||||
const lightEffectSchema = z.object({
|
||||
kind: z.literal('light'),
|
||||
color: z.string().default('#ffffff'),
|
||||
intensityRange: z.tuple([z.number(), z.number()]),
|
||||
distance: z.number().optional(),
|
||||
offset: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
})
|
||||
|
||||
const effectSchema = z.discriminatedUnion('kind', [
|
||||
animationEffectSchema,
|
||||
lightEffectSchema,
|
||||
])
|
||||
|
||||
// --- Interactive descriptor ---
|
||||
|
||||
const interactiveSchema = z.object({
|
||||
controls: z.array(controlSchema).default([]),
|
||||
effects: z.array(effectSchema).default([]),
|
||||
})
|
||||
|
||||
export type ToggleControl = z.infer<typeof toggleControlSchema>
|
||||
export type SliderControl = z.infer<typeof sliderControlSchema>
|
||||
export type TemperatureControl = z.infer<typeof temperatureControlSchema>
|
||||
export type Control = z.infer<typeof controlSchema>
|
||||
export type AnimationEffect = z.infer<typeof animationEffectSchema>
|
||||
export type LightEffect = z.infer<typeof lightEffectSchema>
|
||||
export type Effect = z.infer<typeof effectSchema>
|
||||
export type Interactive = z.infer<typeof interactiveSchema>
|
||||
|
||||
const assetSchema = z.object({
|
||||
id: z.string(),
|
||||
@@ -20,6 +95,7 @@ const assetSchema = z.object({
|
||||
height: z.number(), // where things rest
|
||||
})
|
||||
.optional(), // undefined = can't place things on it
|
||||
interactive: interactiveSchema.optional(),
|
||||
})
|
||||
|
||||
export type AssetInput = z.input<typeof assetSchema>
|
||||
@@ -38,6 +114,9 @@ export const ItemNode = BaseNode.extend({
|
||||
wallId: z.string().optional(),
|
||||
wallT: z.number().optional(), // 0-1 parametric position along wall
|
||||
|
||||
// Denormalized references to collections this node belongs to
|
||||
collectionIds: z.array(z.custom<CollectionId>()).optional(),
|
||||
|
||||
asset: assetSchema,
|
||||
}).describe(dedent`Item node - used to represent a item in the building
|
||||
- position: position in level coordinate system (or parent coordinate system if attached)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { AnyNode, AnyNodeId } from '../../schema'
|
||||
import type { CollectionId } from '../../schema/collections'
|
||||
import type { SceneState } from '../use-scene'
|
||||
|
||||
type AnyContainerNode = AnyNode & { children: string[] }
|
||||
@@ -117,6 +118,7 @@ export const deleteNodesAction = (
|
||||
|
||||
set((state) => {
|
||||
const nextNodes = { ...state.nodes }
|
||||
const nextCollections = { ...state.collections }
|
||||
let nextRootIds = [...state.rootNodeIds]
|
||||
|
||||
for (const id of ids) {
|
||||
@@ -139,7 +141,17 @@ export const deleteNodesAction = (
|
||||
// 2. Remove from Root list
|
||||
nextRootIds = nextRootIds.filter((rid) => rid !== id)
|
||||
|
||||
// 3. Delete the node itself
|
||||
// 3. Remove from any collections it belongs to
|
||||
if ('collectionIds' in node && node.collectionIds) {
|
||||
for (const cid of node.collectionIds as CollectionId[]) {
|
||||
const col = nextCollections[cid]
|
||||
if (col) {
|
||||
nextCollections[cid] = { ...col, nodeIds: col.nodeIds.filter((nid) => nid !== id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Delete the node itself
|
||||
delete nextNodes[id]
|
||||
|
||||
// Inside the deleteNodes loop
|
||||
@@ -149,7 +161,7 @@ export const deleteNodesAction = (
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes: nextNodes, rootNodeIds: nextRootIds }
|
||||
return { nodes: nextNodes, rootNodeIds: nextRootIds, collections: nextCollections }
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
'use client'
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { Interactive } from '../schema/nodes/item'
|
||||
import type { AnyNodeId } from '../schema/types'
|
||||
|
||||
// Runtime value for each control (matches discriminated union kinds)
|
||||
export type ControlValue = boolean | number
|
||||
|
||||
export type ItemInteractiveState = {
|
||||
// Indexed by control position in asset.interactive.controls[]
|
||||
controlValues: ControlValue[]
|
||||
}
|
||||
|
||||
type InteractiveStore = {
|
||||
items: Record<AnyNodeId, ItemInteractiveState>
|
||||
|
||||
/** Initialize a node's interactive state from its asset definition (idempotent) */
|
||||
initItem: (itemId: AnyNodeId, interactive: Interactive) => void
|
||||
|
||||
/** Set a single control value */
|
||||
setControlValue: (itemId: AnyNodeId, index: number, value: ControlValue) => void
|
||||
|
||||
/** Remove a node's state (e.g. on unmount) */
|
||||
removeItem: (itemId: AnyNodeId) => void
|
||||
}
|
||||
|
||||
const defaultControlValue = (interactive: Interactive, index: number): ControlValue => {
|
||||
const control = interactive.controls[index]
|
||||
if (!control) return false
|
||||
switch (control.kind) {
|
||||
case 'toggle':
|
||||
return control.default ?? false
|
||||
case 'slider':
|
||||
return control.default ?? control.min
|
||||
case 'temperature':
|
||||
return control.default ?? control.min
|
||||
}
|
||||
}
|
||||
|
||||
export const useInteractive = create<InteractiveStore>((set, get) => ({
|
||||
items: {},
|
||||
|
||||
initItem: (itemId, interactive) => {
|
||||
const { controls } = interactive
|
||||
if (controls.length === 0) return
|
||||
|
||||
// Don't overwrite existing state (idempotent)
|
||||
if (get().items[itemId]) return
|
||||
|
||||
set((state) => ({
|
||||
items: {
|
||||
...state.items,
|
||||
[itemId]: {
|
||||
controlValues: controls.map((_, i) => defaultControlValue(interactive, i)),
|
||||
},
|
||||
},
|
||||
}))
|
||||
},
|
||||
|
||||
setControlValue: (itemId, index, value) => {
|
||||
set((state) => {
|
||||
const item = state.items[itemId]
|
||||
if (!item) return state
|
||||
const next = [...item.controlValues]
|
||||
next[index] = value
|
||||
return { items: { ...state.items, [itemId]: { controlValues: next } } }
|
||||
})
|
||||
},
|
||||
|
||||
removeItem: (itemId) => {
|
||||
set((state) => {
|
||||
const { [itemId]: _, ...rest } = state.items
|
||||
return { items: rest }
|
||||
})
|
||||
},
|
||||
}))
|
||||
@@ -5,6 +5,8 @@ import { temporal } from 'zundo'
|
||||
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import { BuildingNode } from '../schema'
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
import { generateCollectionId } from '../schema/collections'
|
||||
import { LevelNode } from '../schema/nodes/level'
|
||||
import { SiteNode } from '../schema/nodes/site'
|
||||
import type { AnyNode, AnyNodeId } from '../schema/types'
|
||||
@@ -21,6 +23,9 @@ export type SceneState = {
|
||||
// 3. The "Dirty" Set: For the Wall/Physics systems
|
||||
dirtyNodes: Set<AnyNodeId>
|
||||
|
||||
// 4. Relational metadata — not nodes
|
||||
collections: Record<CollectionId, Collection>
|
||||
|
||||
// Actions
|
||||
loadScene: () => void
|
||||
clearScene: () => void
|
||||
@@ -37,12 +42,19 @@ export type SceneState = {
|
||||
|
||||
deleteNode: (id: AnyNodeId) => void
|
||||
deleteNodes: (ids: AnyNodeId[]) => void
|
||||
|
||||
// Collection actions
|
||||
createCollection: (name: string, nodeIds?: AnyNodeId[]) => CollectionId
|
||||
deleteCollection: (id: CollectionId) => void
|
||||
updateCollection: (id: CollectionId, data: Partial<Omit<Collection, 'id'>>) => void
|
||||
addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void
|
||||
removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void
|
||||
}
|
||||
|
||||
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
|
||||
|
||||
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
||||
temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds'>>>
|
||||
temporal: StoreApi<TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>>>
|
||||
}
|
||||
|
||||
const useScene: UseSceneStore = create<SceneState>()(
|
||||
@@ -58,11 +70,15 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
// 3. Dirty set
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
|
||||
// 4. Collections
|
||||
collections: {} as Record<CollectionId, Collection>,
|
||||
|
||||
clearScene: () => {
|
||||
set({
|
||||
nodes: {},
|
||||
rootNodeIds: [],
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: {},
|
||||
})
|
||||
get().loadScene() // Default scene
|
||||
},
|
||||
@@ -143,11 +159,98 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
|
||||
|
||||
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
|
||||
|
||||
// --- COLLECTIONS ---
|
||||
|
||||
createCollection: (name, nodeIds = []) => {
|
||||
const id = generateCollectionId()
|
||||
const collection: Collection = { id, name, nodeIds }
|
||||
set((state) => {
|
||||
const nextCollections = { ...state.collections, [id]: collection }
|
||||
// Denormalize: stamp collectionId onto each node
|
||||
const nextNodes = { ...state.nodes }
|
||||
for (const nodeId of nodeIds) {
|
||||
const node = nextNodes[nodeId]
|
||||
if (!node) continue
|
||||
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
nextNodes[nodeId] = { ...node, collectionIds: [...existing, id] } as AnyNode
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
})
|
||||
return id
|
||||
},
|
||||
|
||||
deleteCollection: (id) => {
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
const nextCollections = { ...state.collections }
|
||||
delete nextCollections[id]
|
||||
// Remove collectionId from all member nodes
|
||||
const nextNodes = { ...state.nodes }
|
||||
for (const nodeId of col?.nodeIds ?? []) {
|
||||
const node = nextNodes[nodeId]
|
||||
if (!node || !('collectionIds' in node)) continue
|
||||
nextNodes[nodeId] = {
|
||||
...node,
|
||||
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
||||
} as AnyNode
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
})
|
||||
},
|
||||
|
||||
updateCollection: (id, data) => {
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
if (!col) return state
|
||||
return { collections: { ...state.collections, [id]: { ...col, ...data } } }
|
||||
})
|
||||
},
|
||||
|
||||
addToCollection: (id, nodeId) => {
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
if (!col || col.nodeIds.includes(nodeId)) return state
|
||||
const nextCollections = {
|
||||
...state.collections,
|
||||
[id]: { ...col, nodeIds: [...col.nodeIds, nodeId] },
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!node) return { collections: nextCollections }
|
||||
const existing = ('collectionIds' in node ? (node.collectionIds as CollectionId[]) : undefined) ?? []
|
||||
const nextNodes = {
|
||||
...state.nodes,
|
||||
[nodeId]: { ...node, collectionIds: [...existing, id] } as AnyNode,
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
})
|
||||
},
|
||||
|
||||
removeFromCollection: (id, nodeId) => {
|
||||
set((state) => {
|
||||
const col = state.collections[id]
|
||||
if (!col) return state
|
||||
const nextCollections = {
|
||||
...state.collections,
|
||||
[id]: { ...col, nodeIds: col.nodeIds.filter((n) => n !== nodeId) },
|
||||
}
|
||||
const node = state.nodes[nodeId]
|
||||
if (!node || !('collectionIds' in node)) return { collections: nextCollections }
|
||||
const nextNodes = {
|
||||
...state.nodes,
|
||||
[nodeId]: {
|
||||
...node,
|
||||
collectionIds: (node.collectionIds as CollectionId[]).filter((cid) => cid !== id),
|
||||
} as AnyNode,
|
||||
}
|
||||
return { collections: nextCollections, nodes: nextNodes }
|
||||
})
|
||||
},
|
||||
}),
|
||||
{
|
||||
partialize: (state) => {
|
||||
const { nodes, rootNodeIds } = state // Only track nodes and rootNodeIds in history
|
||||
return { nodes, rootNodeIds }
|
||||
const { nodes, rootNodeIds, collections } = state
|
||||
return { nodes, rootNodeIds, collections }
|
||||
},
|
||||
limit: 50, // Limit to last 50 actions
|
||||
},
|
||||
@@ -157,7 +260,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
version: 1,
|
||||
// Keep existing local scenes when the persist version changes.
|
||||
migrate: (persistedState) =>
|
||||
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds'>,
|
||||
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections'>,
|
||||
partialize: (state) => ({
|
||||
nodes: Object.fromEntries(
|
||||
Object.entries(state.nodes).filter(([_, node]) => {
|
||||
@@ -168,6 +271,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
}),
|
||||
),
|
||||
rootNodeIds: state.rootNodeIds,
|
||||
collections: state.collections,
|
||||
}),
|
||||
merge: (persistedState, currentState) => {
|
||||
const persisted = persistedState as Partial<SceneState>
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { type AnyNodeId, type ItemNode, useRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnimationEffect,
|
||||
type AnyNodeId,
|
||||
type Interactive,
|
||||
type ItemNode,
|
||||
type LightEffect,
|
||||
type SliderControl,
|
||||
useInteractive,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useAnimations } from '@react-three/drei'
|
||||
import { Clone } from '@react-three/drei/core/Clone'
|
||||
import { useGLTF } from '@react-three/drei/core/Gltf'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { Suspense, useEffect, useMemo, useRef } from 'react'
|
||||
import type { Group, Material, Mesh } from 'three'
|
||||
import type { AnimationAction, Group, Material, Mesh, PointLight } from 'three'
|
||||
import { MathUtils } from 'three'
|
||||
import { positionLocal, smoothstep, time } from 'three/tsl'
|
||||
import { DoubleSide, MeshStandardNodeMaterial } from 'three/webgpu'
|
||||
import { useNodeEvents } from '../../../hooks/use-node-events'
|
||||
@@ -73,11 +86,17 @@ const PreviewModel = ({ node }: { node: ItemNode }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const multiplyScales = (a: [number, number, number], b: [number, number, number]): [number, number, number] =>
|
||||
[a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||
const multiplyScales = (
|
||||
a: [number, number, number],
|
||||
b: [number, number, number],
|
||||
): [number, number, number] => [a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||
|
||||
const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
const { scene, nodes } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||
const { scene, nodes, animations } = useGLTF(resolveCdnUrl(node.asset.src) || '')
|
||||
const ref = useRef<Group>(null!)
|
||||
const { actions } = useAnimations(animations, ref)
|
||||
// Freeze the interactive definition at mount — asset schemas don't change at runtime
|
||||
const interactiveRef = useRef(node.asset.interactive)
|
||||
|
||||
if (nodes.cutout) {
|
||||
nodes.cutout.visible = false
|
||||
@@ -90,6 +109,13 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
useScene.getState().dirtyNodes.add(node.parentId as AnyNodeId)
|
||||
}, [node.parentId])
|
||||
|
||||
useEffect(() => {
|
||||
const interactive = interactiveRef.current
|
||||
if (!interactive) return
|
||||
useInteractive.getState().initItem(node.id, interactive)
|
||||
return () => useInteractive.getState().removeItem(node.id)
|
||||
}, [node.id])
|
||||
|
||||
useMemo(() => {
|
||||
scene.traverse((child) => {
|
||||
if ((child as Mesh).isMesh) {
|
||||
@@ -115,13 +141,158 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
|
||||
})
|
||||
}, [scene])
|
||||
|
||||
const interactive = interactiveRef.current
|
||||
const animEffect =
|
||||
interactive?.effects.find((e): e is AnimationEffect => e.kind === 'animation') ?? null
|
||||
const lightEffects =
|
||||
interactive?.effects.filter((e): e is LightEffect => e.kind === 'light') ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Clone
|
||||
ref={ref}
|
||||
object={scene}
|
||||
scale={multiplyScales(node.asset.scale || [1, 1, 1], node.scale || [1, 1, 1])}
|
||||
position={node.asset.offset}
|
||||
rotation={node.asset.rotation}
|
||||
{...handlers}
|
||||
/>
|
||||
{animations.length > 0 && (
|
||||
<ItemAnimation
|
||||
nodeId={node.id}
|
||||
animEffect={animEffect}
|
||||
interactive={interactive ?? null}
|
||||
actions={actions}
|
||||
animations={animations}
|
||||
/>
|
||||
)}
|
||||
{lightEffects.map((effect, i) => (
|
||||
<ItemLight key={i} nodeId={node.id} effect={effect} interactive={interactive!} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const ItemAnimation = ({
|
||||
nodeId,
|
||||
animEffect,
|
||||
interactive,
|
||||
actions,
|
||||
animations,
|
||||
}: {
|
||||
nodeId: AnyNodeId
|
||||
animEffect: AnimationEffect | null
|
||||
interactive: Interactive | null
|
||||
actions: Record<string, AnimationAction | null>
|
||||
animations: { name: string }[]
|
||||
}) => {
|
||||
const activeClipRef = useRef<string | null>(null)
|
||||
const fadingOutRef = useRef<AnimationAction | null>(null)
|
||||
|
||||
// Reactive: derive target clip name — only re-renders when the clip name itself changes
|
||||
const targetClip = useInteractive((s) => {
|
||||
const values = s.items[nodeId]?.controlValues
|
||||
if (!animEffect) return animations[0]?.name ?? null
|
||||
const toggleIndex = interactive!.controls.findIndex((c) => c.kind === 'toggle')
|
||||
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex]) : false
|
||||
return isOn
|
||||
? (animEffect.clips.on ?? null)
|
||||
: (animEffect.clips.off ?? animEffect.clips.loop ?? null)
|
||||
})
|
||||
|
||||
// When target clip changes: kick off the transition
|
||||
useEffect(() => {
|
||||
// Cancel any ongoing fade-out immediately
|
||||
if (fadingOutRef.current) {
|
||||
fadingOutRef.current.timeScale = 0
|
||||
fadingOutRef.current = null
|
||||
}
|
||||
// Move current clip to fade-out
|
||||
if (activeClipRef.current && activeClipRef.current !== targetClip) {
|
||||
const old = actions[activeClipRef.current]
|
||||
if (old?.isRunning()) fadingOutRef.current = old
|
||||
}
|
||||
// Start new clip at timeScale 0.01 (as 0 would cause isRunning to be false and thus not play at all), then fade in to 1
|
||||
activeClipRef.current = targetClip
|
||||
if (targetClip) {
|
||||
const next = actions[targetClip]
|
||||
if (next) {
|
||||
next.timeScale = 0.01
|
||||
next.play()
|
||||
}
|
||||
}
|
||||
}, [targetClip, actions])
|
||||
|
||||
// useFrame: only lerping — no logic
|
||||
useFrame((_, delta) => {
|
||||
if (fadingOutRef.current) {
|
||||
const action = fadingOutRef.current
|
||||
action.timeScale = MathUtils.lerp(action.timeScale, 0, Math.min(delta * 5, 1))
|
||||
if (action.timeScale < 0.01) {
|
||||
action.timeScale = 0
|
||||
fadingOutRef.current = null
|
||||
}
|
||||
}
|
||||
if (activeClipRef.current) {
|
||||
const action = actions[activeClipRef.current]
|
||||
if (action?.isRunning() && action.timeScale < 1) {
|
||||
action.timeScale = MathUtils.lerp(action.timeScale, 1, Math.min(delta * 5, 1))
|
||||
if (1 - action.timeScale < 0.01) action.timeScale = 1
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const ItemLight = ({
|
||||
nodeId,
|
||||
effect,
|
||||
interactive,
|
||||
}: {
|
||||
nodeId: AnyNodeId
|
||||
effect: LightEffect
|
||||
interactive: Interactive
|
||||
}) => {
|
||||
const lightRef = useRef<PointLight>(null!)
|
||||
// Precompute stable indices — interactive is frozen at mount
|
||||
const toggleIndex = interactive.controls.findIndex((c) => c.kind === 'toggle')
|
||||
const sliderIndex = interactive.controls.findIndex((c) => c.kind === 'slider')
|
||||
const sliderControl =
|
||||
sliderIndex >= 0 ? (interactive.controls[sliderIndex] as SliderControl) : null
|
||||
|
||||
useFrame((_, delta) => {
|
||||
if (!lightRef.current) return
|
||||
const values = useInteractive.getState().items[nodeId]?.controlValues
|
||||
|
||||
const isOn = toggleIndex >= 0 ? Boolean(values?.[toggleIndex]) : true
|
||||
|
||||
// Normalize slider to 0-1 (default full intensity if no slider)
|
||||
let t = 1
|
||||
if (sliderControl) {
|
||||
const raw = (values?.[sliderIndex] as number) ?? sliderControl.min
|
||||
t = (raw - sliderControl.min) / (sliderControl.max - sliderControl.min)
|
||||
}
|
||||
|
||||
const target = isOn
|
||||
? MathUtils.lerp(effect.intensityRange[0], effect.intensityRange[1], t)
|
||||
: effect.intensityRange[0]
|
||||
|
||||
lightRef.current.intensity = MathUtils.lerp(
|
||||
lightRef.current.intensity,
|
||||
target,
|
||||
Math.min(delta * 12, 1),
|
||||
)
|
||||
})
|
||||
|
||||
return (
|
||||
<pointLight
|
||||
ref={lightRef}
|
||||
color={effect.color}
|
||||
intensity={effect.intensityRange[0]}
|
||||
distance={effect.distance ?? 0}
|
||||
position={effect.offset}
|
||||
castShadow={false}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { default as Viewer } from './components/viewer'
|
||||
export { default as useViewer } from './store/use-viewer'
|
||||
export { ASSETS_CDN_URL, resolveAssetUrl, resolveCdnUrl } from './lib/asset-url'
|
||||
export { InteractiveSystem } from './systems/interactive/interactive-system'
|
||||
export { snapLevelsToTruePositions } from './systems/level/level-utils'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { loadAssetUrl } from '@pascal-app/core'
|
||||
|
||||
export const ASSETS_CDN_URL = 'https://editor.pascal.app'
|
||||
export const ASSETS_CDN_URL = process.env.NEXT_PUBLIC_ASSETS_CDN_URL || 'https://editor.pascal.app'
|
||||
|
||||
/**
|
||||
* Resolves an asset URL to the appropriate format:
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
type Control,
|
||||
type ControlValue,
|
||||
type ItemNode,
|
||||
pointInPolygon,
|
||||
sceneRegistry,
|
||||
useInteractive,
|
||||
useScene,
|
||||
type ZoneNode,
|
||||
} from '@pascal-app/core'
|
||||
import { Html } from '@react-three/drei'
|
||||
import { createPortal, useFrame } from '@react-three/fiber'
|
||||
import { useState } from 'react'
|
||||
import { type Object3D, Vector3 } from 'three'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import useViewer from '../../store/use-viewer'
|
||||
|
||||
const _tempVec = new Vector3()
|
||||
|
||||
// ---- Parent: one overlay per interactive item ----
|
||||
|
||||
export const InteractiveSystem = () => {
|
||||
const interactiveNodeIds = useScene(
|
||||
useShallow((state) =>
|
||||
Object.values(state.nodes)
|
||||
.filter((n): n is ItemNode => n.type === 'item' && n.asset.interactive != null)
|
||||
.map((n) => n.id),
|
||||
),
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{interactiveNodeIds.map((id) => (
|
||||
<ItemControlsOverlay key={id} nodeId={id} />
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Child: polls sceneRegistry then portals controls into the item group ----
|
||||
|
||||
const ItemControlsOverlay = ({ nodeId }: { nodeId: AnyNodeId }) => {
|
||||
const node = useScene((state) => state.nodes[nodeId] as ItemNode)
|
||||
const [itemObj, setItemObj] = useState<Object3D | null>(null)
|
||||
|
||||
useFrame(() => {
|
||||
if (itemObj) return
|
||||
const obj = sceneRegistry.nodes.get(nodeId)
|
||||
if (obj) setItemObj(obj)
|
||||
})
|
||||
|
||||
const controlValues = useInteractive(useShallow((state) => state.items[nodeId]?.controlValues))
|
||||
const setControlValue = useInteractive((state) => state.setControlValue)
|
||||
|
||||
const zoneId = useViewer((s) => s.selection.zoneId)
|
||||
const zonePolygon = useScene((s) => {
|
||||
if (!zoneId) return null
|
||||
const z = s.nodes[zoneId] as ZoneNode | undefined
|
||||
return z?.polygon ?? null
|
||||
})
|
||||
|
||||
if (!itemObj || !controlValues || !node?.asset.interactive) return null
|
||||
|
||||
const { controls } = node.asset.interactive
|
||||
const [, height] = node.asset.dimensions
|
||||
|
||||
let opacity = 0
|
||||
let pointerEvents: 'auto' | 'none' = 'none'
|
||||
if (zoneId && zonePolygon?.length) {
|
||||
itemObj.getWorldPosition(_tempVec)
|
||||
const inside = pointInPolygon(_tempVec.x, _tempVec.z, zonePolygon)
|
||||
opacity = inside ? 1 : 0.1
|
||||
pointerEvents = inside ? 'auto' : 'none'
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<Html center position={[0, height + 0.3, 0]} zIndexRange={[20, 0]} occlude distanceFactor={8}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
background: 'rgba(0,0,0,0.75)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
borderRadius: 8,
|
||||
padding: '8px 12px',
|
||||
minWidth: 120,
|
||||
pointerEvents,
|
||||
userSelect: 'none',
|
||||
opacity,
|
||||
transition: 'opacity 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{controls.map((control, i) => (
|
||||
<ControlWidget
|
||||
key={i}
|
||||
control={control}
|
||||
value={controlValues[i] ?? false}
|
||||
onChange={(v) => setControlValue(nodeId, i, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Html>,
|
||||
itemObj,
|
||||
)
|
||||
}
|
||||
|
||||
// ---- Control widgets ----
|
||||
|
||||
const ControlWidget = ({
|
||||
control,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
control: Control
|
||||
value: ControlValue
|
||||
onChange: (v: ControlValue) => void
|
||||
}) => {
|
||||
const labelStyle: React.CSSProperties = {
|
||||
color: 'white',
|
||||
fontSize: 11,
|
||||
fontFamily: 'monospace',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 2,
|
||||
}
|
||||
|
||||
if (control.kind === 'toggle') {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!value)}
|
||||
style={{
|
||||
background: value ? '#4ade80' : '#374151',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '4px 8px',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontFamily: 'monospace',
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
>
|
||||
{control.label ?? (value ? 'On' : 'Off')}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
if (control.kind === 'slider') {
|
||||
return (
|
||||
<label style={labelStyle}>
|
||||
<span>
|
||||
{control.label}: {value}
|
||||
{control.unit ? ` ${control.unit}` : ''}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={control.step}
|
||||
value={value as number}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
if (control.kind === 'temperature') {
|
||||
return (
|
||||
<label style={labelStyle}>
|
||||
<span>
|
||||
{control.label}: {value}°{control.unit}
|
||||
</span>
|
||||
<input
|
||||
type="range"
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
step={1}
|
||||
value={value as number}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
Reference in New Issue
Block a user