Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ A key rule: **`packages/viewer` must never import from `apps/editor`**. The view

### Building a plugin

New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md) for the contract, and copy [`packages/plugin-trees`](packages/plugin-trees) as a worked example.
New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [Create a plugin](https://editor.pascal.app/docs/developers/plugins) for the contract, and clone [`pascalorg/plugin-trees`](https://github.com/pascalorg/plugin-trees) as a worked example.

## Submitting a PR

Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -339,8 +339,8 @@ Clears dirty flag

The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same `Plugin` manifest the built-ins use — there is no separate internal API.

- **Contract reference** — [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md): the `Plugin` shape, panel contributions, discovery (`setPluginDiscovery`), lifecycle, and what's in/out of v1.
- **Worked example** — [`packages/plugin-trees`](packages/plugin-trees): a first-party plugin (procedural trees, flowers, grass + a presets panel) structurally identical to a third-party pack. Copy it as a starting point.
- **Developer guide** — [Create a plugin](https://editor.pascal.app/docs/developers/plugins): the `Plugin` shape, panel contributions, discovery, lifecycle, and what's in/out of v1.
- **Worked example** — [`pascalorg/plugin-trees`](https://github.com/pascalorg/plugin-trees): a standalone plugin with procedural trees, flowers, grass, and a presets panel. Clone it as a starting point.

---

Expand Down
1 change: 1 addition & 0 deletions apps/editor/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@import "tw-animate-css";
@import "../../../styles/elevation.css";
@source "../../../packages/editor/src";
@source "../../../node_modules/@pascal-app/plugin-trees/src";

@custom-variant dark (&:is(.dark *));

Expand Down
1 change: 1 addition & 0 deletions apps/editor/components/scene-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ function sceneGraphSignature(graph: SceneGraphWithCollections): string {
nodes: graph.nodes,
rootNodeIds: graph.rootNodeIds,
collections: graph.collections,
installedPlugins: graph.installedPlugins,
})
}

Expand Down
1 change: 1 addition & 0 deletions apps/editor/lib/graph-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const apiGraphSchema = z
nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()),
collections: z.unknown().optional(),
installedPlugins: z.array(z.string().min(1)).optional(),
})
.superRefine((value, ctx) => {
for (const [nodeId, node] of Object.entries(value.nodes)) {
Expand Down
2 changes: 1 addition & 1 deletion apps/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
"@pascal-app/plugin-trees": "*",
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7",
Expand Down
36 changes: 2 additions & 34 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ export {
discoverPlugins,
extendPluginDiscovery,
getHostRefFields,
getNodePluginId,
getSelectableKinds,
hasRegistry3DMoveTool,
isDrawnViaTool,
isDrawnViaToolKind,
isNodeKindEnabled,
isPresettable,
isPresettableKind,
isRegistryMovable,
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/registry/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import {
getHostRefFields,
getNodePluginId,
isDrawnViaTool,
isDrawnViaToolKind,
isNodeKindEnabled,
isPresettable,
isPresettableKind,
loadPlugin,
Expand Down Expand Up @@ -188,6 +190,23 @@ describe('loadPlugin', () => {
expect(nodeRegistry.size).toBe(2)
expect(nodeRegistry.has('a')).toBe(true)
expect(nodeRegistry.has('b')).toBe(true)
expect(getNodePluginId('a')).toBe('test:plugin')
expect(getNodePluginId('b')).toBe('test:plugin')
})

test('enables plugin kinds only when the project has the plugin installed', async () => {
await loadPlugin({ id: 'test:plugin', apiVersion: 1, nodes: [makeDefinition('plugin:node')] })

expect(isNodeKindEnabled('plugin:node', [])).toBe(false)
expect(isNodeKindEnabled('plugin:node', ['test:plugin'])).toBe(true)
expect(isNodeKindEnabled('plugin:node')).toBe(true)
expect(isNodeKindEnabled('host:node', [])).toBe(true)
})

test('keeps built-in plugin kinds enabled independently of project installs', async () => {
await loadPlugin({ id: 'pascal:core', apiVersion: 1, nodes: [makeDefinition('wall')] })

expect(isNodeKindEnabled('wall', [])).toBe(true)
})

test('handles plugin with no nodes', async () => {
Expand Down
22 changes: 22 additions & 0 deletions packages/core/src/registry/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import type { ZodObject } from 'zod'
import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types'

const HOST_API_VERSION = 1 as const
const BUILTIN_PLUGIN_ID = 'pascal:core'

const pluginIdsByKind = new Map<string, string>()

// True in dev / test builds, false in production. Tries Vite's
// `import.meta.env.DEV` first (the editor app's bundler) and falls back
Expand Down Expand Up @@ -74,6 +77,7 @@ class NodeRegistryImpl implements NodeRegistry {
// Test-only — clears the registry. Not exported from the package barrel.
_reset(): void {
this.defs.clear()
pluginIdsByKind.clear()
}
}

Expand All @@ -86,6 +90,23 @@ export function registerNode(def: AnyNodeDefinition): void {
nodeRegistry._register(def)
}

/** The plugin that registered a node kind, when it came through {@link loadPlugin}. */
export function getNodePluginId(kind: string): string | undefined {
return pluginIdsByKind.get(kind)
}

/**
* Whether a registered kind should participate in a project. Kinds registered
* directly by the host and the built-in plugin are always enabled. An omitted
* install list means a legacy scene whose plugin state predates persistence, so
* loaded plugins remain visible for backward compatibility.
*/
export function isNodeKindEnabled(kind: string, installedPlugins?: readonly string[]): boolean {
const pluginId = getNodePluginId(kind)
if (!pluginId || pluginId === BUILTIN_PLUGIN_ID || installedPlugins === undefined) return true
return installedPlugins.includes(pluginId)
}

/**
* Returns the set of registered kinds whose definition declares the
* `selectable` capability. Callers that maintain hardcoded "selectable kinds"
Expand Down Expand Up @@ -244,6 +265,7 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
}
for (const def of plugin.nodes ?? []) {
registerNode(def)
pluginIdsByKind.set(def.kind, plugin.id)
}
}

Expand Down
68 changes: 68 additions & 0 deletions packages/core/src/store/use-scene-plugins.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod'
import { loadPlugin, nodeRegistry } from '../registry'
import type { AnyNodeDefinition } from '../registry/types'
import type { AnyNode, AnyNodeId } from '../schema'
import useScene from './use-scene'

describe('scene plugin installation state', () => {
beforeEach(() => {
nodeRegistry._reset()
useScene.getState().setReadOnly(false)
useScene.getState().unloadScene()
})

test('loads an explicit installed plugin list with the scene', () => {
useScene.getState().setScene({}, [], {
installedPlugins: ['pascal:trees'],
hasExplicitPluginInstallState: true,
})

expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(useScene.getState().hasExplicitPluginInstallState).toBe(true)
})

test('install changes are de-duplicated and become explicit', () => {
useScene.getState().setInstalledPlugins(['pascal:trees', 'pascal:trees'], { explicit: true })

expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(useScene.getState().hasExplicitPluginInstallState).toBe(true)
})

test('clearing geometry preserves project plugin installs', () => {
useScene.getState().setInstalledPlugins(['pascal:trees'], { explicit: true })
useScene.getState().clearScene()

expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
expect(useScene.getState().hasExplicitPluginInstallState).toBe(true)
})

test('uninstall clears plugin build work and reinstall schedules it again', async () => {
const kind = 'test:plugin-node'
const definition = {
kind,
schemaVersion: 1,
schema: z.object({ id: z.string(), type: z.literal(kind) }),
category: 'utility',
defaults: () => ({}),
capabilities: {},
} as unknown as AnyNodeDefinition
await loadPlugin({ id: 'test:plugin', apiVersion: 1, nodes: [definition] })
const nodeId = 'plugin_node' as AnyNodeId
useScene.getState().setScene(
{
[nodeId]: { id: nodeId, type: kind } as unknown as AnyNode,
},
[nodeId],
{ installedPlugins: ['test:plugin'], hasExplicitPluginInstallState: true },
)

expect(useScene.getState().dirtyNodes.has(nodeId)).toBe(true)

useScene.getState().setInstalledPlugins([], { explicit: true })
expect(useScene.getState().dirtyNodes.has(nodeId)).toBe(false)

useScene.getState().setInstalledPlugins(['test:plugin'], { explicit: true })
expect(useScene.getState().dirtyNodes.has(nodeId)).toBe(true)
})
})
45 changes: 41 additions & 4 deletions packages/core/src/store/use-scene.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { TemporalState } from 'zundo'
import { temporal } from 'zundo'
import { create, type StoreApi, type UseBoundStore } from 'zustand'
import { parseMaterialRef, toSceneMaterialRef } from '../material-library'
import { nodeRegistry } from '../registry/registry'
import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry'
import { BuildingNode } from '../schema'
import type { Collection, CollectionId } from '../schema/collections'
import { generateCollectionId } from '../schema/collections'
Expand Down Expand Up @@ -952,6 +952,8 @@ export type SceneState = {
// 4. Relational metadata — not nodes
collections: Record<CollectionId, Collection>
materials: Record<SceneMaterialId, SceneMaterial>
installedPlugins: string[]
hasExplicitPluginInstallState: boolean

// 5. Read-only lock — when true all create/update/delete operations are no-ops
readOnly: boolean
Expand All @@ -967,8 +969,11 @@ export type SceneState = {
extra?: {
collections?: Record<CollectionId, Collection>
materials?: Record<SceneMaterialId, SceneMaterial>
installedPlugins?: string[]
hasExplicitPluginInstallState?: boolean
},
) => void
setInstalledPlugins: (pluginIds: string[], options?: { explicit?: boolean }) => void

markDirty: (id: AnyNodeId) => void
clearDirty: (id: AnyNodeId) => void
Expand Down Expand Up @@ -1004,7 +1009,9 @@ export type SceneState = {

type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
temporal: StoreApi<
TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials'>>
TemporalState<
Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials' | 'installedPlugins'>
>
>
}

Expand All @@ -1023,6 +1030,8 @@ const useScene: UseSceneStore = create<SceneState>()(
// 4. Collections
collections: {} as Record<CollectionId, Collection>,
materials: {} as Record<SceneMaterialId, SceneMaterial>,
installedPlugins: [],
hasExplicitPluginInstallState: false,

// 5. Read-only lock
readOnly: false,
Expand All @@ -1035,12 +1044,17 @@ const useScene: UseSceneStore = create<SceneState>()(
dirtyNodes: new Set<AnyNodeId>(),
collections: {},
materials: {},
installedPlugins: [],
hasExplicitPluginInstallState: false,
})
},

clearScene: () => {
const installedPlugins = get().installedPlugins
const hasExplicitPluginInstallState = get().hasExplicitPluginInstallState
get().unloadScene()
get().loadScene() // Default scene
set({ installedPlugins, hasExplicitPluginInstallState })
},

setScene: (nodes, rootNodeIds, extra) => {
Expand Down Expand Up @@ -1086,13 +1100,35 @@ const useScene: UseSceneStore = create<SceneState>()(
dirtyNodes: new Set<AnyNodeId>(),
collections: extra?.collections ?? {},
materials,
installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
})
// Mark all nodes as dirty to trigger re-validation
Object.values(cleanedNodes).forEach((node) => {
get().markDirty(node.id)
})
},

setInstalledPlugins: (pluginIds, options) => {
if (get().readOnly) return
const nextInstalledPlugins = Array.from(new Set(pluginIds))
const previousInstalledPlugins = get().installedPlugins
const dirtyNodes = new Set(get().dirtyNodes)
for (const node of Object.values(get().nodes)) {
if (!getNodePluginId(node.type)) continue
if (!isNodeKindEnabled(node.type, nextInstalledPlugins)) {
dirtyNodes.delete(node.id)
} else if (!isNodeKindEnabled(node.type, previousInstalledPlugins)) {
if (nodeRegistry.get(node.type)?.dirtyTracking !== false) dirtyNodes.add(node.id)
}
}
set({
installedPlugins: nextInstalledPlugins,
hasExplicitPluginInstallState: options?.explicit ?? get().hasExplicitPluginInstallState,
dirtyNodes,
})
},

loadScene: () => {
if (get().rootNodeIds.length > 0) {
// Assign all nodes as dirty to force re-validation
Expand Down Expand Up @@ -1131,6 +1167,7 @@ const useScene: UseSceneStore = create<SceneState>()(

markDirty: (id) => {
const node = get().nodes[id]
if (node && !isNodeKindEnabled(node.type, get().installedPlugins)) return
if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return
get().dirtyNodes.add(id)
},
Expand Down Expand Up @@ -1275,8 +1312,8 @@ const useScene: UseSceneStore = create<SceneState>()(
}),
{
partialize: (state) => {
const { nodes, rootNodeIds, collections, materials } = state
return { nodes, rootNodeIds, collections, materials }
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state
return { nodes, rootNodeIds, collections, materials, installedPlugins }

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Undo skips explicit plugin flag

Medium Severity

Temporal undo/redo tracks installedPlugins but not hasExplicitPluginInstallState. Restoring an older plugin list after install changes can leave the explicit flag out of sync with the undone list, so default-install merging and export semantics no longer match user expectation.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 24016ad. Configure here.

},
limit: 50, // Limit to last 50 actions
},
Expand Down
Loading
Loading