feat: add project plugin management (#501)

This commit is contained in:
Wassim SAMAD
2026-07-16 10:26:59 -04:00
committed by GitHub
parent 4f0aa7f7b6
commit 9ca3eaa7fe
88 changed files with 684 additions and 3100 deletions
+1 -1
View File
@@ -46,7 +46,7 @@ A key rule: **`packages/viewer` must never import from `apps/editor`**. The view
### Building a plugin ### 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 ## Submitting a PR
+2 -2
View File
@@ -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. 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. - **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** — [`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. - **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.
--- ---
+1
View File
@@ -2,6 +2,7 @@
@import "tw-animate-css"; @import "tw-animate-css";
@import "../../../styles/elevation.css"; @import "../../../styles/elevation.css";
@source "../../../packages/editor/src"; @source "../../../packages/editor/src";
@source "../../../node_modules/@pascal-app/plugin-trees/src";
@custom-variant dark (&:is(.dark *)); @custom-variant dark (&:is(.dark *));
+1
View File
@@ -88,6 +88,7 @@ function sceneGraphSignature(graph: SceneGraphWithCollections): string {
nodes: graph.nodes, nodes: graph.nodes,
rootNodeIds: graph.rootNodeIds, rootNodeIds: graph.rootNodeIds,
collections: graph.collections, collections: graph.collections,
installedPlugins: graph.installedPlugins,
}) })
} }
+1
View File
@@ -17,6 +17,7 @@ export const apiGraphSchema = z
nodes: z.record(z.string(), z.unknown()), nodes: z.record(z.string(), z.unknown()),
rootNodeIds: z.array(z.string()), rootNodeIds: z.array(z.string()),
collections: z.unknown().optional(), collections: z.unknown().optional(),
installedPlugins: z.array(z.string().min(1)).optional(),
}) })
.superRefine((value, ctx) => { .superRefine((value, ctx) => {
for (const [nodeId, node] of Object.entries(value.nodes)) { for (const [nodeId, node] of Object.entries(value.nodes)) {
+1 -1
View File
@@ -17,7 +17,7 @@
"@pascal-app/editor": "*", "@pascal-app/editor": "*",
"@pascal-app/mcp": "*", "@pascal-app/mcp": "*",
"@pascal-app/nodes": "*", "@pascal-app/nodes": "*",
"@pascal-app/plugin-trees": "*", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*", "@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
+2 -34
View File
@@ -33,7 +33,7 @@
"@pascal-app/editor": "*", "@pascal-app/editor": "*",
"@pascal-app/mcp": "*", "@pascal-app/mcp": "*",
"@pascal-app/nodes": "*", "@pascal-app/nodes": "*",
"@pascal-app/plugin-trees": "*", "@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
"@pascal-app/viewer": "*", "@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7", "@react-three/drei": "^10.7.7",
@@ -260,38 +260,6 @@
"zustand": "^5", "zustand": "^5",
}, },
}, },
"packages/plugin-trees": {
"name": "@pascal-app/plugin-trees",
"version": "0.1.0",
"dependencies": {
"@dgreenheck/ez-tree": "^1.1.0",
},
"devDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@pascal/typescript-config": "*",
"@react-three/fiber": "^9",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
"react": "^19",
"three": "^0.185",
"typescript": "6.0.3",
"zod": "^4",
"zustand": "^5",
},
"peerDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.185",
"zod": "^4",
"zustand": "^5",
},
},
"packages/typescript-config": { "packages/typescript-config": {
"name": "@repo/typescript-config", "name": "@repo/typescript-config",
"version": "0.0.0", "version": "0.0.0",
@@ -722,7 +690,7 @@
"@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
"@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@workspace:packages/plugin-trees"], "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@github:pascalorg/plugin-trees#56d978c", { "dependencies": { "@dgreenheck/ez-tree": "^1.1.0" }, "peerDependencies": { "@pascal-app/core": ">=0.9.1 <1", "@pascal-app/editor": ">=0.9.1 <1", "@pascal-app/viewer": ">=0.9.1 <1", "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.185", "zod": "^4", "zustand": "^5" } }, "pascalorg-plugin-trees-56d978c", "sha512-16VzWot1oadvxCPqsRwMJbaP0a3u5FESFy7F7++pY5wAAFq9JTFwzHvKAsllrY5TZ5TJ7Y5vSqvbmQk8sy8HaA=="],
"@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"],
+2
View File
@@ -20,10 +20,12 @@ export {
discoverPlugins, discoverPlugins,
extendPluginDiscovery, extendPluginDiscovery,
getHostRefFields, getHostRefFields,
getNodePluginId,
getSelectableKinds, getSelectableKinds,
hasRegistry3DMoveTool, hasRegistry3DMoveTool,
isDrawnViaTool, isDrawnViaTool,
isDrawnViaToolKind, isDrawnViaToolKind,
isNodeKindEnabled,
isPresettable, isPresettable,
isPresettableKind, isPresettableKind,
isRegistryMovable, isRegistryMovable,
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, test } from 'bun:test'
import { z } from 'zod' import { z } from 'zod'
import { import {
getHostRefFields, getHostRefFields,
getNodePluginId,
isDrawnViaTool, isDrawnViaTool,
isDrawnViaToolKind, isDrawnViaToolKind,
isNodeKindEnabled,
isPresettable, isPresettable,
isPresettableKind, isPresettableKind,
loadPlugin, loadPlugin,
@@ -188,6 +190,23 @@ describe('loadPlugin', () => {
expect(nodeRegistry.size).toBe(2) expect(nodeRegistry.size).toBe(2)
expect(nodeRegistry.has('a')).toBe(true) expect(nodeRegistry.has('a')).toBe(true)
expect(nodeRegistry.has('b')).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 () => { test('handles plugin with no nodes', async () => {
+22
View File
@@ -2,6 +2,9 @@ import type { ZodObject } from 'zod'
import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types' import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types'
const HOST_API_VERSION = 1 as const 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 // True in dev / test builds, false in production. Tries Vite's
// `import.meta.env.DEV` first (the editor app's bundler) and falls back // `import.meta.env.DEV` first (the editor app's bundler) and falls back
@@ -74,6 +77,7 @@ class NodeRegistryImpl implements NodeRegistry {
// Test-only — clears the registry. Not exported from the package barrel. // Test-only — clears the registry. Not exported from the package barrel.
_reset(): void { _reset(): void {
this.defs.clear() this.defs.clear()
pluginIdsByKind.clear()
} }
} }
@@ -86,6 +90,23 @@ export function registerNode(def: AnyNodeDefinition): void {
nodeRegistry._register(def) 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 * Returns the set of registered kinds whose definition declares the
* `selectable` capability. Callers that maintain hardcoded "selectable kinds" * `selectable` capability. Callers that maintain hardcoded "selectable kinds"
@@ -244,6 +265,7 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
} }
for (const def of plugin.nodes ?? []) { for (const def of plugin.nodes ?? []) {
registerNode(def) registerNode(def)
pluginIdsByKind.set(def.kind, plugin.id)
} }
} }
@@ -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)
})
})
+41 -4
View File
@@ -4,7 +4,7 @@ import type { TemporalState } from 'zundo'
import { temporal } from 'zundo' import { temporal } from 'zundo'
import { create, type StoreApi, type UseBoundStore } from 'zustand' import { create, type StoreApi, type UseBoundStore } from 'zustand'
import { parseMaterialRef, toSceneMaterialRef } from '../material-library' import { parseMaterialRef, toSceneMaterialRef } from '../material-library'
import { nodeRegistry } from '../registry/registry' import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry'
import { BuildingNode } from '../schema' import { BuildingNode } from '../schema'
import type { Collection, CollectionId } from '../schema/collections' import type { Collection, CollectionId } from '../schema/collections'
import { generateCollectionId } from '../schema/collections' import { generateCollectionId } from '../schema/collections'
@@ -952,6 +952,8 @@ export type SceneState = {
// 4. Relational metadata — not nodes // 4. Relational metadata — not nodes
collections: Record<CollectionId, Collection> collections: Record<CollectionId, Collection>
materials: Record<SceneMaterialId, SceneMaterial> materials: Record<SceneMaterialId, SceneMaterial>
installedPlugins: string[]
hasExplicitPluginInstallState: boolean
// 5. Read-only lock — when true all create/update/delete operations are no-ops // 5. Read-only lock — when true all create/update/delete operations are no-ops
readOnly: boolean readOnly: boolean
@@ -967,8 +969,11 @@ export type SceneState = {
extra?: { extra?: {
collections?: Record<CollectionId, Collection> collections?: Record<CollectionId, Collection>
materials?: Record<SceneMaterialId, SceneMaterial> materials?: Record<SceneMaterialId, SceneMaterial>
installedPlugins?: string[]
hasExplicitPluginInstallState?: boolean
}, },
) => void ) => void
setInstalledPlugins: (pluginIds: string[], options?: { explicit?: boolean }) => void
markDirty: (id: AnyNodeId) => void markDirty: (id: AnyNodeId) => void
clearDirty: (id: AnyNodeId) => void clearDirty: (id: AnyNodeId) => void
@@ -1004,7 +1009,9 @@ export type SceneState = {
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & { type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
temporal: StoreApi< temporal: StoreApi<
TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials'>> TemporalState<
Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials' | 'installedPlugins'>
>
> >
} }
@@ -1023,6 +1030,8 @@ const useScene: UseSceneStore = create<SceneState>()(
// 4. Collections // 4. Collections
collections: {} as Record<CollectionId, Collection>, collections: {} as Record<CollectionId, Collection>,
materials: {} as Record<SceneMaterialId, SceneMaterial>, materials: {} as Record<SceneMaterialId, SceneMaterial>,
installedPlugins: [],
hasExplicitPluginInstallState: false,
// 5. Read-only lock // 5. Read-only lock
readOnly: false, readOnly: false,
@@ -1035,12 +1044,17 @@ const useScene: UseSceneStore = create<SceneState>()(
dirtyNodes: new Set<AnyNodeId>(), dirtyNodes: new Set<AnyNodeId>(),
collections: {}, collections: {},
materials: {}, materials: {},
installedPlugins: [],
hasExplicitPluginInstallState: false,
}) })
}, },
clearScene: () => { clearScene: () => {
const installedPlugins = get().installedPlugins
const hasExplicitPluginInstallState = get().hasExplicitPluginInstallState
get().unloadScene() get().unloadScene()
get().loadScene() // Default scene get().loadScene() // Default scene
set({ installedPlugins, hasExplicitPluginInstallState })
}, },
setScene: (nodes, rootNodeIds, extra) => { setScene: (nodes, rootNodeIds, extra) => {
@@ -1086,6 +1100,8 @@ const useScene: UseSceneStore = create<SceneState>()(
dirtyNodes: new Set<AnyNodeId>(), dirtyNodes: new Set<AnyNodeId>(),
collections: extra?.collections ?? {}, collections: extra?.collections ?? {},
materials, materials,
installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
}) })
// Mark all nodes as dirty to trigger re-validation // Mark all nodes as dirty to trigger re-validation
Object.values(cleanedNodes).forEach((node) => { Object.values(cleanedNodes).forEach((node) => {
@@ -1093,6 +1109,26 @@ const useScene: UseSceneStore = create<SceneState>()(
}) })
}, },
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: () => { loadScene: () => {
if (get().rootNodeIds.length > 0) { if (get().rootNodeIds.length > 0) {
// Assign all nodes as dirty to force re-validation // Assign all nodes as dirty to force re-validation
@@ -1131,6 +1167,7 @@ const useScene: UseSceneStore = create<SceneState>()(
markDirty: (id) => { markDirty: (id) => {
const node = get().nodes[id] const node = get().nodes[id]
if (node && !isNodeKindEnabled(node.type, get().installedPlugins)) return
if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return
get().dirtyNodes.add(id) get().dirtyNodes.add(id)
}, },
@@ -1275,8 +1312,8 @@ const useScene: UseSceneStore = create<SceneState>()(
}), }),
{ {
partialize: (state) => { partialize: (state) => {
const { nodes, rootNodeIds, collections, materials } = state const { nodes, rootNodeIds, collections, materials, installedPlugins } = state
return { nodes, rootNodeIds, collections, materials } return { nodes, rootNodeIds, collections, materials, installedPlugins }
}, },
limit: 50, // Limit to last 50 actions limit: 50, // Limit to last 50 actions
}, },
@@ -41,6 +41,7 @@ function makeSceneGraph(): SceneGraph {
nodeIds: ['scan_1', 'guide_1'] as AnyNodeId[], nodeIds: ['scan_1', 'guide_1'] as AnyNodeId[],
}, },
}, },
installedPlugins: ['pascal:trees'],
} }
} }
@@ -53,6 +54,7 @@ describe('forkSceneGraph', () => {
expect(nodes.some((node) => node.type === 'guide')).toBe(false) expect(nodes.some((node) => node.type === 'guide')).toBe(false)
expect(nodes.some((node) => node.type === 'wall')).toBe(true) expect(nodes.some((node) => node.type === 'wall')).toBe(true)
expect(forked.collections).toEqual({}) expect(forked.collections).toEqual({})
expect(forked.installedPlugins).toEqual(['pascal:trees'])
}) })
test('preserves scan and guide nodes when requested', () => { test('preserves scan and guide nodes when requested', () => {
@@ -66,5 +68,6 @@ describe('forkSceneGraph', () => {
expect( expect(
Object.values(forked.collections ?? {}).flatMap((collection) => collection.nodeIds), Object.values(forked.collections ?? {}).flatMap((collection) => collection.nodeIds),
).toHaveLength(2) ).toHaveLength(2)
expect(forked.installedPlugins).toEqual(['pascal:trees'])
}) })
}) })
+5 -2
View File
@@ -6,6 +6,7 @@ export type SceneGraph = {
nodes: Record<AnyNodeId, AnyNode> nodes: Record<AnyNodeId, AnyNode>
rootNodeIds: AnyNodeId[] rootNodeIds: AnyNodeId[]
collections?: Record<CollectionId, Collection> collections?: Record<CollectionId, Collection>
installedPlugins?: string[]
} }
/** /**
@@ -26,7 +27,7 @@ function extractIdPrefix(id: string): string {
* - Multi-scene in-memory scenarios * - Multi-scene in-memory scenarios
*/ */
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph { export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
const { nodes, rootNodeIds, collections } = sceneGraph const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph
// Build ID mapping: old ID -> new ID // Build ID mapping: old ID -> new ID
const idMap = new Map<string, string>() const idMap = new Map<string, string>()
@@ -134,6 +135,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
nodes: clonedNodes, nodes: clonedNodes,
rootNodeIds: clonedRootNodeIds, rootNodeIds: clonedRootNodeIds,
...(clonedCollections && { collections: clonedCollections }), ...(clonedCollections && { collections: clonedCollections }),
...(installedPlugins && { installedPlugins: [...installedPlugins] }),
} }
} }
@@ -255,7 +257,7 @@ export function forkSceneGraph(
return cloneSceneGraph(sceneGraph) return cloneSceneGraph(sceneGraph)
} }
const { nodes, rootNodeIds, collections } = sceneGraph const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph
// First, identify scan and guide node IDs to exclude (user-uploaded imagery) // First, identify scan and guide node IDs to exclude (user-uploaded imagery)
const excludedNodeIds = new Set<string>() const excludedNodeIds = new Set<string>()
@@ -317,5 +319,6 @@ export function forkSceneGraph(
nodes: filteredNodes, nodes: filteredNodes,
rootNodeIds: filteredRootNodeIds, rootNodeIds: filteredRootNodeIds,
...(filteredCollections && { collections: filteredCollections }), ...(filteredCollections && { collections: filteredCollections }),
...(installedPlugins && { installedPlugins }),
}) })
} }
@@ -20,6 +20,7 @@ export type BuildStats = {
export type ParsedBuildJson = { export type ParsedBuildJson = {
nodes: Record<string, unknown> nodes: Record<string, unknown>
rootNodeIds: string[] rootNodeIds: string[]
installedPlugins?: string[]
} }
export type SchemaIssue = { export type SchemaIssue = {
@@ -100,6 +101,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
const nodesRaw = input.nodes const nodesRaw = input.nodes
const rootNodeIdsRaw = input.rootNodeIds const rootNodeIdsRaw = input.rootNodeIds
const installedPluginsRaw = input.installedPlugins
if (!isPlainObject(nodesRaw)) { if (!isPlainObject(nodesRaw)) {
errors.push({ errors.push({
@@ -135,6 +137,19 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
nodesRaw as Record<string, unknown>, nodesRaw as Record<string, unknown>,
) )
const rootNodeIds = rootNodeIdsRaw as string[] const rootNodeIds = rootNodeIdsRaw as string[]
const installedPlugins =
Array.isArray(installedPluginsRaw) &&
installedPluginsRaw.every((pluginId) => typeof pluginId === 'string')
? Array.from(new Set(installedPluginsRaw))
: undefined
if (installedPluginsRaw !== undefined && installedPlugins === undefined) {
warnings.push({
severity: 'warning',
code: 'invalid_installed_plugins',
message: 'Ignored invalid "installedPlugins" — expected an array of plugin IDs.',
})
}
if (strippedChildRefs > 0 || droppedWallIds.length > 0) { if (strippedChildRefs > 0 || droppedWallIds.length > 0) {
warnings.push({ warnings.push({
@@ -296,7 +311,13 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
const ok = errors.length === 0 const ok = errors.length === 0
return { return {
ok, ok,
parsed: ok ? { nodes, rootNodeIds } : null, parsed: ok
? {
nodes,
rootNodeIds,
...(installedPlugins ? { installedPlugins } : {}),
}
: null,
stats, stats,
errors, errors,
warnings, warnings,
@@ -9,6 +9,7 @@ import {
type FloorplanPalette, type FloorplanPalette,
type FloorplanPoint, type FloorplanPoint,
type GeometryContext, type GeometryContext,
isNodeKindEnabled,
isRegistryMovable, isRegistryMovable,
kindsWithFloorplanScope, kindsWithFloorplanScope,
type LiveNodeOverrides, type LiveNodeOverrides,
@@ -292,6 +293,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const setHoveredId = useViewer((s) => s.setHoveredId) const setHoveredId = useViewer((s) => s.setHoveredId)
const setSelection = useViewer((s) => s.setSelection) const setSelection = useViewer((s) => s.setSelection)
const nodes = useScene((s) => s.nodes) const nodes = useScene((s) => s.nodes)
const installedPlugins = useScene((s) => s.installedPlugins)
const movingNode = useMovingNode() const movingNode = useMovingNode()
// When a building is being moved, its explicit selection may be // When a building is being moved, its explicit selection may be
// cleared as part of the move handoff. Fall back to the // cleared as part of the move handoff. Fall back to the
@@ -761,6 +763,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
const collectLevelDataKind = (id: AnyNodeId) => { const collectLevelDataKind = (id: AnyNodeId) => {
const node = nodes[id] const node = nodes[id]
if (!node) return if (!node) return
if (!isNodeKindEnabled(node.type, installedPlugins)) return
const def = nodeRegistry.get(node.type) const def = nodeRegistry.get(node.type)
if (def?.computeFloorplanLevelData) { if (def?.computeFloorplanLevelData) {
const ids = levelNodeIdsByType.get(node.type) const ids = levelNodeIdsByType.get(node.type)
@@ -776,6 +779,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
collectLevelDataKind(levelId as AnyNodeId) collectLevelDataKind(levelId as AnyNodeId)
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => { const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
if (!isNodeKindEnabled(node.type, installedPlugins)) return
const def = nodeRegistry.get(node.type) const def = nodeRegistry.get(node.type)
if (!def?.floorplan) return if (!def?.floorplan) return
const dependsOnSiblingInputs = !!( const dependsOnSiblingInputs = !!(
@@ -844,7 +848,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type) if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
} }
return { entries: out, levelNodeIdsByType } return { entries: out, levelNodeIdsByType }
}, [levelId, nodes]) }, [installedPlugins, levelId, nodes])
// ── Generic 2D affordance dispatch ───────────────────────────────── // ── Generic 2D affordance dispatch ─────────────────────────────────
// //
@@ -1,5 +1,6 @@
'use client' 'use client'
import { Plus } from 'lucide-react'
import type { ComponentType, ReactNode } from 'react' import type { ComponentType, ReactNode } from 'react'
import { import {
Tooltip, Tooltip,
@@ -10,7 +11,13 @@ import { cn } from './../../../lib/utils'
export type PanelId = string export type PanelId = string
export type ExtraPanel = { id: string; icon: ReactNode; label: string; component: ComponentType } export type ExtraPanel = {
id: string
icon: ReactNode
label: string
component: ComponentType
pluginId?: string
}
interface IconRailProps { interface IconRailProps {
activePanel: PanelId activePanel: PanelId
@@ -41,6 +48,38 @@ export function IconRail({
extraPanels, extraPanels,
className, className,
}: IconRailProps) { }: IconRailProps) {
const regularExtraPanels = extraPanels?.filter((panel) => !panel.pluginId && panel.id !== 'plugins')
const pluginPanels = extraPanels?.filter((panel) => panel.pluginId)
const pluginsPanel = extraPanels?.find((panel) => panel.id === 'plugins')
const renderExtraPanel = (panel: ExtraPanel) => {
const isActive = activePanel === panel.id
return (
<Tooltip key={panel.id}>
<TooltipTrigger asChild>
<button
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
isActive ? 'bg-accent' : 'hover:bg-accent',
)}
onClick={() => onPanelChange(panel.id)}
type="button"
>
<span
className={cn(
'flex h-6 w-6 items-center justify-center transition-all',
!isActive && 'opacity-50',
)}
>
{panel.id === 'plugins' ? <Plus className="h-5 w-5" /> : panel.icon}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{panel.label}</TooltipContent>
</Tooltip>
)
}
return ( return (
<div <div
className={cn( className={cn(
@@ -83,34 +122,7 @@ export function IconRail({
) )
})} })}
{/* Extra panels (injected between site and settings) */} {regularExtraPanels?.map(renderExtraPanel)}
{extraPanels?.map((panel) => {
const isActive = activePanel === panel.id
return (
<Tooltip key={panel.id}>
<TooltipTrigger asChild>
<button
className={cn(
'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
isActive ? 'bg-accent' : 'hover:bg-accent',
)}
onClick={() => onPanelChange(panel.id)}
type="button"
>
<span
className={cn(
'flex h-6 w-6 items-center justify-center transition-all',
!isActive && 'opacity-50',
)}
>
{panel.icon}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{panel.label}</TooltipContent>
</Tooltip>
)
})}
{/* Settings panel */} {/* Settings panel */}
{[settingsPanel].map((panel) => { {[settingsPanel].map((panel) => {
@@ -140,6 +152,13 @@ export function IconRail({
</Tooltip> </Tooltip>
) )
})} })}
{(pluginPanels?.length || pluginsPanel) && (
<div className="mt-1 flex w-9 flex-col items-center gap-1 border-border/70 border-t pt-2">
{pluginPanels?.map(renderExtraPanel)}
{pluginsPanel && renderExtraPanel(pluginsPanel)}
</div>
)}
</div> </div>
) )
} }
@@ -0,0 +1,218 @@
'use client'
import { Icon } from '@iconify/react'
import { type IconRef, useScene } from '@pascal-app/core'
import { ChevronLeft, ChevronRight, ExternalLink, Puzzle } from 'lucide-react'
import { lazy, type ReactNode, Suspense, useState, useSyncExternalStore } from 'react'
import { editorHostPanelRegistry } from '../../../../lib/plugin-panels'
import { Button } from '../../primitives/button'
const PLUGIN_AUTHORING_URL =
'https://editor.pascal.app/docs/developers/plugins'
function renderPluginIcon(ref: IconRef): ReactNode {
if (ref.kind === 'url') {
return <img alt="" className="h-8 w-8 object-contain" src={ref.src} />
}
if (ref.kind === 'iconify') {
return <Icon height={28} icon={ref.name} width={28} />
}
if (ref.kind === 'svg') {
return (
<svg height={28} viewBox={ref.viewBox} width={28}>
<path d={ref.path} fill="currentColor" />
</svg>
)
}
const LazyIcon = lazy(ref.module)
return (
<Suspense fallback={<Puzzle className="h-7 w-7" />}>
<LazyIcon />
</Suspense>
)
}
export function PluginsPanel() {
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null)
const panels = useSyncExternalStore(
editorHostPanelRegistry.subscribe,
editorHostPanelRegistry.getSnapshot,
editorHostPanelRegistry.getSnapshot,
)
const installedPlugins = useScene((state) => state.installedPlugins)
const setInstalledPlugins = useScene((state) => state.setInstalledPlugins)
const readOnly = useScene((state) => state.readOnly)
const plugins = Array.from(
new Map(
panels
.filter((panel) => panel.pluginId)
.map((panel) => [panel.pluginId as string, panel]),
).entries(),
)
const selectedPlugin = selectedPluginId
? plugins.find(([pluginId]) => pluginId === selectedPluginId)
: undefined
if (selectedPlugin) {
const [pluginId, panel] = selectedPlugin
const installed = installedPlugins.includes(pluginId)
return (
<div className="flex h-full flex-col overflow-y-auto p-4">
<div>
<Button
className="rounded-full"
onClick={() => setSelectedPluginId(null)}
size="sm"
variant="ghost"
>
<ChevronLeft className="h-4 w-4" />
All plugins
</Button>
<div className="mt-5 flex items-start gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-background/60">
{renderPluginIcon(panel.icon)}
</div>
<div className="min-w-0 pt-1">
<h2 className="font-semibold text-lg text-sidebar-foreground">{panel.label}</h2>
<p className="text-sidebar-foreground/50 text-sm">
{installed ? 'Installed' : 'Not installed'}
</p>
</div>
</div>
<p className="mt-5 text-sidebar-foreground/70 text-sm">
{panel.description ?? 'Adds a new tool panel to the editor.'}
</p>
<dl className="mt-6 divide-y divide-border/50 rounded-xl border border-border/60">
<div className="p-3">
<dt className="text-sidebar-foreground/50 text-xs">Plugin ID</dt>
<dd className="mt-1 break-all text-sidebar-foreground text-sm">{pluginId}</dd>
</div>
{panel.creator && (
<div className="p-3">
<dt className="text-sidebar-foreground/50 text-xs">Creator</dt>
<dd className="mt-1 text-sm">
{panel.creator.url ? (
<a
className="inline-flex items-center gap-1 text-sidebar-foreground underline-offset-4 hover:underline"
href={panel.creator.url}
rel="noreferrer"
target="_blank"
>
{panel.creator.name}
<ExternalLink className="h-3 w-3" />
</a>
) : (
panel.creator.name
)}
</dd>
</div>
)}
{panel.pluginUrl && (
<div className="p-3">
<dt className="text-sidebar-foreground/50 text-xs">Plugin</dt>
<dd className="mt-1 text-sm">
<a
className="inline-flex items-center gap-1 text-sidebar-foreground underline-offset-4 hover:underline"
href={panel.pluginUrl}
rel="noreferrer"
target="_blank"
>
View plugin
<ExternalLink className="h-3 w-3" />
</a>
</dd>
</div>
)}
</dl>
<Button
className="mt-5 rounded-full"
disabled={readOnly}
onClick={() => {
const next = installed
? installedPlugins.filter((id) => id !== pluginId)
: [...installedPlugins, pluginId]
setInstalledPlugins(next, { explicit: true })
}}
variant={installed ? 'outline' : 'default'}
>
{installed ? 'Uninstall' : 'Install'}
</Button>
</div>
<div className="mt-auto pt-6">
<a
className="inline-flex items-center gap-1.5 text-sidebar-foreground/70 text-sm underline-offset-4 hover:text-sidebar-foreground hover:underline"
href={PLUGIN_AUTHORING_URL}
rel="noreferrer"
target="_blank"
>
Create a Pascal plugin
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
)
}
return (
<div className="flex h-full flex-col overflow-y-auto p-4">
<div className="mb-5">
<h2 className="font-semibold text-lg text-sidebar-foreground">Plugins</h2>
<p className="mt-1 text-sidebar-foreground/60 text-sm">
Add focused tools and content to this project.
</p>
</div>
<div className="flex flex-col gap-3">
{plugins.map(([pluginId, panel]) => {
const installed = installedPlugins.includes(pluginId)
return (
<button
className="w-full rounded-xl border border-border/60 bg-accent/20 p-3 text-left transition-colors hover:bg-accent/40"
key={pluginId}
onClick={() => setSelectedPluginId(pluginId)}
type="button"
>
<div className="flex items-start gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-background/60">
{renderPluginIcon(panel.icon)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-3">
<div>
<h3 className="font-medium text-sidebar-foreground">{panel.label}</h3>
<p className="text-sidebar-foreground/50 text-xs">
{installed ? 'Installed' : 'Not installed'}
</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-sidebar-foreground/50" />
</div>
<p className="mt-2 text-sidebar-foreground/60 text-sm">
{panel.description ?? 'Adds a new tool panel to the editor.'}
</p>
</div>
</div>
</button>
)
})}
</div>
<div className="mt-auto pt-6">
<a
className="inline-flex items-center gap-1.5 text-sidebar-foreground/70 text-sm underline-offset-4 hover:text-sidebar-foreground hover:underline"
href={PLUGIN_AUTHORING_URL}
rel="noreferrer"
target="_blank"
>
Create a Pascal plugin
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
</div>
)
}
@@ -180,6 +180,7 @@ export function SettingsPanel({
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const nodes = useScene((state) => state.nodes) const nodes = useScene((state) => state.nodes)
const rootNodeIds = useScene((state) => state.rootNodeIds) const rootNodeIds = useScene((state) => state.rootNodeIds)
const installedPlugins = useScene((state) => state.installedPlugins)
const setScene = useScene((state) => state.setScene) const setScene = useScene((state) => state.setScene)
const clearScene = useScene((state) => state.clearScene) const clearScene = useScene((state) => state.clearScene)
const resetSelection = useViewer((state) => state.resetSelection) const resetSelection = useViewer((state) => state.resetSelection)
@@ -206,7 +207,7 @@ export function SettingsPanel({
const isLocalProject = false // Props-based; only show cloud sections when projectId provided const isLocalProject = false // Props-based; only show cloud sections when projectId provided
const handleSaveBuild = () => { const handleSaveBuild = () => {
const sceneData = { nodes, rootNodeIds } const sceneData = { nodes, rootNodeIds, installedPlugins }
const json = JSON.stringify(sceneData, null, 2) const json = JSON.stringify(sceneData, null, 2)
const blob = new Blob([json], { type: 'application/json' }) const blob = new Blob([json], { type: 'application/json' })
const url = URL.createObjectURL(blob) const url = URL.createObjectURL(blob)
@@ -262,10 +263,20 @@ export function SettingsPanel({
e.target.value = '' e.target.value = ''
} }
const handleConfirmImport = (parsed: { nodes: Record<string, unknown>; rootNodeIds: string[] }) => { const handleConfirmImport = (parsed: {
nodes: Record<string, unknown>
rootNodeIds: string[]
installedPlugins?: string[]
}) => {
const currentScene = useScene.getState()
setScene( setScene(
parsed.nodes as Parameters<typeof setScene>[0], parsed.nodes as Parameters<typeof setScene>[0],
parsed.rootNodeIds as Parameters<typeof setScene>[1], parsed.rootNodeIds as Parameters<typeof setScene>[1],
{
installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins,
hasExplicitPluginInstallState:
parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,
},
) )
// An import is a scene load: it becomes the undo floor. Without this, // An import is a scene load: it becomes the undo floor. Without this,
// undo could step back into the pre-import scene state. // undo could step back into the pre-import scene state.
@@ -1,6 +1,7 @@
'use client' 'use client'
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { editorHostPanelRegistry } from '../../../lib/plugin-panels'
import { triggerSFX } from './../../../lib/sfx-bus' import { triggerSFX } from './../../../lib/sfx-bus'
import { cn } from './../../../lib/utils' import { cn } from './../../../lib/utils'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip' import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
@@ -65,37 +66,50 @@ interface IconRailProps {
* The label renders as a hover tooltip on the right. * The label renders as a hover tooltip on the right.
*/ */
export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) { export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
const pluginPanelIds = new Set(
editorHostPanelRegistry.getSnapshot().flatMap((panel) =>
panel.pluginId ? [panel.id] : [],
),
)
const defaultTabs = tabs.filter((tab) => !pluginPanelIds.has(tab.id) && tab.id !== 'plugins')
const pluginTabs = tabs.filter((tab) => pluginPanelIds.has(tab.id) || tab.id === 'plugins')
const renderTab = (tab: SidebarTab) => {
const showActive = activeTab === tab.id && !collapsed
return (
<Tooltip key={tab.id}>
<TooltipTrigger asChild>
<button
className={cn(
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
showActive
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
onIconClick(tab.id)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
{tab.icon ?? tab.label.charAt(0)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{tab.label}</TooltipContent>
</Tooltip>
)
}
return ( return (
<TooltipProvider delayDuration={0} disableHoverableContent> <TooltipProvider delayDuration={0} disableHoverableContent>
<div className="flex h-full w-14 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2"> <div className="flex h-full w-14 shrink-0 flex-col items-center gap-1 border-border/50 border-r py-2">
{tabs.map((tab) => { {defaultTabs.map(renderTab)}
// Only show the active highlight while the panel is open. When {pluginTabs.length > 0 && (
// collapsed nothing is "open", so every icon reads as unselected. <div className="mt-1 flex w-11 flex-col items-center gap-1 border-border/70 border-t pt-2">
const showActive = activeTab === tab.id && !collapsed {pluginTabs.map(renderTab)}
return ( </div>
<Tooltip key={tab.id}> )}
<TooltipTrigger asChild>
<button
className={cn(
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
showActive
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
onIconClick(tab.id)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
{tab.icon ?? tab.label.charAt(0)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{tab.label}</TooltipContent>
</Tooltip>
)
})}
</div> </div>
</TooltipProvider> </TooltipProvider>
) )
@@ -1,12 +1,28 @@
'use client' 'use client'
import { Icon } from '@iconify/react' import { Icon } from '@iconify/react'
import { type IconRef } from '@pascal-app/core' import { type IconRef, useScene } from '@pascal-app/core'
import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react' import { Plus } from 'lucide-react'
import {
type ComponentType,
lazy,
type ReactNode,
Suspense,
useEffect,
useSyncExternalStore,
} from 'react'
import useEditor from '../../../store/use-editor' import useEditor from '../../../store/use-editor'
import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels' import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels'
import { ErrorBoundary } from '../primitives/error-boundary' import { ErrorBoundary } from '../primitives/error-boundary'
import type { ExtraPanel } from './icon-rail' import type { ExtraPanel } from './icon-rail'
import { PluginsPanel } from './panels/plugins-panel'
const pluginsManagerPanel: ExtraPanel = {
id: 'plugins',
label: 'Plugins',
icon: <Plus className="h-5 w-5" />,
component: PluginsPanel,
}
/** Resolve a plugin's {@link IconRef} into a rail-sized React node. Mirrors the /** Resolve a plugin's {@link IconRef} into a rail-sized React node. Mirrors the
* inspector's `renderIcon`, sized for the 24px icon-rail button. */ * inspector's `renderIcon`, sized for the 24px icon-rail button. */
@@ -83,16 +99,34 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
editorHostPanelRegistry.getSnapshot, editorHostPanelRegistry.getSnapshot,
) )
const workspaceMode = useEditor((s) => s.workspaceMode) const workspaceMode = useEditor((s) => s.workspaceMode)
const installedPlugins = useScene((s) => s.installedPlugins)
const hostIds = new Set(hostPanels?.map((p) => p.id)) const hostIds = new Set(hostPanels?.map((p) => p.id))
useEffect(() => {
const scene = useScene.getState()
if (scene.hasExplicitPluginInstallState) return
const defaults = editorHostPanelRegistry.getDefaultInstalledPluginIds()
if (defaults.every((pluginId) => scene.installedPlugins.includes(pluginId))) return
scene.setInstalledPlugins([...scene.installedPlugins, ...defaults], { explicit: false })
}, [registered])
const fromRegistry = registered const fromRegistry = registered
.filter((p) => !hostIds.has(p.id) && (p.workspaces ?? ['edit']).includes(workspaceMode)) .filter(
(p) =>
!hostIds.has(p.id) &&
(p.workspaces ?? ['edit']).includes(workspaceMode) &&
(!p.pluginId || installedPlugins.includes(p.pluginId)),
)
.map( .map(
(p): ExtraPanel => ({ (p): ExtraPanel => ({
id: p.id, id: p.id,
label: p.label, label: p.label,
icon: renderIconRef(p.icon), icon: renderIconRef(p.icon),
component: resolvePanelComponent(p), component: resolvePanelComponent(p),
pluginId: p.pluginId,
}), }),
) )
return [...(hostPanels ?? []), ...fromRegistry] const manager =
workspaceMode === 'edit' && !hostIds.has(pluginsManagerPanel.id) ? [pluginsManagerPanel] : []
return [...(hostPanels ?? []), ...fromRegistry, ...manager]
} }
+22 -5
View File
@@ -67,6 +67,7 @@ export function useAutoSave({
// collection change still triggers a save. // collection change still triggers a save.
let lastCollectionsRef = useScene.getState().collections let lastCollectionsRef = useScene.getState().collections
let lastMaterialsRef = useScene.getState().materials let lastMaterialsRef = useScene.getState().materials
let lastInstalledPluginsRef = useScene.getState().installedPlugins
async function executeSave() { async function executeSave() {
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) { if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
@@ -75,8 +76,14 @@ export function useAutoSave({
return return
} }
const { nodes, rootNodeIds, collections, materials } = useScene.getState() const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph const sceneGraph = {
nodes,
rootNodeIds,
collections,
materials,
installedPlugins,
} as SceneGraph
// Guard: refuse to autosave if the scene went from populated to nearly empty. // Guard: refuse to autosave if the scene went from populated to nearly empty.
// This catches accidental full deletions before they're persisted. // This catches accidental full deletions before they're persisted.
@@ -126,6 +133,7 @@ export function useAutoSave({
lastNodesSnapshot = JSON.stringify(state.nodes) lastNodesSnapshot = JSON.stringify(state.nodes)
lastCollectionsRef = state.collections lastCollectionsRef = state.collections
lastMaterialsRef = state.materials lastMaterialsRef = state.materials
lastInstalledPluginsRef = state.installedPlugins
return return
} }
@@ -134,6 +142,7 @@ export function useAutoSave({
lastNodesSnapshot = JSON.stringify(state.nodes) lastNodesSnapshot = JSON.stringify(state.nodes)
lastCollectionsRef = state.collections lastCollectionsRef = state.collections
lastMaterialsRef = state.materials lastMaterialsRef = state.materials
lastInstalledPluginsRef = state.installedPlugins
return return
} }
@@ -141,12 +150,14 @@ export function useAutoSave({
const changed = const changed =
currentNodesSnapshot !== lastNodesSnapshot || currentNodesSnapshot !== lastNodesSnapshot ||
state.collections !== lastCollectionsRef || state.collections !== lastCollectionsRef ||
state.materials !== lastMaterialsRef state.materials !== lastMaterialsRef ||
state.installedPlugins !== lastInstalledPluginsRef
if (!changed) return if (!changed) return
lastNodesSnapshot = currentNodesSnapshot lastNodesSnapshot = currentNodesSnapshot
lastCollectionsRef = state.collections lastCollectionsRef = state.collections
lastMaterialsRef = state.materials lastMaterialsRef = state.materials
lastInstalledPluginsRef = state.installedPlugins
hasDirtyChangesRef.current = true hasDirtyChangesRef.current = true
onDirtyRef.current?.() onDirtyRef.current?.()
setSaveStatus('pending') setSaveStatus('pending')
@@ -172,8 +183,14 @@ export function useAutoSave({
function flushOnExit() { function flushOnExit() {
if (!hasDirtyChangesRef.current) return if (!hasDirtyChangesRef.current) return
hasDirtyChangesRef.current = false hasDirtyChangesRef.current = false
const { nodes, rootNodeIds, collections, materials } = useScene.getState() const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph const sceneGraph = {
nodes,
rootNodeIds,
collections,
materials,
installedPlugins,
} as SceneGraph
if (onSaveRef.current) { if (onSaveRef.current) {
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {}) onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
} else { } else {
+17
View File
@@ -8,6 +8,14 @@ export type EditorHostPanel = {
icon: IconRef icon: IconRef
component: LazyComponent component: LazyComponent
workspaces?: readonly EditorHostPanelWorkspace[] workspaces?: readonly EditorHostPanelWorkspace[]
pluginId?: string
description?: string
creator?: {
name: string
url?: string
}
pluginUrl?: string
defaultInstalled?: boolean
} }
function isDevMode(): boolean { function isDevMode(): boolean {
@@ -37,6 +45,15 @@ class EditorHostPanelRegistryImpl {
getSnapshot = (): EditorHostPanel[] => this.cached getSnapshot = (): EditorHostPanel[] => this.cached
getDefaultInstalledPluginIds = (): string[] =>
Array.from(
new Set(
this.cached
.filter((panel) => panel.pluginId && panel.defaultInstalled)
.map((panel) => panel.pluginId as string),
),
)
reset(): void { reset(): void {
this.panels.clear() this.panels.clear()
this.emit() this.emit()
+7 -1
View File
@@ -13,6 +13,7 @@ import useEditor, {
normalizePersistedEditorUiState, normalizePersistedEditorUiState,
type PersistedEditorUiState, type PersistedEditorUiState,
} from '../store/use-editor' } from '../store/use-editor'
import { editorHostPanelRegistry } from './plugin-panels'
export type SceneGraph = { export type SceneGraph = {
nodes: Record<string, unknown> nodes: Record<string, unknown>
@@ -21,6 +22,7 @@ export type SceneGraph = {
// payloads (and callers that only build nodes) stay valid. // payloads (and callers that only build nodes) stay valid.
collections?: Record<string, unknown> collections?: Record<string, unknown>
materials?: Record<string, unknown> materials?: Record<string, unknown>
installedPlugins?: string[]
} }
type PersistedSelectionPath = { type PersistedSelectionPath = {
@@ -381,14 +383,18 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen
} }
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) { export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds()
if (hasUsableSceneGraph(sceneGraph)) { if (hasUsableSceneGraph(sceneGraph)) {
const { nodes, rootNodeIds, collections, materials } = sceneGraph const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph
useScene.getState().setScene(nodes as any, rootNodeIds as any, { useScene.getState().setScene(nodes as any, rootNodeIds as any, {
collections: collections as any, collections: collections as any,
materials: materials as any, materials: materials as any,
installedPlugins: installedPlugins ?? defaultInstalledPlugins,
hasExplicitPluginInstallState: installedPlugins !== undefined,
}) })
} else { } else {
useScene.getState().clearScene() useScene.getState().clearScene()
useScene.getState().setInstalledPlugins(defaultInstalledPlugins, { explicit: false })
} }
// The loaded scene is the undo floor. Loading records history entries of // The loaded scene is the undo floor. Loading records history entries of
@@ -432,6 +432,21 @@ describe('SceneBridge', () => {
expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length) expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length)
}) })
test('loadJSON preserves explicit plugin installs', () => {
const snap = bridge.exportJSON()
bridge.loadJSON({ ...snap, installedPlugins: ['pascal:trees'] })
expect(bridge.exportJSON().installedPlugins).toEqual(['pascal:trees'])
})
test('legacy graphs do not become explicitly uninstalled on export', () => {
const snap = bridge.exportJSON()
const { installedPlugins: _installedPlugins, ...legacy } = snap
bridge.loadJSON(legacy)
expect(Object.hasOwn(bridge.exportJSON(), 'installedPlugins')).toBe(false)
})
test('loadJSON throws on malformed JSON string', () => { test('loadJSON throws on malformed JSON string', () => {
expect(() => bridge.loadJSON('not json')).toThrow(/invalid JSON/) expect(() => bridge.loadJSON('not json')).toThrow(/invalid JSON/)
}) })
+9
View File
@@ -72,6 +72,9 @@ export class SceneBridge {
nodes: state.nodes, nodes: state.nodes,
rootNodeIds: state.rootNodeIds, rootNodeIds: state.rootNodeIds,
collections: state.collections ?? {}, collections: state.collections ?? {},
...(state.hasExplicitPluginInstallState || state.installedPlugins.length > 0
? { installedPlugins: state.installedPlugins }
: {}),
}), }),
) )
} }
@@ -117,6 +120,12 @@ export class SceneBridge {
} }
this.setScene(nodes as Record<AnyNodeId, AnyNode>, rootNodeIds as AnyNodeId[]) this.setScene(nodes as Record<AnyNodeId, AnyNode>, rootNodeIds as AnyNodeId[])
if (Array.isArray(obj.installedPlugins)) {
useScene.getState().setInstalledPlugins(
obj.installedPlugins.filter((id): id is string => typeof id === 'string'),
{ explicit: true },
)
}
} }
/** Read a single node, or `null` if not present. */ /** Read a single node, or `null` if not present. */
@@ -150,6 +150,7 @@ class SceneOperationsFacade implements SceneOperations {
nodes: exported.nodes, nodes: exported.nodes,
rootNodeIds: exported.rootNodeIds, rootNodeIds: exported.rootNodeIds,
collections: exported.collections as SceneGraph['collections'], collections: exported.collections as SceneGraph['collections'],
installedPlugins: exported.installedPlugins,
} }
} }
-99
View File
@@ -1,99 +0,0 @@
# @pascal-app/plugin-trees
The first-party **example plugin** for the Pascal editor. It contributes
procedural plant nodes plus a separately exported host-side Nature panel, and
exists to prove — and document — the minimal node-plugin surface every future
plugin reuses.
It is structurally identical to a third-party plugin: it peer-depends on
`@pascal-app/{core,viewer,editor}` (plus `react`/`three`/`@react-three/fiber`/
`zustand`) and bundles `@dgreenheck/ez-tree` for the geometry. It imports
nothing private. Copy this folder as the starting point for a new plugin.
## What it demonstrates
The contribution paths this package demonstrates:
1. **Host-side panel extension** — the standalone editor registers
`treesHostPanel` separately from the core plugin manifest. `presets-panel.tsx`
is a plain React component the host mounts behind an error boundary.
2. **Right inspector for free**`def.parametrics` (`parametrics.ts`). The host
renders the preset/height/seed controls + the Randomize action with zero
tree-specific code.
3. **Placement**`def.tool`/`def.preview` (`tool.tsx`, `preview.tsx`). The
tool respects the active snapping mode (`isGridSnapActive()` + `gridSnapStep`)
exactly like the built-in item/shelf tools.
4. **Instanced rendering** (the generic core in `instanced.tsx`, shared by both
kinds) — instead of the per-node `def.geometry` path, plants render via two
pieces:
- `def.system` — a collective renderer mounted once that groups every node of
the kind by its geometry variant and draws each variant as one
`InstancedMesh` per sub-mesh. A forest of N is a handful of draw calls.
Variant geometry is generated once and cached.
- `def.renderer` — a featherweight per-node proxy: a stable invisible box
collider (the raycast target) in an outer group, plus the real geometry
(invisible, mounted only while hovered/selected) in an inner *registered*
group. So the host's outline pass traces the **true silhouette**, picking
stays on the box, and selection / outline / zone machinery works unchanged
with no instanceId bookkeeping.
### Three kinds
- **`trees:tree`** — ez-tree geometry (`geometry.ts`); species presets Oak /
Pine / Aspen / Ash / Bush / Trellis × a Small/Medium/Large **size** (all of
ez-tree's built-in presets), a Deciduous/Evergreen **type**, curated params
(foliage density, trunk thickness, leafless), and leaf/branch **colour tints**
all folded into the variant key. Colours are edit-only (inspector), not on the
placement brush.
- **`trees:flower`** — simple procedural geometry (`flower-geometry.ts`, merged
per material); presets daisy / tulip / lavender, with a per-flower petal colour.
- **`trees:grass`** — procedural blade tufts (`grass-geometry.ts`); presets
meadow / fescue / reed, with a per-tuft blade colour.
Flowers and grass are sibling kinds that reuse the exact same instanced core +
placement helper (`instanced.tsx` / `placement.tsx`) and the shared procedural
RNG (`mulberry32` in `geometry.ts`) — the template for adding more plant kinds.
It also shows the communication triangle: `presets-panel` → plugin store
(`store.ts`) → `def.tool``SceneApi` → scene → reactive `useScene` read-back
(the "N planted" counter in the panel).
`@dgreenheck/ez-tree` ships its bark/leaf textures inlined as base64, so there
are no assets to host. Placement seeds are drawn from a small bounded pool
(`TREE_SEED_POOL`) so trees share variants — that sharing is what makes the
instancing pay off; a unique inspector seed just renders as its own variant.
## Manifest
```ts
import { treesPlugin } from '@pascal-app/plugin-trees'
// host:
setPluginDiscovery(async () => [treesPlugin])
```
`treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`,
`trees:grass`) for the core `loadPlugin` path. The editor app separately imports
`treesHostPanel` to surface the Nature rail entry; panels are not part of the v1
core plugin manifest.
## Notes / known gaps
- `package.json` points `main`/`exports` at raw TypeScript (`./src/index.ts`),
which works here only because the host app's bundler transpiles workspace
packages. A real third-party plugin must ship built JS (with `.d.ts` types) or
otherwise ensure the consuming host transpiles the package.
- `createNode` and the `floorPlaced.footprint` callback are typed against the
host's hand-maintained `AnyNode` union, so the node is cast (`as AnyNode` /
`as TreeNode`). The registry derives `AnyNode` post-migration.
- The placement tool re-derives level-local conversion from the public
`sceneRegistry` because the built-in `floor-placement` helpers aren't part of
the public `@pascal-app/*` surface yet — a candidate for a future
`@pascal-app/plugin-api` re-export package.
- The instance matrices fold in the parent level's world transform; a building
move while plants are static won't refresh until a node of that kind next
changes.
- Heavy *per-node* tweaking of geometry params (or unique seeds) erodes
instancing batching — but it degrades gracefully: such a node just becomes its
own single-instance variant, never worse than the non-instanced path.
See `wiki/architecture/plugin-authoring.md` for the full contract.
-56
View File
@@ -1,56 +0,0 @@
{
"name": "@pascal-app/plugin-trees",
"version": "0.1.0",
"description": "First-party example Pascal editor plugin — a procedural trees node with a presets rail panel. Structurally identical to a third-party plugin (peer-deps on @pascal-app/*).",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"files": [
"src",
"README.md"
],
"scripts": {
"check-types": "tsgo --noEmit"
},
"dependencies": {
"@dgreenheck/ez-tree": "^1.1.0"
},
"peerDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.185",
"zod": "^4",
"zustand": "^5"
},
"devDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@pascal/typescript-config": "*",
"@react-three/fiber": "^9",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
"react": "^19",
"three": "^0.185",
"typescript": "6.0.3",
"zod": "^4",
"zustand": "^5"
},
"keywords": [
"pascal",
"3d",
"editor",
"plugin"
],
"license": "MIT"
}
-48
View File
@@ -1,48 +0,0 @@
import ash from './assets/ash.webp'
import aspen from './assets/aspen.webp'
import bush from './assets/bush.webp'
import daisy from './assets/daisy.webp'
import fescue from './assets/fescue.webp'
import lavender from './assets/lavender.webp'
import meadow from './assets/meadow.webp'
import natureIcon from './assets/nature-icon.webp'
import oak from './assets/oak.webp'
import pine from './assets/pine.webp'
import reed from './assets/reed.webp'
import trellis from './assets/trellis.webp'
import tulip from './assets/tulip.webp'
import type { FlowerPreset } from './flower-schema'
import type { GrassPreset } from './grass-schema'
import type { TreePreset } from './schema'
/**
* Bundled preset artwork. The webp live in `./assets` and travel with the
* package — no CDN, no per-app `public/` mirroring. Both consumers are Next, so
* `transpilePackages` runs these imports through the image pipeline and `.src`
* is the hashed, cached URL. The panel renders each as an `<img src>`.
*/
const url = (asset: { src: string }): string => asset.src
export const TREE_ART: Record<TreePreset, string> = {
oak: url(oak),
pine: url(pine),
aspen: url(aspen),
ash: url(ash),
bush: url(bush),
trellis: url(trellis),
}
export const FLOWER_ART: Record<FlowerPreset, string> = {
daisy: url(daisy),
tulip: url(tulip),
lavender: url(lavender),
}
export const GRASS_ART: Record<GrassPreset, string> = {
meadow: url(meadow),
fescue: url(fescue),
reed: url(reed),
}
/** The Nature panel / section icon. */
export const NATURE_ICON = url(natureIcon)
-8
View File
@@ -1,8 +0,0 @@
// Bundled image assets. Both consumers (community + apps/editor) are Next, whose
// static image import returns a StaticImageData-shaped object; `.src` is the
// hashed, cached URL the bundler emits. Declared locally so the package needs no
// `next` type dependency. See `art.ts`.
declare module '*.webp' {
const asset: { src: string; height: number; width: number; blurDataURL?: string }
export default asset
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

-144
View File
@@ -1,144 +0,0 @@
import type { HandleDescriptor, NodeDefinition } from '@pascal-app/core'
import { buildTreeFloorplan, treeTrunkRadius } from './floorplan'
import { treeParametrics } from './parametrics'
import { TreeNode } from './schema'
const ROTATE_RING_OFFSET = 0.35
/** Ring hugs the ground like the item gizmo — high enough to clear the grass,
* low enough to read as a floor affordance. */
const ROTATE_RING_Y = 0.25
/** Whole-tree Y-rotation gizmo (same rig as shelf/item): a ring around the
* trunk near the ground — not the canopy, which would put the handle meters
* from the trunk on a large oak. */
function treeRotateHandle(): HandleDescriptor<TreeNode> {
const ringRadius = (n: TreeNode) => treeTrunkRadius(n) + ROTATE_RING_OFFSET
const ringY = () => ROTATE_RING_Y
return {
kind: 'arc-resize',
axis: 'angular',
shape: 'rotate',
apply: (initial, delta) => {
const r = initial.rotation ?? [0, 0, 0]
// Negate to match three.js Y-rotation handedness (same as shelf).
return { rotation: [r[0], (r[1] ?? 0) - delta, r[2]] as [number, number, number] }
},
placement: {
position: (n) => {
const r = ringRadius(n)
return [r * Math.SQRT1_2, ringY(), r * Math.SQRT1_2]
},
rotationY: () => -Math.PI / 4,
},
decoration: {
kind: 'ring',
radius: ringRadius,
y: ringY,
},
}
}
/**
* The tree node definition. Rendering uses the instanced path rather than the
* per-node `def.geometry`: a collective `def.system` batches every tree into
* `InstancedMesh`es (forest-scale draw calls), while a featherweight
* `def.renderer` mounts an invisible per-node proxy so the host's selection /
* outline / zone machinery works unchanged. `parametrics` gives the inspector
* for free; `tool`/`preview` drive placement. No host dispatch code per kind.
*/
export const treeDefinition: NodeDefinition<typeof TreeNode> = {
kind: 'trees:tree',
// Static in the bake for portability; our viewer removes the baked meshes and
// re-renders live (wind, LODs) via this def's own path. See plans → Part D.
bake: 'replace',
schemaVersion: 1,
schema: TreeNode,
category: 'furnish',
snapProfile: 'item',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
preset: 'oak',
size: 'medium',
treeType: 'deciduous',
height: 7,
seed: 1,
foliageDensity: 1,
trunkThickness: 1,
leafless: false,
leafColor: '#ffffff',
branchColor: '#ffffff',
}),
capabilities: {
movable: { axes: ['x', 'z'], gridSnap: true },
rotatable: {
axes: ['y'],
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
},
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
groupable: true,
snappable: {},
// The auto-measured drag box would wrap the whole canopy (the proxy shows
// the real geometry while selected) — declare trunk-sized bounds instead.
dragBounds: (node) => {
const tree = node as unknown as TreeNode
const radius = treeTrunkRadius(tree)
return { size: [radius * 2, tree.height ?? 7, radius * 2] }
},
floorPlaced: {
// `footprint` receives the host's `AnyNode`; cast to our schema type the
// same way built-in kinds do (`node as ShelfNode`). Trunk-sized, not
// canopy-sized — the drag/placement box should hug where the tree
// actually plants, not span the whole crown.
footprint: (node) => {
const tree = node as unknown as TreeNode
const radius = treeTrunkRadius(tree)
return {
dimensions: [radius * 2, tree.height, radius * 2] as [number, number, number],
rotation: tree.rotation,
}
},
collides: false,
},
},
parametrics: treeParametrics,
// 2D plan symbol: dashed canopy ring + trunk dot (see floorplan.ts).
floorplan: buildTreeFloorplan,
handles: [treeRotateHandle()],
// Instanced rendering: an invisible per-node proxy for selection/outline...
renderer: { kind: 'parametric', module: () => import('./proxy-renderer') },
// ...and a collective system that batches every tree into InstancedMeshes.
system: { module: () => import('./system'), priority: 3 },
// Baked `/viewer` re-render for `bake: 'replace'` — collective, instanced.
bakeReplaceRenderer: { module: () => import('./static-renderer') },
preview: () => import('./preview'),
tool: () => import('./tool'),
toolHints: [
{ key: 'Left click', label: 'Plant tree' },
{ key: 'Esc', label: 'Stop' },
],
presentation: {
label: 'Tree',
description: 'A procedural ez-tree. Oak, pine, aspen, ash, bush, or trellis.',
icon: { kind: 'iconify', name: 'lucide:trees' },
paletteSection: 'furnish',
hidden: true,
},
mcp: {
description:
'A procedural ez-tree (example plugin node). Species presets (oak/pine/aspen/ash/bush/trellis) × size, deciduous/evergreen type, adjustable height, foliage/trunk, leaf & branch tint, and a seed for variation.',
},
}
-37
View File
@@ -1,37 +0,0 @@
import { type AnyNode, emitter } from '@pascal-app/core'
import type { FlowerNode } from './flower-schema'
import type { GrassNode } from './grass-schema'
import type { TreeNode } from './schema'
import { type TreesPanelMode, useTreesStore } from './store'
/**
* "Find in catalog" sync. The editor's node action menu emits
* `selection:find-node`; the host opens the panel that owns the kind — but
* which *section* of the Nature panel to
* show is plugin knowledge, so the plugin listens too and points its own store
* at the found node's section + preset. Module-level (imported by the plugin
* manifest) so the listener is live from plugin load, even while the panel has
* never been mounted.
*/
const MODE_BY_KIND: Record<string, TreesPanelMode> = {
'trees:tree': 'trees',
'trees:flower': 'flowers',
'trees:grass': 'grass',
}
emitter.on('selection:find-node', (node: AnyNode) => {
const mode = MODE_BY_KIND[node.type as string]
if (!mode) return
const store = useTreesStore.getState()
store.setMode(mode)
if (mode === 'trees') {
const tree = node as unknown as TreeNode
store.setPreset(tree.preset ?? 'oak')
store.setSize(tree.size ?? 'medium')
} else if (mode === 'flowers') {
store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy')
} else {
store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow')
}
})
-125
View File
@@ -1,125 +0,0 @@
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
import { flowerPetalColor } from './flower-geometry'
import { FLOWER_PRESETS } from './flower-presets'
import type { FlowerNode } from './flower-schema'
import { GRASS_PRESETS } from './grass-presets'
import type { GrassNode } from './grass-schema'
import { TREE_PRESETS } from './presets'
import type { TreeNode } from './schema'
/**
* 2D plan builders for the plant kinds (`def.floorplan`) — the registry
* floor-plan layer renders any kind that provides one, so this is all it takes
* for plugin nodes to appear in the 2D view. Classic architect symbols:
* a dashed canopy circle (dashed = overhead element, like a roof overhang)
* with a solid trunk dot for trees; small colour dots for flowers/grass.
*/
/** Trunk radius in plan — also the selection footprint, so the move box hugs
* the trunk instead of the whole canopy. */
export function treeTrunkRadius(tree: TreeNode): number {
return Math.max(0.15, (tree.height ?? 7) * 0.025 * (tree.trunkThickness ?? 1))
}
/** Approximate canopy radius in plan (matches the old whole-tree footprint). */
export function treeCanopyRadius(tree: TreeNode): number {
return Math.max(0.5, (tree.height ?? 7) * 0.28)
}
type ViewChrome = { stroke: string | null; selected: boolean }
/** Selection/hover stroke override shared by the three builders. */
function chromeOf(ctx: GeometryContext): ViewChrome {
const view = ctx.viewState
const palette = view?.palette
if ((view?.selected || view?.highlighted) && palette)
return { stroke: palette.selectedStroke, selected: view?.selected ?? false }
if (view?.hovered && palette) return { stroke: palette.wallHoverStroke, selected: false }
return { stroke: null, selected: false }
}
export function buildTreeFloorplan(node: TreeNode, ctx: GeometryContext): FloorplanGeometry {
const [x, , z] = node.position ?? [0, 0, 0]
const swatch = (TREE_PRESETS[node.preset] ?? TREE_PRESETS.oak).swatch
const chrome = chromeOf(ctx)
const stroke = chrome.stroke ?? swatch
const children: FloorplanGeometry[] = [
// Canopy ring — pointer-events on the stroke only, so the (large) disc
// doesn't steal clicks from whatever sits under the canopy in plan.
{
kind: 'circle',
cx: x,
cy: z,
r: treeCanopyRadius(node),
stroke,
strokeWidth: 0.03,
strokeDasharray: '0.18 0.12',
fill: swatch,
fillOpacity: 0.06,
pointerEvents: 'stroke',
},
// Trunk dot — the solid, always-clickable core of the symbol.
{
kind: 'circle',
cx: x,
cy: z,
r: treeTrunkRadius(node),
fill: chrome.stroke ?? '#6b4f2e',
stroke,
strokeWidth: 0.02,
opacity: 0.95,
},
]
if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] })
return { kind: 'group', children }
}
export function buildFlowerFloorplan(node: FlowerNode, ctx: GeometryContext): FloorplanGeometry {
const [x, , z] = node.position ?? [0, 0, 0]
const preset = FLOWER_PRESETS[node.preset] ?? FLOWER_PRESETS.daisy
const chrome = chromeOf(ctx)
const children: FloorplanGeometry[] = [
{
kind: 'circle',
cx: x,
cy: z,
r: 0.1,
fill: flowerPetalColor(node),
stroke: chrome.stroke ?? preset.stemColor,
strokeWidth: 0.02,
},
{
kind: 'circle',
cx: x,
cy: z,
r: 0.035,
fill: preset.centerColor,
pointerEvents: 'none',
},
]
if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] })
return { kind: 'group', children }
}
export function buildGrassFloorplan(node: GrassNode, ctx: GeometryContext): FloorplanGeometry {
const [x, , z] = node.position ?? [0, 0, 0]
const preset = GRASS_PRESETS[node.preset] ?? GRASS_PRESETS.meadow
const blade = node.bladeColor ?? preset.bladeColor
const chrome = chromeOf(ctx)
const children: FloorplanGeometry[] = [
{
kind: 'circle',
cx: x,
cy: z,
r: 0.12,
fill: blade,
fillOpacity: 0.5,
stroke: chrome.stroke ?? blade,
strokeWidth: 0.02,
strokeDasharray: '0.06 0.05',
},
]
if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] })
return { kind: 'group', children }
}
@@ -1,83 +0,0 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildFlowerFloorplan } from './floorplan'
import { flowerParametrics } from './flower-parametrics'
import { FlowerNode } from './flower-schema'
/**
* The flower node definition — a sibling instanced kind to the tree. Same
* composition: a `def.system` batches every flower into InstancedMeshes, a
* featherweight `def.renderer` proxy keeps selection working, `parametrics`
* gives the inspector, `tool`/`preview` drive placement.
*/
export const flowerDefinition: NodeDefinition<typeof FlowerNode> = {
kind: 'trees:flower',
bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D
schemaVersion: 1,
schema: FlowerNode,
category: 'furnish',
snapProfile: 'item',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
preset: 'daisy',
height: 0.5,
seed: 1,
petalColor: '#fcfcf2',
}),
capabilities: {
movable: { axes: ['x', 'z'], gridSnap: true },
rotatable: {
axes: ['y'],
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
},
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
groupable: true,
snappable: {},
floorPlaced: {
footprint: (node) => {
const flower = node as unknown as FlowerNode
const radius = Math.max(0.1, flower.height * 0.25)
return {
dimensions: [radius * 2, flower.height, radius * 2] as [number, number, number],
rotation: flower.rotation,
}
},
collides: false,
},
},
parametrics: flowerParametrics,
floorplan: buildFlowerFloorplan,
renderer: { kind: 'parametric', module: () => import('./flower-proxy-renderer') },
system: { module: () => import('./flower-system'), priority: 3 },
bakeReplaceRenderer: { module: () => import('./flower-static-renderer') },
preview: () => import('./flower-preview'),
tool: () => import('./flower-tool'),
toolHints: [
{ key: 'Left click', label: 'Plant flower' },
{ key: 'Esc', label: 'Stop' },
],
presentation: {
label: 'Flower',
description: 'A procedural flower. Daisy, tulip, or lavender.',
icon: { kind: 'iconify', name: 'lucide:flower-2' },
paletteSection: 'furnish',
hidden: true,
},
mcp: {
description:
'A procedural flower (example plugin node) — daisy, tulip, or lavender, instanced like the trees.',
},
}
@@ -1,115 +0,0 @@
import {
type BufferGeometry,
ConeGeometry,
CylinderGeometry,
Group,
Mesh,
SphereGeometry,
} from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { FLOWER_PRESETS } from './flower-presets'
import type { FlowerNode, FlowerPreset } from './flower-schema'
import type { SubMesh, VariantData } from './instanced'
import { mulberry32, naturalHeight } from './variant-utils'
import { windStandardMaterial } from './wind-node'
export function flowerVariantKey(preset: FlowerPreset, seed: number, petalColor: string): string {
return `${preset}:${seed}:${petalColor}`
}
/** Petal colour with fallbacks — nodes persisted before the field existed load
* without it, so fall back to the preset colour rather than crash/blank. */
export function flowerPetalColor(node: FlowerNode): string {
return node.petalColor ?? FLOWER_PRESETS[node.preset]?.petalColor ?? '#fcfcf2'
}
const variantCache = new Map<string, VariantData>()
/** Cached procedural flower geometry for a (preset, seed, petalColor). Like the
* trees, one generation per variant is shared across every instance. Built
* merged per material so each variant is ~3 InstancedMeshes (stem/petals/center). */
export function getFlowerVariant(node: FlowerNode): VariantData {
const petalColor = flowerPetalColor(node)
const key = flowerVariantKey(node.preset, node.seed, petalColor)
const cached = variantCache.get(key)
if (cached) return cached
const group = buildFlower(node.preset, node.seed, petalColor)
const subMeshes: SubMesh[] = group.children
.filter((c): c is Mesh => (c as Mesh).isMesh)
.map((mesh) => ({ geometry: mesh.geometry, material: mesh.material }))
const data: VariantData = { subMeshes, naturalHeight: naturalHeight(group) }
variantCache.set(key, data)
return data
}
function buildFlower(preset: FlowerPreset, seed: number, petalColor: string): Group {
const spec = FLOWER_PRESETS[preset] ?? FLOWER_PRESETS.daisy
const rng = mulberry32(seed >>> 0)
const group = new Group()
const stemMat = windStandardMaterial({ color: spec.stemColor, roughness: 0.85 })
const petalMat = windStandardMaterial({ color: petalColor, roughness: 0.7 })
const centerMat = windStandardMaterial({ color: spec.centerColor, roughness: 0.6 })
const stemH = spec.defaultHeight
const stem = new CylinderGeometry(0.008, 0.015, stemH, 5)
stem.translate(0, stemH / 2, 0)
group.add(new Mesh(stem, stemMat))
if (preset === 'lavender') {
// A spike of small florets along the top ~45% of the stem.
const florets: BufferGeometry[] = []
const count = 26
const spikeBase = stemH * 0.55
for (let i = 0; i < count; i++) {
const t = i / count
const y = spikeBase + t * (stemH - spikeBase)
const angle = i * 2.4 + rng() * 0.5
const r = (1 - t) * 0.035 + 0.008
const f = new SphereGeometry(0.016 * (1 - t * 0.4), 5, 4)
f.translate(Math.cos(angle) * r, y, Math.sin(angle) * r)
florets.push(f)
}
group.add(new Mesh(mergeGeometries(florets, false) ?? florets[0], petalMat))
return group
}
if (preset === 'tulip') {
// Six petals forming an upward cup.
const petals: BufferGeometry[] = []
const n = 6
for (let i = 0; i < n; i++) {
const angle = (i / n) * Math.PI * 2 + rng() * 0.1
const p = new ConeGeometry(0.045, 0.16, 4)
p.translate(0, 0.08, 0)
p.rotateZ(0.45)
p.rotateY(-angle)
p.translate(Math.cos(angle) * 0.03, stemH, Math.sin(angle) * 0.03)
petals.push(p)
}
group.add(new Mesh(mergeGeometries(petals, false) ?? petals[0], petalMat))
const core = new ConeGeometry(0.02, 0.1, 4)
core.translate(0, stemH + 0.06, 0)
group.add(new Mesh(core, centerMat))
return group
}
// daisy — a yellow disc with a ring of white petals.
const center = new SphereGeometry(0.035, 8, 6)
center.scale(1, 0.6, 1)
center.translate(0, stemH, 0)
group.add(new Mesh(center, centerMat))
const petals: BufferGeometry[] = []
const n = 12
for (let i = 0; i < n; i++) {
const angle = (i / n) * Math.PI * 2 + rng() * 0.08
const p = new ConeGeometry(0.02, 0.08, 3)
p.rotateZ(Math.PI / 2)
p.translate(0.07, 0, 0)
p.rotateY(-angle)
p.translate(0, stemH + 0.005, 0)
petals.push(p)
}
group.add(new Mesh(mergeGeometries(petals, false) ?? petals[0], petalMat))
return group
}
@@ -1,22 +0,0 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { FlowerNode } from './flower-schema'
/** Inspector for a placed flower — rendered for free by the host's
* `ParametricInspector` from this descriptor. */
export const flowerParametrics: ParametricDescriptor<FlowerNode> = {
groups: [
{
label: 'Flower',
fields: [
{ key: 'preset', kind: 'enum', options: ['daisy', 'tulip', 'lavender'] },
{ key: 'height', kind: 'number', unit: 'm', min: 0.2, max: 2, step: 0.05 },
{ key: 'petalColor', kind: 'color' },
{ key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 },
],
},
{
label: 'Position',
fields: [{ key: 'position', kind: 'vec3' }],
},
],
}
@@ -1,54 +0,0 @@
import { FLOWER_ART } from './art'
import type { FlowerPreset } from './flower-schema'
/** Per-flower colours, default height (metres), and a card `thumbnail` (a
* replaceable placeholder image — see `thumbnails.ts`). Pure data shared by the
* geometry builder and the panel. */
export type FlowerPresetSpec = {
id: FlowerPreset
label: string
petalColor: string
centerColor: string
stemColor: string
defaultHeight: number
swatch: string
thumbnail: string
}
export const FLOWER_PRESETS: Record<FlowerPreset, FlowerPresetSpec> = {
daisy: {
id: 'daisy',
label: 'Daisy',
petalColor: '#fcfcf2',
centerColor: '#f4c430',
stemColor: '#4f7942',
defaultHeight: 0.5,
swatch: '#f4c430',
thumbnail: FLOWER_ART.daisy,
},
tulip: {
id: 'tulip',
label: 'Tulip',
petalColor: '#e0457b',
centerColor: '#c43160',
stemColor: '#3f7a3a',
defaultHeight: 0.45,
swatch: '#e0457b',
thumbnail: FLOWER_ART.tulip,
},
lavender: {
id: 'lavender',
label: 'Lavender',
petalColor: '#9b6fd4',
centerColor: '#7d52b8',
stemColor: '#5a7a4a',
defaultHeight: 0.6,
swatch: '#9b6fd4',
thumbnail: FLOWER_ART.lavender,
},
}
export const FLOWER_PRESET_LIST: FlowerPresetSpec[] = Object.values(FLOWER_PRESETS)
/** Bounded seed pool so flowers share instancing variants (see trees). */
export const FLOWER_SEED_POOL = [1, 7, 13, 21, 34, 55]
@@ -1,51 +0,0 @@
'use client'
import { useEffect, useMemo } from 'react'
import type { Material, MeshStandardMaterial } from 'three'
import { getFlowerVariant } from './flower-geometry'
import type { FlowerNode } from './flower-schema'
const NO_RAYCAST = () => {}
/** Translucent placement ghost for a flower — clones the variant materials so
* the cursor preview is see-through without mutating the cached originals. */
export default function FlowerPreview({ node }: { node: FlowerNode }) {
const data = useMemo(() => getFlowerVariant(node), [node])
const scale = node.height / data.naturalHeight
const ghosts = useMemo(
() =>
data.subMeshes.map((sub) => {
const base = (
Array.isArray(sub.material) ? sub.material[0] : sub.material
) as MeshStandardMaterial
const clone = base.clone()
clone.transparent = true
clone.opacity = 0.55
clone.depthWrite = false
return clone
}),
[data],
)
useEffect(
() => () => {
for (const m of ghosts as Material[]) m.dispose()
},
[ghosts],
)
return (
<group scale={scale}>
{data.subMeshes.map((sub, i) => (
<mesh
dispose={null}
geometry={sub.geometry}
key={i}
material={ghosts[i]}
raycast={NO_RAYCAST}
/>
))}
</group>
)
}
@@ -1,13 +0,0 @@
'use client'
import { getFlowerVariant } from './flower-geometry'
import type { FlowerNode } from './flower-schema'
import { KindProxy } from './instanced'
const getVariant = (node: FlowerNode) => getFlowerVariant(node)
const colliderRadius = (node: FlowerNode) => Math.max(0.06, (node.height ?? 0.5) * 0.22)
/** Per-node selection proxy for the instanced flowers. */
export default function FlowerProxyRenderer({ node }: { node: FlowerNode }) {
return <KindProxy colliderRadius={colliderRadius} getVariant={getVariant} node={node} />
}
@@ -1,23 +0,0 @@
import { BaseNode, nodeType, objectId } from '@pascal-app/core'
import { z } from 'zod'
/** Flower silhouettes the plugin can place. The string persists in scene JSON. */
export const FlowerPreset = z.enum(['daisy', 'tulip', 'lavender'])
export type FlowerPreset = z.infer<typeof FlowerPreset>
/** A placed flower — a sibling instanced kind to the tree, sharing the same
* instanced renderer + selection proxy via the generic `instanced` core. */
export const FlowerNode = BaseNode.extend({
id: objectId('flower'),
type: nodeType('trees:flower'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
preset: FlowerPreset.default('daisy'),
height: z.number().positive().default(0.5),
seed: z.number().int().default(1),
/** Petal colour (hex). Baked from the preset at placement; recolour per-flower
* in the inspector (the flower analog of the tree's leaf tint). */
petalColor: z.string().default('#fcfcf2'),
})
export type FlowerNode = z.infer<typeof FlowerNode>
@@ -1,16 +0,0 @@
'use client'
import { flowerPetalColor, flowerVariantKey, getFlowerVariant } from './flower-geometry'
import type { FlowerNode } from './flower-schema'
import { InstancedNodes } from './instanced'
const variantKeyOf = (node: FlowerNode) =>
flowerVariantKey(node.preset, node.seed, flowerPetalColor(node))
const getVariant = (node: FlowerNode) => getFlowerVariant(node)
/** Collective baked-`/viewer` renderer for one level's flowers (`bakeReplaceRenderer`). */
export default function FlowerReplaceInstances({ nodes }: { nodes: FlowerNode[] }) {
return (
<InstancedNodes getVariant={getVariant} localSpace nodes={nodes} variantKeyOf={variantKeyOf} />
)
}
@@ -1,20 +0,0 @@
'use client'
import { flowerPetalColor, flowerVariantKey, getFlowerVariant } from './flower-geometry'
import type { FlowerNode } from './flower-schema'
import { InstancedKindSystem } from './instanced'
const variantKeyOf = (node: FlowerNode) =>
flowerVariantKey(node.preset, node.seed, flowerPetalColor(node))
const getVariant = (node: FlowerNode) => getFlowerVariant(node)
/** Collective instanced renderer for every placed flower (`def.system`). */
export default function FlowersSystem() {
return (
<InstancedKindSystem<FlowerNode>
getVariant={getVariant}
kind="trees:flower"
variantKeyOf={variantKeyOf}
/>
)
}
-57
View File
@@ -1,57 +0,0 @@
'use client'
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { FLOWER_PRESETS, FLOWER_SEED_POOL } from './flower-presets'
import FlowerPreview from './flower-preview'
import { FlowerNode } from './flower-schema'
import { usePlacement } from './placement'
import { useTreesStore } from './store'
/** The flowers placement tool — mirrors the trees tool, reading the flower
* brush from the shared store. Petal colour is baked from the preset at
* placement, then editable per-flower in the inspector. */
export default function FlowerTool() {
const activeLevelId = useViewer((s) => s.selection.levelId)
const preset = useTreesStore((s) => s.flowerPreset)
const height = useTreesStore((s) => s.flowerHeight)
const previewNode = useMemo(
() =>
FlowerNode.parse({
preset,
height,
petalColor: FLOWER_PRESETS[preset].petalColor,
seed: 1,
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
[preset, height],
)
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
if (!activeLevelId) return
const s = useTreesStore.getState()
const flower = FlowerNode.parse({
preset: s.flowerPreset,
height: s.flowerHeight,
petalColor: FLOWER_PRESETS[s.flowerPreset].petalColor,
seed: FLOWER_SEED_POOL[Math.floor(Math.random() * FLOWER_SEED_POOL.length)] ?? 1,
position,
rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0],
})
useScene.getState().createNode(flower as unknown as AnyNode, activeLevelId as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [flower.id as AnyNodeId] })
triggerSFX('sfx:item-place')
})
if (!activeLevelId) return null
return (
<group ref={cursorRef} visible={cursorVisible}>
<FlowerPreview node={previewNode} />
</group>
)
}
-148
View File
@@ -1,148 +0,0 @@
// ez-tree loads its inlined textures at module scope (needs `document`), so
// this module must only be imported from lazy client modules (renderers,
// systems, tools, previews) — never from `index.ts`, a definition, or
// `floorplan.ts`, or SSR/prerender crashes. Pure helpers shared with the
// flower/grass builders live in `variant-utils.ts` for that reason.
import { Tree } from '@dgreenheck/ez-tree'
import type { BufferGeometry, Material, Mesh, Object3D } from 'three'
import { ezPresetOf } from './presets'
import type { TreeNode } from './schema'
import { naturalHeight } from './variant-utils'
import { toWindMaterial } from './wind-node'
/** The geometry-affecting fields of a tree. Two trees with the same spec share
* one generated variant (and thus one InstancedMesh set). Per-instance fields
* (position/rotation/height) are deliberately NOT here — they're cheap matrix
* work, not geometry. */
export type TreeSpec = Pick<
TreeNode,
| 'preset'
| 'size'
| 'treeType'
| 'seed'
| 'foliageDensity'
| 'trunkThickness'
| 'leafless'
| 'leafColor'
| 'branchColor'
>
export function treeSpecOf(node: TreeNode): TreeSpec {
// Default the non-override fields (nodes persisted before a field existed load
// without it). The four overrides are left as-is — `undefined` means "inherit
// the ez-tree preset" (its own seed/type/tints), resolved in `generateTree`.
return {
preset: node.preset ?? 'oak',
size: node.size ?? 'medium',
treeType: node.treeType,
seed: node.seed,
foliageDensity: node.foliageDensity ?? 1,
trunkThickness: node.trunkThickness ?? 1,
leafless: node.leafless ?? false,
leafColor: node.leafColor,
branchColor: node.branchColor,
}
}
/** Stable variant id. Trees with the same key share one set of InstancedMeshes. */
export function treeVariantKey(spec: TreeSpec): string {
return [
spec.preset,
spec.size,
spec.treeType,
spec.seed,
spec.foliageDensity,
spec.trunkThickness,
spec.leafless,
spec.leafColor,
spec.branchColor,
].join(':')
}
/** `#rrggbb` → 0xrrggbb, defaulting to white on anything missing/unparseable. */
function hexToInt(hex: string | undefined): number {
const n = Number.parseInt((hex ?? '').replace('#', ''), 16)
return Number.isFinite(n) ? n : 0xffffff
}
/**
* Generate an ez-tree for a spec. ez-tree's `Tree` is a `THREE.Group`; textures
* are inlined in the library (no asset hosting). `loadPreset` owns the full look
* (seed, growth model, tints, branch/leaf structure); the curated params then
* apply *on top* — but only where the node actually set them, so an unset field
* keeps the preset's value (its canonical silhouette/colours). `trunkThickness`
* and `foliageDensity` are multipliers (1 = preset default). Pure given its
* inputs — same spec ⇒ same tree — which lets the renderer cache one generation
* per variant.
*/
export function generateTree(spec: TreeSpec): Tree {
const tree = new Tree()
tree.loadPreset(ezPresetOf(spec.preset, spec.size))
if (spec.seed != null) tree.options.seed = spec.seed
if (spec.treeType != null) (tree.options as { type: string }).type = spec.treeType
const radius = tree.options.branch.radius as unknown as Record<string, number>
for (const level of Object.keys(radius)) {
const value = radius[level]
if (value !== undefined) radius[level] = value * spec.trunkThickness
}
const leaves = tree.options.leaves as { count: number; tint: number }
leaves.count = spec.leafless ? 0 : Math.round(leaves.count * spec.foliageDensity)
if (spec.leafColor != null) leaves.tint = hexToInt(spec.leafColor)
if (spec.branchColor != null)
(tree.options.bark as { tint: number }).tint = hexToInt(spec.branchColor)
tree.generate()
return tree
}
/** A renderable sub-mesh of a tree: geometry (baked into tree-local space) +
* its material. The instanced renderer builds one InstancedMesh per sub-mesh
* per variant. */
export type TreeSubMesh = { geometry: BufferGeometry; material: Material | Material[] }
/** Geometry + height for one tree variant, generated once and shared across
* every instance of that spec. */
export type TreeVariantData = { subMeshes: TreeSubMesh[]; naturalHeight: number }
const variantCache = new Map<string, TreeVariantData>()
/**
* Cached geometry for a spec. ez-tree's `generate()` is heavy, so it runs once
* per variant; the resulting geometries/materials are retained here and shared
* by every instance. The renderer must NOT dispose them (it sets `dispose={null}`
* on the InstancedMesh).
*/
export function getVariantData(spec: TreeSpec): TreeVariantData {
const key = treeVariantKey(spec)
const cached = variantCache.get(key)
if (cached) return cached
const tree = generateTree(spec)
const data: TreeVariantData = {
subMeshes: extractSubMeshes(tree),
naturalHeight: naturalHeight(tree),
}
variantCache.set(key, data)
return data
}
/** Extract the leaf/bark sub-meshes, baking each mesh's local transform into a
* cloned geometry so instance matrices only carry the node's own transform. */
export function extractSubMeshes(tree: Object3D): TreeSubMesh[] {
const out: TreeSubMesh[] = []
tree.traverse((child) => {
const mesh = child as Partial<Mesh>
if (mesh.isMesh && mesh.geometry && mesh.material) {
const geometry = mesh.geometry.clone()
;(child as Mesh).updateMatrix()
geometry.applyMatrix4((child as Mesh).matrix)
// Swap ez-tree's plain materials for swaying node materials (WebGPU/TSL).
const material = Array.isArray(mesh.material)
? mesh.material.map(toWindMaterial)
: toWindMaterial(mesh.material)
out.push({ geometry, material })
}
})
return out
}
@@ -1,83 +0,0 @@
import type { NodeDefinition } from '@pascal-app/core'
import { buildGrassFloorplan } from './floorplan'
import { grassParametrics } from './grass-parametrics'
import { GrassNode } from './grass-schema'
/**
* The grass node definition — a third instanced kind alongside trees & flowers.
* Same composition: a `def.system` batches every tuft into InstancedMeshes, a
* featherweight `def.renderer` proxy keeps selection working, `parametrics`
* gives the inspector, `tool`/`preview` drive placement.
*/
export const grassDefinition: NodeDefinition<typeof GrassNode> = {
kind: 'trees:grass',
bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D
schemaVersion: 1,
schema: GrassNode,
category: 'furnish',
snapProfile: 'item',
defaults: () => ({
object: 'node',
parentId: null,
visible: true,
metadata: {},
position: [0, 0, 0],
rotation: [0, 0, 0],
preset: 'meadow',
height: 0.4,
seed: 1,
bladeColor: '#5a8f3c',
}),
capabilities: {
movable: { axes: ['x', 'z'], gridSnap: true },
rotatable: {
axes: ['y'],
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
},
selectable: { hitVolume: 'bbox' },
duplicable: true,
deletable: true,
groupable: true,
snappable: {},
floorPlaced: {
footprint: (node) => {
const grass = node as unknown as GrassNode
const radius = Math.max(0.1, grass.height * 0.3)
return {
dimensions: [radius * 2, grass.height, radius * 2] as [number, number, number],
rotation: grass.rotation,
}
},
collides: false,
},
},
parametrics: grassParametrics,
floorplan: buildGrassFloorplan,
renderer: { kind: 'parametric', module: () => import('./grass-proxy-renderer') },
system: { module: () => import('./grass-system'), priority: 3 },
bakeReplaceRenderer: { module: () => import('./grass-static-renderer') },
preview: () => import('./grass-preview'),
tool: () => import('./grass-tool'),
toolHints: [
{ key: 'Left click', label: 'Plant grass' },
{ key: 'Esc', label: 'Stop' },
],
presentation: {
label: 'Grass',
description: 'A procedural grass tuft. Meadow, fescue, or reed.',
icon: { kind: 'iconify', name: 'lucide:wheat' },
paletteSection: 'furnish',
hidden: true,
},
mcp: {
description:
'A procedural grass tuft (example plugin node) — meadow, fescue, or reed, instanced like the trees.',
},
}
@@ -1,55 +0,0 @@
import { type BufferGeometry, ConeGeometry, DoubleSide, Group, Mesh } from 'three'
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
import { GRASS_PRESETS } from './grass-presets'
import type { GrassNode, GrassPreset } from './grass-schema'
import type { SubMesh, VariantData } from './instanced'
import { mulberry32, naturalHeight } from './variant-utils'
import { windStandardMaterial } from './wind-node'
export function grassVariantKey(preset: GrassPreset, seed: number, bladeColor: string): string {
return `${preset}:${seed}:${bladeColor}`
}
const variantCache = new Map<string, VariantData>()
/** Cached procedural grass geometry for a (preset, seed, bladeColor). One
* generation per variant is shared across every instance — a whole lawn of the
* same tuft is a single InstancedMesh. */
export function getGrassVariant(node: GrassNode): VariantData {
const key = grassVariantKey(node.preset, node.seed, node.bladeColor)
const cached = variantCache.get(key)
if (cached) return cached
const group = buildGrass(node.preset, node.seed, node.bladeColor)
const subMeshes: SubMesh[] = group.children
.filter((c): c is Mesh => (c as Mesh).isMesh)
.map((mesh) => ({ geometry: mesh.geometry, material: mesh.material }))
const data: VariantData = { subMeshes, naturalHeight: naturalHeight(group) }
variantCache.set(key, data)
return data
}
/** A tuft of flattened, leaning blades merged into one geometry (one draw per
* instance). Deterministic in `seed` so the same variant renders identically. */
function buildGrass(preset: GrassPreset, seed: number, bladeColor: string): Group {
const spec = GRASS_PRESETS[preset] ?? GRASS_PRESETS.meadow
const rng = mulberry32(seed >>> 0)
const group = new Group()
const mat = windStandardMaterial({ color: bladeColor, roughness: 0.9, side: DoubleSide })
const h = spec.defaultHeight
const blades: BufferGeometry[] = []
for (let i = 0; i < spec.blades; i++) {
const bh = h * (0.6 + rng() * 0.6)
const blade = new ConeGeometry(0.02, bh, 3)
blade.scale(1, 1, 0.3) // flatten the cone into a blade
blade.translate(0, bh / 2, 0)
blade.rotateZ((rng() - 0.5) * 0.7) // lean
const angle = rng() * Math.PI * 2
blade.rotateY(angle)
const r = rng() * 0.07
blade.translate(Math.cos(angle) * r, 0, Math.sin(angle) * r)
blades.push(blade)
}
group.add(new Mesh(mergeGeometries(blades, false) ?? blades[0], mat))
return group
}
@@ -1,22 +0,0 @@
import type { ParametricDescriptor } from '@pascal-app/core'
import type { GrassNode } from './grass-schema'
/** Inspector for a placed grass tuft — rendered for free by the host's
* `ParametricInspector` from this descriptor. */
export const grassParametrics: ParametricDescriptor<GrassNode> = {
groups: [
{
label: 'Grass',
fields: [
{ key: 'preset', kind: 'enum', options: ['meadow', 'fescue', 'reed'] },
{ key: 'height', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.05 },
{ key: 'bladeColor', kind: 'color' },
{ key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 },
],
},
{
label: 'Position',
fields: [{ key: 'position', kind: 'vec3' }],
},
],
}
@@ -1,50 +0,0 @@
import { GRASS_ART } from './art'
import type { GrassPreset } from './grass-schema'
/** Per-grass config: blade colour, blade count per tuft, default height (metres),
* a swatch, and a replaceable card `thumbnail` (see `thumbnails.ts`). Pure data
* shared by the geometry builder and the panel. */
export type GrassPresetSpec = {
id: GrassPreset
label: string
bladeColor: string
blades: number
defaultHeight: number
swatch: string
thumbnail: string
}
export const GRASS_PRESETS: Record<GrassPreset, GrassPresetSpec> = {
meadow: {
id: 'meadow',
label: 'Meadow',
bladeColor: '#5a8f3c',
blades: 10,
defaultHeight: 0.4,
swatch: '#5a8f3c',
thumbnail: GRASS_ART.meadow,
},
fescue: {
id: 'fescue',
label: 'Fescue',
bladeColor: '#7fae55',
blades: 8,
defaultHeight: 0.7,
swatch: '#7fae55',
thumbnail: GRASS_ART.fescue,
},
reed: {
id: 'reed',
label: 'Reed',
bladeColor: '#4a7d63',
blades: 6,
defaultHeight: 1.1,
swatch: '#4a7d63',
thumbnail: GRASS_ART.reed,
},
}
export const GRASS_PRESET_LIST: GrassPresetSpec[] = Object.values(GRASS_PRESETS)
/** Bounded seed pool so grass tufts share instancing variants (see trees). */
export const GRASS_SEED_POOL = [1, 7, 13, 21, 34]
@@ -1,51 +0,0 @@
'use client'
import { useEffect, useMemo } from 'react'
import type { Material, MeshStandardMaterial } from 'three'
import { getGrassVariant } from './grass-geometry'
import type { GrassNode } from './grass-schema'
const NO_RAYCAST = () => {}
/** Translucent placement ghost for a grass tuft — clones the variant materials
* so the cursor preview is see-through without mutating the cached originals. */
export default function GrassPreview({ node }: { node: GrassNode }) {
const data = useMemo(() => getGrassVariant(node), [node])
const scale = node.height / data.naturalHeight
const ghosts = useMemo(
() =>
data.subMeshes.map((sub) => {
const base = (
Array.isArray(sub.material) ? sub.material[0] : sub.material
) as MeshStandardMaterial
const clone = base.clone()
clone.transparent = true
clone.opacity = 0.55
clone.depthWrite = false
return clone
}),
[data],
)
useEffect(
() => () => {
for (const m of ghosts as Material[]) m.dispose()
},
[ghosts],
)
return (
<group scale={scale}>
{data.subMeshes.map((sub, i) => (
<mesh
dispose={null}
geometry={sub.geometry}
key={i}
material={ghosts[i]}
raycast={NO_RAYCAST}
/>
))}
</group>
)
}
@@ -1,13 +0,0 @@
'use client'
import { getGrassVariant } from './grass-geometry'
import type { GrassNode } from './grass-schema'
import { KindProxy } from './instanced'
const getVariant = (node: GrassNode) => getGrassVariant(node)
const colliderRadius = (node: GrassNode) => Math.max(0.08, (node.height ?? 0.4) * 0.3)
/** Per-node selection proxy for the instanced grass tufts. */
export default function GrassProxyRenderer({ node }: { node: GrassNode }) {
return <KindProxy colliderRadius={colliderRadius} getVariant={getVariant} node={node} />
}
-23
View File
@@ -1,23 +0,0 @@
import { BaseNode, nodeType, objectId } from '@pascal-app/core'
import { z } from 'zod'
/** Grass tufts the plugin can place. The string persists in scene JSON. */
export const GrassPreset = z.enum(['meadow', 'fescue', 'reed'])
export type GrassPreset = z.infer<typeof GrassPreset>
/** A placed grass tuft — a third instanced kind alongside trees & flowers,
* sharing the same instanced renderer + selection proxy via `instanced`. */
export const GrassNode = BaseNode.extend({
id: objectId('grass'),
type: nodeType('trees:grass'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
preset: GrassPreset.default('meadow'),
height: z.number().positive().default(0.4),
seed: z.number().int().default(1),
/** Blade colour (hex). Baked from the preset at placement; recolour per-tuft
* in the inspector. */
bladeColor: z.string().default('#5a8f3c'),
})
export type GrassNode = z.infer<typeof GrassNode>
@@ -1,15 +0,0 @@
'use client'
import { getGrassVariant, grassVariantKey } from './grass-geometry'
import type { GrassNode } from './grass-schema'
import { InstancedNodes } from './instanced'
const variantKeyOf = (node: GrassNode) => grassVariantKey(node.preset, node.seed, node.bladeColor)
const getVariant = (node: GrassNode) => getGrassVariant(node)
/** Collective baked-`/viewer` renderer for one level's grass (`bakeReplaceRenderer`). */
export default function GrassReplaceInstances({ nodes }: { nodes: GrassNode[] }) {
return (
<InstancedNodes getVariant={getVariant} localSpace nodes={nodes} variantKeyOf={variantKeyOf} />
)
}
@@ -1,19 +0,0 @@
'use client'
import { getGrassVariant, grassVariantKey } from './grass-geometry'
import type { GrassNode } from './grass-schema'
import { InstancedKindSystem } from './instanced'
const variantKeyOf = (node: GrassNode) => grassVariantKey(node.preset, node.seed, node.bladeColor)
const getVariant = (node: GrassNode) => getGrassVariant(node)
/** Collective instanced renderer for every placed grass tuft (`def.system`). */
export default function GrassSystem() {
return (
<InstancedKindSystem<GrassNode>
getVariant={getVariant}
kind="trees:grass"
variantKeyOf={variantKeyOf}
/>
)
}
-57
View File
@@ -1,57 +0,0 @@
'use client'
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
import { triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { GRASS_PRESETS, GRASS_SEED_POOL } from './grass-presets'
import GrassPreview from './grass-preview'
import { GrassNode } from './grass-schema'
import { usePlacement } from './placement'
import { useTreesStore } from './store'
/** The grass placement tool — mirrors the trees/flowers tools, reading the grass
* brush from the shared store. Blade colour is baked from the preset at
* placement, then editable per-tuft in the inspector. */
export default function GrassTool() {
const activeLevelId = useViewer((s) => s.selection.levelId)
const preset = useTreesStore((s) => s.grassPreset)
const height = useTreesStore((s) => s.grassHeight)
const previewNode = useMemo(
() =>
GrassNode.parse({
preset,
height,
bladeColor: GRASS_PRESETS[preset].bladeColor,
seed: 1,
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
[preset, height],
)
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
if (!activeLevelId) return
const s = useTreesStore.getState()
const grass = GrassNode.parse({
preset: s.grassPreset,
height: s.grassHeight,
bladeColor: GRASS_PRESETS[s.grassPreset].bladeColor,
seed: GRASS_SEED_POOL[Math.floor(Math.random() * GRASS_SEED_POOL.length)] ?? 1,
position,
rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0],
})
useScene.getState().createNode(grass as unknown as AnyNode, activeLevelId as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [grass.id as AnyNodeId] })
triggerSFX('sfx:item-place')
})
if (!activeLevelId) return null
return (
<group ref={cursorRef} visible={cursorVisible}>
<GrassPreview node={previewNode} />
</group>
)
}
-43
View File
@@ -1,43 +0,0 @@
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
import type { EditorHostPanel } from '@pascal-app/editor'
// Side-effect: subscribes the panel store to `selection:find-node` so the
// host's "find in catalog" lands on the right Nature section (see find-sync.ts).
import './find-sync'
import { NATURE_ICON } from './art'
import { treeDefinition } from './definition'
import { flowerDefinition } from './flower-definition'
import { grassDefinition } from './grass-definition'
/**
* The trees plugin manifest — the entire public surface of this package. A host
* loads it through the same `loadPlugin` path the built-ins use: three node kinds
* (`trees:tree`, `trees:flower`, `trees:grass`) and one left-rail panel
* (`Trees`). Cast mirrors the built-in bundle: `AnyNodeDefinition` is the
* hand-maintained union today; the registry derives it post-migration.
*/
export const treesPlugin: Plugin = {
id: 'pascal:trees',
apiVersion: 1,
nodes: [
treeDefinition as unknown as AnyNodeDefinition,
flowerDefinition as unknown as AnyNodeDefinition,
grassDefinition as unknown as AnyNodeDefinition,
],
}
export const treesHostPanel: EditorHostPanel = {
id: 'pascal:trees:trees',
label: 'Nature',
icon: { kind: 'url', src: NATURE_ICON },
component: () => import('./presets-panel'),
}
// NOTE: no re-export from './geometry' — it imports ez-tree, which touches
// `document` at module scope and would crash SSR (this barrel is eagerly
// imported by host bootstraps). Lazy client modules import it directly.
export { treeDefinition } from './definition'
export { flowerDefinition } from './flower-definition'
export { FlowerNode, FlowerPreset } from './flower-schema'
export { grassDefinition } from './grass-definition'
export { GrassNode, GrassPreset } from './grass-schema'
export { TreeNode, TreePreset } from './schema'
-362
View File
@@ -1,362 +0,0 @@
'use client'
import {
type AnyNodeId,
sceneRegistry,
useLiveNodeOverrides,
useLiveTransforms,
useRegistry,
useScene,
} from '@pascal-app/core'
import { useNodeEvents, useViewer } from '@pascal-app/viewer'
import { useFrame } from '@react-three/fiber'
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three'
import { toStaticMaterial } from './wind-node'
/**
* Generic instanced-rendering core shared by every plant kind (trees, flowers,
* …). A kind plugs in two pure functions — `variantKeyOf` (how to bucket nodes
* that can share geometry) and `getVariant` (cached geometry for a node) — and
* gets forest-scale instancing plus true-silhouette selection for free.
*/
export type SubMesh = { geometry: BufferGeometry; material: Material | Material[] }
export type VariantData = { subMeshes: SubMesh[]; naturalHeight: number }
/** The shape every placeable plant node shares. */
export interface Placeable {
id: string
type: string
parentId: string | null
position: [number, number, number]
rotation: [number, number, number]
height: number
visible?: boolean
}
const DUMMY = new Object3D()
const INSTANCE_MATRIX = new Matrix4()
const NO_RAYCAST = () => {}
// Wind is a TSL vertex bend baked into the variant materials (see `wind-node.ts`)
// — animated on the GPU, so the instance matrices here stay static.
// ── Collective instanced renderer (a `def.system`) ───────────────────────────
export function InstancedKindSystem<N extends Placeable>({
kind,
variantKeyOf,
getVariant,
}: {
kind: string
variantKeyOf: (node: N) => string
getVariant: (node: N) => VariantData
}) {
const scene = useScene((s) => s.nodes)
const hoveredId = useViewer((s) => s.hoveredId)
const selectedIds = useViewer((s) => s.selection.selectedIds)
// Hovered/selected plants render through their proxy instead (real geometry,
// static materials) so the outline matches the visible mesh and a move drag
// animates in realtime — skip them here to avoid a double draw. Keyed by the
// *relevant* ids only, so hovering unrelated kinds doesn't churn matrices.
const activeKey = useMemo(() => {
const ids: string[] = []
if (hoveredId && (scene[hoveredId as AnyNodeId]?.type as string) === kind) ids.push(hoveredId)
for (const id of selectedIds) {
if ((scene[id as AnyNodeId]?.type as string) === kind) ids.push(id)
}
return ids.sort().join('|')
}, [scene, kind, hoveredId, selectedIds])
const nodes = useMemo(() => {
const active = new Set(activeKey ? activeKey.split('|') : [])
return Object.values(scene).filter(
(n) => (n.type as string) === kind && !active.has(n.id as string),
) as unknown as N[]
}, [scene, kind, activeKey])
// Consume the dirty marks for this kind. Instances rebuild synchronously
// from the store (the memos above), so a rendered node is already "built" —
// but `FloorElevationSystem` deliberately leaves the mark for kinds with a
// `def.system`, expecting that system to clear it. Without this pass the
// marks live forever: `hasPendingSceneBuildWork` never goes false, so the
// scene-ready signal (and every headless bake) stalls at its frame cap.
// Priority 2 = after the priority-1 floor-elevation lift in the same frame;
// clearing only registered nodes leaves unmounted proxies for a later frame.
useFrame(() => {
const { dirtyNodes, nodes: sceneNodes, clearDirty } = useScene.getState()
if (dirtyNodes.size === 0) return
for (const id of dirtyNodes) {
const node = sceneNodes[id]
if (!node || (node.type as string) !== kind) continue
if (!sceneRegistry.nodes.has(id)) continue
clearDirty(id)
}
}, 2)
return <InstancedNodes getVariant={getVariant} nodes={nodes} variantKeyOf={variantKeyOf} />
}
/**
* Instance a given set of nodes, bucketed by geometry variant. Two callers:
* - `InstancedKindSystem` (editor `def.system`) passes every node of a kind with
* `localSpace={false}` — instances live at the scene root, so each matrix folds
* in the parent level's world matrix (positions are stored level-local).
* - the baked `/viewer` (`bakeReplaceRenderer`) passes one level's nodes with
* `localSpace` — the meshes are portaled into that baked level (which supplies
* the level transform), so instance matrices stay level-local, and the meshes
* are `NO_RAYCAST` (scenery; a pick would resolve to the level anyway).
*
* Instancing carries the per-tree wind phase for free via `instanceIndex`; a
* per-node render (one mesh each) would give every tree phase 0 → a whole
* variant sways in unison.
*/
export function InstancedNodes<N extends Placeable>({
nodes,
variantKeyOf,
getVariant,
localSpace = false,
}: {
nodes: N[]
variantKeyOf: (node: N) => string
getVariant: (node: N) => VariantData
localSpace?: boolean
}) {
const buckets = useMemo(() => {
const map = new Map<string, { sample: N; nodes: N[] }>()
for (const node of nodes) {
const key = variantKeyOf(node)
const bucket = map.get(key)
if (bucket) bucket.nodes.push(node)
else map.set(key, { sample: node, nodes: [node] })
}
return Array.from(map, ([key, value]) => ({ key, ...value }))
}, [nodes, variantKeyOf])
return (
<>
{buckets.map((bucket) => (
<Variant
getVariant={getVariant}
key={bucket.key}
localSpace={localSpace}
nodes={bucket.nodes}
sample={bucket.sample}
/>
))}
</>
)
}
function Variant<N extends Placeable>({
sample,
nodes,
getVariant,
localSpace,
}: {
sample: N
nodes: N[]
getVariant: (node: N) => VariantData
localSpace: boolean
}) {
const data = useMemo(() => getVariant(sample), [sample, getVariant])
return (
<>
{data.subMeshes.map((subMesh, i) => (
<InstancedSubMesh
key={i}
localSpace={localSpace}
naturalHeight={data.naturalHeight}
nodes={nodes}
subMesh={subMesh}
/>
))}
</>
)
}
function InstancedSubMesh<N extends Placeable>({
subMesh,
nodes,
naturalHeight,
localSpace,
}: {
subMesh: SubMesh
nodes: N[]
naturalHeight: number
localSpace: boolean
}) {
const ref = useRef<InstancedMesh>(null)
// Round capacity up so the InstancedMesh isn't recreated on every placement —
// only when crossing a 32-instance boundary. `dispose={null}` keeps the shared
// (cached) geometry/material alive across any recreation.
const capacity = Math.max(16, Math.ceil(nodes.length / 32) * 32)
// Snapshot of each referenced parent level's matrixWorld at the last matrix
// write — the per-frame staleness check below compares against it so a level
// move (explode, elevation edit) refreshes instances without a node change.
const parentWorlds = useRef(new Map<string, number[]>())
const writeMatrices = useCallback(() => {
const mesh = ref.current
if (!mesh) return
parentWorlds.current.clear()
for (let i = 0; i < nodes.length; i += 1) {
const node = nodes[i]
if (!node) continue
const scale = node.height / naturalHeight
DUMMY.position.set(node.position[0], node.position[1], node.position[2])
DUMMY.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
DUMMY.scale.set(scale, scale, scale)
DUMMY.updateMatrix()
// `localSpace`: portaled into the parent level, which supplies the level
// transform — matrices stay level-local. Otherwise instances live at the
// scene root, so fold in the parent level's world matrix.
const parent =
!localSpace && node.parentId ? sceneRegistry.nodes.get(node.parentId) : undefined
if (parent && node.parentId) {
parent.updateWorldMatrix(true, false)
if (!parentWorlds.current.has(node.parentId)) {
parentWorlds.current.set(node.parentId, parent.matrixWorld.toArray())
}
INSTANCE_MATRIX.multiplyMatrices(parent.matrixWorld, DUMMY.matrix)
mesh.setMatrixAt(i, INSTANCE_MATRIX)
} else {
mesh.setMatrixAt(i, DUMMY.matrix)
}
}
mesh.count = nodes.length
mesh.instanceMatrix.needsUpdate = true
mesh.computeBoundingSphere()
}, [nodes, naturalHeight, localSpace])
useLayoutEffect(() => {
writeMatrices()
}, [writeMatrices])
// A parent level can move without any node of this kind changing (level
// explode, elevation edits), which would leave the baked-in world transform
// stale. Compare each referenced level's matrixWorld against the snapshot —
// a handful of levels × 16 floats per frame — and rewrite only on change.
useFrame(() => {
if (localSpace || !ref.current) return
for (const [id, cached] of parentWorlds.current) {
const parent = sceneRegistry.nodes.get(id)
if (!parent) continue
parent.updateWorldMatrix(true, false)
const elements = parent.matrixWorld.elements
for (let i = 0; i < 16; i += 1) {
if (elements[i] !== cached[i]) {
writeMatrices()
return
}
}
}
})
return (
<instancedMesh
args={[subMesh.geometry as BufferGeometry, subMesh.material as Material, capacity]}
castShadow
dispose={null}
frustumCulled={false}
raycast={localSpace ? NO_RAYCAST : undefined}
ref={ref}
/>
)
}
// ── Per-node selection proxy (a `def.renderer`) ──────────────────────────────
const toStatic = (material: Material | Material[]) =>
Array.isArray(material) ? material.map(toStaticMaterial) : toStaticMaterial(material)
/**
* Per-node proxy that keeps the host's selection machinery working for an
* instanced kind. The registered group carries the node transform (host
* contract: move tools drive `sceneRegistry.nodes.get(id)` imperatively with
* absolute level-local positions and mirror them via `useLiveTransforms` —
* see `ParametricNodeRenderer`), so registering a nested child would apply
* drag deltas in the node's rotated frame. The box collider is a positioned
* sibling: the raycast target, kept out of the registered group so the
* outline pass (which traces the registered object) shows the true
* silhouette, not a box.
*
* While hovered/selected the collective system skips this node and the proxy
* mounts the real geometry with **static twins** of the wind materials — the
* outline mask renders with an override material and can't follow GPU sway,
* so the plant holds still while outlined and the silhouette matches exactly.
* During a GLB export the geometry mounts with the real materials instead, so
* the exporter (which clones only the `scene-renderer` subtree, not the
* collective InstancedMesh) captures each plant; the collider is dropped so it
* doesn't bake as a phantom solid.
*/
export function KindProxy<N extends Placeable & { id: string }>({
node,
getVariant,
colliderRadius,
}: {
node: N
getVariant: (node: N) => VariantData
colliderRadius: (node: N) => number
}) {
const registeredRef = useRef<Object3D>(null!)
const handlers = useNodeEvents(node as never, node.type as never)
useRegistry(node.id as AnyNodeId, node.type, registeredRef)
const isExporting = useViewer((s) => s.isExporting)
const active = useViewer(
(s) => s.hoveredId === node.id || s.selection.selectedIds.includes(node.id as never),
)
const showGeometry = active || isExporting
// Live drag transform — the move tool writes the same absolute position
// imperatively to the registered group; applying it React-side too keeps the
// two in agreement (and moves the collider along with the drag). The rotate /
// resize gizmos publish through `useLiveNodeOverrides` instead — fold that in
// too (mirrors ParametricNodeRenderer) so the plant turns live mid-drag
// rather than snapping on commit.
const live = useLiveTransforms((s) => s.get(node.id))
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(node.id))
const overridePosition = liveOverride?.position as [number, number, number] | undefined
const overrideRotation = liveOverride?.rotation as [number, number, number] | undefined
const position = live?.position ?? overridePosition ?? node.position ?? [0, 0, 0]
const baseRotation = overrideRotation ?? node.rotation ?? [0, 0, 0]
const rotation: [number, number, number] = live
? [baseRotation[0], live.rotation, baseRotation[2]]
: baseRotation
const height = Math.max(0.2, node.height ?? 1)
const radius = colliderRadius(node)
const variant = useMemo(
() => (showGeometry ? getVariant(node) : null),
[showGeometry, node, getVariant],
)
const geometryScale = variant ? height / variant.naturalHeight : 1
return (
<group visible={node.visible !== false} {...handlers}>
{!isExporting && (
<mesh position={[position[0], position[1] + height / 2, position[2]]}>
<boxGeometry args={[radius * 2, height, radius * 2]} />
<meshBasicMaterial colorWrite={false} depthWrite={false} />
</mesh>
)}
<group position={position} ref={registeredRef} rotation={rotation}>
{variant && (
<group scale={geometryScale}>
{variant.subMeshes.map((subMesh, i) => (
<mesh
dispose={null}
geometry={subMesh.geometry}
key={i}
material={isExporting ? subMesh.material : toStatic(subMesh.material)}
raycast={NO_RAYCAST}
/>
))}
</group>
)}
</group>
</group>
)
}
-91
View File
@@ -1,91 +0,0 @@
import { type AnyNodeId, type ParametricDescriptor, useScene } from '@pascal-app/core'
import { defaultHeightOf, TREE_SEED_POOL } from './presets'
import type { TreeNode } from './schema'
/**
* The tree's right-hand inspector. This descriptor is the entire inspector —
* the host's `ParametricInspector` renders every control (selects, sliders,
* segmented switches, the native colour pickers, the vec3, and the Randomize
* action) with zero tree-specific code in the editor. Demonstrates the "right
* inspector comes free from `def.parametrics`" leg of the plugin surface.
*
* Colours (`leafColor`/`branchColor`) are edit-only — they're not on the
* placement brush, so a planted tree starts neutral (texture colours) and is
* tinted here per-tree.
*/
export const treeParametrics: ParametricDescriptor<TreeNode> = {
groups: [
{
label: 'Tree',
fields: [
{
key: 'preset',
kind: 'enum',
options: ['oak', 'pine', 'aspen', 'ash', 'bush', 'trellis'],
},
{
key: 'size',
kind: 'enum',
options: ['small', 'medium', 'large'],
display: 'segmented',
visibleIf: (n) => n.preset !== 'trellis',
},
{
key: 'treeType',
kind: 'enum',
options: ['deciduous', 'evergreen'],
display: 'segmented',
},
{ key: 'height', kind: 'number', unit: 'm', min: 1, max: 15, step: 0.5 },
{ key: 'branchColor', kind: 'color' },
{ key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 },
],
},
{
label: 'Foliage',
fields: [
{ key: 'leafless', kind: 'boolean' },
{
key: 'foliageDensity',
kind: 'number',
min: 0,
max: 1.5,
step: 0.1,
visibleIf: (n) => !n.leafless,
},
{ key: 'trunkThickness', kind: 'number', min: 0.3, max: 2.5, step: 0.1 },
{ key: 'leafColor', kind: 'color', visibleIf: (n) => !n.leafless },
],
},
{
label: 'Position',
fields: [{ key: 'position', kind: 'vec3' }],
},
],
actions: [
{
label: 'Randomize',
// The action receives the live node; writing a new seed re-generates the
// tree. Pick from the bounded pool so the result stays an instancing
// variant shared with other trees, not a one-off mesh.
onClick: (n) =>
useScene.getState().updateNode(
n.id as AnyNodeId,
{
seed: TREE_SEED_POOL[Math.floor(Math.random() * TREE_SEED_POOL.length)] ?? 1,
} as Partial<TreeNode> as never,
),
},
{
label: 'Reset height',
// Snap height back to the preset+size default (handy after changing size).
onClick: (n) =>
useScene
.getState()
.updateNode(
n.id as AnyNodeId,
{ height: defaultHeightOf(n.preset, n.size) } as Partial<TreeNode> as never,
),
},
],
}
-81
View File
@@ -1,81 +0,0 @@
'use client'
import { emitter, type GridEvent, sceneRegistry, snapPointToGrid } from '@pascal-app/core'
import { isGridSnapActive, useEditor } from '@pascal-app/editor'
import { useEffect, useRef, useState } from 'react'
import { type Group, Vector3 } from 'three'
const worldVec = new Vector3()
/** Snap a planar position to the grid when grid snapping is the active mode —
* reading the same `isGridSnapActive()` toggle + `gridSnapStep` the built-in
* item/shelf tools use, so plants honour the snap mode like every other item. */
export function snapXZ(x: number, z: number): readonly [number, number] {
if (!isGridSnapActive()) return [x, z]
return snapPointToGrid([x, z], useEditor.getState().gridSnapStep)
}
/**
* Convert a world-space grid hit into the active level's local frame, the way
* the host stores node positions. Re-derived from the public `sceneRegistry`
* because the built-in `floor-placement` helpers aren't part of the public
* `@pascal-app/*` surface yet — a candidate for a future `@pascal-app/plugin-api`.
*/
export function toLevelLocal(
levelId: string,
world: [number, number, number],
): [number, number, number] {
const levelObject = sceneRegistry.nodes.get(levelId)
if (!levelObject) return [world[0], 0, world[2]]
worldVec.set(world[0], world[1], world[2])
levelObject.updateWorldMatrix(true, false)
levelObject.worldToLocal(worldVec)
return [worldVec.x, 0, worldVec.z]
}
/**
* Shared placement wiring for any plant tool: ghosts a preview at the snapped
* cursor on `grid:move`, and calls `onCommit` with the snapped level-local
* position on `grid:click`. Returns the cursor group ref + visibility for the
* tool to attach its preview to. `onCommit` is read through a ref so a tool can
* close over live brush state without re-subscribing every render.
*/
export function usePlacement(
activeLevelId: string | null,
onCommit: (levelLocalPosition: [number, number, number]) => void,
) {
const cursorRef = useRef<Group>(null)
const [cursorVisible, setCursorVisible] = useState(false)
const commitRef = useRef(onCommit)
commitRef.current = onCommit
useEffect(() => {
if (!activeLevelId) return
setCursorVisible(false)
let lastWorld: [number, number, number] | null = null
const onMove = (event: GridEvent) => {
setCursorVisible(true)
const [lx, , lz] = event.localPosition
const [sx, sz] = snapXZ(lx, lz)
cursorRef.current?.position.set(sx, 0, sz)
lastWorld = event.position
}
const onClick = (event: GridEvent) => {
const world = lastWorld ?? event.position
const [lx, , lz] = toLevelLocal(activeLevelId, world)
const [sx, sz] = snapXZ(lx, lz)
commitRef.current([sx, 0, sz])
}
emitter.on('grid:move', onMove)
emitter.on('grid:click', onClick)
return () => {
emitter.off('grid:move', onMove)
emitter.off('grid:click', onClick)
}
}, [activeLevelId])
return { cursorRef, cursorVisible }
}
-267
View File
@@ -1,267 +0,0 @@
'use client'
import { useScene } from '@pascal-app/core'
import { SegmentedControl, SliderControl, ToggleControl, useEditor } from '@pascal-app/editor'
import { FLOWER_PRESET_LIST } from './flower-presets'
import type { FlowerPreset } from './flower-schema'
import { GRASS_PRESET_LIST } from './grass-presets'
import type { GrassPreset } from './grass-schema'
import { TREE_PRESET_LIST } from './presets'
import type { TreePreset } from './schema'
import { type TreesPanelMode as Mode, useTreesStore } from './store'
const KIND: Record<Mode, string> = {
trees: 'trees:tree',
flowers: 'trees:flower',
grass: 'trees:grass',
}
const NOUN: Record<Mode, string> = { trees: 'tree', flowers: 'flower', grass: 'grass' }
/**
* The plugin's left-rail panel. A Trees / Flowers / Grass segmented control
* switches the brush; picking a preset arms placement for that kind
* (`setTool('trees:*')` + build mode). The count chip reads the scene reactively,
* closing the triangle: panel → store → tool → scene → panel. It composes the
* host's exported controls (`SegmentedControl`/`SliderControl`/`ToggleControl`)
* so the brush matches the right-hand inspector pixel-for-pixel.
*/
export default function TreesPanel() {
// Section lives in the plugin store (not local state) so "find in catalog"
// can point the panel at the found node's section — see find-sync.ts.
const mode = useTreesStore((s) => s.mode)
const setMode = useTreesStore((s) => s.setMode)
const activeTool = useEditor((s) => s.tool)
const count = useScene(
(s) => Object.values(s.nodes).filter((n) => (n.type as string) === KIND[mode]).length,
)
const arming = activeTool === KIND[mode]
return (
<div className="flex flex-col gap-4 p-4 text-sidebar-foreground">
<header className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-base">Nature</h2>
<span className="rounded-full bg-sidebar-accent px-2 py-0.5 text-sidebar-foreground/70 text-xs">
{count} planted
</span>
</div>
<SegmentedControl
onChange={setMode}
options={[
{ label: 'Trees', value: 'trees' },
{ label: 'Flowers', value: 'flowers' },
{ label: 'Grass', value: 'grass' },
]}
value={mode}
/>
<p className="text-sidebar-foreground/50 text-xs">
{arming
? 'Click the ground to plant. Press Esc to stop.'
: `Pick ${mode === 'grass' ? 'a grass' : `a ${NOUN[mode]}`}, then click the ground.`}
</p>
</header>
{mode === 'trees' && <TreesSection arming={arming} />}
{mode === 'flowers' && <FlowersSection arming={arming} />}
{mode === 'grass' && <GrassSection arming={arming} />}
<footer className="-mx-4 -mb-4 sticky bottom-0 mt-1 border-sidebar-border/50 border-t bg-sidebar px-4 py-3 text-[11px] text-sidebar-foreground/50 leading-relaxed">
Trees generated with{' '}
<a
className="underline decoration-dotted underline-offset-2 hover:text-sidebar-foreground/70"
href="https://github.com/dgreenheck/ez-tree"
rel="noreferrer"
target="_blank"
>
ez-tree
</a>{' '}
by{' '}
<a
className="underline decoration-dotted underline-offset-2 hover:text-sidebar-foreground/70"
href="https://x.com/dangreenheck"
rel="noreferrer"
target="_blank"
>
Daniel Greenheck
</a>{' '}
(MIT).
</footer>
</div>
)
}
function TreesSection({ arming }: { arming: boolean }) {
const selected = useTreesStore((s) => s.preset)
const size = useTreesStore((s) => s.size)
const height = useTreesStore((s) => s.height)
const foliageDensity = useTreesStore((s) => s.foliageDensity)
const trunkThickness = useTreesStore((s) => s.trunkThickness)
const leafless = useTreesStore((s) => s.leafless)
const activate = (preset: TreePreset) => {
useTreesStore.getState().setPreset(preset)
useEditor.getState().setTool('trees:tree')
useEditor.getState().setMode('build')
}
return (
<>
<PresetGrid items={TREE_PRESET_LIST} onPick={activate} selected={arming ? selected : null} />
{selected !== 'trellis' && (
<div className="flex flex-col gap-2">
<SegmentedControl
onChange={useTreesStore.getState().setSize}
options={[
{ label: 'S', value: 'small' },
{ label: 'M', value: 'medium' },
{ label: 'L', value: 'large' },
]}
value={size}
/>
</div>
)}
<div className="flex flex-col gap-0.5">
<SliderControl
label="Height"
max={15}
min={1}
onChange={useTreesStore.getState().setHeight}
precision={1}
restoreOnCommit={false}
step={0.5}
unit="m"
value={height}
/>
{!leafless && (
<SliderControl
label="Foliage"
max={1.5}
min={0}
onChange={useTreesStore.getState().setFoliageDensity}
precision={1}
restoreOnCommit={false}
step={0.1}
value={foliageDensity}
/>
)}
<SliderControl
label="Trunk"
max={2.5}
min={0.3}
onChange={useTreesStore.getState().setTrunkThickness}
precision={1}
restoreOnCommit={false}
step={0.1}
value={trunkThickness}
/>
<ToggleControl
checked={leafless}
label="Bare (leafless)"
onChange={useTreesStore.getState().setLeafless}
/>
</div>
</>
)
}
function FlowersSection({ arming }: { arming: boolean }) {
const selected = useTreesStore((s) => s.flowerPreset)
const height = useTreesStore((s) => s.flowerHeight)
const activate = (preset: FlowerPreset) => {
useTreesStore.getState().setFlowerPreset(preset)
useEditor.getState().setTool('trees:flower')
useEditor.getState().setMode('build')
}
return (
<>
<PresetGrid
items={FLOWER_PRESET_LIST}
onPick={activate}
selected={arming ? selected : null}
/>
<SliderControl
label="Height"
max={2}
min={0.2}
onChange={useTreesStore.getState().setFlowerHeight}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={height}
/>
</>
)
}
function GrassSection({ arming }: { arming: boolean }) {
const selected = useTreesStore((s) => s.grassPreset)
const height = useTreesStore((s) => s.grassHeight)
const activate = (preset: GrassPreset) => {
useTreesStore.getState().setGrassPreset(preset)
useEditor.getState().setTool('trees:grass')
useEditor.getState().setMode('build')
}
return (
<>
<PresetGrid items={GRASS_PRESET_LIST} onPick={activate} selected={arming ? selected : null} />
<SliderControl
label="Height"
max={2}
min={0.1}
onChange={useTreesStore.getState().setGrassHeight}
precision={2}
restoreOnCommit={false}
step={0.05}
unit="m"
value={height}
/>
</>
)
}
function PresetGrid<T extends string>({
items,
selected,
onPick,
}: {
items: ReadonlyArray<{ id: T; label: string; thumbnail: string }>
selected: T | null
onPick: (id: T) => void
}) {
return (
<div className="grid grid-cols-2 gap-2">
{items.map((item) => {
const isSelected = selected === item.id
return (
<button
className={`group relative flex flex-col gap-2 rounded-xl border p-2 transition-all ${
isSelected
? 'border-sidebar-ring bg-sidebar-accent shadow-sm'
: 'border-sidebar-border hover:border-sidebar-ring/50 hover:bg-sidebar-accent/40'
}`}
key={item.id}
onClick={() => onPick(item.id)}
type="button"
>
<img
alt=""
aria-hidden
className="aspect-square w-full rounded-lg bg-[#f3f4f6] object-cover ring-1 ring-black/10 transition-transform group-hover:scale-[1.02]"
src={item.thumbnail}
/>
<span className="pl-0.5 font-medium text-xs">{item.label}</span>
{isSelected && (
<span className="absolute top-3 right-3 h-2 w-2 rounded-full bg-sidebar-ring ring-2 ring-sidebar-accent" />
)}
</button>
)
})}
</div>
)
}
-111
View File
@@ -1,111 +0,0 @@
import { TREE_ART } from './art'
import type { TreePreset, TreeSize } from './schema'
/**
* Per-species config: the ez-tree preset name for each size, a default placement
* height per size (metres), a swatch colour, and a card `thumbnail` (a
* replaceable placeholder image — see `thumbnails.ts`). Pure data — no three.js,
* no React — shared by the panel grid and the instanced renderer so they stay in
* lockstep. `ez[size]` is the exact ez-tree preset name passed to
* `tree.loadPreset(...)`, exposing all of ez-tree's built-in presets through a
* clean species × size model. `trellis` has a single preset (size ignored).
*/
export type TreePresetSpec = {
id: TreePreset
label: string
/** ez-tree preset name keyed by size. */
ez: Record<TreeSize, string>
/** Default placement height keyed by size. */
height: Record<TreeSize, number>
/** Whether the size control applies (false for `trellis`). */
sized: boolean
swatch: string
thumbnail: string
}
function ezSizes(family: string): Record<TreeSize, string> {
return { small: `${family} Small`, medium: `${family} Medium`, large: `${family} Large` }
}
export const TREE_PRESETS: Record<TreePreset, TreePresetSpec> = {
oak: {
id: 'oak',
label: 'Oak',
ez: ezSizes('Oak'),
height: { small: 5, medium: 7, large: 11 },
sized: true,
swatch: '#4f7942',
thumbnail: TREE_ART.oak,
},
pine: {
id: 'pine',
label: 'Pine',
ez: ezSizes('Pine'),
height: { small: 6, medium: 9, large: 14 },
sized: true,
swatch: '#2f5d3a',
thumbnail: TREE_ART.pine,
},
aspen: {
id: 'aspen',
label: 'Aspen',
ez: ezSizes('Aspen'),
height: { small: 5, medium: 8, large: 12 },
sized: true,
swatch: '#8fae5d',
thumbnail: TREE_ART.aspen,
},
ash: {
id: 'ash',
label: 'Ash',
ez: ezSizes('Ash'),
height: { small: 5, medium: 8, large: 12 },
sized: true,
swatch: '#6f9457',
thumbnail: TREE_ART.ash,
},
bush: {
id: 'bush',
label: 'Bush',
ez: { small: 'Bush 1', medium: 'Bush 2', large: 'Bush 3' },
height: { small: 1.2, medium: 1.5, large: 1.8 },
sized: true,
swatch: '#5c8a4a',
thumbnail: TREE_ART.bush,
},
trellis: {
id: 'trellis',
label: 'Trellis',
ez: { small: 'Trellis', medium: 'Trellis', large: 'Trellis' },
height: { small: 3, medium: 3, large: 3 },
sized: false,
swatch: '#8b6b45',
thumbnail: TREE_ART.trellis,
},
}
export const TREE_PRESET_LIST: TreePresetSpec[] = Object.values(TREE_PRESETS)
/** The ez-tree preset name for a species + size (size ignored for `trellis`). */
export function ezPresetOf(preset: TreePreset, size: TreeSize): string {
return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).ez[size]
}
/** Default placement height for a species + size. */
export function defaultHeightOf(preset: TreePreset, size: TreeSize): number {
return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).height[size]
}
/**
* Bounded seed pool. The placement tool and the Randomize action pick from
* this set so trees share geometry variants — that sharing is what makes
* instancing pay off. A power user can still type an arbitrary seed in the
* inspector; that tree just renders as its own single-instance variant.
*/
export const TREE_SEED_POOL = [1, 7, 13, 21, 34, 55, 89, 144]
/** Pick a seed from the pool, varied by an index so it stays deterministic
* (no Math.random in schema-importable code paths). */
export function seedFromPool(index: number): number {
return TREE_SEED_POOL[Math.abs(index) % TREE_SEED_POOL.length] ?? 1
}
-48
View File
@@ -1,48 +0,0 @@
'use client'
import { EDITOR_LAYER } from '@pascal-app/editor'
import { useEffect, useMemo } from 'react'
import type { Material } from 'three'
import { generateTree, treeSpecOf } from './geometry'
import type { TreeNode } from './schema'
import { naturalHeight } from './variant-utils'
/**
* Translucent placement ghost — a single ez-tree (not instanced) scaled to the
* node's height, following the cursor. Clones each material for the see-through
* look and disables raycast so the ghost never intercepts the cursor ray (which
* would freeze `grid:move`).
*/
export default function TreePreview({ node }: { node: TreeNode }) {
const built = useMemo(() => {
const tree = generateTree(treeSpecOf(node))
tree.scale.setScalar(node.height / naturalHeight(tree))
// Overlay layer keeps the ghost out of export/snapshot passes. Layers
// don't inherit, so every object in the built tree needs it.
tree.traverse((obj) => obj.layers.set(EDITOR_LAYER))
return tree
}, [node])
useEffect(() => {
const cloned: Material[] = []
built.traverse((obj) => {
;(obj as unknown as { raycast: () => void }).raycast = () => {}
const mesh = obj as { material?: Material | Material[] }
if (!mesh.material) return
const ghost = (mat: Material): Material => {
const c = mat.clone()
c.transparent = true
c.opacity = 0.5
c.depthWrite = false
cloned.push(c)
return c
}
mesh.material = Array.isArray(mesh.material) ? mesh.material.map(ghost) : ghost(mesh.material)
})
return () => {
for (const c of cloned) c.dispose()
}
}, [built])
return <primitive object={built} />
}
@@ -1,16 +0,0 @@
'use client'
import { getVariantData, treeSpecOf } from './geometry'
import { KindProxy } from './instanced'
import type { TreeNode } from './schema'
const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node))
const colliderRadius = (node: TreeNode) => Math.max(0.4, (node.height ?? 5) * 0.18)
/**
* Per-node selection proxy for the instanced trees — a thin binding of the
* generic {@link KindProxy} to this kind's geometry + collider size.
*/
export default function TreeProxyRenderer({ node }: { node: TreeNode }) {
return <KindProxy colliderRadius={colliderRadius} getVariant={getVariant} node={node} />
}
-57
View File
@@ -1,57 +0,0 @@
import { BaseNode, nodeType, objectId } from '@pascal-app/core'
import { z } from 'zod'
/** Tree species the plugin can place, each backed by an ez-tree preset family.
* The string persists in scene JSON. */
export const TreePreset = z.enum(['oak', 'pine', 'aspen', 'ash', 'bush', 'trellis'])
export type TreePreset = z.infer<typeof TreePreset>
/** Preset size variant. Maps to ez-tree's Small/Medium/Large presets (and
* Bush 1/2/3); ignored for `trellis`, which has a single preset. */
export const TreeSize = z.enum(['small', 'medium', 'large'])
export type TreeSize = z.infer<typeof TreeSize>
/** ez-tree's two growth models — deciduous (spreading) vs evergreen (conical). */
export const TreeType = z.enum(['deciduous', 'evergreen'])
export type TreeType = z.infer<typeof TreeType>
/**
* Schema for a placed tree. Composed from the public `BaseNode` exactly the way
* built-in node kinds are — `objectId`/`nodeType` come from `@pascal-app/core`,
* so a plugin needs no private host internals to mint a persistable node.
*
* `type` is the namespaced kind `trees:tree`. Every geometry-relevant field
* (preset/size/treeType/seed/foliage/trunk/leafless/leafColor/branchColor) is
* folded into the instancing variant key; `height`/`position`/`rotation` are
* cheap per-instance transforms.
*/
export const TreeNode = BaseNode.extend({
id: objectId('tree'),
type: nodeType('trees:tree'),
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
preset: TreePreset.default('oak'),
size: TreeSize.default('medium'),
// Overrides — all optional so an unset field inherits the ez-tree preset (its
// own seed/type/tints), exactly like flower `petalColor`. Placing a tree stores
// none of these, so a fresh tree is the pure preset; the inspector sets them.
/** Growth-model override. Unset ⇒ inherit the preset (oak → deciduous, pine → evergreen). */
treeType: TreeType.optional(),
height: z.number().positive().default(7),
/** Geometry-seed override. Unset ⇒ the preset's own seed (its canonical silhouette);
* set (e.g. via Randomize) to vary the tree. */
seed: z.number().int().optional(),
// Curated geometry params (folded into the instancing variant key):
/** Leaf-count multiplier vs the preset (1 = preset default). */
foliageDensity: z.number().min(0).max(1.5).default(1),
/** Branch-radius multiplier (1 = preset default). */
trunkThickness: z.number().min(0.3).max(2.5).default(1),
/** Strip all leaves — a bare winter silhouette. */
leafless: z.boolean().default(false),
/** Leaf tint override (hex). Unset ⇒ the preset's leaf tint. */
leafColor: z.string().optional(),
/** Bark/branch tint override (hex). Unset ⇒ the preset's bark tint. */
branchColor: z.string().optional(),
})
export type TreeNode = z.infer<typeof TreeNode>
@@ -1,20 +0,0 @@
'use client'
import { getVariantData, treeSpecOf, treeVariantKey } from './geometry'
import { InstancedNodes } from './instanced'
import type { TreeNode } from './schema'
const variantKeyOf = (node: TreeNode) => treeVariantKey(treeSpecOf(node))
const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node))
/**
* Collective renderer for the baked `/viewer` (`bakeReplaceRenderer`): one baked
* level's trees, instanced in level-local space (the viewer portals this into
* that level's `Object3D`). Same instancing as the editor `system`, so wind
* phase varies per tree via `instanceIndex` and a forest is a few draw calls.
*/
export default function TreeReplaceInstances({ nodes }: { nodes: TreeNode[] }) {
return (
<InstancedNodes getVariant={getVariant} localSpace nodes={nodes} variantKeyOf={variantKeyOf} />
)
}
-80
View File
@@ -1,80 +0,0 @@
import { create } from 'zustand'
import { FLOWER_PRESETS } from './flower-presets'
import type { FlowerPreset } from './flower-schema'
import { GRASS_PRESETS } from './grass-presets'
import type { GrassPreset } from './grass-schema'
import { defaultHeightOf, TREE_PRESETS } from './presets'
import type { TreePreset, TreeSize } from './schema'
/**
* The plugin's own module-level state — the example of "plugins self-manage
* runtime state with module-level stores" from the plugin-authoring contract.
* It holds the placement "brush": what the next planted tree/flower looks like.
* The presets panel writes it; the placement tool reads it. No host lifecycle
* slot. Colours are intentionally absent — they're edit-only (inspector).
*/
/** Which section of the Nature panel is showing. */
export type TreesPanelMode = 'trees' | 'flowers' | 'grass'
type TreesStore = {
/** Active panel section — in the store (not panel-local state) so the host's
* "find in catalog" can land on the right section (see `find-sync.ts`). */
mode: TreesPanelMode
setMode: (mode: TreesPanelMode) => void
preset: TreePreset
size: TreeSize
/** Height (m) of the next tree — a per-instance scale, never affects placed trees. */
height: number
/** Leaf-count multiplier vs the preset (folded into the instancing variant). */
foliageDensity: number
/** Branch-radius multiplier (folded into the instancing variant). */
trunkThickness: number
/** Plant bare (leafless) trees. */
leafless: boolean
setPreset: (preset: TreePreset) => void
setSize: (size: TreeSize) => void
setHeight: (height: number) => void
setFoliageDensity: (value: number) => void
setTrunkThickness: (value: number) => void
setLeafless: (value: boolean) => void
// Flower brush (sibling kind).
flowerPreset: FlowerPreset
flowerHeight: number
setFlowerPreset: (preset: FlowerPreset) => void
setFlowerHeight: (height: number) => void
// Grass brush (sibling kind).
grassPreset: GrassPreset
grassHeight: number
setGrassPreset: (preset: GrassPreset) => void
setGrassHeight: (height: number) => void
}
export const useTreesStore = create<TreesStore>((set, get) => ({
mode: 'trees',
setMode: (mode) => set({ mode }),
preset: 'oak',
size: 'medium',
height: TREE_PRESETS.oak.height.medium,
foliageDensity: 1,
trunkThickness: 1,
leafless: false,
// Switching preset/size re-seeds the height to that combo's natural default;
// the foliage/trunk brush settings carry over. Growth model comes from the
// preset (oak → deciduous, pine → evergreen); override per-tree in the inspector.
setPreset: (preset) => set({ preset, height: defaultHeightOf(preset, get().size) }),
setSize: (size) => set({ size, height: defaultHeightOf(get().preset, size) }),
setHeight: (height) => set({ height }),
setFoliageDensity: (foliageDensity) => set({ foliageDensity }),
setTrunkThickness: (trunkThickness) => set({ trunkThickness }),
setLeafless: (leafless) => set({ leafless }),
flowerPreset: 'daisy',
flowerHeight: FLOWER_PRESETS.daisy.defaultHeight,
setFlowerPreset: (flowerPreset) =>
set({ flowerPreset, flowerHeight: FLOWER_PRESETS[flowerPreset].defaultHeight }),
setFlowerHeight: (flowerHeight) => set({ flowerHeight }),
grassPreset: 'meadow',
grassHeight: GRASS_PRESETS.meadow.defaultHeight,
setGrassPreset: (grassPreset) =>
set({ grassPreset, grassHeight: GRASS_PRESETS[grassPreset].defaultHeight }),
setGrassHeight: (grassHeight) => set({ grassHeight }),
}))
-25
View File
@@ -1,25 +0,0 @@
'use client'
import { getVariantData, treeSpecOf, treeVariantKey } from './geometry'
import { InstancedKindSystem } from './instanced'
import type { TreeNode } from './schema'
// Module-scope so identities stay stable (the system memoises on them).
const variantKeyOf = (node: TreeNode) => treeVariantKey(treeSpecOf(node))
const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node))
/**
* Collective instanced renderer for every placed tree — contributed via
* `def.system`. Buckets trees by their geometry variant and draws each variant
* as one InstancedMesh per ez-tree sub-mesh, so a forest is a handful of draw
* calls. Selection/outline come from the per-node proxy renderer.
*/
export default function TreesSystem() {
return (
<InstancedKindSystem<TreeNode>
getVariant={getVariant}
kind="trees:tree"
variantKeyOf={variantKeyOf}
/>
)
}
-72
View File
@@ -1,72 +0,0 @@
'use client'
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
import { EDITOR_LAYER, triggerSFX } from '@pascal-app/editor'
import { useViewer } from '@pascal-app/viewer'
import { useMemo } from 'react'
import { usePlacement } from './placement'
import TreePreview from './preview'
import { TreeNode } from './schema'
import { useTreesStore } from './store'
/**
* The trees placement tool. Mounted by the host's registry-first `ToolManager`
* whenever `tool === 'trees:tree'` — no host edit per kind. Reads the panel
* brush from the plugin store, ghosts a preview at the snapped cursor, and
* commits a tree on click. Snapping + level conversion live in `usePlacement`.
*/
export default function TreeTool() {
const activeLevelId = useViewer((s) => s.selection.levelId)
const preset = useTreesStore((s) => s.preset)
const size = useTreesStore((s) => s.size)
const height = useTreesStore((s) => s.height)
const foliageDensity = useTreesStore((s) => s.foliageDensity)
const trunkThickness = useTreesStore((s) => s.trunkThickness)
const leafless = useTreesStore((s) => s.leafless)
const previewNode = useMemo(
() =>
TreeNode.parse({
preset,
size,
height,
foliageDensity,
trunkThickness,
leafless,
// seed/treeType left unset → the ghost shows the pure preset, as placed.
position: [0, 0, 0],
rotation: [0, 0, 0],
}),
[preset, size, height, foliageDensity, trunkThickness, leafless],
)
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
if (!activeLevelId) return
const s = useTreesStore.getState()
const tree = TreeNode.parse({
preset: s.preset,
size: s.size,
height: s.height,
foliageDensity: s.foliageDensity,
trunkThickness: s.trunkThickness,
leafless: s.leafless,
// seed/treeType unset → the pure ez-tree preset (its canonical seed + type).
// All same-preset trees then share one instancing variant; a random Y
// rotation keeps a planted row from looking cloned. Use Randomize (inspector)
// to vary a tree's seed.
position,
rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0],
})
useScene.getState().createNode(tree as unknown as AnyNode, activeLevelId as AnyNodeId)
useViewer.getState().setSelection({ selectedIds: [tree.id as AnyNodeId] })
triggerSFX('sfx:item-place')
})
if (!activeLevelId) return null
return (
<group layers={EDITOR_LAYER} ref={cursorRef} visible={cursorVisible}>
<TreePreview node={previewNode} />
</group>
)
}
@@ -1,27 +0,0 @@
import { Box3, type Object3D } from 'three'
// Pure helpers shared by the tree/flower/grass variant builders. They live
// apart from `geometry.ts` because that module imports ez-tree, which loads
// its inlined textures at module scope (needs `document`) and therefore must
// never sit on an eagerly-imported path (index → definitions → floorplan) —
// SSR/prerender would crash. Only lazy client modules may import `geometry.ts`.
/** Natural (unscaled) height of a generated plant, so the renderer can scale
* each instance to the node's `height`. */
export function naturalHeight(obj: Object3D): number {
const box = new Box3().setFromObject(obj)
return Math.max(0.001, box.max.y - box.min.y)
}
/** Deterministic 32-bit RNG (mulberry32) — same seed ⇒ same geometry. Shared by
* the procedural flower/grass builders so a variant is stable across instances. */
export function mulberry32(seed: number): () => number {
let a = seed || 1
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
-159
View File
@@ -1,159 +0,0 @@
import type { Color, Material, Side, Texture } from 'three'
import { cos, Fn, float, instanceIndex, positionLocal, sin, time, uv } from 'three/tsl'
import { MeshStandardNodeMaterial, type Node, type NodeBuilder } from 'three/webgpu'
/**
* Always-on wind for the plant kinds, done in TSL so it runs on the editor's
* WebGPU renderer (which ignores WebGL's `onBeforeCompile`). Two motions:
*
* - **Leaf flutter** (`LEAF_FLUTTER`) — ez-tree's own approach: the sway scales
* with the leaf card's `uv.y`, so each leaf swings from its attachment while
* the trunk and branches stay put. A whole-tree height-based bend, by contrast,
* is a rigid rotation about the base and reads as the tree spinning in place.
* Multi-frequency for a natural gust; phased per instance + per leaf.
* - **Stem bend** (`STEM_BEND`) — for the small procedural kinds (flowers,
* grass), a gentle whole-plant lean proportional to height reads fine.
*
* ez-tree isn't touched: its generated materials are re-created as node materials
* carrying the texture/tint (`toWindMaterial`) — only the `leaves` material gets
* the flutter node; bark stays static. `time` is advanced by the renderer.
*/
// ── Leaf flutter (tree leaves) ───────────────────────────────────────────────
const LEAF_FREQUENCY = 1.2
const LEAF_STRENGTH = 0.3
const leafFlutter = Fn(() => {
const p = positionLocal.toVar()
const offset = float(instanceIndex).mul(0.7).add(p.x.add(p.z).mul(0.3))
const t = time.mul(LEAF_FREQUENCY)
const wave = sin(t.add(offset))
.mul(0.5)
.add(sin(t.mul(2).add(offset.mul(1.3))).mul(0.3))
.add(sin(t.mul(5).add(offset.mul(1.5))).mul(0.2))
const sway = uv().y.mul(LEAF_STRENGTH).mul(wave)
p.x.addAssign(sway)
p.z.addAssign(sway)
return p
})
const LEAF_FLUTTER = leafFlutter()
// ── Stem bend (flowers, grass) ───────────────────────────────────────────────
const STEM_FREQUENCY = 1.3
const STEM_STRENGTH = 0.05
const stemBend = Fn(() => {
const p = positionLocal.toVar()
const h = p.y.max(0)
const phase = float(instanceIndex).mul(0.618)
const t = time.mul(STEM_FREQUENCY).add(phase)
p.x.addAssign(h.mul(STEM_STRENGTH).mul(sin(t)))
p.z.addAssign(h.mul(STEM_STRENGTH).mul(cos(t.mul(1.1))))
return p
})
const STEM_BEND = stemBend()
/**
* Standard node material whose wind displacement runs **before** the instance
* transform. The wind nodes read `positionLocal` expecting raw geometry-local
* coordinates — leaf phase from the card's position inside its own tree, stem
* height from the plant's own base — with the per-instance scale/rotation/
* translation applied on top. three r184 happened to emit `positionNode`
* statements in exactly that order; r185 fixed the emission order so
* `positionNode` now runs *after* instancing, which put the wind in level
* space: sway stopped scaling with the instance, leaf phase followed world
* placement, and STEM_BEND's height term read the floor elevation, so plants
* on upper levels slid around rigidly. Assigning `positionLocal` inside
* `setupPosition` before `super` applies instancing restores the r184
* (geometry-space) semantics on r185.
*/
class WindNodeMaterial extends MeshStandardNodeMaterial {
windNode: Node | null = null
setupPosition(builder: NodeBuilder): Node {
if (this.windNode !== null) positionLocal.assign(this.windNode)
return super.setupPosition(builder)
}
customProgramCacheKey(): string {
return `${super.customProgramCacheKey()}|wind:${this.windNode ? this.windNode.id : 'none'}`
}
}
/** The classic-material fields we carry over — enough to reproduce ez-tree's
* bark/leaf look (textured, tinted, alpha-cut billboards). */
type ClassicMaterial = Material & {
map?: Texture | null
alphaMap?: Texture | null
color?: Color
side?: Side
alphaTest?: number
opacity?: number
transparent?: boolean
depthWrite?: boolean
}
const cache = new WeakMap<Material, MeshStandardNodeMaterial>()
/** Re-create a generated (ez-tree) material as a `MeshStandardNodeMaterial`,
* transferring its texture/tint explicitly (node materials don't pick these up
* via `Material.copy()`). Only the `leaves` material flutters; bark stays static.
* Cached per source so shared variant materials convert once. */
export function toWindMaterial(material: Material): MeshStandardNodeMaterial {
const cached = cache.get(material)
if (cached) return cached
const src = material as ClassicMaterial
const node = new WindNodeMaterial({
map: src.map ?? null,
alphaMap: src.alphaMap ?? null,
color: src.color,
side: src.side,
alphaTest: src.alphaTest ?? 0,
transparent: src.transparent ?? false,
opacity: src.opacity ?? 1,
depthWrite: src.depthWrite ?? true,
roughness: 1,
metalness: 0,
})
if (material.name === 'leaves') node.windNode = LEAF_FLUTTER
cache.set(material, node)
return node
}
/** Build a swaying node material for the procedural kinds (flowers/grass). */
export function windStandardMaterial(
params: ConstructorParameters<typeof MeshStandardNodeMaterial>[0],
): MeshStandardNodeMaterial {
const material = new WindNodeMaterial(params)
material.windNode = STEM_BEND
return material
}
const staticCache = new WeakMap<Material, Material>()
/** Windless twin of a wind material — same look, no wind node. The outline
* mask pass renders outlined meshes with a shared override material, so an
* outline can never follow the GPU sway; the selection proxy renders this twin
* instead, so the outlined silhouette and the visible mesh match exactly (the
* plant simply holds still while hovered/selected). Built by explicit property
* transfer, not `.clone()` — node-material clone drops `map`/`color` (same
* pitfall as `toWindMaterial`). Cached per source. */
export function toStaticMaterial(material: Material): Material {
const cached = staticCache.get(material)
if (cached) return cached
const src = material as MeshStandardNodeMaterial
const twin = new MeshStandardNodeMaterial({
map: src.map ?? null,
alphaMap: src.alphaMap ?? null,
color: src.color,
side: src.side,
alphaTest: src.alphaTest ?? 0,
transparent: src.transparent ?? false,
opacity: src.opacity ?? 1,
depthWrite: src.depthWrite ?? true,
roughness: src.roughness,
metalness: src.metalness,
})
staticCache.set(material, twin)
return twin
}
-10
View File
@@ -1,10 +0,0 @@
{
"extends": "@pascal/typescript-config/react-library.json",
"compilerOptions": {
"rootDir": "src",
"noEmit": true,
"types": ["node"]
},
"include": ["src"],
"exclude": ["node_modules"]
}
@@ -1,6 +1,12 @@
'use client' 'use client'
import { type AnyNode, nodeRegistry, type RendererSource, useScene } from '@pascal-app/core' import {
type AnyNode,
isNodeKindEnabled,
nodeRegistry,
type RendererSource,
useScene,
} from '@pascal-app/core'
import { type ComponentType, lazy, Suspense } from 'react' import { type ComponentType, lazy, Suspense } from 'react'
import { ParametricNodeRenderer } from './parametric-node-renderer' import { ParametricNodeRenderer } from './parametric-node-renderer'
@@ -23,7 +29,9 @@ export function getRegistryRenderer(
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => { export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
const node = useScene((state) => state.nodes[nodeId]) const node = useScene((state) => state.nodes[nodeId])
const installedPlugins = useScene((state) => state.installedPlugins)
if (!node) return null if (!node) return null
if (!isNodeKindEnabled(node.type, installedPlugins)) return null
const def = nodeRegistry.get(node.type) const def = nodeRegistry.get(node.type)
if (!def) return null if (!def) return null
// Two-checkbox dispatch (see wiki/architecture/node-definitions.md): // Two-checkbox dispatch (see wiki/architecture/node-definitions.md):
@@ -3,6 +3,7 @@
import { import {
type AnyNode, type AnyNode,
bakePolicyOf, bakePolicyOf,
isNodeKindEnabled,
nodeRegistry, nodeRegistry,
type RendererSource, type RendererSource,
type SceneGraph, type SceneGraph,
@@ -30,6 +31,7 @@ export function buildGlbReferenceNodes(
const out: AnyNode[] = [] const out: AnyNode[] = []
for (const raw of Object.values(nodes)) { for (const raw of Object.values(nodes)) {
const node = raw as AnyNode const node = raw as AnyNode
if (!isNodeKindEnabled(node.type, sceneGraph?.installedPlugins)) continue
if (bakePolicyOf(node.type) !== 'strip') continue if (bakePolicyOf(node.type) !== 'strip') continue
if (node.type === 'scan' && !allow.scans) continue if (node.type === 'scan' && !allow.scans) continue
if (node.type === 'guide' && !allow.guides) continue if (node.type === 'guide' && !allow.guides) continue
@@ -52,6 +54,7 @@ export function buildGlbReplaceNodes(sceneGraph: SceneGraph | null | undefined):
const out: AnyNode[] = [] const out: AnyNode[] = []
for (const raw of Object.values(nodes)) { for (const raw of Object.values(nodes)) {
const node = raw as AnyNode const node = raw as AnyNode
if (!isNodeKindEnabled(node.type, sceneGraph?.installedPlugins)) continue
if (bakePolicyOf(node.type) === 'replace') out.push(node) if (bakePolicyOf(node.type) === 'replace') out.push(node)
} }
return out return out
@@ -1,6 +1,12 @@
'use client' 'use client'
import { type AnyNodeDefinition, createSceneApi, nodeRegistry, useScene } from '@pascal-app/core' import {
type AnyNodeDefinition,
createSceneApi,
isNodeKindEnabled,
nodeRegistry,
useScene,
} from '@pascal-app/core'
import { type ComponentType, lazy, Suspense, useMemo } from 'react' import { type ComponentType, lazy, Suspense, useMemo } from 'react'
const DEFAULT_PRIORITY = 5 const DEFAULT_PRIORITY = 5
@@ -34,6 +40,7 @@ function loadSystem(def: AnyNodeDefinition): ComponentType<RegisteredSystemProps
*/ */
export function RegisteredSystems() { export function RegisteredSystems() {
const sceneApi = useMemo(() => createSceneApi(useScene), []) const sceneApi = useMemo(() => createSceneApi(useScene), [])
const installedPlugins = useScene((state) => state.installedPlugins)
const entries = useMemo(() => { const entries = useMemo(() => {
return Array.from(nodeRegistry.entries()) return Array.from(nodeRegistry.entries())
.filter(([, def]) => def.system != null) .filter(([, def]) => def.system != null)
@@ -49,6 +56,7 @@ export function RegisteredSystems() {
return ( return (
<Suspense fallback={null}> <Suspense fallback={null}>
{entries.map(([kind, def]) => { {entries.map(([kind, def]) => {
if (!isNodeKindEnabled(kind, installedPlugins)) return null
const Comp = loadSystem(def) const Comp = loadSystem(def)
if (!Comp) return null if (!Comp) return null
return <Comp key={`registered-system:${kind}`} sceneApi={sceneApi} /> return <Comp key={`registered-system:${kind}`} sceneApi={sceneApi} />
+37 -7
View File
@@ -30,7 +30,7 @@ export const myPlugin: Plugin = {
| `apiVersion` | yes | Currently `1`. The host throws on mismatch — bumping breaks plugins, intentionally. | | `apiVersion` | yes | Currently `1`. The host throws on mismatch — bumping breaks plugins, intentionally. |
| `nodes` | optional | Array of `AnyNodeDefinition`. | | `nodes` | optional | Array of `AnyNodeDefinition`. |
The first-party [`@pascal-app/plugin-trees`](../../packages/plugin-trees) package is the worked example. Copy it as a starting point. The standalone [`pascalorg/plugin-trees`](https://github.com/pascalorg/plugin-trees) repository is the worked example. Clone it as a starting point.
The same shape powers the built-in `pascal:core` plugin in `@pascal-app/nodes` — there's no "internal" plugin format. Whatever works for built-ins works for third parties. The same shape powers the built-in `pascal:core` plugin in `@pascal-app/nodes` — there's no "internal" plugin format. Whatever works for built-ins works for third parties.
@@ -79,10 +79,10 @@ The packages are **peer dependencies**, not normal dependencies — the host app
```mermaid ```mermaid
graph TD graph TD
Boot[App boot] --> LoadBuiltin[loadPlugin(builtinPlugin)] Boot["App boot"] --> LoadBuiltin["loadPlugin(builtinPlugin)"]
LoadBuiltin --> Discover[await discoverPlugins()] LoadBuiltin --> Discover["await discoverPlugins()"]
Discover --> LoadEach[for each: await loadPlugin(p)] Discover --> LoadEach["for each plugin: await loadPlugin(plugin)"]
LoadEach --> Ready[Registry frozen for the session] LoadEach --> Ready["Registry frozen for the session"]
``` ```
`loadPlugin` is **add-only** for v1. Hot-removing a kind would require tearing down every mounted instance in the scene — out of scope. Plugins are loaded once at boot. `loadPlugin` is **add-only** for v1. Hot-removing a kind would require tearing down every mounted instance in the scene — out of scope. Plugins are loaded once at boot.
@@ -110,6 +110,36 @@ setPluginDiscovery(async () => {
`setPluginDiscovery` is global. Calling it twice silently overwrites — order with the bootstrap import matters. `setPluginDiscovery` is global. Calling it twice silently overwrites — order with the bootstrap import matters.
## Host panels and project installation
The core `Plugin` manifest remains renderer-agnostic. A plugin that also ships editor UI exports an `EditorHostPanel` separately:
```ts
import type { EditorHostPanel } from '@pascal-app/editor'
export const myHostPanel: EditorHostPanel = {
id: 'acme:furniture-pack:catalog',
pluginId: 'acme:furniture-pack',
label: 'Furniture pack',
description: 'A curated furniture catalog.',
creator: {
name: 'Acme',
url: 'https://acme.example',
},
pluginUrl: 'https://github.com/acme/pascal-furniture-pack',
icon: { kind: 'iconify', name: 'lucide:armchair' },
component: () => import('./catalog-panel'),
}
```
The host registers that panel with `registerEditorHostPanel`. Registered plugins appear in the Plugins sidebar, while the scene graph's `installedPlugins: string[]` controls which plugin panels appear in that project's icon rail. `defaultInstalled: true` opts a first-party plugin into legacy and newly created projects; Nature uses this today.
Install/uninstall is a project-level visibility operation. Plugin code and node definitions stay loaded for the browser session because `loadPlugin` is add-only, but an uninstalled plugin's panel, placement UI, renderers, systems, and floor-plan output are disabled. Existing plugin nodes remain serialized in the scene graph and become visible again when the plugin is reinstalled; uninstall never deletes project data.
`creator` and `pluginUrl` are optional manager metadata. Selecting a plugin in the Plugins sidebar opens its detail page, where the host shows this metadata and the project install/uninstall control.
Host panels mount lazily inside an error boundary. Use host CSS variables, keep CSS scoped to the plugin, and do not write global styles.
## Versioning ## Versioning
`apiVersion: 1` covers the surface above. The host bumps the major when it removes or changes the shape of an existing field. New optional fields don't bump. The plan is to keep additions backwards-compatible as long as possible — the bump is the escape hatch, not the default. `apiVersion: 1` covers the surface above. The host bumps the major when it removes or changes the shape of an existing field. New optional fields don't bump. The plan is to keep additions backwards-compatible as long as possible — the bump is the escape hatch, not the default.
@@ -120,7 +150,7 @@ A plugin's own data versioning is `schemaVersion` on each `NodeDefinition`. The
- **Materials** — there's no `plugin.materials` slot. Use `createMaterial` from `@pascal-app/viewer` inside your `def.renderer` / `def.system`. - **Materials** — there's no `plugin.materials` slot. Use `createMaterial` from `@pascal-app/viewer` inside your `def.renderer` / `def.system`.
- **Floor-plan primitives** — the `FloorplanGeometry` union is host-owned. To draw something the union can't express, fall back to `def.renderer` and render through a different 2D mount (or open an issue). - **Floor-plan primitives** — the `FloorplanGeometry` union is host-owned. To draw something the union can't express, fall back to `def.renderer` and render through a different 2D mount (or open an issue).
- **Panels / sidebar UI** — host-specific. A host may layer its own extension surface on top of the core plugin manifest, but `@pascal-app/core` does not own that contract. - **Panels / sidebar UI in the core manifest** — host-specific. Export an `EditorHostPanel` separately for hosts that use `@pascal-app/editor`.
- **Stores** — plugins create their own Zustand stores; they don't extend `useScene`, `useEditor`, or `useViewer`. Host stores are not part of the v1 plugin surface. - **Stores** — plugins create their own Zustand stores; they don't extend `useScene`, `useEditor`, or `useViewer`. Host stores are not part of the v1 plugin surface.
- **Routes / pages** — plugins are visualisation + interaction code, not full app surfaces. Hosting a settings page belongs to the app. - **Routes / pages** — plugins are visualisation + interaction code, not full app surfaces. Hosting a settings page belongs to the app.
@@ -128,7 +158,7 @@ The boundary stays narrow on purpose so the contract is shippable. Each "not yet
## Testing your plugin ## Testing your plugin
`@pascal-app/nodes` is the reference implementation — every built-in kind is structurally a plugin. To test locally: `@pascal-app/nodes` is the built-in reference implementation, and [`pascalorg/plugin-trees`](https://github.com/pascalorg/plugin-trees) is the standalone example. To test locally:
1. Build your plugin as a normal npm package with `@pascal-app/*` as peerDependencies. 1. Build your plugin as a normal npm package with `@pascal-app/*` as peerDependencies.
2. In a host app that consumes your built-ins (`apps/editor` is the easiest target), wire `setPluginDiscovery` to return your plugin. 2. In a host app that consumes your built-ins (`apps/editor` is the easiest target), wire `setPluginDiscovery` to return your plugin.