feat: add project plugin management (#501)

This commit is contained in:
Wassim SAMAD
2026-07-16 10:26:59 -04:00
committed by GitHub
parent 4f0aa7f7b6
commit 9ca3eaa7fe
88 changed files with 684 additions and 3100 deletions
+2
View File
@@ -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 () => {
+22
View File
@@ -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)
})
})
+41 -4
View File
@@ -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'])
})
})
+5 -2
View File
@@ -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,37 +66,50 @@ interface IconRailProps {
* The label renders as a hover tooltip on the right.
*/
export function IconRail({ tabs, activeTab, collapsed, onIconClick }: IconRailProps) {
const pluginPanelIds = new Set(
editorHostPanelRegistry.getSnapshot().flatMap((panel) =>
panel.pluginId ? [panel.id] : [],
),
)
const defaultTabs = tabs.filter((tab) => !pluginPanelIds.has(tab.id) && tab.id !== 'plugins')
const pluginTabs = tabs.filter((tab) => pluginPanelIds.has(tab.id) || tab.id === 'plugins')
const renderTab = (tab: SidebarTab) => {
const showActive = activeTab === tab.id && !collapsed
return (
<Tooltip key={tab.id}>
<TooltipTrigger asChild>
<button
className={cn(
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
showActive
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
onIconClick(tab.id)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
{tab.icon ?? tab.label.charAt(0)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{tab.label}</TooltipContent>
</Tooltip>
)
}
return (
<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 showActive = activeTab === tab.id && !collapsed
return (
<Tooltip key={tab.id}>
<TooltipTrigger asChild>
<button
className={cn(
'group flex h-11 w-11 items-center justify-center rounded-xl transition-all duration-200 [&_img]:transition-[opacity,filter] [&_img]:duration-200',
showActive
? 'bg-accent text-foreground shadow-sm [&_img]:opacity-100 [&_img]:grayscale-0'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground [&_img]:opacity-60 [&_img]:grayscale hover:[&_img]:opacity-100 hover:[&_img]:grayscale-0',
)}
onClick={() => {
triggerSFX('sfx:menu-click')
onIconClick(tab.id)
}}
onMouseEnter={() => triggerSFX('sfx:menu-hover')}
type="button"
>
{tab.icon ?? tab.label.charAt(0)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{tab.label}</TooltipContent>
</Tooltip>
)
})}
{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]
}
+22 -5
View File
@@ -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 {
+17
View File
@@ -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()
+7 -1
View File
@@ -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/)
})
+9
View File
@@ -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,
}
}
-99
View File
@@ -1,99 +0,0 @@
# @pascal-app/plugin-trees
The first-party **example plugin** for the Pascal editor. It contributes
procedural plant nodes plus a separately exported host-side Nature panel, and
exists to prove — and document — the minimal node-plugin surface every future
plugin reuses.
It is structurally identical to a third-party plugin: it peer-depends on
`@pascal-app/{core,viewer,editor}` (plus `react`/`three`/`@react-three/fiber`/
`zustand`) and bundles `@dgreenheck/ez-tree` for the geometry. It imports
nothing private. Copy this folder as the starting point for a new plugin.
## What it demonstrates
The contribution paths this package demonstrates:
1. **Host-side panel extension** — the standalone editor registers
`treesHostPanel` separately from the core plugin manifest. `presets-panel.tsx`
is a plain React component the host mounts behind an error boundary.
2. **Right inspector for free**`def.parametrics` (`parametrics.ts`). The host
renders the preset/height/seed controls + the Randomize action with zero
tree-specific code.
3. **Placement**`def.tool`/`def.preview` (`tool.tsx`, `preview.tsx`). The
tool respects the active snapping mode (`isGridSnapActive()` + `gridSnapStep`)
exactly like the built-in item/shelf tools.
4. **Instanced rendering** (the generic core in `instanced.tsx`, shared by both
kinds) — instead of the per-node `def.geometry` path, plants render via two
pieces:
- `def.system` — a collective renderer mounted once that groups every node of
the kind by its geometry variant and draws each variant as one
`InstancedMesh` per sub-mesh. A forest of N is a handful of draw calls.
Variant geometry is generated once and cached.
- `def.renderer` — a featherweight per-node proxy: a stable invisible box
collider (the raycast target) in an outer group, plus the real geometry
(invisible, mounted only while hovered/selected) in an inner *registered*
group. So the host's outline pass traces the **true silhouette**, picking
stays on the box, and selection / outline / zone machinery works unchanged
with no instanceId bookkeeping.
### Three kinds
- **`trees:tree`** — ez-tree geometry (`geometry.ts`); species presets Oak /
Pine / Aspen / Ash / Bush / Trellis × a Small/Medium/Large **size** (all of
ez-tree's built-in presets), a Deciduous/Evergreen **type**, curated params
(foliage density, trunk thickness, leafless), and leaf/branch **colour tints**
all folded into the variant key. Colours are edit-only (inspector), not on the
placement brush.
- **`trees:flower`** — simple procedural geometry (`flower-geometry.ts`, merged
per material); presets daisy / tulip / lavender, with a per-flower petal colour.
- **`trees:grass`** — procedural blade tufts (`grass-geometry.ts`); presets
meadow / fescue / reed, with a per-tuft blade colour.
Flowers and grass are sibling kinds that reuse the exact same instanced core +
placement helper (`instanced.tsx` / `placement.tsx`) and the shared procedural
RNG (`mulberry32` in `geometry.ts`) — the template for adding more plant kinds.
It also shows the communication triangle: `presets-panel` → plugin store
(`store.ts`) → `def.tool``SceneApi` → scene → reactive `useScene` read-back
(the "N planted" counter in the panel).
`@dgreenheck/ez-tree` ships its bark/leaf textures inlined as base64, so there
are no assets to host. Placement seeds are drawn from a small bounded pool
(`TREE_SEED_POOL`) so trees share variants — that sharing is what makes the
instancing pay off; a unique inspector seed just renders as its own variant.
## Manifest
```ts
import { treesPlugin } from '@pascal-app/plugin-trees'
// host:
setPluginDiscovery(async () => [treesPlugin])
```
`treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`,
`trees:grass`) for the core `loadPlugin` path. The editor app separately imports
`treesHostPanel` to surface the Nature rail entry; panels are not part of the v1
core plugin manifest.
## Notes / known gaps
- `package.json` points `main`/`exports` at raw TypeScript (`./src/index.ts`),
which works here only because the host app's bundler transpiles workspace
packages. A real third-party plugin must ship built JS (with `.d.ts` types) or
otherwise ensure the consuming host transpiles the package.
- `createNode` and the `floorPlaced.footprint` callback are typed against the
host's hand-maintained `AnyNode` union, so the node is cast (`as AnyNode` /
`as TreeNode`). The registry derives `AnyNode` post-migration.
- The placement tool re-derives level-local conversion from the public
`sceneRegistry` because the built-in `floor-placement` helpers aren't part of
the public `@pascal-app/*` surface yet — a candidate for a future
`@pascal-app/plugin-api` re-export package.
- The instance matrices fold in the parent level's world transform; a building
move while plants are static won't refresh until a node of that kind next
changes.
- Heavy *per-node* tweaking of geometry params (or unique seeds) erodes
instancing batching — but it degrades gracefully: such a node just becomes its
own single-instance variant, never worse than the non-instanced path.
See `wiki/architecture/plugin-authoring.md` for the full contract.
-56
View File
@@ -1,56 +0,0 @@
{
"name": "@pascal-app/plugin-trees",
"version": "0.1.0",
"description": "First-party example Pascal editor plugin — a procedural trees node with a presets rail panel. Structurally identical to a third-party plugin (peer-deps on @pascal-app/*).",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"files": [
"src",
"README.md"
],
"scripts": {
"check-types": "tsgo --noEmit"
},
"dependencies": {
"@dgreenheck/ez-tree": "^1.1.0"
},
"peerDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.185",
"zod": "^4",
"zustand": "^5"
},
"devDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@pascal/typescript-config": "*",
"@react-three/fiber": "^9",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
"react": "^19",
"three": "^0.185",
"typescript": "6.0.3",
"zod": "^4",
"zustand": "^5"
},
"keywords": [
"pascal",
"3d",
"editor",
"plugin"
],
"license": "MIT"
}
-48
View File
@@ -1,48 +0,0 @@
import ash from './assets/ash.webp'
import aspen from './assets/aspen.webp'
import bush from './assets/bush.webp'
import daisy from './assets/daisy.webp'
import fescue from './assets/fescue.webp'
import lavender from './assets/lavender.webp'
import meadow from './assets/meadow.webp'
import natureIcon from './assets/nature-icon.webp'
import oak from './assets/oak.webp'
import pine from './assets/pine.webp'
import reed from './assets/reed.webp'
import trellis from './assets/trellis.webp'
import tulip from './assets/tulip.webp'
import type { FlowerPreset } from './flower-schema'
import type { GrassPreset } from './grass-schema'
import type { TreePreset } from './schema'
/**
* Bundled preset artwork. The webp live in `./assets` and travel with the
* package — no CDN, no per-app `public/` mirroring. Both consumers are Next, so
* `transpilePackages` runs these imports through the image pipeline and `.src`
* is the hashed, cached URL. The panel renders each as an `<img src>`.
*/
const url = (asset: { src: string }): string => asset.src
export const TREE_ART: Record<TreePreset, string> = {
oak: url(oak),
pine: url(pine),
aspen: url(aspen),
ash: url(ash),
bush: url(bush),
trellis: url(trellis),
}
export const FLOWER_ART: Record<FlowerPreset, string> = {
daisy: url(daisy),
tulip: url(tulip),
lavender: url(lavender),
}
export const GRASS_ART: Record<GrassPreset, string> = {
meadow: url(meadow),
fescue: url(fescue),
reed: url(reed),
}
/** The Nature panel / section icon. */
export const NATURE_ICON = url(natureIcon)
-8
View File
@@ -1,8 +0,0 @@
// Bundled image assets. Both consumers (community + apps/editor) are Next, whose
// static image import returns a StaticImageData-shaped object; `.src` is the
// hashed, cached URL the bundler emits. Declared locally so the package needs no
// `next` type dependency. See `art.ts`.
declare module '*.webp' {
const asset: { src: string; height: number; width: number; blurDataURL?: string }
export default asset
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

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