feat: add project plugin management (#501)
@@ -46,7 +46,7 @@ A key rule: **`packages/viewer` must never import from `apps/editor`**. The view
|
||||
|
||||
### Building a plugin
|
||||
|
||||
New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md) for the contract, and copy [`packages/plugin-trees`](packages/plugin-trees) as a worked example.
|
||||
New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [Create a plugin](https://editor.pascal.app/docs/developers/plugins) for the contract, and clone [`pascalorg/plugin-trees`](https://github.com/pascalorg/plugin-trees) as a worked example.
|
||||
|
||||
## Submitting a PR
|
||||
|
||||
|
||||
@@ -339,8 +339,8 @@ Clears dirty flag
|
||||
|
||||
The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same `Plugin` manifest the built-ins use — there is no separate internal API.
|
||||
|
||||
- **Contract reference** — [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md): the `Plugin` shape, panel contributions, discovery (`setPluginDiscovery`), lifecycle, and what's in/out of v1.
|
||||
- **Worked example** — [`packages/plugin-trees`](packages/plugin-trees): a first-party plugin (procedural trees, flowers, grass + a presets panel) structurally identical to a third-party pack. Copy it as a starting point.
|
||||
- **Developer guide** — [Create a plugin](https://editor.pascal.app/docs/developers/plugins): the `Plugin` shape, panel contributions, discovery, lifecycle, and what's in/out of v1.
|
||||
- **Worked example** — [`pascalorg/plugin-trees`](https://github.com/pascalorg/plugin-trees): a standalone plugin with procedural trees, flowers, grass, and a presets panel. Clone it as a starting point.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
@import "tw-animate-css";
|
||||
@import "../../../styles/elevation.css";
|
||||
@source "../../../packages/editor/src";
|
||||
@source "../../../node_modules/@pascal-app/plugin-trees/src";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ function sceneGraphSignature(graph: SceneGraphWithCollections): string {
|
||||
nodes: graph.nodes,
|
||||
rootNodeIds: graph.rootNodeIds,
|
||||
collections: graph.collections,
|
||||
installedPlugins: graph.installedPlugins,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export const apiGraphSchema = z
|
||||
nodes: z.record(z.string(), z.unknown()),
|
||||
rootNodeIds: z.array(z.string()),
|
||||
collections: z.unknown().optional(),
|
||||
installedPlugins: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
for (const [nodeId, node] of Object.entries(value.nodes)) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"@pascal-app/editor": "*",
|
||||
"@pascal-app/mcp": "*",
|
||||
"@pascal-app/nodes": "*",
|
||||
"@pascal-app/plugin-trees": "*",
|
||||
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@pascal-app/editor": "*",
|
||||
"@pascal-app/mcp": "*",
|
||||
"@pascal-app/nodes": "*",
|
||||
"@pascal-app/plugin-trees": "*",
|
||||
"@pascal-app/plugin-trees": "github:pascalorg/plugin-trees#56d978cd9b409b716207b3f3d269455d3cd6f067",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@react-three/drei": "^10.7.7",
|
||||
@@ -260,38 +260,6 @@
|
||||
"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": {
|
||||
"name": "@repo/typescript-config",
|
||||
"version": "0.0.0",
|
||||
@@ -722,7 +690,7 @@
|
||||
|
||||
"@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"],
|
||||
|
||||
|
||||
@@ -20,10 +20,12 @@ export {
|
||||
discoverPlugins,
|
||||
extendPluginDiscovery,
|
||||
getHostRefFields,
|
||||
getNodePluginId,
|
||||
getSelectableKinds,
|
||||
hasRegistry3DMoveTool,
|
||||
isDrawnViaTool,
|
||||
isDrawnViaToolKind,
|
||||
isNodeKindEnabled,
|
||||
isPresettable,
|
||||
isPresettableKind,
|
||||
isRegistryMovable,
|
||||
|
||||
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
getHostRefFields,
|
||||
getNodePluginId,
|
||||
isDrawnViaTool,
|
||||
isDrawnViaToolKind,
|
||||
isNodeKindEnabled,
|
||||
isPresettable,
|
||||
isPresettableKind,
|
||||
loadPlugin,
|
||||
@@ -188,6 +190,23 @@ describe('loadPlugin', () => {
|
||||
expect(nodeRegistry.size).toBe(2)
|
||||
expect(nodeRegistry.has('a')).toBe(true)
|
||||
expect(nodeRegistry.has('b')).toBe(true)
|
||||
expect(getNodePluginId('a')).toBe('test:plugin')
|
||||
expect(getNodePluginId('b')).toBe('test:plugin')
|
||||
})
|
||||
|
||||
test('enables plugin kinds only when the project has the plugin installed', async () => {
|
||||
await loadPlugin({ id: 'test:plugin', apiVersion: 1, nodes: [makeDefinition('plugin:node')] })
|
||||
|
||||
expect(isNodeKindEnabled('plugin:node', [])).toBe(false)
|
||||
expect(isNodeKindEnabled('plugin:node', ['test:plugin'])).toBe(true)
|
||||
expect(isNodeKindEnabled('plugin:node')).toBe(true)
|
||||
expect(isNodeKindEnabled('host:node', [])).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps built-in plugin kinds enabled independently of project installs', async () => {
|
||||
await loadPlugin({ id: 'pascal:core', apiVersion: 1, nodes: [makeDefinition('wall')] })
|
||||
|
||||
expect(isNodeKindEnabled('wall', [])).toBe(true)
|
||||
})
|
||||
|
||||
test('handles plugin with no nodes', async () => {
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { ZodObject } from 'zod'
|
||||
import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types'
|
||||
|
||||
const HOST_API_VERSION = 1 as const
|
||||
const BUILTIN_PLUGIN_ID = 'pascal:core'
|
||||
|
||||
const pluginIdsByKind = new Map<string, string>()
|
||||
|
||||
// True in dev / test builds, false in production. Tries Vite's
|
||||
// `import.meta.env.DEV` first (the editor app's bundler) and falls back
|
||||
@@ -74,6 +77,7 @@ class NodeRegistryImpl implements NodeRegistry {
|
||||
// Test-only — clears the registry. Not exported from the package barrel.
|
||||
_reset(): void {
|
||||
this.defs.clear()
|
||||
pluginIdsByKind.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +90,23 @@ export function registerNode(def: AnyNodeDefinition): void {
|
||||
nodeRegistry._register(def)
|
||||
}
|
||||
|
||||
/** The plugin that registered a node kind, when it came through {@link loadPlugin}. */
|
||||
export function getNodePluginId(kind: string): string | undefined {
|
||||
return pluginIdsByKind.get(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a registered kind should participate in a project. Kinds registered
|
||||
* directly by the host and the built-in plugin are always enabled. An omitted
|
||||
* install list means a legacy scene whose plugin state predates persistence, so
|
||||
* loaded plugins remain visible for backward compatibility.
|
||||
*/
|
||||
export function isNodeKindEnabled(kind: string, installedPlugins?: readonly string[]): boolean {
|
||||
const pluginId = getNodePluginId(kind)
|
||||
if (!pluginId || pluginId === BUILTIN_PLUGIN_ID || installedPlugins === undefined) return true
|
||||
return installedPlugins.includes(pluginId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of registered kinds whose definition declares the
|
||||
* `selectable` capability. Callers that maintain hardcoded "selectable kinds"
|
||||
@@ -244,6 +265,7 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
|
||||
}
|
||||
for (const def of plugin.nodes ?? []) {
|
||||
registerNode(def)
|
||||
pluginIdsByKind.set(def.kind, plugin.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import type { TemporalState } from 'zundo'
|
||||
import { temporal } from 'zundo'
|
||||
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
||||
import { parseMaterialRef, toSceneMaterialRef } from '../material-library'
|
||||
import { nodeRegistry } from '../registry/registry'
|
||||
import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry'
|
||||
import { BuildingNode } from '../schema'
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
import { generateCollectionId } from '../schema/collections'
|
||||
@@ -952,6 +952,8 @@ export type SceneState = {
|
||||
// 4. Relational metadata — not nodes
|
||||
collections: Record<CollectionId, Collection>
|
||||
materials: Record<SceneMaterialId, SceneMaterial>
|
||||
installedPlugins: string[]
|
||||
hasExplicitPluginInstallState: boolean
|
||||
|
||||
// 5. Read-only lock — when true all create/update/delete operations are no-ops
|
||||
readOnly: boolean
|
||||
@@ -967,8 +969,11 @@ export type SceneState = {
|
||||
extra?: {
|
||||
collections?: Record<CollectionId, Collection>
|
||||
materials?: Record<SceneMaterialId, SceneMaterial>
|
||||
installedPlugins?: string[]
|
||||
hasExplicitPluginInstallState?: boolean
|
||||
},
|
||||
) => void
|
||||
setInstalledPlugins: (pluginIds: string[], options?: { explicit?: boolean }) => void
|
||||
|
||||
markDirty: (id: AnyNodeId) => void
|
||||
clearDirty: (id: AnyNodeId) => void
|
||||
@@ -1004,7 +1009,9 @@ export type SceneState = {
|
||||
|
||||
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
||||
temporal: StoreApi<
|
||||
TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials'>>
|
||||
TemporalState<
|
||||
Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials' | 'installedPlugins'>
|
||||
>
|
||||
>
|
||||
}
|
||||
|
||||
@@ -1023,6 +1030,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
// 4. Collections
|
||||
collections: {} as Record<CollectionId, Collection>,
|
||||
materials: {} as Record<SceneMaterialId, SceneMaterial>,
|
||||
installedPlugins: [],
|
||||
hasExplicitPluginInstallState: false,
|
||||
|
||||
// 5. Read-only lock
|
||||
readOnly: false,
|
||||
@@ -1035,12 +1044,17 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: {},
|
||||
materials: {},
|
||||
installedPlugins: [],
|
||||
hasExplicitPluginInstallState: false,
|
||||
})
|
||||
},
|
||||
|
||||
clearScene: () => {
|
||||
const installedPlugins = get().installedPlugins
|
||||
const hasExplicitPluginInstallState = get().hasExplicitPluginInstallState
|
||||
get().unloadScene()
|
||||
get().loadScene() // Default scene
|
||||
set({ installedPlugins, hasExplicitPluginInstallState })
|
||||
},
|
||||
|
||||
setScene: (nodes, rootNodeIds, extra) => {
|
||||
@@ -1086,6 +1100,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: extra?.collections ?? {},
|
||||
materials,
|
||||
installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
|
||||
hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
|
||||
})
|
||||
// Mark all nodes as dirty to trigger re-validation
|
||||
Object.values(cleanedNodes).forEach((node) => {
|
||||
@@ -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: () => {
|
||||
if (get().rootNodeIds.length > 0) {
|
||||
// Assign all nodes as dirty to force re-validation
|
||||
@@ -1131,6 +1167,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
|
||||
markDirty: (id) => {
|
||||
const node = get().nodes[id]
|
||||
if (node && !isNodeKindEnabled(node.type, get().installedPlugins)) return
|
||||
if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return
|
||||
get().dirtyNodes.add(id)
|
||||
},
|
||||
@@ -1275,8 +1312,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
}),
|
||||
{
|
||||
partialize: (state) => {
|
||||
const { nodes, rootNodeIds, collections, materials } = state
|
||||
return { nodes, rootNodeIds, collections, materials }
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state
|
||||
return { nodes, rootNodeIds, collections, materials, installedPlugins }
|
||||
},
|
||||
limit: 50, // Limit to last 50 actions
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ function makeSceneGraph(): SceneGraph {
|
||||
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 === 'wall')).toBe(true)
|
||||
expect(forked.collections).toEqual({})
|
||||
expect(forked.installedPlugins).toEqual(['pascal:trees'])
|
||||
})
|
||||
|
||||
test('preserves scan and guide nodes when requested', () => {
|
||||
@@ -66,5 +68,6 @@ describe('forkSceneGraph', () => {
|
||||
expect(
|
||||
Object.values(forked.collections ?? {}).flatMap((collection) => collection.nodeIds),
|
||||
).toHaveLength(2)
|
||||
expect(forked.installedPlugins).toEqual(['pascal:trees'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ export type SceneGraph = {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
rootNodeIds: AnyNodeId[]
|
||||
collections?: Record<CollectionId, Collection>
|
||||
installedPlugins?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +27,7 @@ function extractIdPrefix(id: string): string {
|
||||
* - Multi-scene in-memory scenarios
|
||||
*/
|
||||
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
const { nodes, rootNodeIds, collections } = sceneGraph
|
||||
const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph
|
||||
|
||||
// Build ID mapping: old ID -> new ID
|
||||
const idMap = new Map<string, string>()
|
||||
@@ -134,6 +135,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
nodes: clonedNodes,
|
||||
rootNodeIds: clonedRootNodeIds,
|
||||
...(clonedCollections && { collections: clonedCollections }),
|
||||
...(installedPlugins && { installedPlugins: [...installedPlugins] }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +257,7 @@ export function forkSceneGraph(
|
||||
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)
|
||||
const excludedNodeIds = new Set<string>()
|
||||
@@ -317,5 +319,6 @@ export function forkSceneGraph(
|
||||
nodes: filteredNodes,
|
||||
rootNodeIds: filteredRootNodeIds,
|
||||
...(filteredCollections && { collections: filteredCollections }),
|
||||
...(installedPlugins && { installedPlugins }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export type BuildStats = {
|
||||
export type ParsedBuildJson = {
|
||||
nodes: Record<string, unknown>
|
||||
rootNodeIds: string[]
|
||||
installedPlugins?: string[]
|
||||
}
|
||||
|
||||
export type SchemaIssue = {
|
||||
@@ -100,6 +101,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
|
||||
|
||||
const nodesRaw = input.nodes
|
||||
const rootNodeIdsRaw = input.rootNodeIds
|
||||
const installedPluginsRaw = input.installedPlugins
|
||||
|
||||
if (!isPlainObject(nodesRaw)) {
|
||||
errors.push({
|
||||
@@ -135,6 +137,19 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
|
||||
nodesRaw as Record<string, unknown>,
|
||||
)
|
||||
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) {
|
||||
warnings.push({
|
||||
@@ -296,7 +311,13 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
|
||||
const ok = errors.length === 0
|
||||
return {
|
||||
ok,
|
||||
parsed: ok ? { nodes, rootNodeIds } : null,
|
||||
parsed: ok
|
||||
? {
|
||||
nodes,
|
||||
rootNodeIds,
|
||||
...(installedPlugins ? { installedPlugins } : {}),
|
||||
}
|
||||
: null,
|
||||
stats,
|
||||
errors,
|
||||
warnings,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type FloorplanPalette,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
isNodeKindEnabled,
|
||||
isRegistryMovable,
|
||||
kindsWithFloorplanScope,
|
||||
type LiveNodeOverrides,
|
||||
@@ -292,6 +293,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const setHoveredId = useViewer((s) => s.setHoveredId)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const installedPlugins = useScene((s) => s.installedPlugins)
|
||||
const movingNode = useMovingNode()
|
||||
// When a building is being moved, its explicit selection may be
|
||||
// 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 node = nodes[id]
|
||||
if (!node) return
|
||||
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (def?.computeFloorplanLevelData) {
|
||||
const ids = levelNodeIdsByType.get(node.type)
|
||||
@@ -776,6 +779,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
collectLevelDataKind(levelId as AnyNodeId)
|
||||
|
||||
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
|
||||
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (!def?.floorplan) return
|
||||
const dependsOnSiblingInputs = !!(
|
||||
@@ -844,7 +848,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
|
||||
}
|
||||
return { entries: out, levelNodeIdsByType }
|
||||
}, [levelId, nodes])
|
||||
}, [installedPlugins, levelId, nodes])
|
||||
|
||||
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -10,7 +11,13 @@ import { cn } from './../../../lib/utils'
|
||||
|
||||
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 {
|
||||
activePanel: PanelId
|
||||
@@ -41,6 +48,38 @@ export function IconRail({
|
||||
extraPanels,
|
||||
className,
|
||||
}: 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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -83,34 +122,7 @@ export function IconRail({
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Extra panels (injected between site and settings) */}
|
||||
{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>
|
||||
)
|
||||
})}
|
||||
{regularExtraPanels?.map(renderExtraPanel)}
|
||||
|
||||
{/* Settings panel */}
|
||||
{[settingsPanel].map((panel) => {
|
||||
@@ -140,6 +152,13 @@ export function IconRail({
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 nodes = useScene((state) => state.nodes)
|
||||
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
||||
const installedPlugins = useScene((state) => state.installedPlugins)
|
||||
const setScene = useScene((state) => state.setScene)
|
||||
const clearScene = useScene((state) => state.clearScene)
|
||||
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 handleSaveBuild = () => {
|
||||
const sceneData = { nodes, rootNodeIds }
|
||||
const sceneData = { nodes, rootNodeIds, installedPlugins }
|
||||
const json = JSON.stringify(sceneData, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -262,10 +263,20 @@ export function SettingsPanel({
|
||||
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(
|
||||
parsed.nodes as Parameters<typeof setScene>[0],
|
||||
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,
|
||||
// undo could step back into the pre-import scene state.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { editorHostPanelRegistry } from '../../../lib/plugin-panels'
|
||||
import { triggerSFX } from './../../../lib/sfx-bus'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
|
||||
@@ -65,12 +66,15 @@ interface IconRailProps {
|
||||
* The label renders as a hover tooltip on the right.
|
||||
*/
|
||||
export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
|
||||
return (
|
||||
<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">
|
||||
{tabs.map((tab) => {
|
||||
// Only show the active highlight while the panel is open. When
|
||||
// collapsed nothing is "open", so every icon reads as unselected.
|
||||
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}>
|
||||
@@ -95,7 +99,17 @@ export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailPr
|
||||
<TooltipContent side="right">{tab.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
}
|
||||
|
||||
return (
|
||||
<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">
|
||||
{defaultTabs.map(renderTab)}
|
||||
{pluginTabs.length > 0 && (
|
||||
<div className="mt-1 flex w-11 flex-col items-center gap-1 border-border/70 border-t pt-2">
|
||||
{pluginTabs.map(renderTab)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
)
|
||||
|
||||
@@ -1,12 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import { type IconRef } from '@pascal-app/core'
|
||||
import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react'
|
||||
import { type IconRef, useScene } from '@pascal-app/core'
|
||||
import { Plus } from 'lucide-react'
|
||||
import {
|
||||
type ComponentType,
|
||||
lazy,
|
||||
type ReactNode,
|
||||
Suspense,
|
||||
useEffect,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import useEditor from '../../../store/use-editor'
|
||||
import { editorHostPanelRegistry, type EditorHostPanel } from '../../../lib/plugin-panels'
|
||||
import { ErrorBoundary } from '../primitives/error-boundary'
|
||||
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
|
||||
* inspector's `renderIcon`, sized for the 24px icon-rail button. */
|
||||
@@ -83,16 +99,34 @@ export function useHostPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
|
||||
editorHostPanelRegistry.getSnapshot,
|
||||
)
|
||||
const workspaceMode = useEditor((s) => s.workspaceMode)
|
||||
const installedPlugins = useScene((s) => s.installedPlugins)
|
||||
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
|
||||
.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(
|
||||
(p): ExtraPanel => ({
|
||||
id: p.id,
|
||||
label: p.label,
|
||||
icon: renderIconRef(p.icon),
|
||||
component: resolvePanelComponent(p),
|
||||
pluginId: p.pluginId,
|
||||
}),
|
||||
)
|
||||
return [...(hostPanels ?? []), ...fromRegistry]
|
||||
const manager =
|
||||
workspaceMode === 'edit' && !hostIds.has(pluginsManagerPanel.id) ? [pluginsManagerPanel] : []
|
||||
return [...(hostPanels ?? []), ...fromRegistry, ...manager]
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export function useAutoSave({
|
||||
// collection change still triggers a save.
|
||||
let lastCollectionsRef = useScene.getState().collections
|
||||
let lastMaterialsRef = useScene.getState().materials
|
||||
let lastInstalledPluginsRef = useScene.getState().installedPlugins
|
||||
|
||||
async function executeSave() {
|
||||
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
|
||||
@@ -75,8 +76,14 @@ export function useAutoSave({
|
||||
return
|
||||
}
|
||||
|
||||
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
|
||||
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
|
||||
const sceneGraph = {
|
||||
nodes,
|
||||
rootNodeIds,
|
||||
collections,
|
||||
materials,
|
||||
installedPlugins,
|
||||
} as SceneGraph
|
||||
|
||||
// Guard: refuse to autosave if the scene went from populated to nearly empty.
|
||||
// This catches accidental full deletions before they're persisted.
|
||||
@@ -126,6 +133,7 @@ export function useAutoSave({
|
||||
lastNodesSnapshot = JSON.stringify(state.nodes)
|
||||
lastCollectionsRef = state.collections
|
||||
lastMaterialsRef = state.materials
|
||||
lastInstalledPluginsRef = state.installedPlugins
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,6 +142,7 @@ export function useAutoSave({
|
||||
lastNodesSnapshot = JSON.stringify(state.nodes)
|
||||
lastCollectionsRef = state.collections
|
||||
lastMaterialsRef = state.materials
|
||||
lastInstalledPluginsRef = state.installedPlugins
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,12 +150,14 @@ export function useAutoSave({
|
||||
const changed =
|
||||
currentNodesSnapshot !== lastNodesSnapshot ||
|
||||
state.collections !== lastCollectionsRef ||
|
||||
state.materials !== lastMaterialsRef
|
||||
state.materials !== lastMaterialsRef ||
|
||||
state.installedPlugins !== lastInstalledPluginsRef
|
||||
if (!changed) return
|
||||
|
||||
lastNodesSnapshot = currentNodesSnapshot
|
||||
lastCollectionsRef = state.collections
|
||||
lastMaterialsRef = state.materials
|
||||
lastInstalledPluginsRef = state.installedPlugins
|
||||
hasDirtyChangesRef.current = true
|
||||
onDirtyRef.current?.()
|
||||
setSaveStatus('pending')
|
||||
@@ -172,8 +183,14 @@ export function useAutoSave({
|
||||
function flushOnExit() {
|
||||
if (!hasDirtyChangesRef.current) return
|
||||
hasDirtyChangesRef.current = false
|
||||
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
|
||||
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
|
||||
const sceneGraph = {
|
||||
nodes,
|
||||
rootNodeIds,
|
||||
collections,
|
||||
materials,
|
||||
installedPlugins,
|
||||
} as SceneGraph
|
||||
if (onSaveRef.current) {
|
||||
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,14 @@ export type EditorHostPanel = {
|
||||
icon: IconRef
|
||||
component: LazyComponent
|
||||
workspaces?: readonly EditorHostPanelWorkspace[]
|
||||
pluginId?: string
|
||||
description?: string
|
||||
creator?: {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
pluginUrl?: string
|
||||
defaultInstalled?: boolean
|
||||
}
|
||||
|
||||
function isDevMode(): boolean {
|
||||
@@ -37,6 +45,15 @@ class EditorHostPanelRegistryImpl {
|
||||
|
||||
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 {
|
||||
this.panels.clear()
|
||||
this.emit()
|
||||
|
||||
@@ -13,6 +13,7 @@ import useEditor, {
|
||||
normalizePersistedEditorUiState,
|
||||
type PersistedEditorUiState,
|
||||
} from '../store/use-editor'
|
||||
import { editorHostPanelRegistry } from './plugin-panels'
|
||||
|
||||
export type SceneGraph = {
|
||||
nodes: Record<string, unknown>
|
||||
@@ -21,6 +22,7 @@ export type SceneGraph = {
|
||||
// payloads (and callers that only build nodes) stay valid.
|
||||
collections?: Record<string, unknown>
|
||||
materials?: Record<string, unknown>
|
||||
installedPlugins?: string[]
|
||||
}
|
||||
|
||||
type PersistedSelectionPath = {
|
||||
@@ -381,14 +383,18 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen
|
||||
}
|
||||
|
||||
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
||||
const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds()
|
||||
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, {
|
||||
collections: collections as any,
|
||||
materials: materials as any,
|
||||
installedPlugins: installedPlugins ?? defaultInstalledPlugins,
|
||||
hasExplicitPluginInstallState: installedPlugins !== undefined,
|
||||
})
|
||||
} else {
|
||||
useScene.getState().clearScene()
|
||||
useScene.getState().setInstalledPlugins(defaultInstalledPlugins, { explicit: false })
|
||||
}
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
expect(() => bridge.loadJSON('not json')).toThrow(/invalid JSON/)
|
||||
})
|
||||
|
||||
@@ -72,6 +72,9 @@ export class SceneBridge {
|
||||
nodes: state.nodes,
|
||||
rootNodeIds: state.rootNodeIds,
|
||||
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[])
|
||||
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. */
|
||||
|
||||
@@ -150,6 +150,7 @@ class SceneOperationsFacade implements SceneOperations {
|
||||
nodes: exported.nodes,
|
||||
rootNodeIds: exported.rootNodeIds,
|
||||
collections: exported.collections as SceneGraph['collections'],
|
||||
installedPlugins: exported.installedPlugins,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -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.',
|
||||
},
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
})
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -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} />
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
}))
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
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 { ParametricNodeRenderer } from './parametric-node-renderer'
|
||||
|
||||
@@ -23,7 +29,9 @@ export function getRegistryRenderer(
|
||||
|
||||
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
||||
const node = useScene((state) => state.nodes[nodeId])
|
||||
const installedPlugins = useScene((state) => state.installedPlugins)
|
||||
if (!node) return null
|
||||
if (!isNodeKindEnabled(node.type, installedPlugins)) return null
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (!def) return null
|
||||
// Two-checkbox dispatch (see wiki/architecture/node-definitions.md):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
bakePolicyOf,
|
||||
isNodeKindEnabled,
|
||||
nodeRegistry,
|
||||
type RendererSource,
|
||||
type SceneGraph,
|
||||
@@ -30,6 +31,7 @@ export function buildGlbReferenceNodes(
|
||||
const out: AnyNode[] = []
|
||||
for (const raw of Object.values(nodes)) {
|
||||
const node = raw as AnyNode
|
||||
if (!isNodeKindEnabled(node.type, sceneGraph?.installedPlugins)) continue
|
||||
if (bakePolicyOf(node.type) !== 'strip') continue
|
||||
if (node.type === 'scan' && !allow.scans) continue
|
||||
if (node.type === 'guide' && !allow.guides) continue
|
||||
@@ -52,6 +54,7 @@ export function buildGlbReplaceNodes(sceneGraph: SceneGraph | null | undefined):
|
||||
const out: AnyNode[] = []
|
||||
for (const raw of Object.values(nodes)) {
|
||||
const node = raw as AnyNode
|
||||
if (!isNodeKindEnabled(node.type, sceneGraph?.installedPlugins)) continue
|
||||
if (bakePolicyOf(node.type) === 'replace') out.push(node)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'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'
|
||||
|
||||
const DEFAULT_PRIORITY = 5
|
||||
@@ -34,6 +40,7 @@ function loadSystem(def: AnyNodeDefinition): ComponentType<RegisteredSystemProps
|
||||
*/
|
||||
export function RegisteredSystems() {
|
||||
const sceneApi = useMemo(() => createSceneApi(useScene), [])
|
||||
const installedPlugins = useScene((state) => state.installedPlugins)
|
||||
const entries = useMemo(() => {
|
||||
return Array.from(nodeRegistry.entries())
|
||||
.filter(([, def]) => def.system != null)
|
||||
@@ -49,6 +56,7 @@ export function RegisteredSystems() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
{entries.map(([kind, def]) => {
|
||||
if (!isNodeKindEnabled(kind, installedPlugins)) return null
|
||||
const Comp = loadSystem(def)
|
||||
if (!Comp) return null
|
||||
return <Comp key={`registered-system:${kind}`} sceneApi={sceneApi} />
|
||||
|
||||
@@ -30,7 +30,7 @@ export const myPlugin: Plugin = {
|
||||
| `apiVersion` | yes | Currently `1`. The host throws on mismatch — bumping breaks plugins, intentionally. |
|
||||
| `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.
|
||||
|
||||
@@ -79,10 +79,10 @@ The packages are **peer dependencies**, not normal dependencies — the host app
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Boot[App boot] --> LoadBuiltin[loadPlugin(builtinPlugin)]
|
||||
LoadBuiltin --> Discover[await discoverPlugins()]
|
||||
Discover --> LoadEach[for each: await loadPlugin(p)]
|
||||
LoadEach --> Ready[Registry frozen for the session]
|
||||
Boot["App boot"] --> LoadBuiltin["loadPlugin(builtinPlugin)"]
|
||||
LoadBuiltin --> Discover["await discoverPlugins()"]
|
||||
Discover --> LoadEach["for each plugin: await loadPlugin(plugin)"]
|
||||
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.
|
||||
@@ -110,6 +110,36 @@ setPluginDiscovery(async () => {
|
||||
|
||||
`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
|
||||
|
||||
`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`.
|
||||
- **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.
|
||||
- **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
|
||||
|
||||
`@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.
|
||||
2. In a host app that consumes your built-ins (`apps/editor` is the easiest target), wire `setPluginDiscovery` to return your plugin.
|
||||
|
||||