diff --git a/apps/editor/app/viewer/[id]/collections-panel.tsx b/apps/editor/app/viewer/[id]/collections-panel.tsx
new file mode 100644
index 00000000..f5a68c06
--- /dev/null
+++ b/apps/editor/app/viewer/[id]/collections-panel.tsx
@@ -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 (
+
+ )
+ }
+
+ return (
+
+
+ {def.label ?? def.kind}
+ {value}{def.kind === 'temperature' ? '°' : ''}{def.unit ? ` ${def.unit}` : ''}
+
+
onChange(Number(e.target.value))}
+ onPointerDown={(e) => e.stopPropagation()}
+ className="w-full accent-white"
+ />
+
+ )
+}
+
+// ─── Individual item control widget ──────────────────────────────────────────
+
+function ItemWidget({ control, value, onChange }: { control: Control; value: ControlValue; onChange: (v: ControlValue) => void }) {
+ if (control.kind === 'toggle') {
+ return (
+
+ )
+ }
+
+ return (
+
+
+ {control.label ?? control.kind}
+ {value}{control.kind === 'temperature' ? '°' : ''}{'unit' in control && control.unit ? ` ${control.unit}` : ''}
+
+
onChange(Number(e.target.value))}
+ onPointerDown={(e) => e.stopPropagation()}
+ className="w-full accent-white"
+ />
+
+ )
+}
+
+// ─── 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>(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 (
+
+ {/* Header */}
+
+
+ {/* Expanded */}
+ {expanded && (
+
+ {interactiveItems.length === 0 ? (
+
No interactive items.
+ ) : (
+ <>
+ {/* Shared controls */}
+ {sharedControls.length > 0 && (
+
+
All
+
+ {sharedControls.map((def, i) => (
+ setSharedValue(def, v)}
+ />
+ ))}
+
+
+ )}
+
+ {/* Individual items */}
+ {interactiveItems.map((item) => {
+ const isItemExpanded = expandedItemIds.has(item.id)
+ const controls = item.asset.interactive!.controls
+ const values = controlValuesByItem[item.id] ?? []
+
+ return (
+
+
+
+ {isItemExpanded && (
+
+ {controls.map((control, i) => (
+ setControlValue(item.id, i, v)}
+ />
+ ))}
+
+ )}
+
+ )
+ })}
+ >
+ )}
+
+ )}
+
+ )
+}
+
+// ─── Main panel ───────────────────────────────────────────────────────────────
+
+export function CollectionsPanel() {
+ const collectionIds = useScene(
+ useShallow((s) => Object.keys(s.collections) as CollectionId[]),
+ )
+
+ if (collectionIds.length === 0) return null
+
+ return (
+
+
+ Collections
+
+
+ {collectionIds.map((id) => (
+
+ ))}
+
+
+ )
+}
diff --git a/apps/editor/app/viewer/[id]/page.tsx b/apps/editor/app/viewer/[id]/page.tsx
index bc173276..f643a9cd 100644
--- a/apps/editor/app/viewer/[id]/page.tsx
+++ b/apps/editor/app/viewer/[id]/page.tsx
@@ -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() {
+
>
diff --git a/apps/editor/app/viewer/[id]/viewer-overlay.tsx b/apps/editor/app/viewer/[id]/viewer-overlay.tsx
index b2c06bb1..03ff3721 100644
--- a/apps/editor/app/viewer/[id]/viewer-overlay.tsx
+++ b/apps/editor/app/viewer/[id]/viewer-overlay.tsx
@@ -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 = ({
)}
+ {/* Collections Panel - Top Right */}
+
+
+
+
{/* Controls Panel - Bottom Center */}
diff --git a/apps/editor/components/editor/index.tsx b/apps/editor/components/editor/index.tsx
index ff8af373..ee41bdd3 100644
--- a/apps/editor/components/editor/index.tsx
+++ b/apps/editor/components/editor/index.tsx
@@ -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) {
+
)
diff --git a/apps/editor/components/systems/zone/zone-label-editor-system.tsx b/apps/editor/components/systems/zone/zone-label-editor-system.tsx
new file mode 100644
index 00000000..33881f0e
--- /dev/null
+++ b/apps/editor/components/systems/zone/zone-label-editor-system.tsx
@@ -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(null)
+ const [labelEl, setLabelEl] = useState(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 ? (
+ e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ >
+ 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',
+ }}
+ />
+
+
+ ) : (
+
+ ),
+ 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) => (
+
+ ))}
+ >
+ )
+}
diff --git a/apps/editor/components/tools/zone/zone-tool.tsx b/apps/editor/components/tools/zone/zone-tool.tsx
index 51344563..62ae22f1 100644
--- a/apps/editor/components/tools/zone/zone-tool.tsx
+++ b/apps/editor/components/tools/zone/zone-tool.tsx
@@ -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,
diff --git a/apps/editor/components/ui/item-catalog/catalog-items.tsx b/apps/editor/components/ui/item-catalog/catalog-items.tsx
index 876936cf..9c386f46 100644
--- a/apps/editor/components/ui/item-catalog/catalog-items.tsx
+++ b/apps/editor/components/ui/item-catalog/catalog-items.tsx
@@ -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],
+ }
+ ]
+ }
},
{
diff --git a/apps/editor/components/ui/panels/collections/collections-popover.tsx b/apps/editor/components/ui/panels/collections/collections-popover.tsx
new file mode 100644
index 00000000..6fe2b7ba
--- /dev/null
+++ b/apps/editor/components/ui/panels/collections/collections-popover.tsx
@@ -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(null)
+ const [renameValue, setRenameValue] = useState('')
+ const [renameColor, setRenameColor] = useState('')
+
+ const [deletingId, setDeletingId] = useState(null)
+ const [expandedIds, setExpandedIds] = useState>(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 (
+
+ {children}
+
+ {/* Header */}
+
+
+
+ Collections
+
+
+
+
+ {/* Create input */}
+ {showCreateInput && (
+
+ 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"
+ />
+
+
+
+ )}
+
+ {/* Collections list */}
+
+ {allCollections.length === 0 ? (
+
+
+
+ No collections yet. Create one to group items together.
+
+
+ ) : (
+
+ )}
+
+
+
+ )
+}
diff --git a/apps/editor/components/ui/panels/item-panel.tsx b/apps/editor/components/ui/panels/item-panel.tsx
index 2bf67a76..8bbf4d03 100644
--- a/apps/editor/components/ui/panels/item-panel.tsx
+++ b/apps/editor/components/ui/panels/item-panel.tsx
@@ -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,14 +232,22 @@ export function ItemPanel() {
+
+
+
+
+
+
+
+
} label="Move" onClick={handleMove} />
} label="Duplicate" onClick={handleDuplicate} />
- }
- label="Delete"
- onClick={handleDelete}
+ }
+ label="Delete"
+ onClick={handleDelete}
className="hover:bg-red-500/20"
/>
diff --git a/apps/editor/components/ui/primitives/color-dot.tsx b/apps/editor/components/ui/primitives/color-dot.tsx
new file mode 100644
index 00000000..8d21fd59
--- /dev/null
+++ b/apps/editor/components/ui/primitives/color-dot.tsx
@@ -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 (
+
+
+
+
+
+ {PALETTE_COLORS.map((c) => (
+
+
+
+ )
+}
diff --git a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
index bf40beeb..55482c97 100644
--- a/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
+++ b/apps/editor/components/ui/sidebar/panels/site-panel/index.tsx
@@ -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 */}
-
-
-
- e.stopPropagation()}
- >
-
- {PRESET_COLORS.map((color) => (
-
-
-
+
+
+
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 (
updateNode(node.id, { color })}
/>
}
label={
diff --git a/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx
index 12cfd20e..aa6ae9d5 100644
--- a/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx
+++ b/apps/editor/components/ui/sidebar/panels/zone-panel/index.tsx
@@ -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}
>
-
-
-
- e.stopPropagation()}
- >
-
- {PRESET_COLORS.map((color) => (
-
-
-
+
+
+
{zone.name}
{/* Camera snapshot button */}
diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts
index 7e9dfaaa..b822ff06 100644
--- a/apps/editor/next.config.ts
+++ b/apps/editor/next.config.ts
@@ -8,6 +8,7 @@ const nextConfig: NextConfig = {
},
},
images: {
+ unoptimized: process.env.NEXT_PUBLIC_ASSETS_CDN_URL?.startsWith('http://localhost') ?? false,
remotePatterns: [
{
protocol: 'https',
diff --git a/apps/editor/public/items/ceiling-fan/model.glb b/apps/editor/public/items/ceiling-fan/model.glb
index f8f3724f..f439d400 100644
Binary files a/apps/editor/public/items/ceiling-fan/model.glb and b/apps/editor/public/items/ceiling-fan/model.glb differ
diff --git a/apps/editor/public/items/ev-wall-charger/model.glb b/apps/editor/public/items/ev-wall-charger/model.glb
new file mode 100644
index 00000000..9134299c
Binary files /dev/null and b/apps/editor/public/items/ev-wall-charger/model.glb differ
diff --git a/apps/editor/public/items/ev-wall-charger/thumbnail.webp b/apps/editor/public/items/ev-wall-charger/thumbnail.webp
new file mode 100644
index 00000000..e96d20d8
Binary files /dev/null and b/apps/editor/public/items/ev-wall-charger/thumbnail.webp differ
diff --git a/apps/editor/public/items/recessed-light/model.glb b/apps/editor/public/items/recessed-light/model.glb
new file mode 100644
index 00000000..4acc9378
Binary files /dev/null and b/apps/editor/public/items/recessed-light/model.glb differ
diff --git a/apps/editor/public/items/recessed-light/thumbnail.webp b/apps/editor/public/items/recessed-light/thumbnail.webp
new file mode 100644
index 00000000..981f1f55
Binary files /dev/null and b/apps/editor/public/items/recessed-light/thumbnail.webp differ
diff --git a/apps/editor/public/items/tesla/model.glb b/apps/editor/public/items/tesla/model.glb
new file mode 100644
index 00000000..d0e1a1cf
Binary files /dev/null and b/apps/editor/public/items/tesla/model.glb differ
diff --git a/apps/editor/public/items/tesla/thumbnail.webp b/apps/editor/public/items/tesla/thumbnail.webp
new file mode 100644
index 00000000..755d39a8
Binary files /dev/null and b/apps/editor/public/items/tesla/thumbnail.webp differ
diff --git a/apps/editor/store/use-editor.tsx b/apps/editor/store/use-editor.tsx
index 6c625043..61191140 100644
--- a/apps/editor/store/use-editor.tsx
+++ b/apps/editor/store/use-editor.tsx
@@ -86,8 +86,23 @@ const useEditor = create()((set, get) => ({
set({ phase })
- // Reset to select mode and clear tool/catalog when switching phases
- set({ mode: 'select', tool: null, catalogCategory: null })
+ 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()((set, get) => ({
setTool: (tool) => set({ tool }),
structureLayer: 'elements',
setStructureLayer: (layer) => {
- set({ structureLayer: layer, mode: 'select', tool: null })
+ 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({
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 0f5ea252..80bb3b25 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -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'
diff --git a/packages/core/src/schema/collections.ts b/packages/core/src/schema/collections.ts
new file mode 100644
index 00000000..32fb01c8
--- /dev/null
+++ b/packages/core/src/schema/collections.ts
@@ -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')
diff --git a/packages/core/src/schema/index.ts b/packages/core/src/schema/index.ts
index c3de2791..4e4cc39b 100644
--- a/packages/core/src/schema/index.ts
+++ b/packages/core/src/schema/index.ts
@@ -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
diff --git a/packages/core/src/schema/nodes/item.ts b/packages/core/src/schema/nodes/item.ts
index bda8f4c5..374e1709 100644
--- a/packages/core/src/schema/nodes/item.ts
+++ b/packages/core/src/schema/nodes/item.ts
@@ -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
+export type SliderControl = z.infer
+export type TemperatureControl = z.infer
+export type Control = z.infer
+export type AnimationEffect = z.infer
+export type LightEffect = z.infer
+export type Effect = z.infer
+export type Interactive = z.infer
const assetSchema = z.object({
id: z.string(),
@@ -17,9 +92,10 @@ const assetSchema = z.object({
scale: z.tuple([z.number(), z.number(), z.number()]).default([1, 1, 1]),
surface: z
.object({
- height: z.number(), // where things rest
+ height: z.number(), // where things rest
})
.optional(), // undefined = can't place things on it
+ interactive: interactiveSchema.optional(),
})
export type AssetInput = z.input
@@ -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()).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)
diff --git a/packages/core/src/store/actions/node-actions.ts b/packages/core/src/store/actions/node-actions.ts
index f42c24cf..288fc339 100644
--- a/packages/core/src/store/actions/node-actions.ts
+++ b/packages/core/src/store/actions/node-actions.ts
@@ -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 }
})
diff --git a/packages/core/src/store/use-interactive.ts b/packages/core/src/store/use-interactive.ts
new file mode 100644
index 00000000..8b165559
--- /dev/null
+++ b/packages/core/src/store/use-interactive.ts
@@ -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
+
+ /** 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((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 }
+ })
+ },
+}))
diff --git a/packages/core/src/store/use-scene.ts b/packages/core/src/store/use-scene.ts
index 7d8c0211..2f3c8f48 100644
--- a/packages/core/src/store/use-scene.ts
+++ b/packages/core/src/store/use-scene.ts
@@ -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
+ // 4. Relational metadata — not nodes
+ collections: Record
+
// 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>) => void
+ addToCollection: (id: CollectionId, nodeId: AnyNodeId) => void
+ removeFromCollection: (id: CollectionId, nodeId: AnyNodeId) => void
}
// type PartializedStoreState = Pick;
type UseSceneStore = UseBoundStore> & {
- temporal: StoreApi>>
+ temporal: StoreApi>>
}
const useScene: UseSceneStore = create()(
@@ -58,11 +70,15 @@ const useScene: UseSceneStore = create()(
// 3. Dirty set
dirtyNodes: new Set(),
+ // 4. Collections
+ collections: {} as Record,
+
clearScene: () => {
set({
nodes: {},
rootNodeIds: [],
dirtyNodes: new Set(),
+ collections: {},
})
get().loadScene() // Default scene
},
@@ -143,11 +159,98 @@ const useScene: UseSceneStore = create()(
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()(
version: 1,
// Keep existing local scenes when the persist version changes.
migrate: (persistedState) =>
- persistedState as Pick,
+ persistedState as Pick,
partialize: (state) => ({
nodes: Object.fromEntries(
Object.entries(state.nodes).filter(([_, node]) => {
@@ -168,6 +271,7 @@ const useScene: UseSceneStore = create()(
}),
),
rootNodeIds: state.rootNodeIds,
+ collections: state.collections,
}),
merge: (persistedState, currentState) => {
const persisted = persistedState as Partial
diff --git a/packages/viewer/src/components/renderers/item/item-renderer.tsx b/packages/viewer/src/components/renderers/item/item-renderer.tsx
index 4eda3deb..c2236122 100644
--- a/packages/viewer/src/components/renderers/item/item-renderer.tsx
+++ b/packages/viewer/src/components/renderers/item/item-renderer.tsx
@@ -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'
@@ -45,8 +58,8 @@ export const ItemRenderer = ({ node }: { node: ItemNode }) => {
{node.children?.map((childId) => (
-
- ))}
+
+ ))}
)
}
@@ -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(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 (
-
+
+ {animations.length > 0 && (
+
+ )}
+ {lightEffects.map((effect, i) => (
+
+ ))}
+ >
+ )
+}
+
+const ItemAnimation = ({
+ nodeId,
+ animEffect,
+ interactive,
+ actions,
+ animations,
+}: {
+ nodeId: AnyNodeId
+ animEffect: AnimationEffect | null
+ interactive: Interactive | null
+ actions: Record
+ animations: { name: string }[]
+}) => {
+ const activeClipRef = useRef(null)
+ const fadingOutRef = useRef(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(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 (
+
)
}
diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts
index ca2f7a93..d97a508c 100644
--- a/packages/viewer/src/index.ts
+++ b/packages/viewer/src/index.ts
@@ -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'
\ No newline at end of file
diff --git a/packages/viewer/src/lib/asset-url.ts b/packages/viewer/src/lib/asset-url.ts
index 242be239..6ec7d317 100644
--- a/packages/viewer/src/lib/asset-url.ts
+++ b/packages/viewer/src/lib/asset-url.ts
@@ -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:
diff --git a/packages/viewer/src/systems/interactive/interactive-system.tsx b/packages/viewer/src/systems/interactive/interactive-system.tsx
new file mode 100644
index 00000000..83c89c66
--- /dev/null
+++ b/packages/viewer/src/systems/interactive/interactive-system.tsx
@@ -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) => (
+
+ ))}
+ >
+ )
+}
+
+// ---- 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(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(
+
+
+ {controls.map((control, i) => (
+ setControlValue(nodeId, i, v)}
+ />
+ ))}
+
+ ,
+ 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 (
+
+ )
+ }
+
+ if (control.kind === 'slider') {
+ return (
+
+ )
+ }
+
+ if (control.kind === 'temperature') {
+ return (
+
+ )
+ }
+
+ return null
+}