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

> Organize and group 3D scene nodes in Pascal Editor using its lightweight collection system. Efficiently manage nodes with user-defined labels and a synchronized global store.

- Repository: [Pascal/editor](https://github.com/pascalorg/editor)
- Tags: internals
- Published: 2026-03-25

---

**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`](https://github.com/pascalorg/editor/blob/main/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`](https://github.com/pascalorg/editor/blob/main/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:

```typescript
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`](https://github.com/pascalorg/editor/blob/main/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`](https://github.com/pascalorg/editor/blob/main/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`](https://github.com/pascalorg/editor/blob/main/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:

```tsx
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:

```tsx
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:

```tsx
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`](https://github.com/pascalorg/editor/blob/main/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`](https://github.com/pascalorg/editor/blob/main/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`](https://github.com/pascalorg/editor/blob/main/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`](https://github.com/pascalorg/editor/blob/main/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`](https://github.com/pascalorg/editor/blob/main/packages/core/src/schema/collections.ts) utilizes the base `generateId('collection')` utility from [`packages/core/src/schema/base.ts`](https://github.com/pascalorg/editor/blob/main/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.