Pascal Editor Collection System: Organizing and Grouping Nodes in 3D Scenes

Pascal Editor implements a lightweight collection system that lets users group arbitrary scene nodes under user-defined labels using a denormalized data model synchronized through a Zustand global store.

The Pascal Editor collection system enables flexible organization of 3D scene elements by grouping walls, slabs, doors, and items into labeled collections. Built on a three-layer architecture spanning schema definitions, state management in packages/core/src/store/use-scene.ts, and UI components in packages/editor/src/components/ui/panels/collections, this system uses bidirectional membership tracking to enable O(1) lookups from either collections or individual nodes.

Schema Layer – Defining Collection Structure in packages/core/src/schema/collections.ts

The foundation of the collection system resides in the core schema package, where the Collection type and its associated ID generator are defined. This ensures type-safe identifiers throughout the codebase.

The Collection interface specifies five key properties:

export type CollectionId = `collection_${string}`

export type Collection = {
  id: CollectionId               // unique id, generated by generateCollectionId()
  name: string                   // user-provided label
  color?: string                 // optional UI colour
  nodeIds: AnyNodeId[]           // flat list of member node ids
  controlNodeId?: AnyNodeId      // (future) reference for a control node
}

Type safety is enforced through the branded CollectionId type, which uses a template literal pattern. The accompanying generateCollectionId() function prefixes random identifiers with "collection" using the underlying generateId('collection') utility from packages/core/src/schema/base.ts. This guarantees that all collection IDs conform to the expected format, enabling compile-time checks across the application.

State Management – CRUD Operations in packages/core/src/store/use-scene.ts

The global scene store manages collections through a Zustand hook (useScene) that maintains a flat dictionary of collections (Record<CollectionId, Collection>) alongside denormalized membership data on each node.

Core Store Actions

The store exports five primary mutation methods that maintain synchronization between the collections map and individual node.collectionIds arrays:

  • createCollection(name, nodeIds?) – Generates a new CollectionId, creates the collection object, and immediately adds the collection ID to each supplied node's collectionIds property. According to the source code at lines 4018–4023, this denormalization happens atomically within the set callback to ensure both data structures update simultaneously.

  • deleteCollection(id) – Removes the collection from the store's collections map and filters the deleted ID from all member nodes' collectionIds arrays. The implementation iterates over col?.nodeIds and performs the cleanup at lines 4028–4037.

  • updateCollection(id, data) – Performs a shallow merge of new fields (such as name or color) into the existing collection object at lines 4042–4046.

  • addToCollection(collectionId, nodeId) and removeFromCollection(collectionId, nodeId) – These helper methods maintain bidirectional consistency by pushing or removing node IDs from the collection's nodeIds array while simultaneously updating the node's collectionIds property.

This denormalized storage strategy—keeping membership data on both the collection (nodeIds) and the node (collectionIds)—enables constant-time lookups in either direction, which is essential for fast UI rendering and spatial-grid calculations.

User Interface – Managing Collections via packages/editor/src/components/ui/panels/collections/collections-popover.tsx

The CollectionsPopover component provides a self-contained interface for collection management without direct dependencies on core rendering logic. It consumes store actions exclusively through the useScene hook, preserving the separation between the editor package and core viewer functionality.

Key UI behaviors include:

  • Creating Collections – When users click the "New" button and submit a name, the component calls createCollection(name, [nodeId]) at lines 60–66, immediately grouping the active node into the new collection.

  • Renaming – Clicking the pencil icon triggers an inline editor that invokes updateCollection(id, { name, color }) upon confirmation (lines 67–71).

  • Deleting – The trash icon toggles a confirmation state; upon confirmation, deleteCollection(id) executes at lines 88–92 to remove the collection and clean up all node references.

  • Toggling Membership – Clicking a collection name in the list calls either addToCollection or removeFromCollection at lines 73–78 to instantly update membership.

  • Expanding Members – Users can inspect collection contents via an expand/collapse toggle that renders the list of member nodes (lines 81–87).

  • Color Coding – Each collection displays a color dot that users can modify inline, triggering updateCollection(..., { color }) at lines 42–46.

All UI actions immediately mutate the central Zustand store, causing any component reading useScene((s) => s.collections) or node.collectionIds to re-render automatically via Zustand's subscription model.

Practical Implementation Examples

Creating a Collection Programmatically

You can group nodes programmatically without using the popover UI by calling the store action directly:

import { useScene } from '@pascal-app/core'

function useCollectionCreator() {
  const createCollection = useScene(s => s.createCollection)

  return function groupSelection(selectionIds: AnyNodeId[]) {
    if (selectionIds.length === 0) return
    const name = `Group ${Date.now()}`
    const collectionId = createCollection(name, selectionIds)
    console.log('Created collection', collectionId)
  }
}

Source: Store method createCollection (lines 4018–4023) and UI usage pattern (lines 60–66).

Toggling Node Membership

Implement custom toggle buttons using the membership helpers:

import { useScene } from '@pascal-app/core'

function ToggleCollectionButton({ 
  nodeId, 
  collectionId 
}: { 
  nodeId: AnyNodeId
  collectionId: CollectionId 
}) {
  const add = useScene(s => s.addToCollection)
  const remove = useScene(s => s.removeFromCollection)
  const collections = useScene(s => s.collections)

  const inCollection = collections[collectionId]?.nodeIds.includes(nodeId)

  return (
    <button
      onClick={() => 
        inCollection 
          ? remove(collectionId, nodeId) 
          : add(collectionId, nodeId)
      }
    >
      {inCollection ? 'Remove from' : 'Add to'} {collections[collectionId]?.name}
    </button>
  )
}

Source: Toggle logic in CollectionsPopover (lines 73–78).

Rendering Collections with Member Previews

Display all collections and their members by accessing the denormalized data:

import { useScene } from '@pascal-app/core'
import { ColorDot } from '@/components/ui/primitives/color-dot'

export function CollectionsList() {
  const { collections, nodes } = useScene(s => ({
    collections: s.collections,
    nodes: s.nodes,
  }))

  return (
    <ul>
      {Object.values(collections).map(col => (
        <li key={col.id}>
          <ColorDot color={col.color ?? '#6366f1'} />
          <strong>{col.name}</strong> ({col.nodeIds.length})
          <ul>
            {col.nodeIds.map(id => (
              <li key={id}>{nodes[id]?.name ?? id}</li>
            ))}
          </ul>
        </li>
      ))}
    </ul>
  )
}

Source: Expanded member list rendering pattern in CollectionsPopover (lines 225–245).

Summary

  • Denormalized Data Model – Pascal Editor stores collection membership bidirectionally: collection.nodeIds tracks members while node.collectionIds tracks group affiliations, enabling O(1) lookups from either direction.
  • Type-Safe Schema – Collection IDs follow the collection_${string} pattern defined in packages/core/src/schema/collections.ts, enforced by generateCollectionId().
  • Centralized State – All mutations flow through the Zustand store in packages/core/src/store/use-scene.ts, which automatically synchronizes both sides of the membership relationship.
  • Decoupled UI – The CollectionsPopover component in the editor package consumes store hooks exclusively, maintaining clean separation between UI logic and core scene management.
  • Runtime Flexibility – Collections support user-defined colors, programmatic creation, and dynamic membership toggling without requiring scene graph restructuring.

Frequently Asked Questions

How does Pascal Editor maintain consistency between collections and nodes?

The store actions in packages/core/src/store/use-scene.ts handle denormalization atomically. When createCollection, deleteCollection, addToCollection, or removeFromCollection are called, the Zustand set callback updates both the collections map and the affected nodes' collectionIds arrays in a single transaction. This ensures that collection.nodeIds and node.collectionIds never drift out of sync, providing consistent data for both the UI and spatial indexing systems.

What is the performance impact of querying collection membership?

Membership lookups operate in constant time O(1) due to the denormalized storage strategy. Because both the collection stores its member IDs in nodeIds and each node stores its collection affiliations in collectionIds, the system never needs to traverse the entire scene graph to determine group relationships. This design is essential for maintaining frame rates when rendering complex scenes with hundreds of collections.

Can collections contain nested collections or only leaf nodes?

According to the schema definition in packages/core/src/schema/collections.ts, the nodeIds property is typed as AnyNodeId[], meaning collections reference individual nodes directly rather than other collections. The system implements a flat grouping mechanism rather than a hierarchical tree structure. For nested organization, users should rely on the scene graph's parentId relationships on nodes themselves, while using collections as orthogonal cross-cutting groupings.

How are collection IDs generated to prevent collisions?

The generateCollectionId() function in packages/core/src/schema/collections.ts utilizes the base generateId('collection') utility from packages/core/src/schema/base.ts. This produces random strings prefixed with "collection_", ensuring unique identifiers across the session. The branded type CollectionId provides compile-time guarantees that only properly generated IDs are passed to store methods, preventing accidental use of raw strings or other entity IDs in collection contexts.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →