feat: add project plugin management (#501)
@@ -20,10 +20,12 @@ export {
|
||||
discoverPlugins,
|
||||
extendPluginDiscovery,
|
||||
getHostRefFields,
|
||||
getNodePluginId,
|
||||
getSelectableKinds,
|
||||
hasRegistry3DMoveTool,
|
||||
isDrawnViaTool,
|
||||
isDrawnViaToolKind,
|
||||
isNodeKindEnabled,
|
||||
isPresettable,
|
||||
isPresettableKind,
|
||||
isRegistryMovable,
|
||||
|
||||
@@ -2,8 +2,10 @@ import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
getHostRefFields,
|
||||
getNodePluginId,
|
||||
isDrawnViaTool,
|
||||
isDrawnViaToolKind,
|
||||
isNodeKindEnabled,
|
||||
isPresettable,
|
||||
isPresettableKind,
|
||||
loadPlugin,
|
||||
@@ -188,6 +190,23 @@ describe('loadPlugin', () => {
|
||||
expect(nodeRegistry.size).toBe(2)
|
||||
expect(nodeRegistry.has('a')).toBe(true)
|
||||
expect(nodeRegistry.has('b')).toBe(true)
|
||||
expect(getNodePluginId('a')).toBe('test:plugin')
|
||||
expect(getNodePluginId('b')).toBe('test:plugin')
|
||||
})
|
||||
|
||||
test('enables plugin kinds only when the project has the plugin installed', async () => {
|
||||
await loadPlugin({ id: 'test:plugin', apiVersion: 1, nodes: [makeDefinition('plugin:node')] })
|
||||
|
||||
expect(isNodeKindEnabled('plugin:node', [])).toBe(false)
|
||||
expect(isNodeKindEnabled('plugin:node', ['test:plugin'])).toBe(true)
|
||||
expect(isNodeKindEnabled('plugin:node')).toBe(true)
|
||||
expect(isNodeKindEnabled('host:node', [])).toBe(true)
|
||||
})
|
||||
|
||||
test('keeps built-in plugin kinds enabled independently of project installs', async () => {
|
||||
await loadPlugin({ id: 'pascal:core', apiVersion: 1, nodes: [makeDefinition('wall')] })
|
||||
|
||||
expect(isNodeKindEnabled('wall', [])).toBe(true)
|
||||
})
|
||||
|
||||
test('handles plugin with no nodes', async () => {
|
||||
|
||||
@@ -2,6 +2,9 @@ import type { ZodObject } from 'zod'
|
||||
import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin } from './types'
|
||||
|
||||
const HOST_API_VERSION = 1 as const
|
||||
const BUILTIN_PLUGIN_ID = 'pascal:core'
|
||||
|
||||
const pluginIdsByKind = new Map<string, string>()
|
||||
|
||||
// True in dev / test builds, false in production. Tries Vite's
|
||||
// `import.meta.env.DEV` first (the editor app's bundler) and falls back
|
||||
@@ -74,6 +77,7 @@ class NodeRegistryImpl implements NodeRegistry {
|
||||
// Test-only — clears the registry. Not exported from the package barrel.
|
||||
_reset(): void {
|
||||
this.defs.clear()
|
||||
pluginIdsByKind.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,6 +90,23 @@ export function registerNode(def: AnyNodeDefinition): void {
|
||||
nodeRegistry._register(def)
|
||||
}
|
||||
|
||||
/** The plugin that registered a node kind, when it came through {@link loadPlugin}. */
|
||||
export function getNodePluginId(kind: string): string | undefined {
|
||||
return pluginIdsByKind.get(kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a registered kind should participate in a project. Kinds registered
|
||||
* directly by the host and the built-in plugin are always enabled. An omitted
|
||||
* install list means a legacy scene whose plugin state predates persistence, so
|
||||
* loaded plugins remain visible for backward compatibility.
|
||||
*/
|
||||
export function isNodeKindEnabled(kind: string, installedPlugins?: readonly string[]): boolean {
|
||||
const pluginId = getNodePluginId(kind)
|
||||
if (!pluginId || pluginId === BUILTIN_PLUGIN_ID || installedPlugins === undefined) return true
|
||||
return installedPlugins.includes(pluginId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of registered kinds whose definition declares the
|
||||
* `selectable` capability. Callers that maintain hardcoded "selectable kinds"
|
||||
@@ -244,6 +265,7 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
|
||||
}
|
||||
for (const def of plugin.nodes ?? []) {
|
||||
registerNode(def)
|
||||
pluginIdsByKind.set(def.kind, plugin.id)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { z } from 'zod'
|
||||
import { loadPlugin, nodeRegistry } from '../registry'
|
||||
import type { AnyNodeDefinition } from '../registry/types'
|
||||
import type { AnyNode, AnyNodeId } from '../schema'
|
||||
import useScene from './use-scene'
|
||||
|
||||
describe('scene plugin installation state', () => {
|
||||
beforeEach(() => {
|
||||
nodeRegistry._reset()
|
||||
useScene.getState().setReadOnly(false)
|
||||
useScene.getState().unloadScene()
|
||||
})
|
||||
|
||||
test('loads an explicit installed plugin list with the scene', () => {
|
||||
useScene.getState().setScene({}, [], {
|
||||
installedPlugins: ['pascal:trees'],
|
||||
hasExplicitPluginInstallState: true,
|
||||
})
|
||||
|
||||
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
|
||||
expect(useScene.getState().hasExplicitPluginInstallState).toBe(true)
|
||||
})
|
||||
|
||||
test('install changes are de-duplicated and become explicit', () => {
|
||||
useScene.getState().setInstalledPlugins(['pascal:trees', 'pascal:trees'], { explicit: true })
|
||||
|
||||
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
|
||||
expect(useScene.getState().hasExplicitPluginInstallState).toBe(true)
|
||||
})
|
||||
|
||||
test('clearing geometry preserves project plugin installs', () => {
|
||||
useScene.getState().setInstalledPlugins(['pascal:trees'], { explicit: true })
|
||||
useScene.getState().clearScene()
|
||||
|
||||
expect(useScene.getState().installedPlugins).toEqual(['pascal:trees'])
|
||||
expect(useScene.getState().hasExplicitPluginInstallState).toBe(true)
|
||||
})
|
||||
|
||||
test('uninstall clears plugin build work and reinstall schedules it again', async () => {
|
||||
const kind = 'test:plugin-node'
|
||||
const definition = {
|
||||
kind,
|
||||
schemaVersion: 1,
|
||||
schema: z.object({ id: z.string(), type: z.literal(kind) }),
|
||||
category: 'utility',
|
||||
defaults: () => ({}),
|
||||
capabilities: {},
|
||||
} as unknown as AnyNodeDefinition
|
||||
await loadPlugin({ id: 'test:plugin', apiVersion: 1, nodes: [definition] })
|
||||
const nodeId = 'plugin_node' as AnyNodeId
|
||||
useScene.getState().setScene(
|
||||
{
|
||||
[nodeId]: { id: nodeId, type: kind } as unknown as AnyNode,
|
||||
},
|
||||
[nodeId],
|
||||
{ installedPlugins: ['test:plugin'], hasExplicitPluginInstallState: true },
|
||||
)
|
||||
|
||||
expect(useScene.getState().dirtyNodes.has(nodeId)).toBe(true)
|
||||
|
||||
useScene.getState().setInstalledPlugins([], { explicit: true })
|
||||
expect(useScene.getState().dirtyNodes.has(nodeId)).toBe(false)
|
||||
|
||||
useScene.getState().setInstalledPlugins(['test:plugin'], { explicit: true })
|
||||
expect(useScene.getState().dirtyNodes.has(nodeId)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import type { TemporalState } from 'zundo'
|
||||
import { temporal } from 'zundo'
|
||||
import { create, type StoreApi, type UseBoundStore } from 'zustand'
|
||||
import { parseMaterialRef, toSceneMaterialRef } from '../material-library'
|
||||
import { nodeRegistry } from '../registry/registry'
|
||||
import { getNodePluginId, isNodeKindEnabled, nodeRegistry } from '../registry/registry'
|
||||
import { BuildingNode } from '../schema'
|
||||
import type { Collection, CollectionId } from '../schema/collections'
|
||||
import { generateCollectionId } from '../schema/collections'
|
||||
@@ -952,6 +952,8 @@ export type SceneState = {
|
||||
// 4. Relational metadata — not nodes
|
||||
collections: Record<CollectionId, Collection>
|
||||
materials: Record<SceneMaterialId, SceneMaterial>
|
||||
installedPlugins: string[]
|
||||
hasExplicitPluginInstallState: boolean
|
||||
|
||||
// 5. Read-only lock — when true all create/update/delete operations are no-ops
|
||||
readOnly: boolean
|
||||
@@ -967,8 +969,11 @@ export type SceneState = {
|
||||
extra?: {
|
||||
collections?: Record<CollectionId, Collection>
|
||||
materials?: Record<SceneMaterialId, SceneMaterial>
|
||||
installedPlugins?: string[]
|
||||
hasExplicitPluginInstallState?: boolean
|
||||
},
|
||||
) => void
|
||||
setInstalledPlugins: (pluginIds: string[], options?: { explicit?: boolean }) => void
|
||||
|
||||
markDirty: (id: AnyNodeId) => void
|
||||
clearDirty: (id: AnyNodeId) => void
|
||||
@@ -1004,7 +1009,9 @@ export type SceneState = {
|
||||
|
||||
type UseSceneStore = UseBoundStore<StoreApi<SceneState>> & {
|
||||
temporal: StoreApi<
|
||||
TemporalState<Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials'>>
|
||||
TemporalState<
|
||||
Pick<SceneState, 'nodes' | 'rootNodeIds' | 'collections' | 'materials' | 'installedPlugins'>
|
||||
>
|
||||
>
|
||||
}
|
||||
|
||||
@@ -1023,6 +1030,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
// 4. Collections
|
||||
collections: {} as Record<CollectionId, Collection>,
|
||||
materials: {} as Record<SceneMaterialId, SceneMaterial>,
|
||||
installedPlugins: [],
|
||||
hasExplicitPluginInstallState: false,
|
||||
|
||||
// 5. Read-only lock
|
||||
readOnly: false,
|
||||
@@ -1035,12 +1044,17 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: {},
|
||||
materials: {},
|
||||
installedPlugins: [],
|
||||
hasExplicitPluginInstallState: false,
|
||||
})
|
||||
},
|
||||
|
||||
clearScene: () => {
|
||||
const installedPlugins = get().installedPlugins
|
||||
const hasExplicitPluginInstallState = get().hasExplicitPluginInstallState
|
||||
get().unloadScene()
|
||||
get().loadScene() // Default scene
|
||||
set({ installedPlugins, hasExplicitPluginInstallState })
|
||||
},
|
||||
|
||||
setScene: (nodes, rootNodeIds, extra) => {
|
||||
@@ -1086,6 +1100,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
dirtyNodes: new Set<AnyNodeId>(),
|
||||
collections: extra?.collections ?? {},
|
||||
materials,
|
||||
installedPlugins: Array.from(new Set(extra?.installedPlugins ?? [])),
|
||||
hasExplicitPluginInstallState: extra?.hasExplicitPluginInstallState ?? false,
|
||||
})
|
||||
// Mark all nodes as dirty to trigger re-validation
|
||||
Object.values(cleanedNodes).forEach((node) => {
|
||||
@@ -1093,6 +1109,26 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
})
|
||||
},
|
||||
|
||||
setInstalledPlugins: (pluginIds, options) => {
|
||||
if (get().readOnly) return
|
||||
const nextInstalledPlugins = Array.from(new Set(pluginIds))
|
||||
const previousInstalledPlugins = get().installedPlugins
|
||||
const dirtyNodes = new Set(get().dirtyNodes)
|
||||
for (const node of Object.values(get().nodes)) {
|
||||
if (!getNodePluginId(node.type)) continue
|
||||
if (!isNodeKindEnabled(node.type, nextInstalledPlugins)) {
|
||||
dirtyNodes.delete(node.id)
|
||||
} else if (!isNodeKindEnabled(node.type, previousInstalledPlugins)) {
|
||||
if (nodeRegistry.get(node.type)?.dirtyTracking !== false) dirtyNodes.add(node.id)
|
||||
}
|
||||
}
|
||||
set({
|
||||
installedPlugins: nextInstalledPlugins,
|
||||
hasExplicitPluginInstallState: options?.explicit ?? get().hasExplicitPluginInstallState,
|
||||
dirtyNodes,
|
||||
})
|
||||
},
|
||||
|
||||
loadScene: () => {
|
||||
if (get().rootNodeIds.length > 0) {
|
||||
// Assign all nodes as dirty to force re-validation
|
||||
@@ -1131,6 +1167,7 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
|
||||
markDirty: (id) => {
|
||||
const node = get().nodes[id]
|
||||
if (node && !isNodeKindEnabled(node.type, get().installedPlugins)) return
|
||||
if (node && nodeRegistry.get(node.type)?.dirtyTracking === false) return
|
||||
get().dirtyNodes.add(id)
|
||||
},
|
||||
@@ -1275,8 +1312,8 @@ const useScene: UseSceneStore = create<SceneState>()(
|
||||
}),
|
||||
{
|
||||
partialize: (state) => {
|
||||
const { nodes, rootNodeIds, collections, materials } = state
|
||||
return { nodes, rootNodeIds, collections, materials }
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = state
|
||||
return { nodes, rootNodeIds, collections, materials, installedPlugins }
|
||||
},
|
||||
limit: 50, // Limit to last 50 actions
|
||||
},
|
||||
|
||||
@@ -41,6 +41,7 @@ function makeSceneGraph(): SceneGraph {
|
||||
nodeIds: ['scan_1', 'guide_1'] as AnyNodeId[],
|
||||
},
|
||||
},
|
||||
installedPlugins: ['pascal:trees'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ describe('forkSceneGraph', () => {
|
||||
expect(nodes.some((node) => node.type === 'guide')).toBe(false)
|
||||
expect(nodes.some((node) => node.type === 'wall')).toBe(true)
|
||||
expect(forked.collections).toEqual({})
|
||||
expect(forked.installedPlugins).toEqual(['pascal:trees'])
|
||||
})
|
||||
|
||||
test('preserves scan and guide nodes when requested', () => {
|
||||
@@ -66,5 +68,6 @@ describe('forkSceneGraph', () => {
|
||||
expect(
|
||||
Object.values(forked.collections ?? {}).flatMap((collection) => collection.nodeIds),
|
||||
).toHaveLength(2)
|
||||
expect(forked.installedPlugins).toEqual(['pascal:trees'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ export type SceneGraph = {
|
||||
nodes: Record<AnyNodeId, AnyNode>
|
||||
rootNodeIds: AnyNodeId[]
|
||||
collections?: Record<CollectionId, Collection>
|
||||
installedPlugins?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -26,7 +27,7 @@ function extractIdPrefix(id: string): string {
|
||||
* - Multi-scene in-memory scenarios
|
||||
*/
|
||||
export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
const { nodes, rootNodeIds, collections } = sceneGraph
|
||||
const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph
|
||||
|
||||
// Build ID mapping: old ID -> new ID
|
||||
const idMap = new Map<string, string>()
|
||||
@@ -134,6 +135,7 @@ export function cloneSceneGraph(sceneGraph: SceneGraph): SceneGraph {
|
||||
nodes: clonedNodes,
|
||||
rootNodeIds: clonedRootNodeIds,
|
||||
...(clonedCollections && { collections: clonedCollections }),
|
||||
...(installedPlugins && { installedPlugins: [...installedPlugins] }),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -255,7 +257,7 @@ export function forkSceneGraph(
|
||||
return cloneSceneGraph(sceneGraph)
|
||||
}
|
||||
|
||||
const { nodes, rootNodeIds, collections } = sceneGraph
|
||||
const { nodes, rootNodeIds, collections, installedPlugins } = sceneGraph
|
||||
|
||||
// First, identify scan and guide node IDs to exclude (user-uploaded imagery)
|
||||
const excludedNodeIds = new Set<string>()
|
||||
@@ -317,5 +319,6 @@ export function forkSceneGraph(
|
||||
nodes: filteredNodes,
|
||||
rootNodeIds: filteredRootNodeIds,
|
||||
...(filteredCollections && { collections: filteredCollections }),
|
||||
...(installedPlugins && { installedPlugins }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export type BuildStats = {
|
||||
export type ParsedBuildJson = {
|
||||
nodes: Record<string, unknown>
|
||||
rootNodeIds: string[]
|
||||
installedPlugins?: string[]
|
||||
}
|
||||
|
||||
export type SchemaIssue = {
|
||||
@@ -100,6 +101,7 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
|
||||
|
||||
const nodesRaw = input.nodes
|
||||
const rootNodeIdsRaw = input.rootNodeIds
|
||||
const installedPluginsRaw = input.installedPlugins
|
||||
|
||||
if (!isPlainObject(nodesRaw)) {
|
||||
errors.push({
|
||||
@@ -135,6 +137,19 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
|
||||
nodesRaw as Record<string, unknown>,
|
||||
)
|
||||
const rootNodeIds = rootNodeIdsRaw as string[]
|
||||
const installedPlugins =
|
||||
Array.isArray(installedPluginsRaw) &&
|
||||
installedPluginsRaw.every((pluginId) => typeof pluginId === 'string')
|
||||
? Array.from(new Set(installedPluginsRaw))
|
||||
: undefined
|
||||
|
||||
if (installedPluginsRaw !== undefined && installedPlugins === undefined) {
|
||||
warnings.push({
|
||||
severity: 'warning',
|
||||
code: 'invalid_installed_plugins',
|
||||
message: 'Ignored invalid "installedPlugins" — expected an array of plugin IDs.',
|
||||
})
|
||||
}
|
||||
|
||||
if (strippedChildRefs > 0 || droppedWallIds.length > 0) {
|
||||
warnings.push({
|
||||
@@ -296,7 +311,13 @@ export function validateBuildJson(input: unknown): ValidateBuildJsonResult {
|
||||
const ok = errors.length === 0
|
||||
return {
|
||||
ok,
|
||||
parsed: ok ? { nodes, rootNodeIds } : null,
|
||||
parsed: ok
|
||||
? {
|
||||
nodes,
|
||||
rootNodeIds,
|
||||
...(installedPlugins ? { installedPlugins } : {}),
|
||||
}
|
||||
: null,
|
||||
stats,
|
||||
errors,
|
||||
warnings,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type FloorplanPalette,
|
||||
type FloorplanPoint,
|
||||
type GeometryContext,
|
||||
isNodeKindEnabled,
|
||||
isRegistryMovable,
|
||||
kindsWithFloorplanScope,
|
||||
type LiveNodeOverrides,
|
||||
@@ -292,6 +293,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const setHoveredId = useViewer((s) => s.setHoveredId)
|
||||
const setSelection = useViewer((s) => s.setSelection)
|
||||
const nodes = useScene((s) => s.nodes)
|
||||
const installedPlugins = useScene((s) => s.installedPlugins)
|
||||
const movingNode = useMovingNode()
|
||||
// When a building is being moved, its explicit selection may be
|
||||
// cleared as part of the move handoff. Fall back to the
|
||||
@@ -761,6 +763,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
const collectLevelDataKind = (id: AnyNodeId) => {
|
||||
const node = nodes[id]
|
||||
if (!node) return
|
||||
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (def?.computeFloorplanLevelData) {
|
||||
const ids = levelNodeIdsByType.get(node.type)
|
||||
@@ -776,6 +779,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
collectLevelDataKind(levelId as AnyNodeId)
|
||||
|
||||
const pushEntry = (id: AnyNodeId, node: AnyNode, ctxOverrides?: FloorplanContextOverrides) => {
|
||||
if (!isNodeKindEnabled(node.type, installedPlugins)) return
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (!def?.floorplan) return
|
||||
const dependsOnSiblingInputs = !!(
|
||||
@@ -844,7 +848,7 @@ export const FloorplanRegistryLayer = memo(function FloorplanRegistryLayer() {
|
||||
if (!levelNodeIdsByType.has(type)) levelDataCacheRef.current.delete(type)
|
||||
}
|
||||
return { entries: out, levelNodeIdsByType }
|
||||
}, [levelId, nodes])
|
||||
}, [installedPlugins, levelId, nodes])
|
||||
|
||||
// ── Generic 2D affordance dispatch ─────────────────────────────────
|
||||
//
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { Plus } from 'lucide-react'
|
||||
import type { ComponentType, ReactNode } from 'react'
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -10,7 +11,13 @@ import { cn } from './../../../lib/utils'
|
||||
|
||||
export type PanelId = string
|
||||
|
||||
export type ExtraPanel = { id: string; icon: ReactNode; label: string; component: ComponentType }
|
||||
export type ExtraPanel = {
|
||||
id: string
|
||||
icon: ReactNode
|
||||
label: string
|
||||
component: ComponentType
|
||||
pluginId?: string
|
||||
}
|
||||
|
||||
interface IconRailProps {
|
||||
activePanel: PanelId
|
||||
@@ -41,6 +48,38 @@ export function IconRail({
|
||||
extraPanels,
|
||||
className,
|
||||
}: IconRailProps) {
|
||||
const regularExtraPanels = extraPanels?.filter((panel) => !panel.pluginId && panel.id !== 'plugins')
|
||||
const pluginPanels = extraPanels?.filter((panel) => panel.pluginId)
|
||||
const pluginsPanel = extraPanels?.find((panel) => panel.id === 'plugins')
|
||||
|
||||
const renderExtraPanel = (panel: ExtraPanel) => {
|
||||
const isActive = activePanel === panel.id
|
||||
return (
|
||||
<Tooltip key={panel.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
|
||||
isActive ? 'bg-accent' : 'hover:bg-accent',
|
||||
)}
|
||||
onClick={() => onPanelChange(panel.id)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center transition-all',
|
||||
!isActive && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{panel.id === 'plugins' ? <Plus className="h-5 w-5" /> : panel.icon}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{panel.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -83,34 +122,7 @@ export function IconRail({
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Extra panels (injected between site and settings) */}
|
||||
{extraPanels?.map((panel) => {
|
||||
const isActive = activePanel === panel.id
|
||||
return (
|
||||
<Tooltip key={panel.id}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg transition-all',
|
||||
isActive ? 'bg-accent' : 'hover:bg-accent',
|
||||
)}
|
||||
onClick={() => onPanelChange(panel.id)}
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 w-6 items-center justify-center transition-all',
|
||||
!isActive && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
{panel.icon}
|
||||
</span>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right">{panel.label}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
{regularExtraPanels?.map(renderExtraPanel)}
|
||||
|
||||
{/* Settings panel */}
|
||||
{[settingsPanel].map((panel) => {
|
||||
@@ -140,6 +152,13 @@ export function IconRail({
|
||||
</Tooltip>
|
||||
)
|
||||
})}
|
||||
|
||||
{(pluginPanels?.length || pluginsPanel) && (
|
||||
<div className="mt-1 flex w-9 flex-col items-center gap-1 border-border/70 border-t pt-2">
|
||||
{pluginPanels?.map(renderExtraPanel)}
|
||||
{pluginsPanel && renderExtraPanel(pluginsPanel)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
'use client'
|
||||
|
||||
import { Icon } from '@iconify/react'
|
||||
import { type IconRef, useScene } from '@pascal-app/core'
|
||||
import { ChevronLeft, ChevronRight, ExternalLink, Puzzle } from 'lucide-react'
|
||||
import { lazy, type ReactNode, Suspense, useState, useSyncExternalStore } from 'react'
|
||||
import { editorHostPanelRegistry } from '../../../../lib/plugin-panels'
|
||||
import { Button } from '../../primitives/button'
|
||||
|
||||
const PLUGIN_AUTHORING_URL =
|
||||
'https://editor.pascal.app/docs/developers/plugins'
|
||||
|
||||
function renderPluginIcon(ref: IconRef): ReactNode {
|
||||
if (ref.kind === 'url') {
|
||||
return <img alt="" className="h-8 w-8 object-contain" src={ref.src} />
|
||||
}
|
||||
if (ref.kind === 'iconify') {
|
||||
return <Icon height={28} icon={ref.name} width={28} />
|
||||
}
|
||||
if (ref.kind === 'svg') {
|
||||
return (
|
||||
<svg height={28} viewBox={ref.viewBox} width={28}>
|
||||
<path d={ref.path} fill="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
const LazyIcon = lazy(ref.module)
|
||||
return (
|
||||
<Suspense fallback={<Puzzle className="h-7 w-7" />}>
|
||||
<LazyIcon />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export function PluginsPanel() {
|
||||
const [selectedPluginId, setSelectedPluginId] = useState<string | null>(null)
|
||||
const panels = useSyncExternalStore(
|
||||
editorHostPanelRegistry.subscribe,
|
||||
editorHostPanelRegistry.getSnapshot,
|
||||
editorHostPanelRegistry.getSnapshot,
|
||||
)
|
||||
const installedPlugins = useScene((state) => state.installedPlugins)
|
||||
const setInstalledPlugins = useScene((state) => state.setInstalledPlugins)
|
||||
const readOnly = useScene((state) => state.readOnly)
|
||||
const plugins = Array.from(
|
||||
new Map(
|
||||
panels
|
||||
.filter((panel) => panel.pluginId)
|
||||
.map((panel) => [panel.pluginId as string, panel]),
|
||||
).entries(),
|
||||
)
|
||||
const selectedPlugin = selectedPluginId
|
||||
? plugins.find(([pluginId]) => pluginId === selectedPluginId)
|
||||
: undefined
|
||||
|
||||
if (selectedPlugin) {
|
||||
const [pluginId, panel] = selectedPlugin
|
||||
const installed = installedPlugins.includes(pluginId)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto p-4">
|
||||
<div>
|
||||
<Button
|
||||
className="rounded-full"
|
||||
onClick={() => setSelectedPluginId(null)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
All plugins
|
||||
</Button>
|
||||
|
||||
<div className="mt-5 flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-2xl bg-background/60">
|
||||
{renderPluginIcon(panel.icon)}
|
||||
</div>
|
||||
<div className="min-w-0 pt-1">
|
||||
<h2 className="font-semibold text-lg text-sidebar-foreground">{panel.label}</h2>
|
||||
<p className="text-sidebar-foreground/50 text-sm">
|
||||
{installed ? 'Installed' : 'Not installed'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="mt-5 text-sidebar-foreground/70 text-sm">
|
||||
{panel.description ?? 'Adds a new tool panel to the editor.'}
|
||||
</p>
|
||||
|
||||
<dl className="mt-6 divide-y divide-border/50 rounded-xl border border-border/60">
|
||||
<div className="p-3">
|
||||
<dt className="text-sidebar-foreground/50 text-xs">Plugin ID</dt>
|
||||
<dd className="mt-1 break-all text-sidebar-foreground text-sm">{pluginId}</dd>
|
||||
</div>
|
||||
{panel.creator && (
|
||||
<div className="p-3">
|
||||
<dt className="text-sidebar-foreground/50 text-xs">Creator</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
{panel.creator.url ? (
|
||||
<a
|
||||
className="inline-flex items-center gap-1 text-sidebar-foreground underline-offset-4 hover:underline"
|
||||
href={panel.creator.url}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{panel.creator.name}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
) : (
|
||||
panel.creator.name
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
{panel.pluginUrl && (
|
||||
<div className="p-3">
|
||||
<dt className="text-sidebar-foreground/50 text-xs">Plugin</dt>
|
||||
<dd className="mt-1 text-sm">
|
||||
<a
|
||||
className="inline-flex items-center gap-1 text-sidebar-foreground underline-offset-4 hover:underline"
|
||||
href={panel.pluginUrl}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
View plugin
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<Button
|
||||
className="mt-5 rounded-full"
|
||||
disabled={readOnly}
|
||||
onClick={() => {
|
||||
const next = installed
|
||||
? installedPlugins.filter((id) => id !== pluginId)
|
||||
: [...installedPlugins, pluginId]
|
||||
setInstalledPlugins(next, { explicit: true })
|
||||
}}
|
||||
variant={installed ? 'outline' : 'default'}
|
||||
>
|
||||
{installed ? 'Uninstall' : 'Install'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-sidebar-foreground/70 text-sm underline-offset-4 hover:text-sidebar-foreground hover:underline"
|
||||
href={PLUGIN_AUTHORING_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Create a Pascal plugin
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-y-auto p-4">
|
||||
<div className="mb-5">
|
||||
<h2 className="font-semibold text-lg text-sidebar-foreground">Plugins</h2>
|
||||
<p className="mt-1 text-sidebar-foreground/60 text-sm">
|
||||
Add focused tools and content to this project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{plugins.map(([pluginId, panel]) => {
|
||||
const installed = installedPlugins.includes(pluginId)
|
||||
return (
|
||||
<button
|
||||
className="w-full rounded-xl border border-border/60 bg-accent/20 p-3 text-left transition-colors hover:bg-accent/40"
|
||||
key={pluginId}
|
||||
onClick={() => setSelectedPluginId(pluginId)}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-background/60">
|
||||
{renderPluginIcon(panel.icon)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-sidebar-foreground">{panel.label}</h3>
|
||||
<p className="text-sidebar-foreground/50 text-xs">
|
||||
{installed ? 'Installed' : 'Not installed'}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-sidebar-foreground/50" />
|
||||
</div>
|
||||
<p className="mt-2 text-sidebar-foreground/60 text-sm">
|
||||
{panel.description ?? 'Adds a new tool panel to the editor.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-6">
|
||||
<a
|
||||
className="inline-flex items-center gap-1.5 text-sidebar-foreground/70 text-sm underline-offset-4 hover:text-sidebar-foreground hover:underline"
|
||||
href={PLUGIN_AUTHORING_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Create a Pascal plugin
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -180,6 +180,7 @@ export function SettingsPanel({
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const nodes = useScene((state) => state.nodes)
|
||||
const rootNodeIds = useScene((state) => state.rootNodeIds)
|
||||
const installedPlugins = useScene((state) => state.installedPlugins)
|
||||
const setScene = useScene((state) => state.setScene)
|
||||
const clearScene = useScene((state) => state.clearScene)
|
||||
const resetSelection = useViewer((state) => state.resetSelection)
|
||||
@@ -206,7 +207,7 @@ export function SettingsPanel({
|
||||
const isLocalProject = false // Props-based; only show cloud sections when projectId provided
|
||||
|
||||
const handleSaveBuild = () => {
|
||||
const sceneData = { nodes, rootNodeIds }
|
||||
const sceneData = { nodes, rootNodeIds, installedPlugins }
|
||||
const json = JSON.stringify(sceneData, null, 2)
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
@@ -262,10 +263,20 @@ export function SettingsPanel({
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
const handleConfirmImport = (parsed: { nodes: Record<string, unknown>; rootNodeIds: string[] }) => {
|
||||
const handleConfirmImport = (parsed: {
|
||||
nodes: Record<string, unknown>
|
||||
rootNodeIds: string[]
|
||||
installedPlugins?: string[]
|
||||
}) => {
|
||||
const currentScene = useScene.getState()
|
||||
setScene(
|
||||
parsed.nodes as Parameters<typeof setScene>[0],
|
||||
parsed.rootNodeIds as Parameters<typeof setScene>[1],
|
||||
{
|
||||
installedPlugins: parsed.installedPlugins ?? currentScene.installedPlugins,
|
||||
hasExplicitPluginInstallState:
|
||||
parsed.installedPlugins !== undefined || currentScene.hasExplicitPluginInstallState,
|
||||
},
|
||||
)
|
||||
// An import is a scene load: it becomes the undo floor. Without this,
|
||||
// undo could step back into the pre-import scene state.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { editorHostPanelRegistry } from '../../../lib/plugin-panels'
|
||||
import { triggerSFX } from './../../../lib/sfx-bus'
|
||||
import { cn } from './../../../lib/utils'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../primitives/tooltip'
|
||||
@@ -65,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]
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ export function useAutoSave({
|
||||
// collection change still triggers a save.
|
||||
let lastCollectionsRef = useScene.getState().collections
|
||||
let lastMaterialsRef = useScene.getState().materials
|
||||
let lastInstalledPluginsRef = useScene.getState().installedPlugins
|
||||
|
||||
async function executeSave() {
|
||||
if (isLoadingSceneRef.current || isVersionPreviewModeRef.current) {
|
||||
@@ -75,8 +76,14 @@ export function useAutoSave({
|
||||
return
|
||||
}
|
||||
|
||||
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
|
||||
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
|
||||
const sceneGraph = {
|
||||
nodes,
|
||||
rootNodeIds,
|
||||
collections,
|
||||
materials,
|
||||
installedPlugins,
|
||||
} as SceneGraph
|
||||
|
||||
// Guard: refuse to autosave if the scene went from populated to nearly empty.
|
||||
// This catches accidental full deletions before they're persisted.
|
||||
@@ -126,6 +133,7 @@ export function useAutoSave({
|
||||
lastNodesSnapshot = JSON.stringify(state.nodes)
|
||||
lastCollectionsRef = state.collections
|
||||
lastMaterialsRef = state.materials
|
||||
lastInstalledPluginsRef = state.installedPlugins
|
||||
return
|
||||
}
|
||||
|
||||
@@ -134,6 +142,7 @@ export function useAutoSave({
|
||||
lastNodesSnapshot = JSON.stringify(state.nodes)
|
||||
lastCollectionsRef = state.collections
|
||||
lastMaterialsRef = state.materials
|
||||
lastInstalledPluginsRef = state.installedPlugins
|
||||
return
|
||||
}
|
||||
|
||||
@@ -141,12 +150,14 @@ export function useAutoSave({
|
||||
const changed =
|
||||
currentNodesSnapshot !== lastNodesSnapshot ||
|
||||
state.collections !== lastCollectionsRef ||
|
||||
state.materials !== lastMaterialsRef
|
||||
state.materials !== lastMaterialsRef ||
|
||||
state.installedPlugins !== lastInstalledPluginsRef
|
||||
if (!changed) return
|
||||
|
||||
lastNodesSnapshot = currentNodesSnapshot
|
||||
lastCollectionsRef = state.collections
|
||||
lastMaterialsRef = state.materials
|
||||
lastInstalledPluginsRef = state.installedPlugins
|
||||
hasDirtyChangesRef.current = true
|
||||
onDirtyRef.current?.()
|
||||
setSaveStatus('pending')
|
||||
@@ -172,8 +183,14 @@ export function useAutoSave({
|
||||
function flushOnExit() {
|
||||
if (!hasDirtyChangesRef.current) return
|
||||
hasDirtyChangesRef.current = false
|
||||
const { nodes, rootNodeIds, collections, materials } = useScene.getState()
|
||||
const sceneGraph = { nodes, rootNodeIds, collections, materials } as SceneGraph
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = useScene.getState()
|
||||
const sceneGraph = {
|
||||
nodes,
|
||||
rootNodeIds,
|
||||
collections,
|
||||
materials,
|
||||
installedPlugins,
|
||||
} as SceneGraph
|
||||
if (onSaveRef.current) {
|
||||
onSaveRef.current(sceneGraph, { keepalive: true }).catch(() => {})
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,14 @@ export type EditorHostPanel = {
|
||||
icon: IconRef
|
||||
component: LazyComponent
|
||||
workspaces?: readonly EditorHostPanelWorkspace[]
|
||||
pluginId?: string
|
||||
description?: string
|
||||
creator?: {
|
||||
name: string
|
||||
url?: string
|
||||
}
|
||||
pluginUrl?: string
|
||||
defaultInstalled?: boolean
|
||||
}
|
||||
|
||||
function isDevMode(): boolean {
|
||||
@@ -37,6 +45,15 @@ class EditorHostPanelRegistryImpl {
|
||||
|
||||
getSnapshot = (): EditorHostPanel[] => this.cached
|
||||
|
||||
getDefaultInstalledPluginIds = (): string[] =>
|
||||
Array.from(
|
||||
new Set(
|
||||
this.cached
|
||||
.filter((panel) => panel.pluginId && panel.defaultInstalled)
|
||||
.map((panel) => panel.pluginId as string),
|
||||
),
|
||||
)
|
||||
|
||||
reset(): void {
|
||||
this.panels.clear()
|
||||
this.emit()
|
||||
|
||||
@@ -13,6 +13,7 @@ import useEditor, {
|
||||
normalizePersistedEditorUiState,
|
||||
type PersistedEditorUiState,
|
||||
} from '../store/use-editor'
|
||||
import { editorHostPanelRegistry } from './plugin-panels'
|
||||
|
||||
export type SceneGraph = {
|
||||
nodes: Record<string, unknown>
|
||||
@@ -21,6 +22,7 @@ export type SceneGraph = {
|
||||
// payloads (and callers that only build nodes) stay valid.
|
||||
collections?: Record<string, unknown>
|
||||
materials?: Record<string, unknown>
|
||||
installedPlugins?: string[]
|
||||
}
|
||||
|
||||
type PersistedSelectionPath = {
|
||||
@@ -381,14 +383,18 @@ function hasUsableSceneGraph(sceneGraph?: SceneGraph | null): sceneGraph is Scen
|
||||
}
|
||||
|
||||
export function applySceneGraphToEditor(sceneGraph?: SceneGraph | null) {
|
||||
const defaultInstalledPlugins = editorHostPanelRegistry.getDefaultInstalledPluginIds()
|
||||
if (hasUsableSceneGraph(sceneGraph)) {
|
||||
const { nodes, rootNodeIds, collections, materials } = sceneGraph
|
||||
const { nodes, rootNodeIds, collections, materials, installedPlugins } = sceneGraph
|
||||
useScene.getState().setScene(nodes as any, rootNodeIds as any, {
|
||||
collections: collections as any,
|
||||
materials: materials as any,
|
||||
installedPlugins: installedPlugins ?? defaultInstalledPlugins,
|
||||
hasExplicitPluginInstallState: installedPlugins !== undefined,
|
||||
})
|
||||
} else {
|
||||
useScene.getState().clearScene()
|
||||
useScene.getState().setInstalledPlugins(defaultInstalledPlugins, { explicit: false })
|
||||
}
|
||||
|
||||
// The loaded scene is the undo floor. Loading records history entries of
|
||||
|
||||
@@ -432,6 +432,21 @@ describe('SceneBridge', () => {
|
||||
expect(Object.keys(bridge.getNodes()).length).toBe(Object.keys(snap.nodes).length)
|
||||
})
|
||||
|
||||
test('loadJSON preserves explicit plugin installs', () => {
|
||||
const snap = bridge.exportJSON()
|
||||
bridge.loadJSON({ ...snap, installedPlugins: ['pascal:trees'] })
|
||||
|
||||
expect(bridge.exportJSON().installedPlugins).toEqual(['pascal:trees'])
|
||||
})
|
||||
|
||||
test('legacy graphs do not become explicitly uninstalled on export', () => {
|
||||
const snap = bridge.exportJSON()
|
||||
const { installedPlugins: _installedPlugins, ...legacy } = snap
|
||||
bridge.loadJSON(legacy)
|
||||
|
||||
expect(Object.hasOwn(bridge.exportJSON(), 'installedPlugins')).toBe(false)
|
||||
})
|
||||
|
||||
test('loadJSON throws on malformed JSON string', () => {
|
||||
expect(() => bridge.loadJSON('not json')).toThrow(/invalid JSON/)
|
||||
})
|
||||
|
||||
@@ -72,6 +72,9 @@ export class SceneBridge {
|
||||
nodes: state.nodes,
|
||||
rootNodeIds: state.rootNodeIds,
|
||||
collections: state.collections ?? {},
|
||||
...(state.hasExplicitPluginInstallState || state.installedPlugins.length > 0
|
||||
? { installedPlugins: state.installedPlugins }
|
||||
: {}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -117,6 +120,12 @@ export class SceneBridge {
|
||||
}
|
||||
|
||||
this.setScene(nodes as Record<AnyNodeId, AnyNode>, rootNodeIds as AnyNodeId[])
|
||||
if (Array.isArray(obj.installedPlugins)) {
|
||||
useScene.getState().setInstalledPlugins(
|
||||
obj.installedPlugins.filter((id): id is string => typeof id === 'string'),
|
||||
{ explicit: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read a single node, or `null` if not present. */
|
||||
|
||||
@@ -150,6 +150,7 @@ class SceneOperationsFacade implements SceneOperations {
|
||||
nodes: exported.nodes,
|
||||
rootNodeIds: exported.rootNodeIds,
|
||||
collections: exported.collections as SceneGraph['collections'],
|
||||
installedPlugins: exported.installedPlugins,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# @pascal-app/plugin-trees
|
||||
|
||||
The first-party **example plugin** for the Pascal editor. It contributes
|
||||
procedural plant nodes plus a separately exported host-side Nature panel, and
|
||||
exists to prove — and document — the minimal node-plugin surface every future
|
||||
plugin reuses.
|
||||
|
||||
It is structurally identical to a third-party plugin: it peer-depends on
|
||||
`@pascal-app/{core,viewer,editor}` (plus `react`/`three`/`@react-three/fiber`/
|
||||
`zustand`) and bundles `@dgreenheck/ez-tree` for the geometry. It imports
|
||||
nothing private. Copy this folder as the starting point for a new plugin.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
The contribution paths this package demonstrates:
|
||||
|
||||
1. **Host-side panel extension** — the standalone editor registers
|
||||
`treesHostPanel` separately from the core plugin manifest. `presets-panel.tsx`
|
||||
is a plain React component the host mounts behind an error boundary.
|
||||
2. **Right inspector for free** — `def.parametrics` (`parametrics.ts`). The host
|
||||
renders the preset/height/seed controls + the Randomize action with zero
|
||||
tree-specific code.
|
||||
3. **Placement** — `def.tool`/`def.preview` (`tool.tsx`, `preview.tsx`). The
|
||||
tool respects the active snapping mode (`isGridSnapActive()` + `gridSnapStep`)
|
||||
exactly like the built-in item/shelf tools.
|
||||
4. **Instanced rendering** (the generic core in `instanced.tsx`, shared by both
|
||||
kinds) — instead of the per-node `def.geometry` path, plants render via two
|
||||
pieces:
|
||||
- `def.system` — a collective renderer mounted once that groups every node of
|
||||
the kind by its geometry variant and draws each variant as one
|
||||
`InstancedMesh` per sub-mesh. A forest of N is a handful of draw calls.
|
||||
Variant geometry is generated once and cached.
|
||||
- `def.renderer` — a featherweight per-node proxy: a stable invisible box
|
||||
collider (the raycast target) in an outer group, plus the real geometry
|
||||
(invisible, mounted only while hovered/selected) in an inner *registered*
|
||||
group. So the host's outline pass traces the **true silhouette**, picking
|
||||
stays on the box, and selection / outline / zone machinery works unchanged
|
||||
with no instanceId bookkeeping.
|
||||
|
||||
### Three kinds
|
||||
|
||||
- **`trees:tree`** — ez-tree geometry (`geometry.ts`); species presets Oak /
|
||||
Pine / Aspen / Ash / Bush / Trellis × a Small/Medium/Large **size** (all of
|
||||
ez-tree's built-in presets), a Deciduous/Evergreen **type**, curated params
|
||||
(foliage density, trunk thickness, leafless), and leaf/branch **colour tints** —
|
||||
all folded into the variant key. Colours are edit-only (inspector), not on the
|
||||
placement brush.
|
||||
- **`trees:flower`** — simple procedural geometry (`flower-geometry.ts`, merged
|
||||
per material); presets daisy / tulip / lavender, with a per-flower petal colour.
|
||||
- **`trees:grass`** — procedural blade tufts (`grass-geometry.ts`); presets
|
||||
meadow / fescue / reed, with a per-tuft blade colour.
|
||||
|
||||
Flowers and grass are sibling kinds that reuse the exact same instanced core +
|
||||
placement helper (`instanced.tsx` / `placement.tsx`) and the shared procedural
|
||||
RNG (`mulberry32` in `geometry.ts`) — the template for adding more plant kinds.
|
||||
|
||||
It also shows the communication triangle: `presets-panel` → plugin store
|
||||
(`store.ts`) → `def.tool` → `SceneApi` → scene → reactive `useScene` read-back
|
||||
(the "N planted" counter in the panel).
|
||||
|
||||
`@dgreenheck/ez-tree` ships its bark/leaf textures inlined as base64, so there
|
||||
are no assets to host. Placement seeds are drawn from a small bounded pool
|
||||
(`TREE_SEED_POOL`) so trees share variants — that sharing is what makes the
|
||||
instancing pay off; a unique inspector seed just renders as its own variant.
|
||||
|
||||
## Manifest
|
||||
|
||||
```ts
|
||||
import { treesPlugin } from '@pascal-app/plugin-trees'
|
||||
// host:
|
||||
setPluginDiscovery(async () => [treesPlugin])
|
||||
```
|
||||
|
||||
`treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`,
|
||||
`trees:grass`) for the core `loadPlugin` path. The editor app separately imports
|
||||
`treesHostPanel` to surface the Nature rail entry; panels are not part of the v1
|
||||
core plugin manifest.
|
||||
|
||||
## Notes / known gaps
|
||||
|
||||
- `package.json` points `main`/`exports` at raw TypeScript (`./src/index.ts`),
|
||||
which works here only because the host app's bundler transpiles workspace
|
||||
packages. A real third-party plugin must ship built JS (with `.d.ts` types) or
|
||||
otherwise ensure the consuming host transpiles the package.
|
||||
- `createNode` and the `floorPlaced.footprint` callback are typed against the
|
||||
host's hand-maintained `AnyNode` union, so the node is cast (`as AnyNode` /
|
||||
`as TreeNode`). The registry derives `AnyNode` post-migration.
|
||||
- The placement tool re-derives level-local conversion from the public
|
||||
`sceneRegistry` because the built-in `floor-placement` helpers aren't part of
|
||||
the public `@pascal-app/*` surface yet — a candidate for a future
|
||||
`@pascal-app/plugin-api` re-export package.
|
||||
- The instance matrices fold in the parent level's world transform; a building
|
||||
move while plants are static won't refresh until a node of that kind next
|
||||
changes.
|
||||
- Heavy *per-node* tweaking of geometry params (or unique seeds) erodes
|
||||
instancing batching — but it degrades gracefully: such a node just becomes its
|
||||
own single-instance variant, never worse than the non-instanced path.
|
||||
|
||||
See `wiki/architecture/plugin-authoring.md` for the full contract.
|
||||
@@ -1,56 +0,0 @@
|
||||
{
|
||||
"name": "@pascal-app/plugin-trees",
|
||||
"version": "0.1.0",
|
||||
"description": "First-party example Pascal editor plugin — a procedural trees node with a presets rail panel. Structurally identical to a third-party plugin (peer-deps on @pascal-app/*).",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"default": "./src/index.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"check-types": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dgreenheck/ez-tree": "^1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@pascal-app/core": "*",
|
||||
"@pascal-app/editor": "*",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@react-three/fiber": "^9",
|
||||
"react": "^18 || ^19",
|
||||
"three": "^0.185",
|
||||
"zod": "^4",
|
||||
"zustand": "^5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pascal-app/core": "*",
|
||||
"@pascal-app/editor": "*",
|
||||
"@pascal-app/viewer": "*",
|
||||
"@pascal/typescript-config": "*",
|
||||
"@react-three/fiber": "^9",
|
||||
"@types/node": "^22.19.12",
|
||||
"@types/react": "^19.2.2",
|
||||
"@types/three": "^0.184.0",
|
||||
"react": "^19",
|
||||
"three": "^0.185",
|
||||
"typescript": "6.0.3",
|
||||
"zod": "^4",
|
||||
"zustand": "^5"
|
||||
},
|
||||
"keywords": [
|
||||
"pascal",
|
||||
"3d",
|
||||
"editor",
|
||||
"plugin"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import ash from './assets/ash.webp'
|
||||
import aspen from './assets/aspen.webp'
|
||||
import bush from './assets/bush.webp'
|
||||
import daisy from './assets/daisy.webp'
|
||||
import fescue from './assets/fescue.webp'
|
||||
import lavender from './assets/lavender.webp'
|
||||
import meadow from './assets/meadow.webp'
|
||||
import natureIcon from './assets/nature-icon.webp'
|
||||
import oak from './assets/oak.webp'
|
||||
import pine from './assets/pine.webp'
|
||||
import reed from './assets/reed.webp'
|
||||
import trellis from './assets/trellis.webp'
|
||||
import tulip from './assets/tulip.webp'
|
||||
import type { FlowerPreset } from './flower-schema'
|
||||
import type { GrassPreset } from './grass-schema'
|
||||
import type { TreePreset } from './schema'
|
||||
|
||||
/**
|
||||
* Bundled preset artwork. The webp live in `./assets` and travel with the
|
||||
* package — no CDN, no per-app `public/` mirroring. Both consumers are Next, so
|
||||
* `transpilePackages` runs these imports through the image pipeline and `.src`
|
||||
* is the hashed, cached URL. The panel renders each as an `<img src>`.
|
||||
*/
|
||||
const url = (asset: { src: string }): string => asset.src
|
||||
|
||||
export const TREE_ART: Record<TreePreset, string> = {
|
||||
oak: url(oak),
|
||||
pine: url(pine),
|
||||
aspen: url(aspen),
|
||||
ash: url(ash),
|
||||
bush: url(bush),
|
||||
trellis: url(trellis),
|
||||
}
|
||||
|
||||
export const FLOWER_ART: Record<FlowerPreset, string> = {
|
||||
daisy: url(daisy),
|
||||
tulip: url(tulip),
|
||||
lavender: url(lavender),
|
||||
}
|
||||
|
||||
export const GRASS_ART: Record<GrassPreset, string> = {
|
||||
meadow: url(meadow),
|
||||
fescue: url(fescue),
|
||||
reed: url(reed),
|
||||
}
|
||||
|
||||
/** The Nature panel / section icon. */
|
||||
export const NATURE_ICON = url(natureIcon)
|
||||
@@ -1,8 +0,0 @@
|
||||
// Bundled image assets. Both consumers (community + apps/editor) are Next, whose
|
||||
// static image import returns a StaticImageData-shaped object; `.src` is the
|
||||
// hashed, cached URL the bundler emits. Declared locally so the package needs no
|
||||
// `next` type dependency. See `art.ts`.
|
||||
declare module '*.webp' {
|
||||
const asset: { src: string; height: number; width: number; blurDataURL?: string }
|
||||
export default asset
|
||||
}
|
||||
|
Before Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,144 +0,0 @@
|
||||
import type { HandleDescriptor, NodeDefinition } from '@pascal-app/core'
|
||||
import { buildTreeFloorplan, treeTrunkRadius } from './floorplan'
|
||||
import { treeParametrics } from './parametrics'
|
||||
import { TreeNode } from './schema'
|
||||
|
||||
const ROTATE_RING_OFFSET = 0.35
|
||||
/** Ring hugs the ground like the item gizmo — high enough to clear the grass,
|
||||
* low enough to read as a floor affordance. */
|
||||
const ROTATE_RING_Y = 0.25
|
||||
|
||||
/** Whole-tree Y-rotation gizmo (same rig as shelf/item): a ring around the
|
||||
* trunk near the ground — not the canopy, which would put the handle meters
|
||||
* from the trunk on a large oak. */
|
||||
function treeRotateHandle(): HandleDescriptor<TreeNode> {
|
||||
const ringRadius = (n: TreeNode) => treeTrunkRadius(n) + ROTATE_RING_OFFSET
|
||||
const ringY = () => ROTATE_RING_Y
|
||||
return {
|
||||
kind: 'arc-resize',
|
||||
axis: 'angular',
|
||||
shape: 'rotate',
|
||||
apply: (initial, delta) => {
|
||||
const r = initial.rotation ?? [0, 0, 0]
|
||||
// Negate to match three.js Y-rotation handedness (same as shelf).
|
||||
return { rotation: [r[0], (r[1] ?? 0) - delta, r[2]] as [number, number, number] }
|
||||
},
|
||||
placement: {
|
||||
position: (n) => {
|
||||
const r = ringRadius(n)
|
||||
return [r * Math.SQRT1_2, ringY(), r * Math.SQRT1_2]
|
||||
},
|
||||
rotationY: () => -Math.PI / 4,
|
||||
},
|
||||
decoration: {
|
||||
kind: 'ring',
|
||||
radius: ringRadius,
|
||||
y: ringY,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree node definition. Rendering uses the instanced path rather than the
|
||||
* per-node `def.geometry`: a collective `def.system` batches every tree into
|
||||
* `InstancedMesh`es (forest-scale draw calls), while a featherweight
|
||||
* `def.renderer` mounts an invisible per-node proxy so the host's selection /
|
||||
* outline / zone machinery works unchanged. `parametrics` gives the inspector
|
||||
* for free; `tool`/`preview` drive placement. No host dispatch code per kind.
|
||||
*/
|
||||
export const treeDefinition: NodeDefinition<typeof TreeNode> = {
|
||||
kind: 'trees:tree',
|
||||
// Static in the bake for portability; our viewer removes the baked meshes and
|
||||
// re-renders live (wind, LODs) via this def's own path. See plans → Part D.
|
||||
bake: 'replace',
|
||||
schemaVersion: 1,
|
||||
schema: TreeNode,
|
||||
category: 'furnish',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
preset: 'oak',
|
||||
size: 'medium',
|
||||
treeType: 'deciduous',
|
||||
height: 7,
|
||||
seed: 1,
|
||||
foliageDensity: 1,
|
||||
trunkThickness: 1,
|
||||
leafless: false,
|
||||
leafColor: '#ffffff',
|
||||
branchColor: '#ffffff',
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
rotatable: {
|
||||
axes: ['y'],
|
||||
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
|
||||
},
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
groupable: true,
|
||||
snappable: {},
|
||||
// The auto-measured drag box would wrap the whole canopy (the proxy shows
|
||||
// the real geometry while selected) — declare trunk-sized bounds instead.
|
||||
dragBounds: (node) => {
|
||||
const tree = node as unknown as TreeNode
|
||||
const radius = treeTrunkRadius(tree)
|
||||
return { size: [radius * 2, tree.height ?? 7, radius * 2] }
|
||||
},
|
||||
floorPlaced: {
|
||||
// `footprint` receives the host's `AnyNode`; cast to our schema type the
|
||||
// same way built-in kinds do (`node as ShelfNode`). Trunk-sized, not
|
||||
// canopy-sized — the drag/placement box should hug where the tree
|
||||
// actually plants, not span the whole crown.
|
||||
footprint: (node) => {
|
||||
const tree = node as unknown as TreeNode
|
||||
const radius = treeTrunkRadius(tree)
|
||||
return {
|
||||
dimensions: [radius * 2, tree.height, radius * 2] as [number, number, number],
|
||||
rotation: tree.rotation,
|
||||
}
|
||||
},
|
||||
collides: false,
|
||||
},
|
||||
},
|
||||
|
||||
parametrics: treeParametrics,
|
||||
// 2D plan symbol: dashed canopy ring + trunk dot (see floorplan.ts).
|
||||
floorplan: buildTreeFloorplan,
|
||||
handles: [treeRotateHandle()],
|
||||
|
||||
// Instanced rendering: an invisible per-node proxy for selection/outline...
|
||||
renderer: { kind: 'parametric', module: () => import('./proxy-renderer') },
|
||||
// ...and a collective system that batches every tree into InstancedMeshes.
|
||||
system: { module: () => import('./system'), priority: 3 },
|
||||
// Baked `/viewer` re-render for `bake: 'replace'` — collective, instanced.
|
||||
bakeReplaceRenderer: { module: () => import('./static-renderer') },
|
||||
|
||||
preview: () => import('./preview'),
|
||||
tool: () => import('./tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Plant tree' },
|
||||
{ key: 'Esc', label: 'Stop' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Tree',
|
||||
description: 'A procedural ez-tree. Oak, pine, aspen, ash, bush, or trellis.',
|
||||
icon: { kind: 'iconify', name: 'lucide:trees' },
|
||||
paletteSection: 'furnish',
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A procedural ez-tree (example plugin node). Species presets (oak/pine/aspen/ash/bush/trellis) × size, deciduous/evergreen type, adjustable height, foliage/trunk, leaf & branch tint, and a seed for variation.',
|
||||
},
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { type AnyNode, emitter } from '@pascal-app/core'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import type { TreeNode } from './schema'
|
||||
import { type TreesPanelMode, useTreesStore } from './store'
|
||||
|
||||
/**
|
||||
* "Find in catalog" sync. The editor's node action menu emits
|
||||
* `selection:find-node`; the host opens the panel that owns the kind — but
|
||||
* which *section* of the Nature panel to
|
||||
* show is plugin knowledge, so the plugin listens too and points its own store
|
||||
* at the found node's section + preset. Module-level (imported by the plugin
|
||||
* manifest) so the listener is live from plugin load, even while the panel has
|
||||
* never been mounted.
|
||||
*/
|
||||
|
||||
const MODE_BY_KIND: Record<string, TreesPanelMode> = {
|
||||
'trees:tree': 'trees',
|
||||
'trees:flower': 'flowers',
|
||||
'trees:grass': 'grass',
|
||||
}
|
||||
|
||||
emitter.on('selection:find-node', (node: AnyNode) => {
|
||||
const mode = MODE_BY_KIND[node.type as string]
|
||||
if (!mode) return
|
||||
const store = useTreesStore.getState()
|
||||
store.setMode(mode)
|
||||
if (mode === 'trees') {
|
||||
const tree = node as unknown as TreeNode
|
||||
store.setPreset(tree.preset ?? 'oak')
|
||||
store.setSize(tree.size ?? 'medium')
|
||||
} else if (mode === 'flowers') {
|
||||
store.setFlowerPreset((node as unknown as FlowerNode).preset ?? 'daisy')
|
||||
} else {
|
||||
store.setGrassPreset((node as unknown as GrassNode).preset ?? 'meadow')
|
||||
}
|
||||
})
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { FloorplanGeometry, GeometryContext } from '@pascal-app/core'
|
||||
import { flowerPetalColor } from './flower-geometry'
|
||||
import { FLOWER_PRESETS } from './flower-presets'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import { GRASS_PRESETS } from './grass-presets'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import { TREE_PRESETS } from './presets'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
/**
|
||||
* 2D plan builders for the plant kinds (`def.floorplan`) — the registry
|
||||
* floor-plan layer renders any kind that provides one, so this is all it takes
|
||||
* for plugin nodes to appear in the 2D view. Classic architect symbols:
|
||||
* a dashed canopy circle (dashed = overhead element, like a roof overhang)
|
||||
* with a solid trunk dot for trees; small colour dots for flowers/grass.
|
||||
*/
|
||||
|
||||
/** Trunk radius in plan — also the selection footprint, so the move box hugs
|
||||
* the trunk instead of the whole canopy. */
|
||||
export function treeTrunkRadius(tree: TreeNode): number {
|
||||
return Math.max(0.15, (tree.height ?? 7) * 0.025 * (tree.trunkThickness ?? 1))
|
||||
}
|
||||
|
||||
/** Approximate canopy radius in plan (matches the old whole-tree footprint). */
|
||||
export function treeCanopyRadius(tree: TreeNode): number {
|
||||
return Math.max(0.5, (tree.height ?? 7) * 0.28)
|
||||
}
|
||||
|
||||
type ViewChrome = { stroke: string | null; selected: boolean }
|
||||
|
||||
/** Selection/hover stroke override shared by the three builders. */
|
||||
function chromeOf(ctx: GeometryContext): ViewChrome {
|
||||
const view = ctx.viewState
|
||||
const palette = view?.palette
|
||||
if ((view?.selected || view?.highlighted) && palette)
|
||||
return { stroke: palette.selectedStroke, selected: view?.selected ?? false }
|
||||
if (view?.hovered && palette) return { stroke: palette.wallHoverStroke, selected: false }
|
||||
return { stroke: null, selected: false }
|
||||
}
|
||||
|
||||
export function buildTreeFloorplan(node: TreeNode, ctx: GeometryContext): FloorplanGeometry {
|
||||
const [x, , z] = node.position ?? [0, 0, 0]
|
||||
const swatch = (TREE_PRESETS[node.preset] ?? TREE_PRESETS.oak).swatch
|
||||
const chrome = chromeOf(ctx)
|
||||
const stroke = chrome.stroke ?? swatch
|
||||
|
||||
const children: FloorplanGeometry[] = [
|
||||
// Canopy ring — pointer-events on the stroke only, so the (large) disc
|
||||
// doesn't steal clicks from whatever sits under the canopy in plan.
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: treeCanopyRadius(node),
|
||||
stroke,
|
||||
strokeWidth: 0.03,
|
||||
strokeDasharray: '0.18 0.12',
|
||||
fill: swatch,
|
||||
fillOpacity: 0.06,
|
||||
pointerEvents: 'stroke',
|
||||
},
|
||||
// Trunk dot — the solid, always-clickable core of the symbol.
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: treeTrunkRadius(node),
|
||||
fill: chrome.stroke ?? '#6b4f2e',
|
||||
stroke,
|
||||
strokeWidth: 0.02,
|
||||
opacity: 0.95,
|
||||
},
|
||||
]
|
||||
if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] })
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
export function buildFlowerFloorplan(node: FlowerNode, ctx: GeometryContext): FloorplanGeometry {
|
||||
const [x, , z] = node.position ?? [0, 0, 0]
|
||||
const preset = FLOWER_PRESETS[node.preset] ?? FLOWER_PRESETS.daisy
|
||||
const chrome = chromeOf(ctx)
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: 0.1,
|
||||
fill: flowerPetalColor(node),
|
||||
stroke: chrome.stroke ?? preset.stemColor,
|
||||
strokeWidth: 0.02,
|
||||
},
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: 0.035,
|
||||
fill: preset.centerColor,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
]
|
||||
if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] })
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
|
||||
export function buildGrassFloorplan(node: GrassNode, ctx: GeometryContext): FloorplanGeometry {
|
||||
const [x, , z] = node.position ?? [0, 0, 0]
|
||||
const preset = GRASS_PRESETS[node.preset] ?? GRASS_PRESETS.meadow
|
||||
const blade = node.bladeColor ?? preset.bladeColor
|
||||
const chrome = chromeOf(ctx)
|
||||
const children: FloorplanGeometry[] = [
|
||||
{
|
||||
kind: 'circle',
|
||||
cx: x,
|
||||
cy: z,
|
||||
r: 0.12,
|
||||
fill: blade,
|
||||
fillOpacity: 0.5,
|
||||
stroke: chrome.stroke ?? blade,
|
||||
strokeWidth: 0.02,
|
||||
strokeDasharray: '0.06 0.05',
|
||||
},
|
||||
]
|
||||
if (chrome.selected) children.push({ kind: 'move-handle', point: [x, z] })
|
||||
return { kind: 'group', children }
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildFlowerFloorplan } from './floorplan'
|
||||
import { flowerParametrics } from './flower-parametrics'
|
||||
import { FlowerNode } from './flower-schema'
|
||||
|
||||
/**
|
||||
* The flower node definition — a sibling instanced kind to the tree. Same
|
||||
* composition: a `def.system` batches every flower into InstancedMeshes, a
|
||||
* featherweight `def.renderer` proxy keeps selection working, `parametrics`
|
||||
* gives the inspector, `tool`/`preview` drive placement.
|
||||
*/
|
||||
export const flowerDefinition: NodeDefinition<typeof FlowerNode> = {
|
||||
kind: 'trees:flower',
|
||||
bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D
|
||||
schemaVersion: 1,
|
||||
schema: FlowerNode,
|
||||
category: 'furnish',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
preset: 'daisy',
|
||||
height: 0.5,
|
||||
seed: 1,
|
||||
petalColor: '#fcfcf2',
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
rotatable: {
|
||||
axes: ['y'],
|
||||
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
|
||||
},
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
groupable: true,
|
||||
snappable: {},
|
||||
floorPlaced: {
|
||||
footprint: (node) => {
|
||||
const flower = node as unknown as FlowerNode
|
||||
const radius = Math.max(0.1, flower.height * 0.25)
|
||||
return {
|
||||
dimensions: [radius * 2, flower.height, radius * 2] as [number, number, number],
|
||||
rotation: flower.rotation,
|
||||
}
|
||||
},
|
||||
collides: false,
|
||||
},
|
||||
},
|
||||
|
||||
parametrics: flowerParametrics,
|
||||
floorplan: buildFlowerFloorplan,
|
||||
|
||||
renderer: { kind: 'parametric', module: () => import('./flower-proxy-renderer') },
|
||||
system: { module: () => import('./flower-system'), priority: 3 },
|
||||
bakeReplaceRenderer: { module: () => import('./flower-static-renderer') },
|
||||
|
||||
preview: () => import('./flower-preview'),
|
||||
tool: () => import('./flower-tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Plant flower' },
|
||||
{ key: 'Esc', label: 'Stop' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Flower',
|
||||
description: 'A procedural flower. Daisy, tulip, or lavender.',
|
||||
icon: { kind: 'iconify', name: 'lucide:flower-2' },
|
||||
paletteSection: 'furnish',
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A procedural flower (example plugin node) — daisy, tulip, or lavender, instanced like the trees.',
|
||||
},
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
import {
|
||||
type BufferGeometry,
|
||||
ConeGeometry,
|
||||
CylinderGeometry,
|
||||
Group,
|
||||
Mesh,
|
||||
SphereGeometry,
|
||||
} from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { FLOWER_PRESETS } from './flower-presets'
|
||||
import type { FlowerNode, FlowerPreset } from './flower-schema'
|
||||
import type { SubMesh, VariantData } from './instanced'
|
||||
import { mulberry32, naturalHeight } from './variant-utils'
|
||||
import { windStandardMaterial } from './wind-node'
|
||||
|
||||
export function flowerVariantKey(preset: FlowerPreset, seed: number, petalColor: string): string {
|
||||
return `${preset}:${seed}:${petalColor}`
|
||||
}
|
||||
|
||||
/** Petal colour with fallbacks — nodes persisted before the field existed load
|
||||
* without it, so fall back to the preset colour rather than crash/blank. */
|
||||
export function flowerPetalColor(node: FlowerNode): string {
|
||||
return node.petalColor ?? FLOWER_PRESETS[node.preset]?.petalColor ?? '#fcfcf2'
|
||||
}
|
||||
|
||||
const variantCache = new Map<string, VariantData>()
|
||||
|
||||
/** Cached procedural flower geometry for a (preset, seed, petalColor). Like the
|
||||
* trees, one generation per variant is shared across every instance. Built
|
||||
* merged per material so each variant is ~3 InstancedMeshes (stem/petals/center). */
|
||||
export function getFlowerVariant(node: FlowerNode): VariantData {
|
||||
const petalColor = flowerPetalColor(node)
|
||||
const key = flowerVariantKey(node.preset, node.seed, petalColor)
|
||||
const cached = variantCache.get(key)
|
||||
if (cached) return cached
|
||||
const group = buildFlower(node.preset, node.seed, petalColor)
|
||||
const subMeshes: SubMesh[] = group.children
|
||||
.filter((c): c is Mesh => (c as Mesh).isMesh)
|
||||
.map((mesh) => ({ geometry: mesh.geometry, material: mesh.material }))
|
||||
const data: VariantData = { subMeshes, naturalHeight: naturalHeight(group) }
|
||||
variantCache.set(key, data)
|
||||
return data
|
||||
}
|
||||
|
||||
function buildFlower(preset: FlowerPreset, seed: number, petalColor: string): Group {
|
||||
const spec = FLOWER_PRESETS[preset] ?? FLOWER_PRESETS.daisy
|
||||
const rng = mulberry32(seed >>> 0)
|
||||
const group = new Group()
|
||||
const stemMat = windStandardMaterial({ color: spec.stemColor, roughness: 0.85 })
|
||||
const petalMat = windStandardMaterial({ color: petalColor, roughness: 0.7 })
|
||||
const centerMat = windStandardMaterial({ color: spec.centerColor, roughness: 0.6 })
|
||||
const stemH = spec.defaultHeight
|
||||
|
||||
const stem = new CylinderGeometry(0.008, 0.015, stemH, 5)
|
||||
stem.translate(0, stemH / 2, 0)
|
||||
group.add(new Mesh(stem, stemMat))
|
||||
|
||||
if (preset === 'lavender') {
|
||||
// A spike of small florets along the top ~45% of the stem.
|
||||
const florets: BufferGeometry[] = []
|
||||
const count = 26
|
||||
const spikeBase = stemH * 0.55
|
||||
for (let i = 0; i < count; i++) {
|
||||
const t = i / count
|
||||
const y = spikeBase + t * (stemH - spikeBase)
|
||||
const angle = i * 2.4 + rng() * 0.5
|
||||
const r = (1 - t) * 0.035 + 0.008
|
||||
const f = new SphereGeometry(0.016 * (1 - t * 0.4), 5, 4)
|
||||
f.translate(Math.cos(angle) * r, y, Math.sin(angle) * r)
|
||||
florets.push(f)
|
||||
}
|
||||
group.add(new Mesh(mergeGeometries(florets, false) ?? florets[0], petalMat))
|
||||
return group
|
||||
}
|
||||
|
||||
if (preset === 'tulip') {
|
||||
// Six petals forming an upward cup.
|
||||
const petals: BufferGeometry[] = []
|
||||
const n = 6
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = (i / n) * Math.PI * 2 + rng() * 0.1
|
||||
const p = new ConeGeometry(0.045, 0.16, 4)
|
||||
p.translate(0, 0.08, 0)
|
||||
p.rotateZ(0.45)
|
||||
p.rotateY(-angle)
|
||||
p.translate(Math.cos(angle) * 0.03, stemH, Math.sin(angle) * 0.03)
|
||||
petals.push(p)
|
||||
}
|
||||
group.add(new Mesh(mergeGeometries(petals, false) ?? petals[0], petalMat))
|
||||
const core = new ConeGeometry(0.02, 0.1, 4)
|
||||
core.translate(0, stemH + 0.06, 0)
|
||||
group.add(new Mesh(core, centerMat))
|
||||
return group
|
||||
}
|
||||
|
||||
// daisy — a yellow disc with a ring of white petals.
|
||||
const center = new SphereGeometry(0.035, 8, 6)
|
||||
center.scale(1, 0.6, 1)
|
||||
center.translate(0, stemH, 0)
|
||||
group.add(new Mesh(center, centerMat))
|
||||
|
||||
const petals: BufferGeometry[] = []
|
||||
const n = 12
|
||||
for (let i = 0; i < n; i++) {
|
||||
const angle = (i / n) * Math.PI * 2 + rng() * 0.08
|
||||
const p = new ConeGeometry(0.02, 0.08, 3)
|
||||
p.rotateZ(Math.PI / 2)
|
||||
p.translate(0.07, 0, 0)
|
||||
p.rotateY(-angle)
|
||||
p.translate(0, stemH + 0.005, 0)
|
||||
petals.push(p)
|
||||
}
|
||||
group.add(new Mesh(mergeGeometries(petals, false) ?? petals[0], petalMat))
|
||||
return group
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
|
||||
/** Inspector for a placed flower — rendered for free by the host's
|
||||
* `ParametricInspector` from this descriptor. */
|
||||
export const flowerParametrics: ParametricDescriptor<FlowerNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Flower',
|
||||
fields: [
|
||||
{ key: 'preset', kind: 'enum', options: ['daisy', 'tulip', 'lavender'] },
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 0.2, max: 2, step: 0.05 },
|
||||
{ key: 'petalColor', kind: 'color' },
|
||||
{ key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Position',
|
||||
fields: [{ key: 'position', kind: 'vec3' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { FLOWER_ART } from './art'
|
||||
import type { FlowerPreset } from './flower-schema'
|
||||
|
||||
/** Per-flower colours, default height (metres), and a card `thumbnail` (a
|
||||
* replaceable placeholder image — see `thumbnails.ts`). Pure data shared by the
|
||||
* geometry builder and the panel. */
|
||||
export type FlowerPresetSpec = {
|
||||
id: FlowerPreset
|
||||
label: string
|
||||
petalColor: string
|
||||
centerColor: string
|
||||
stemColor: string
|
||||
defaultHeight: number
|
||||
swatch: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export const FLOWER_PRESETS: Record<FlowerPreset, FlowerPresetSpec> = {
|
||||
daisy: {
|
||||
id: 'daisy',
|
||||
label: 'Daisy',
|
||||
petalColor: '#fcfcf2',
|
||||
centerColor: '#f4c430',
|
||||
stemColor: '#4f7942',
|
||||
defaultHeight: 0.5,
|
||||
swatch: '#f4c430',
|
||||
thumbnail: FLOWER_ART.daisy,
|
||||
},
|
||||
tulip: {
|
||||
id: 'tulip',
|
||||
label: 'Tulip',
|
||||
petalColor: '#e0457b',
|
||||
centerColor: '#c43160',
|
||||
stemColor: '#3f7a3a',
|
||||
defaultHeight: 0.45,
|
||||
swatch: '#e0457b',
|
||||
thumbnail: FLOWER_ART.tulip,
|
||||
},
|
||||
lavender: {
|
||||
id: 'lavender',
|
||||
label: 'Lavender',
|
||||
petalColor: '#9b6fd4',
|
||||
centerColor: '#7d52b8',
|
||||
stemColor: '#5a7a4a',
|
||||
defaultHeight: 0.6,
|
||||
swatch: '#9b6fd4',
|
||||
thumbnail: FLOWER_ART.lavender,
|
||||
},
|
||||
}
|
||||
|
||||
export const FLOWER_PRESET_LIST: FlowerPresetSpec[] = Object.values(FLOWER_PRESETS)
|
||||
|
||||
/** Bounded seed pool so flowers share instancing variants (see trees). */
|
||||
export const FLOWER_SEED_POOL = [1, 7, 13, 21, 34, 55]
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { Material, MeshStandardMaterial } from 'three'
|
||||
import { getFlowerVariant } from './flower-geometry'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
|
||||
const NO_RAYCAST = () => {}
|
||||
|
||||
/** Translucent placement ghost for a flower — clones the variant materials so
|
||||
* the cursor preview is see-through without mutating the cached originals. */
|
||||
export default function FlowerPreview({ node }: { node: FlowerNode }) {
|
||||
const data = useMemo(() => getFlowerVariant(node), [node])
|
||||
const scale = node.height / data.naturalHeight
|
||||
|
||||
const ghosts = useMemo(
|
||||
() =>
|
||||
data.subMeshes.map((sub) => {
|
||||
const base = (
|
||||
Array.isArray(sub.material) ? sub.material[0] : sub.material
|
||||
) as MeshStandardMaterial
|
||||
const clone = base.clone()
|
||||
clone.transparent = true
|
||||
clone.opacity = 0.55
|
||||
clone.depthWrite = false
|
||||
return clone
|
||||
}),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const m of ghosts as Material[]) m.dispose()
|
||||
},
|
||||
[ghosts],
|
||||
)
|
||||
|
||||
return (
|
||||
<group scale={scale}>
|
||||
{data.subMeshes.map((sub, i) => (
|
||||
<mesh
|
||||
dispose={null}
|
||||
geometry={sub.geometry}
|
||||
key={i}
|
||||
material={ghosts[i]}
|
||||
raycast={NO_RAYCAST}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getFlowerVariant } from './flower-geometry'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import { KindProxy } from './instanced'
|
||||
|
||||
const getVariant = (node: FlowerNode) => getFlowerVariant(node)
|
||||
const colliderRadius = (node: FlowerNode) => Math.max(0.06, (node.height ?? 0.5) * 0.22)
|
||||
|
||||
/** Per-node selection proxy for the instanced flowers. */
|
||||
export default function FlowerProxyRenderer({ node }: { node: FlowerNode }) {
|
||||
return <KindProxy colliderRadius={colliderRadius} getVariant={getVariant} node={node} />
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { BaseNode, nodeType, objectId } from '@pascal-app/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Flower silhouettes the plugin can place. The string persists in scene JSON. */
|
||||
export const FlowerPreset = z.enum(['daisy', 'tulip', 'lavender'])
|
||||
export type FlowerPreset = z.infer<typeof FlowerPreset>
|
||||
|
||||
/** A placed flower — a sibling instanced kind to the tree, sharing the same
|
||||
* instanced renderer + selection proxy via the generic `instanced` core. */
|
||||
export const FlowerNode = BaseNode.extend({
|
||||
id: objectId('flower'),
|
||||
type: nodeType('trees:flower'),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
preset: FlowerPreset.default('daisy'),
|
||||
height: z.number().positive().default(0.5),
|
||||
seed: z.number().int().default(1),
|
||||
/** Petal colour (hex). Baked from the preset at placement; recolour per-flower
|
||||
* in the inspector (the flower analog of the tree's leaf tint). */
|
||||
petalColor: z.string().default('#fcfcf2'),
|
||||
})
|
||||
|
||||
export type FlowerNode = z.infer<typeof FlowerNode>
|
||||
@@ -1,16 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { flowerPetalColor, flowerVariantKey, getFlowerVariant } from './flower-geometry'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import { InstancedNodes } from './instanced'
|
||||
|
||||
const variantKeyOf = (node: FlowerNode) =>
|
||||
flowerVariantKey(node.preset, node.seed, flowerPetalColor(node))
|
||||
const getVariant = (node: FlowerNode) => getFlowerVariant(node)
|
||||
|
||||
/** Collective baked-`/viewer` renderer for one level's flowers (`bakeReplaceRenderer`). */
|
||||
export default function FlowerReplaceInstances({ nodes }: { nodes: FlowerNode[] }) {
|
||||
return (
|
||||
<InstancedNodes getVariant={getVariant} localSpace nodes={nodes} variantKeyOf={variantKeyOf} />
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { flowerPetalColor, flowerVariantKey, getFlowerVariant } from './flower-geometry'
|
||||
import type { FlowerNode } from './flower-schema'
|
||||
import { InstancedKindSystem } from './instanced'
|
||||
|
||||
const variantKeyOf = (node: FlowerNode) =>
|
||||
flowerVariantKey(node.preset, node.seed, flowerPetalColor(node))
|
||||
const getVariant = (node: FlowerNode) => getFlowerVariant(node)
|
||||
|
||||
/** Collective instanced renderer for every placed flower (`def.system`). */
|
||||
export default function FlowersSystem() {
|
||||
return (
|
||||
<InstancedKindSystem<FlowerNode>
|
||||
getVariant={getVariant}
|
||||
kind="trees:flower"
|
||||
variantKeyOf={variantKeyOf}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { FLOWER_PRESETS, FLOWER_SEED_POOL } from './flower-presets'
|
||||
import FlowerPreview from './flower-preview'
|
||||
import { FlowerNode } from './flower-schema'
|
||||
import { usePlacement } from './placement'
|
||||
import { useTreesStore } from './store'
|
||||
|
||||
/** The flowers placement tool — mirrors the trees tool, reading the flower
|
||||
* brush from the shared store. Petal colour is baked from the preset at
|
||||
* placement, then editable per-flower in the inspector. */
|
||||
export default function FlowerTool() {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const preset = useTreesStore((s) => s.flowerPreset)
|
||||
const height = useTreesStore((s) => s.flowerHeight)
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
FlowerNode.parse({
|
||||
preset,
|
||||
height,
|
||||
petalColor: FLOWER_PRESETS[preset].petalColor,
|
||||
seed: 1,
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
[preset, height],
|
||||
)
|
||||
|
||||
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
|
||||
if (!activeLevelId) return
|
||||
const s = useTreesStore.getState()
|
||||
const flower = FlowerNode.parse({
|
||||
preset: s.flowerPreset,
|
||||
height: s.flowerHeight,
|
||||
petalColor: FLOWER_PRESETS[s.flowerPreset].petalColor,
|
||||
seed: FLOWER_SEED_POOL[Math.floor(Math.random() * FLOWER_SEED_POOL.length)] ?? 1,
|
||||
position,
|
||||
rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0],
|
||||
})
|
||||
useScene.getState().createNode(flower as unknown as AnyNode, activeLevelId as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [flower.id as AnyNodeId] })
|
||||
triggerSFX('sfx:item-place')
|
||||
})
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return (
|
||||
<group ref={cursorRef} visible={cursorVisible}>
|
||||
<FlowerPreview node={previewNode} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
// ez-tree loads its inlined textures at module scope (needs `document`), so
|
||||
// this module must only be imported from lazy client modules (renderers,
|
||||
// systems, tools, previews) — never from `index.ts`, a definition, or
|
||||
// `floorplan.ts`, or SSR/prerender crashes. Pure helpers shared with the
|
||||
// flower/grass builders live in `variant-utils.ts` for that reason.
|
||||
import { Tree } from '@dgreenheck/ez-tree'
|
||||
import type { BufferGeometry, Material, Mesh, Object3D } from 'three'
|
||||
import { ezPresetOf } from './presets'
|
||||
import type { TreeNode } from './schema'
|
||||
import { naturalHeight } from './variant-utils'
|
||||
import { toWindMaterial } from './wind-node'
|
||||
|
||||
/** The geometry-affecting fields of a tree. Two trees with the same spec share
|
||||
* one generated variant (and thus one InstancedMesh set). Per-instance fields
|
||||
* (position/rotation/height) are deliberately NOT here — they're cheap matrix
|
||||
* work, not geometry. */
|
||||
export type TreeSpec = Pick<
|
||||
TreeNode,
|
||||
| 'preset'
|
||||
| 'size'
|
||||
| 'treeType'
|
||||
| 'seed'
|
||||
| 'foliageDensity'
|
||||
| 'trunkThickness'
|
||||
| 'leafless'
|
||||
| 'leafColor'
|
||||
| 'branchColor'
|
||||
>
|
||||
|
||||
export function treeSpecOf(node: TreeNode): TreeSpec {
|
||||
// Default the non-override fields (nodes persisted before a field existed load
|
||||
// without it). The four overrides are left as-is — `undefined` means "inherit
|
||||
// the ez-tree preset" (its own seed/type/tints), resolved in `generateTree`.
|
||||
return {
|
||||
preset: node.preset ?? 'oak',
|
||||
size: node.size ?? 'medium',
|
||||
treeType: node.treeType,
|
||||
seed: node.seed,
|
||||
foliageDensity: node.foliageDensity ?? 1,
|
||||
trunkThickness: node.trunkThickness ?? 1,
|
||||
leafless: node.leafless ?? false,
|
||||
leafColor: node.leafColor,
|
||||
branchColor: node.branchColor,
|
||||
}
|
||||
}
|
||||
|
||||
/** Stable variant id. Trees with the same key share one set of InstancedMeshes. */
|
||||
export function treeVariantKey(spec: TreeSpec): string {
|
||||
return [
|
||||
spec.preset,
|
||||
spec.size,
|
||||
spec.treeType,
|
||||
spec.seed,
|
||||
spec.foliageDensity,
|
||||
spec.trunkThickness,
|
||||
spec.leafless,
|
||||
spec.leafColor,
|
||||
spec.branchColor,
|
||||
].join(':')
|
||||
}
|
||||
|
||||
/** `#rrggbb` → 0xrrggbb, defaulting to white on anything missing/unparseable. */
|
||||
function hexToInt(hex: string | undefined): number {
|
||||
const n = Number.parseInt((hex ?? '').replace('#', ''), 16)
|
||||
return Number.isFinite(n) ? n : 0xffffff
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an ez-tree for a spec. ez-tree's `Tree` is a `THREE.Group`; textures
|
||||
* are inlined in the library (no asset hosting). `loadPreset` owns the full look
|
||||
* (seed, growth model, tints, branch/leaf structure); the curated params then
|
||||
* apply *on top* — but only where the node actually set them, so an unset field
|
||||
* keeps the preset's value (its canonical silhouette/colours). `trunkThickness`
|
||||
* and `foliageDensity` are multipliers (1 = preset default). Pure given its
|
||||
* inputs — same spec ⇒ same tree — which lets the renderer cache one generation
|
||||
* per variant.
|
||||
*/
|
||||
export function generateTree(spec: TreeSpec): Tree {
|
||||
const tree = new Tree()
|
||||
tree.loadPreset(ezPresetOf(spec.preset, spec.size))
|
||||
if (spec.seed != null) tree.options.seed = spec.seed
|
||||
if (spec.treeType != null) (tree.options as { type: string }).type = spec.treeType
|
||||
|
||||
const radius = tree.options.branch.radius as unknown as Record<string, number>
|
||||
for (const level of Object.keys(radius)) {
|
||||
const value = radius[level]
|
||||
if (value !== undefined) radius[level] = value * spec.trunkThickness
|
||||
}
|
||||
|
||||
const leaves = tree.options.leaves as { count: number; tint: number }
|
||||
leaves.count = spec.leafless ? 0 : Math.round(leaves.count * spec.foliageDensity)
|
||||
if (spec.leafColor != null) leaves.tint = hexToInt(spec.leafColor)
|
||||
if (spec.branchColor != null)
|
||||
(tree.options.bark as { tint: number }).tint = hexToInt(spec.branchColor)
|
||||
|
||||
tree.generate()
|
||||
return tree
|
||||
}
|
||||
|
||||
/** A renderable sub-mesh of a tree: geometry (baked into tree-local space) +
|
||||
* its material. The instanced renderer builds one InstancedMesh per sub-mesh
|
||||
* per variant. */
|
||||
export type TreeSubMesh = { geometry: BufferGeometry; material: Material | Material[] }
|
||||
|
||||
/** Geometry + height for one tree variant, generated once and shared across
|
||||
* every instance of that spec. */
|
||||
export type TreeVariantData = { subMeshes: TreeSubMesh[]; naturalHeight: number }
|
||||
|
||||
const variantCache = new Map<string, TreeVariantData>()
|
||||
|
||||
/**
|
||||
* Cached geometry for a spec. ez-tree's `generate()` is heavy, so it runs once
|
||||
* per variant; the resulting geometries/materials are retained here and shared
|
||||
* by every instance. The renderer must NOT dispose them (it sets `dispose={null}`
|
||||
* on the InstancedMesh).
|
||||
*/
|
||||
export function getVariantData(spec: TreeSpec): TreeVariantData {
|
||||
const key = treeVariantKey(spec)
|
||||
const cached = variantCache.get(key)
|
||||
if (cached) return cached
|
||||
const tree = generateTree(spec)
|
||||
const data: TreeVariantData = {
|
||||
subMeshes: extractSubMeshes(tree),
|
||||
naturalHeight: naturalHeight(tree),
|
||||
}
|
||||
variantCache.set(key, data)
|
||||
return data
|
||||
}
|
||||
|
||||
/** Extract the leaf/bark sub-meshes, baking each mesh's local transform into a
|
||||
* cloned geometry so instance matrices only carry the node's own transform. */
|
||||
export function extractSubMeshes(tree: Object3D): TreeSubMesh[] {
|
||||
const out: TreeSubMesh[] = []
|
||||
tree.traverse((child) => {
|
||||
const mesh = child as Partial<Mesh>
|
||||
if (mesh.isMesh && mesh.geometry && mesh.material) {
|
||||
const geometry = mesh.geometry.clone()
|
||||
;(child as Mesh).updateMatrix()
|
||||
geometry.applyMatrix4((child as Mesh).matrix)
|
||||
// Swap ez-tree's plain materials for swaying node materials (WebGPU/TSL).
|
||||
const material = Array.isArray(mesh.material)
|
||||
? mesh.material.map(toWindMaterial)
|
||||
: toWindMaterial(mesh.material)
|
||||
out.push({ geometry, material })
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import type { NodeDefinition } from '@pascal-app/core'
|
||||
import { buildGrassFloorplan } from './floorplan'
|
||||
import { grassParametrics } from './grass-parametrics'
|
||||
import { GrassNode } from './grass-schema'
|
||||
|
||||
/**
|
||||
* The grass node definition — a third instanced kind alongside trees & flowers.
|
||||
* Same composition: a `def.system` batches every tuft into InstancedMeshes, a
|
||||
* featherweight `def.renderer` proxy keeps selection working, `parametrics`
|
||||
* gives the inspector, `tool`/`preview` drive placement.
|
||||
*/
|
||||
export const grassDefinition: NodeDefinition<typeof GrassNode> = {
|
||||
kind: 'trees:grass',
|
||||
bake: 'replace', // static in bake, live-rebuilt in our viewer — see plans → Part D
|
||||
schemaVersion: 1,
|
||||
schema: GrassNode,
|
||||
category: 'furnish',
|
||||
snapProfile: 'item',
|
||||
|
||||
defaults: () => ({
|
||||
object: 'node',
|
||||
parentId: null,
|
||||
visible: true,
|
||||
metadata: {},
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
preset: 'meadow',
|
||||
height: 0.4,
|
||||
seed: 1,
|
||||
bladeColor: '#5a8f3c',
|
||||
}),
|
||||
|
||||
capabilities: {
|
||||
movable: { axes: ['x', 'z'], gridSnap: true },
|
||||
rotatable: {
|
||||
axes: ['y'],
|
||||
snapAngles: Array.from({ length: 8 }, (_, i) => (i * Math.PI) / 4),
|
||||
},
|
||||
selectable: { hitVolume: 'bbox' },
|
||||
duplicable: true,
|
||||
deletable: true,
|
||||
groupable: true,
|
||||
snappable: {},
|
||||
floorPlaced: {
|
||||
footprint: (node) => {
|
||||
const grass = node as unknown as GrassNode
|
||||
const radius = Math.max(0.1, grass.height * 0.3)
|
||||
return {
|
||||
dimensions: [radius * 2, grass.height, radius * 2] as [number, number, number],
|
||||
rotation: grass.rotation,
|
||||
}
|
||||
},
|
||||
collides: false,
|
||||
},
|
||||
},
|
||||
|
||||
parametrics: grassParametrics,
|
||||
floorplan: buildGrassFloorplan,
|
||||
|
||||
renderer: { kind: 'parametric', module: () => import('./grass-proxy-renderer') },
|
||||
system: { module: () => import('./grass-system'), priority: 3 },
|
||||
bakeReplaceRenderer: { module: () => import('./grass-static-renderer') },
|
||||
|
||||
preview: () => import('./grass-preview'),
|
||||
tool: () => import('./grass-tool'),
|
||||
toolHints: [
|
||||
{ key: 'Left click', label: 'Plant grass' },
|
||||
{ key: 'Esc', label: 'Stop' },
|
||||
],
|
||||
|
||||
presentation: {
|
||||
label: 'Grass',
|
||||
description: 'A procedural grass tuft. Meadow, fescue, or reed.',
|
||||
icon: { kind: 'iconify', name: 'lucide:wheat' },
|
||||
paletteSection: 'furnish',
|
||||
hidden: true,
|
||||
},
|
||||
|
||||
mcp: {
|
||||
description:
|
||||
'A procedural grass tuft (example plugin node) — meadow, fescue, or reed, instanced like the trees.',
|
||||
},
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { type BufferGeometry, ConeGeometry, DoubleSide, Group, Mesh } from 'three'
|
||||
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js'
|
||||
import { GRASS_PRESETS } from './grass-presets'
|
||||
import type { GrassNode, GrassPreset } from './grass-schema'
|
||||
import type { SubMesh, VariantData } from './instanced'
|
||||
import { mulberry32, naturalHeight } from './variant-utils'
|
||||
import { windStandardMaterial } from './wind-node'
|
||||
|
||||
export function grassVariantKey(preset: GrassPreset, seed: number, bladeColor: string): string {
|
||||
return `${preset}:${seed}:${bladeColor}`
|
||||
}
|
||||
|
||||
const variantCache = new Map<string, VariantData>()
|
||||
|
||||
/** Cached procedural grass geometry for a (preset, seed, bladeColor). One
|
||||
* generation per variant is shared across every instance — a whole lawn of the
|
||||
* same tuft is a single InstancedMesh. */
|
||||
export function getGrassVariant(node: GrassNode): VariantData {
|
||||
const key = grassVariantKey(node.preset, node.seed, node.bladeColor)
|
||||
const cached = variantCache.get(key)
|
||||
if (cached) return cached
|
||||
const group = buildGrass(node.preset, node.seed, node.bladeColor)
|
||||
const subMeshes: SubMesh[] = group.children
|
||||
.filter((c): c is Mesh => (c as Mesh).isMesh)
|
||||
.map((mesh) => ({ geometry: mesh.geometry, material: mesh.material }))
|
||||
const data: VariantData = { subMeshes, naturalHeight: naturalHeight(group) }
|
||||
variantCache.set(key, data)
|
||||
return data
|
||||
}
|
||||
|
||||
/** A tuft of flattened, leaning blades merged into one geometry (one draw per
|
||||
* instance). Deterministic in `seed` so the same variant renders identically. */
|
||||
function buildGrass(preset: GrassPreset, seed: number, bladeColor: string): Group {
|
||||
const spec = GRASS_PRESETS[preset] ?? GRASS_PRESETS.meadow
|
||||
const rng = mulberry32(seed >>> 0)
|
||||
const group = new Group()
|
||||
const mat = windStandardMaterial({ color: bladeColor, roughness: 0.9, side: DoubleSide })
|
||||
const h = spec.defaultHeight
|
||||
|
||||
const blades: BufferGeometry[] = []
|
||||
for (let i = 0; i < spec.blades; i++) {
|
||||
const bh = h * (0.6 + rng() * 0.6)
|
||||
const blade = new ConeGeometry(0.02, bh, 3)
|
||||
blade.scale(1, 1, 0.3) // flatten the cone into a blade
|
||||
blade.translate(0, bh / 2, 0)
|
||||
blade.rotateZ((rng() - 0.5) * 0.7) // lean
|
||||
const angle = rng() * Math.PI * 2
|
||||
blade.rotateY(angle)
|
||||
const r = rng() * 0.07
|
||||
blade.translate(Math.cos(angle) * r, 0, Math.sin(angle) * r)
|
||||
blades.push(blade)
|
||||
}
|
||||
group.add(new Mesh(mergeGeometries(blades, false) ?? blades[0], mat))
|
||||
return group
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import type { ParametricDescriptor } from '@pascal-app/core'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
|
||||
/** Inspector for a placed grass tuft — rendered for free by the host's
|
||||
* `ParametricInspector` from this descriptor. */
|
||||
export const grassParametrics: ParametricDescriptor<GrassNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Grass',
|
||||
fields: [
|
||||
{ key: 'preset', kind: 'enum', options: ['meadow', 'fescue', 'reed'] },
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 0.1, max: 2, step: 0.05 },
|
||||
{ key: 'bladeColor', kind: 'color' },
|
||||
{ key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Position',
|
||||
fields: [{ key: 'position', kind: 'vec3' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { GRASS_ART } from './art'
|
||||
import type { GrassPreset } from './grass-schema'
|
||||
|
||||
/** Per-grass config: blade colour, blade count per tuft, default height (metres),
|
||||
* a swatch, and a replaceable card `thumbnail` (see `thumbnails.ts`). Pure data
|
||||
* shared by the geometry builder and the panel. */
|
||||
export type GrassPresetSpec = {
|
||||
id: GrassPreset
|
||||
label: string
|
||||
bladeColor: string
|
||||
blades: number
|
||||
defaultHeight: number
|
||||
swatch: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
export const GRASS_PRESETS: Record<GrassPreset, GrassPresetSpec> = {
|
||||
meadow: {
|
||||
id: 'meadow',
|
||||
label: 'Meadow',
|
||||
bladeColor: '#5a8f3c',
|
||||
blades: 10,
|
||||
defaultHeight: 0.4,
|
||||
swatch: '#5a8f3c',
|
||||
thumbnail: GRASS_ART.meadow,
|
||||
},
|
||||
fescue: {
|
||||
id: 'fescue',
|
||||
label: 'Fescue',
|
||||
bladeColor: '#7fae55',
|
||||
blades: 8,
|
||||
defaultHeight: 0.7,
|
||||
swatch: '#7fae55',
|
||||
thumbnail: GRASS_ART.fescue,
|
||||
},
|
||||
reed: {
|
||||
id: 'reed',
|
||||
label: 'Reed',
|
||||
bladeColor: '#4a7d63',
|
||||
blades: 6,
|
||||
defaultHeight: 1.1,
|
||||
swatch: '#4a7d63',
|
||||
thumbnail: GRASS_ART.reed,
|
||||
},
|
||||
}
|
||||
|
||||
export const GRASS_PRESET_LIST: GrassPresetSpec[] = Object.values(GRASS_PRESETS)
|
||||
|
||||
/** Bounded seed pool so grass tufts share instancing variants (see trees). */
|
||||
export const GRASS_SEED_POOL = [1, 7, 13, 21, 34]
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { Material, MeshStandardMaterial } from 'three'
|
||||
import { getGrassVariant } from './grass-geometry'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
|
||||
const NO_RAYCAST = () => {}
|
||||
|
||||
/** Translucent placement ghost for a grass tuft — clones the variant materials
|
||||
* so the cursor preview is see-through without mutating the cached originals. */
|
||||
export default function GrassPreview({ node }: { node: GrassNode }) {
|
||||
const data = useMemo(() => getGrassVariant(node), [node])
|
||||
const scale = node.height / data.naturalHeight
|
||||
|
||||
const ghosts = useMemo(
|
||||
() =>
|
||||
data.subMeshes.map((sub) => {
|
||||
const base = (
|
||||
Array.isArray(sub.material) ? sub.material[0] : sub.material
|
||||
) as MeshStandardMaterial
|
||||
const clone = base.clone()
|
||||
clone.transparent = true
|
||||
clone.opacity = 0.55
|
||||
clone.depthWrite = false
|
||||
return clone
|
||||
}),
|
||||
[data],
|
||||
)
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
for (const m of ghosts as Material[]) m.dispose()
|
||||
},
|
||||
[ghosts],
|
||||
)
|
||||
|
||||
return (
|
||||
<group scale={scale}>
|
||||
{data.subMeshes.map((sub, i) => (
|
||||
<mesh
|
||||
dispose={null}
|
||||
geometry={sub.geometry}
|
||||
key={i}
|
||||
material={ghosts[i]}
|
||||
raycast={NO_RAYCAST}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getGrassVariant } from './grass-geometry'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import { KindProxy } from './instanced'
|
||||
|
||||
const getVariant = (node: GrassNode) => getGrassVariant(node)
|
||||
const colliderRadius = (node: GrassNode) => Math.max(0.08, (node.height ?? 0.4) * 0.3)
|
||||
|
||||
/** Per-node selection proxy for the instanced grass tufts. */
|
||||
export default function GrassProxyRenderer({ node }: { node: GrassNode }) {
|
||||
return <KindProxy colliderRadius={colliderRadius} getVariant={getVariant} node={node} />
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import { BaseNode, nodeType, objectId } from '@pascal-app/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Grass tufts the plugin can place. The string persists in scene JSON. */
|
||||
export const GrassPreset = z.enum(['meadow', 'fescue', 'reed'])
|
||||
export type GrassPreset = z.infer<typeof GrassPreset>
|
||||
|
||||
/** A placed grass tuft — a third instanced kind alongside trees & flowers,
|
||||
* sharing the same instanced renderer + selection proxy via `instanced`. */
|
||||
export const GrassNode = BaseNode.extend({
|
||||
id: objectId('grass'),
|
||||
type: nodeType('trees:grass'),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
preset: GrassPreset.default('meadow'),
|
||||
height: z.number().positive().default(0.4),
|
||||
seed: z.number().int().default(1),
|
||||
/** Blade colour (hex). Baked from the preset at placement; recolour per-tuft
|
||||
* in the inspector. */
|
||||
bladeColor: z.string().default('#5a8f3c'),
|
||||
})
|
||||
|
||||
export type GrassNode = z.infer<typeof GrassNode>
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getGrassVariant, grassVariantKey } from './grass-geometry'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import { InstancedNodes } from './instanced'
|
||||
|
||||
const variantKeyOf = (node: GrassNode) => grassVariantKey(node.preset, node.seed, node.bladeColor)
|
||||
const getVariant = (node: GrassNode) => getGrassVariant(node)
|
||||
|
||||
/** Collective baked-`/viewer` renderer for one level's grass (`bakeReplaceRenderer`). */
|
||||
export default function GrassReplaceInstances({ nodes }: { nodes: GrassNode[] }) {
|
||||
return (
|
||||
<InstancedNodes getVariant={getVariant} localSpace nodes={nodes} variantKeyOf={variantKeyOf} />
|
||||
)
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getGrassVariant, grassVariantKey } from './grass-geometry'
|
||||
import type { GrassNode } from './grass-schema'
|
||||
import { InstancedKindSystem } from './instanced'
|
||||
|
||||
const variantKeyOf = (node: GrassNode) => grassVariantKey(node.preset, node.seed, node.bladeColor)
|
||||
const getVariant = (node: GrassNode) => getGrassVariant(node)
|
||||
|
||||
/** Collective instanced renderer for every placed grass tuft (`def.system`). */
|
||||
export default function GrassSystem() {
|
||||
return (
|
||||
<InstancedKindSystem<GrassNode>
|
||||
getVariant={getVariant}
|
||||
kind="trees:grass"
|
||||
variantKeyOf={variantKeyOf}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { GRASS_PRESETS, GRASS_SEED_POOL } from './grass-presets'
|
||||
import GrassPreview from './grass-preview'
|
||||
import { GrassNode } from './grass-schema'
|
||||
import { usePlacement } from './placement'
|
||||
import { useTreesStore } from './store'
|
||||
|
||||
/** The grass placement tool — mirrors the trees/flowers tools, reading the grass
|
||||
* brush from the shared store. Blade colour is baked from the preset at
|
||||
* placement, then editable per-tuft in the inspector. */
|
||||
export default function GrassTool() {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const preset = useTreesStore((s) => s.grassPreset)
|
||||
const height = useTreesStore((s) => s.grassHeight)
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
GrassNode.parse({
|
||||
preset,
|
||||
height,
|
||||
bladeColor: GRASS_PRESETS[preset].bladeColor,
|
||||
seed: 1,
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
[preset, height],
|
||||
)
|
||||
|
||||
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
|
||||
if (!activeLevelId) return
|
||||
const s = useTreesStore.getState()
|
||||
const grass = GrassNode.parse({
|
||||
preset: s.grassPreset,
|
||||
height: s.grassHeight,
|
||||
bladeColor: GRASS_PRESETS[s.grassPreset].bladeColor,
|
||||
seed: GRASS_SEED_POOL[Math.floor(Math.random() * GRASS_SEED_POOL.length)] ?? 1,
|
||||
position,
|
||||
rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0],
|
||||
})
|
||||
useScene.getState().createNode(grass as unknown as AnyNode, activeLevelId as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [grass.id as AnyNodeId] })
|
||||
triggerSFX('sfx:item-place')
|
||||
})
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return (
|
||||
<group ref={cursorRef} visible={cursorVisible}>
|
||||
<GrassPreview node={previewNode} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { AnyNodeDefinition, Plugin } from '@pascal-app/core'
|
||||
import type { EditorHostPanel } from '@pascal-app/editor'
|
||||
// Side-effect: subscribes the panel store to `selection:find-node` so the
|
||||
// host's "find in catalog" lands on the right Nature section (see find-sync.ts).
|
||||
import './find-sync'
|
||||
import { NATURE_ICON } from './art'
|
||||
import { treeDefinition } from './definition'
|
||||
import { flowerDefinition } from './flower-definition'
|
||||
import { grassDefinition } from './grass-definition'
|
||||
|
||||
/**
|
||||
* The trees plugin manifest — the entire public surface of this package. A host
|
||||
* loads it through the same `loadPlugin` path the built-ins use: three node kinds
|
||||
* (`trees:tree`, `trees:flower`, `trees:grass`) and one left-rail panel
|
||||
* (`Trees`). Cast mirrors the built-in bundle: `AnyNodeDefinition` is the
|
||||
* hand-maintained union today; the registry derives it post-migration.
|
||||
*/
|
||||
export const treesPlugin: Plugin = {
|
||||
id: 'pascal:trees',
|
||||
apiVersion: 1,
|
||||
nodes: [
|
||||
treeDefinition as unknown as AnyNodeDefinition,
|
||||
flowerDefinition as unknown as AnyNodeDefinition,
|
||||
grassDefinition as unknown as AnyNodeDefinition,
|
||||
],
|
||||
}
|
||||
|
||||
export const treesHostPanel: EditorHostPanel = {
|
||||
id: 'pascal:trees:trees',
|
||||
label: 'Nature',
|
||||
icon: { kind: 'url', src: NATURE_ICON },
|
||||
component: () => import('./presets-panel'),
|
||||
}
|
||||
|
||||
// NOTE: no re-export from './geometry' — it imports ez-tree, which touches
|
||||
// `document` at module scope and would crash SSR (this barrel is eagerly
|
||||
// imported by host bootstraps). Lazy client modules import it directly.
|
||||
export { treeDefinition } from './definition'
|
||||
export { flowerDefinition } from './flower-definition'
|
||||
export { FlowerNode, FlowerPreset } from './flower-schema'
|
||||
export { grassDefinition } from './grass-definition'
|
||||
export { GrassNode, GrassPreset } from './grass-schema'
|
||||
export { TreeNode, TreePreset } from './schema'
|
||||
@@ -1,362 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type AnyNodeId,
|
||||
sceneRegistry,
|
||||
useLiveNodeOverrides,
|
||||
useLiveTransforms,
|
||||
useRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { useNodeEvents, useViewer } from '@pascal-app/viewer'
|
||||
import { useFrame } from '@react-three/fiber'
|
||||
import { useCallback, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three'
|
||||
import { toStaticMaterial } from './wind-node'
|
||||
|
||||
/**
|
||||
* Generic instanced-rendering core shared by every plant kind (trees, flowers,
|
||||
* …). A kind plugs in two pure functions — `variantKeyOf` (how to bucket nodes
|
||||
* that can share geometry) and `getVariant` (cached geometry for a node) — and
|
||||
* gets forest-scale instancing plus true-silhouette selection for free.
|
||||
*/
|
||||
|
||||
export type SubMesh = { geometry: BufferGeometry; material: Material | Material[] }
|
||||
export type VariantData = { subMeshes: SubMesh[]; naturalHeight: number }
|
||||
|
||||
/** The shape every placeable plant node shares. */
|
||||
export interface Placeable {
|
||||
id: string
|
||||
type: string
|
||||
parentId: string | null
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
height: number
|
||||
visible?: boolean
|
||||
}
|
||||
|
||||
const DUMMY = new Object3D()
|
||||
const INSTANCE_MATRIX = new Matrix4()
|
||||
const NO_RAYCAST = () => {}
|
||||
|
||||
// Wind is a TSL vertex bend baked into the variant materials (see `wind-node.ts`)
|
||||
// — animated on the GPU, so the instance matrices here stay static.
|
||||
|
||||
// ── Collective instanced renderer (a `def.system`) ───────────────────────────
|
||||
|
||||
export function InstancedKindSystem<N extends Placeable>({
|
||||
kind,
|
||||
variantKeyOf,
|
||||
getVariant,
|
||||
}: {
|
||||
kind: string
|
||||
variantKeyOf: (node: N) => string
|
||||
getVariant: (node: N) => VariantData
|
||||
}) {
|
||||
const scene = useScene((s) => s.nodes)
|
||||
const hoveredId = useViewer((s) => s.hoveredId)
|
||||
const selectedIds = useViewer((s) => s.selection.selectedIds)
|
||||
// Hovered/selected plants render through their proxy instead (real geometry,
|
||||
// static materials) so the outline matches the visible mesh and a move drag
|
||||
// animates in realtime — skip them here to avoid a double draw. Keyed by the
|
||||
// *relevant* ids only, so hovering unrelated kinds doesn't churn matrices.
|
||||
const activeKey = useMemo(() => {
|
||||
const ids: string[] = []
|
||||
if (hoveredId && (scene[hoveredId as AnyNodeId]?.type as string) === kind) ids.push(hoveredId)
|
||||
for (const id of selectedIds) {
|
||||
if ((scene[id as AnyNodeId]?.type as string) === kind) ids.push(id)
|
||||
}
|
||||
return ids.sort().join('|')
|
||||
}, [scene, kind, hoveredId, selectedIds])
|
||||
const nodes = useMemo(() => {
|
||||
const active = new Set(activeKey ? activeKey.split('|') : [])
|
||||
return Object.values(scene).filter(
|
||||
(n) => (n.type as string) === kind && !active.has(n.id as string),
|
||||
) as unknown as N[]
|
||||
}, [scene, kind, activeKey])
|
||||
|
||||
// Consume the dirty marks for this kind. Instances rebuild synchronously
|
||||
// from the store (the memos above), so a rendered node is already "built" —
|
||||
// but `FloorElevationSystem` deliberately leaves the mark for kinds with a
|
||||
// `def.system`, expecting that system to clear it. Without this pass the
|
||||
// marks live forever: `hasPendingSceneBuildWork` never goes false, so the
|
||||
// scene-ready signal (and every headless bake) stalls at its frame cap.
|
||||
// Priority 2 = after the priority-1 floor-elevation lift in the same frame;
|
||||
// clearing only registered nodes leaves unmounted proxies for a later frame.
|
||||
useFrame(() => {
|
||||
const { dirtyNodes, nodes: sceneNodes, clearDirty } = useScene.getState()
|
||||
if (dirtyNodes.size === 0) return
|
||||
for (const id of dirtyNodes) {
|
||||
const node = sceneNodes[id]
|
||||
if (!node || (node.type as string) !== kind) continue
|
||||
if (!sceneRegistry.nodes.has(id)) continue
|
||||
clearDirty(id)
|
||||
}
|
||||
}, 2)
|
||||
|
||||
return <InstancedNodes getVariant={getVariant} nodes={nodes} variantKeyOf={variantKeyOf} />
|
||||
}
|
||||
|
||||
/**
|
||||
* Instance a given set of nodes, bucketed by geometry variant. Two callers:
|
||||
* - `InstancedKindSystem` (editor `def.system`) passes every node of a kind with
|
||||
* `localSpace={false}` — instances live at the scene root, so each matrix folds
|
||||
* in the parent level's world matrix (positions are stored level-local).
|
||||
* - the baked `/viewer` (`bakeReplaceRenderer`) passes one level's nodes with
|
||||
* `localSpace` — the meshes are portaled into that baked level (which supplies
|
||||
* the level transform), so instance matrices stay level-local, and the meshes
|
||||
* are `NO_RAYCAST` (scenery; a pick would resolve to the level anyway).
|
||||
*
|
||||
* Instancing carries the per-tree wind phase for free via `instanceIndex`; a
|
||||
* per-node render (one mesh each) would give every tree phase 0 → a whole
|
||||
* variant sways in unison.
|
||||
*/
|
||||
export function InstancedNodes<N extends Placeable>({
|
||||
nodes,
|
||||
variantKeyOf,
|
||||
getVariant,
|
||||
localSpace = false,
|
||||
}: {
|
||||
nodes: N[]
|
||||
variantKeyOf: (node: N) => string
|
||||
getVariant: (node: N) => VariantData
|
||||
localSpace?: boolean
|
||||
}) {
|
||||
const buckets = useMemo(() => {
|
||||
const map = new Map<string, { sample: N; nodes: N[] }>()
|
||||
for (const node of nodes) {
|
||||
const key = variantKeyOf(node)
|
||||
const bucket = map.get(key)
|
||||
if (bucket) bucket.nodes.push(node)
|
||||
else map.set(key, { sample: node, nodes: [node] })
|
||||
}
|
||||
return Array.from(map, ([key, value]) => ({ key, ...value }))
|
||||
}, [nodes, variantKeyOf])
|
||||
|
||||
return (
|
||||
<>
|
||||
{buckets.map((bucket) => (
|
||||
<Variant
|
||||
getVariant={getVariant}
|
||||
key={bucket.key}
|
||||
localSpace={localSpace}
|
||||
nodes={bucket.nodes}
|
||||
sample={bucket.sample}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Variant<N extends Placeable>({
|
||||
sample,
|
||||
nodes,
|
||||
getVariant,
|
||||
localSpace,
|
||||
}: {
|
||||
sample: N
|
||||
nodes: N[]
|
||||
getVariant: (node: N) => VariantData
|
||||
localSpace: boolean
|
||||
}) {
|
||||
const data = useMemo(() => getVariant(sample), [sample, getVariant])
|
||||
return (
|
||||
<>
|
||||
{data.subMeshes.map((subMesh, i) => (
|
||||
<InstancedSubMesh
|
||||
key={i}
|
||||
localSpace={localSpace}
|
||||
naturalHeight={data.naturalHeight}
|
||||
nodes={nodes}
|
||||
subMesh={subMesh}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function InstancedSubMesh<N extends Placeable>({
|
||||
subMesh,
|
||||
nodes,
|
||||
naturalHeight,
|
||||
localSpace,
|
||||
}: {
|
||||
subMesh: SubMesh
|
||||
nodes: N[]
|
||||
naturalHeight: number
|
||||
localSpace: boolean
|
||||
}) {
|
||||
const ref = useRef<InstancedMesh>(null)
|
||||
// Round capacity up so the InstancedMesh isn't recreated on every placement —
|
||||
// only when crossing a 32-instance boundary. `dispose={null}` keeps the shared
|
||||
// (cached) geometry/material alive across any recreation.
|
||||
const capacity = Math.max(16, Math.ceil(nodes.length / 32) * 32)
|
||||
|
||||
// Snapshot of each referenced parent level's matrixWorld at the last matrix
|
||||
// write — the per-frame staleness check below compares against it so a level
|
||||
// move (explode, elevation edit) refreshes instances without a node change.
|
||||
const parentWorlds = useRef(new Map<string, number[]>())
|
||||
|
||||
const writeMatrices = useCallback(() => {
|
||||
const mesh = ref.current
|
||||
if (!mesh) return
|
||||
parentWorlds.current.clear()
|
||||
for (let i = 0; i < nodes.length; i += 1) {
|
||||
const node = nodes[i]
|
||||
if (!node) continue
|
||||
const scale = node.height / naturalHeight
|
||||
DUMMY.position.set(node.position[0], node.position[1], node.position[2])
|
||||
DUMMY.rotation.set(node.rotation[0], node.rotation[1], node.rotation[2])
|
||||
DUMMY.scale.set(scale, scale, scale)
|
||||
DUMMY.updateMatrix()
|
||||
// `localSpace`: portaled into the parent level, which supplies the level
|
||||
// transform — matrices stay level-local. Otherwise instances live at the
|
||||
// scene root, so fold in the parent level's world matrix.
|
||||
const parent =
|
||||
!localSpace && node.parentId ? sceneRegistry.nodes.get(node.parentId) : undefined
|
||||
if (parent && node.parentId) {
|
||||
parent.updateWorldMatrix(true, false)
|
||||
if (!parentWorlds.current.has(node.parentId)) {
|
||||
parentWorlds.current.set(node.parentId, parent.matrixWorld.toArray())
|
||||
}
|
||||
INSTANCE_MATRIX.multiplyMatrices(parent.matrixWorld, DUMMY.matrix)
|
||||
mesh.setMatrixAt(i, INSTANCE_MATRIX)
|
||||
} else {
|
||||
mesh.setMatrixAt(i, DUMMY.matrix)
|
||||
}
|
||||
}
|
||||
mesh.count = nodes.length
|
||||
mesh.instanceMatrix.needsUpdate = true
|
||||
mesh.computeBoundingSphere()
|
||||
}, [nodes, naturalHeight, localSpace])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
writeMatrices()
|
||||
}, [writeMatrices])
|
||||
|
||||
// A parent level can move without any node of this kind changing (level
|
||||
// explode, elevation edits), which would leave the baked-in world transform
|
||||
// stale. Compare each referenced level's matrixWorld against the snapshot —
|
||||
// a handful of levels × 16 floats per frame — and rewrite only on change.
|
||||
useFrame(() => {
|
||||
if (localSpace || !ref.current) return
|
||||
for (const [id, cached] of parentWorlds.current) {
|
||||
const parent = sceneRegistry.nodes.get(id)
|
||||
if (!parent) continue
|
||||
parent.updateWorldMatrix(true, false)
|
||||
const elements = parent.matrixWorld.elements
|
||||
for (let i = 0; i < 16; i += 1) {
|
||||
if (elements[i] !== cached[i]) {
|
||||
writeMatrices()
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<instancedMesh
|
||||
args={[subMesh.geometry as BufferGeometry, subMesh.material as Material, capacity]}
|
||||
castShadow
|
||||
dispose={null}
|
||||
frustumCulled={false}
|
||||
raycast={localSpace ? NO_RAYCAST : undefined}
|
||||
ref={ref}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Per-node selection proxy (a `def.renderer`) ──────────────────────────────
|
||||
|
||||
const toStatic = (material: Material | Material[]) =>
|
||||
Array.isArray(material) ? material.map(toStaticMaterial) : toStaticMaterial(material)
|
||||
|
||||
/**
|
||||
* Per-node proxy that keeps the host's selection machinery working for an
|
||||
* instanced kind. The registered group carries the node transform (host
|
||||
* contract: move tools drive `sceneRegistry.nodes.get(id)` imperatively with
|
||||
* absolute level-local positions and mirror them via `useLiveTransforms` —
|
||||
* see `ParametricNodeRenderer`), so registering a nested child would apply
|
||||
* drag deltas in the node's rotated frame. The box collider is a positioned
|
||||
* sibling: the raycast target, kept out of the registered group so the
|
||||
* outline pass (which traces the registered object) shows the true
|
||||
* silhouette, not a box.
|
||||
*
|
||||
* While hovered/selected the collective system skips this node and the proxy
|
||||
* mounts the real geometry with **static twins** of the wind materials — the
|
||||
* outline mask renders with an override material and can't follow GPU sway,
|
||||
* so the plant holds still while outlined and the silhouette matches exactly.
|
||||
* During a GLB export the geometry mounts with the real materials instead, so
|
||||
* the exporter (which clones only the `scene-renderer` subtree, not the
|
||||
* collective InstancedMesh) captures each plant; the collider is dropped so it
|
||||
* doesn't bake as a phantom solid.
|
||||
*/
|
||||
export function KindProxy<N extends Placeable & { id: string }>({
|
||||
node,
|
||||
getVariant,
|
||||
colliderRadius,
|
||||
}: {
|
||||
node: N
|
||||
getVariant: (node: N) => VariantData
|
||||
colliderRadius: (node: N) => number
|
||||
}) {
|
||||
const registeredRef = useRef<Object3D>(null!)
|
||||
const handlers = useNodeEvents(node as never, node.type as never)
|
||||
useRegistry(node.id as AnyNodeId, node.type, registeredRef)
|
||||
|
||||
const isExporting = useViewer((s) => s.isExporting)
|
||||
const active = useViewer(
|
||||
(s) => s.hoveredId === node.id || s.selection.selectedIds.includes(node.id as never),
|
||||
)
|
||||
const showGeometry = active || isExporting
|
||||
|
||||
// Live drag transform — the move tool writes the same absolute position
|
||||
// imperatively to the registered group; applying it React-side too keeps the
|
||||
// two in agreement (and moves the collider along with the drag). The rotate /
|
||||
// resize gizmos publish through `useLiveNodeOverrides` instead — fold that in
|
||||
// too (mirrors ParametricNodeRenderer) so the plant turns live mid-drag
|
||||
// rather than snapping on commit.
|
||||
const live = useLiveTransforms((s) => s.get(node.id))
|
||||
const liveOverride = useLiveNodeOverrides((s) => s.overrides.get(node.id))
|
||||
const overridePosition = liveOverride?.position as [number, number, number] | undefined
|
||||
const overrideRotation = liveOverride?.rotation as [number, number, number] | undefined
|
||||
const position = live?.position ?? overridePosition ?? node.position ?? [0, 0, 0]
|
||||
const baseRotation = overrideRotation ?? node.rotation ?? [0, 0, 0]
|
||||
const rotation: [number, number, number] = live
|
||||
? [baseRotation[0], live.rotation, baseRotation[2]]
|
||||
: baseRotation
|
||||
|
||||
const height = Math.max(0.2, node.height ?? 1)
|
||||
const radius = colliderRadius(node)
|
||||
const variant = useMemo(
|
||||
() => (showGeometry ? getVariant(node) : null),
|
||||
[showGeometry, node, getVariant],
|
||||
)
|
||||
const geometryScale = variant ? height / variant.naturalHeight : 1
|
||||
|
||||
return (
|
||||
<group visible={node.visible !== false} {...handlers}>
|
||||
{!isExporting && (
|
||||
<mesh position={[position[0], position[1] + height / 2, position[2]]}>
|
||||
<boxGeometry args={[radius * 2, height, radius * 2]} />
|
||||
<meshBasicMaterial colorWrite={false} depthWrite={false} />
|
||||
</mesh>
|
||||
)}
|
||||
<group position={position} ref={registeredRef} rotation={rotation}>
|
||||
{variant && (
|
||||
<group scale={geometryScale}>
|
||||
{variant.subMeshes.map((subMesh, i) => (
|
||||
<mesh
|
||||
dispose={null}
|
||||
geometry={subMesh.geometry}
|
||||
key={i}
|
||||
material={isExporting ? subMesh.material : toStatic(subMesh.material)}
|
||||
raycast={NO_RAYCAST}
|
||||
/>
|
||||
))}
|
||||
</group>
|
||||
)}
|
||||
</group>
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { type AnyNodeId, type ParametricDescriptor, useScene } from '@pascal-app/core'
|
||||
import { defaultHeightOf, TREE_SEED_POOL } from './presets'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
/**
|
||||
* The tree's right-hand inspector. This descriptor is the entire inspector —
|
||||
* the host's `ParametricInspector` renders every control (selects, sliders,
|
||||
* segmented switches, the native colour pickers, the vec3, and the Randomize
|
||||
* action) with zero tree-specific code in the editor. Demonstrates the "right
|
||||
* inspector comes free from `def.parametrics`" leg of the plugin surface.
|
||||
*
|
||||
* Colours (`leafColor`/`branchColor`) are edit-only — they're not on the
|
||||
* placement brush, so a planted tree starts neutral (texture colours) and is
|
||||
* tinted here per-tree.
|
||||
*/
|
||||
export const treeParametrics: ParametricDescriptor<TreeNode> = {
|
||||
groups: [
|
||||
{
|
||||
label: 'Tree',
|
||||
fields: [
|
||||
{
|
||||
key: 'preset',
|
||||
kind: 'enum',
|
||||
options: ['oak', 'pine', 'aspen', 'ash', 'bush', 'trellis'],
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
kind: 'enum',
|
||||
options: ['small', 'medium', 'large'],
|
||||
display: 'segmented',
|
||||
visibleIf: (n) => n.preset !== 'trellis',
|
||||
},
|
||||
{
|
||||
key: 'treeType',
|
||||
kind: 'enum',
|
||||
options: ['deciduous', 'evergreen'],
|
||||
display: 'segmented',
|
||||
},
|
||||
{ key: 'height', kind: 'number', unit: 'm', min: 1, max: 15, step: 0.5 },
|
||||
{ key: 'branchColor', kind: 'color' },
|
||||
{ key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Foliage',
|
||||
fields: [
|
||||
{ key: 'leafless', kind: 'boolean' },
|
||||
{
|
||||
key: 'foliageDensity',
|
||||
kind: 'number',
|
||||
min: 0,
|
||||
max: 1.5,
|
||||
step: 0.1,
|
||||
visibleIf: (n) => !n.leafless,
|
||||
},
|
||||
{ key: 'trunkThickness', kind: 'number', min: 0.3, max: 2.5, step: 0.1 },
|
||||
{ key: 'leafColor', kind: 'color', visibleIf: (n) => !n.leafless },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Position',
|
||||
fields: [{ key: 'position', kind: 'vec3' }],
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
label: 'Randomize',
|
||||
// The action receives the live node; writing a new seed re-generates the
|
||||
// tree. Pick from the bounded pool so the result stays an instancing
|
||||
// variant shared with other trees, not a one-off mesh.
|
||||
onClick: (n) =>
|
||||
useScene.getState().updateNode(
|
||||
n.id as AnyNodeId,
|
||||
{
|
||||
seed: TREE_SEED_POOL[Math.floor(Math.random() * TREE_SEED_POOL.length)] ?? 1,
|
||||
} as Partial<TreeNode> as never,
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Reset height',
|
||||
// Snap height back to the preset+size default (handy after changing size).
|
||||
onClick: (n) =>
|
||||
useScene
|
||||
.getState()
|
||||
.updateNode(
|
||||
n.id as AnyNodeId,
|
||||
{ height: defaultHeightOf(n.preset, n.size) } as Partial<TreeNode> as never,
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { emitter, type GridEvent, sceneRegistry, snapPointToGrid } from '@pascal-app/core'
|
||||
import { isGridSnapActive, useEditor } from '@pascal-app/editor'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { type Group, Vector3 } from 'three'
|
||||
|
||||
const worldVec = new Vector3()
|
||||
|
||||
/** Snap a planar position to the grid when grid snapping is the active mode —
|
||||
* reading the same `isGridSnapActive()` toggle + `gridSnapStep` the built-in
|
||||
* item/shelf tools use, so plants honour the snap mode like every other item. */
|
||||
export function snapXZ(x: number, z: number): readonly [number, number] {
|
||||
if (!isGridSnapActive()) return [x, z]
|
||||
return snapPointToGrid([x, z], useEditor.getState().gridSnapStep)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a world-space grid hit into the active level's local frame, the way
|
||||
* the host stores node positions. Re-derived from the public `sceneRegistry`
|
||||
* because the built-in `floor-placement` helpers aren't part of the public
|
||||
* `@pascal-app/*` surface yet — a candidate for a future `@pascal-app/plugin-api`.
|
||||
*/
|
||||
export function toLevelLocal(
|
||||
levelId: string,
|
||||
world: [number, number, number],
|
||||
): [number, number, number] {
|
||||
const levelObject = sceneRegistry.nodes.get(levelId)
|
||||
if (!levelObject) return [world[0], 0, world[2]]
|
||||
worldVec.set(world[0], world[1], world[2])
|
||||
levelObject.updateWorldMatrix(true, false)
|
||||
levelObject.worldToLocal(worldVec)
|
||||
return [worldVec.x, 0, worldVec.z]
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared placement wiring for any plant tool: ghosts a preview at the snapped
|
||||
* cursor on `grid:move`, and calls `onCommit` with the snapped level-local
|
||||
* position on `grid:click`. Returns the cursor group ref + visibility for the
|
||||
* tool to attach its preview to. `onCommit` is read through a ref so a tool can
|
||||
* close over live brush state without re-subscribing every render.
|
||||
*/
|
||||
export function usePlacement(
|
||||
activeLevelId: string | null,
|
||||
onCommit: (levelLocalPosition: [number, number, number]) => void,
|
||||
) {
|
||||
const cursorRef = useRef<Group>(null)
|
||||
const [cursorVisible, setCursorVisible] = useState(false)
|
||||
const commitRef = useRef(onCommit)
|
||||
commitRef.current = onCommit
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeLevelId) return
|
||||
setCursorVisible(false)
|
||||
let lastWorld: [number, number, number] | null = null
|
||||
|
||||
const onMove = (event: GridEvent) => {
|
||||
setCursorVisible(true)
|
||||
const [lx, , lz] = event.localPosition
|
||||
const [sx, sz] = snapXZ(lx, lz)
|
||||
cursorRef.current?.position.set(sx, 0, sz)
|
||||
lastWorld = event.position
|
||||
}
|
||||
|
||||
const onClick = (event: GridEvent) => {
|
||||
const world = lastWorld ?? event.position
|
||||
const [lx, , lz] = toLevelLocal(activeLevelId, world)
|
||||
const [sx, sz] = snapXZ(lx, lz)
|
||||
commitRef.current([sx, 0, sz])
|
||||
}
|
||||
|
||||
emitter.on('grid:move', onMove)
|
||||
emitter.on('grid:click', onClick)
|
||||
return () => {
|
||||
emitter.off('grid:move', onMove)
|
||||
emitter.off('grid:click', onClick)
|
||||
}
|
||||
}, [activeLevelId])
|
||||
|
||||
return { cursorRef, cursorVisible }
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useScene } from '@pascal-app/core'
|
||||
import { SegmentedControl, SliderControl, ToggleControl, useEditor } from '@pascal-app/editor'
|
||||
import { FLOWER_PRESET_LIST } from './flower-presets'
|
||||
import type { FlowerPreset } from './flower-schema'
|
||||
import { GRASS_PRESET_LIST } from './grass-presets'
|
||||
import type { GrassPreset } from './grass-schema'
|
||||
import { TREE_PRESET_LIST } from './presets'
|
||||
import type { TreePreset } from './schema'
|
||||
import { type TreesPanelMode as Mode, useTreesStore } from './store'
|
||||
|
||||
const KIND: Record<Mode, string> = {
|
||||
trees: 'trees:tree',
|
||||
flowers: 'trees:flower',
|
||||
grass: 'trees:grass',
|
||||
}
|
||||
const NOUN: Record<Mode, string> = { trees: 'tree', flowers: 'flower', grass: 'grass' }
|
||||
|
||||
/**
|
||||
* The plugin's left-rail panel. A Trees / Flowers / Grass segmented control
|
||||
* switches the brush; picking a preset arms placement for that kind
|
||||
* (`setTool('trees:*')` + build mode). The count chip reads the scene reactively,
|
||||
* closing the triangle: panel → store → tool → scene → panel. It composes the
|
||||
* host's exported controls (`SegmentedControl`/`SliderControl`/`ToggleControl`)
|
||||
* so the brush matches the right-hand inspector pixel-for-pixel.
|
||||
*/
|
||||
export default function TreesPanel() {
|
||||
// Section lives in the plugin store (not local state) so "find in catalog"
|
||||
// can point the panel at the found node's section — see find-sync.ts.
|
||||
const mode = useTreesStore((s) => s.mode)
|
||||
const setMode = useTreesStore((s) => s.setMode)
|
||||
const activeTool = useEditor((s) => s.tool)
|
||||
const count = useScene(
|
||||
(s) => Object.values(s.nodes).filter((n) => (n.type as string) === KIND[mode]).length,
|
||||
)
|
||||
|
||||
const arming = activeTool === KIND[mode]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-4 text-sidebar-foreground">
|
||||
<header className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-semibold text-base">Nature</h2>
|
||||
<span className="rounded-full bg-sidebar-accent px-2 py-0.5 text-sidebar-foreground/70 text-xs">
|
||||
{count} planted
|
||||
</span>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
onChange={setMode}
|
||||
options={[
|
||||
{ label: 'Trees', value: 'trees' },
|
||||
{ label: 'Flowers', value: 'flowers' },
|
||||
{ label: 'Grass', value: 'grass' },
|
||||
]}
|
||||
value={mode}
|
||||
/>
|
||||
<p className="text-sidebar-foreground/50 text-xs">
|
||||
{arming
|
||||
? 'Click the ground to plant. Press Esc to stop.'
|
||||
: `Pick ${mode === 'grass' ? 'a grass' : `a ${NOUN[mode]}`}, then click the ground.`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{mode === 'trees' && <TreesSection arming={arming} />}
|
||||
{mode === 'flowers' && <FlowersSection arming={arming} />}
|
||||
{mode === 'grass' && <GrassSection arming={arming} />}
|
||||
|
||||
<footer className="-mx-4 -mb-4 sticky bottom-0 mt-1 border-sidebar-border/50 border-t bg-sidebar px-4 py-3 text-[11px] text-sidebar-foreground/50 leading-relaxed">
|
||||
Trees generated with{' '}
|
||||
<a
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-sidebar-foreground/70"
|
||||
href="https://github.com/dgreenheck/ez-tree"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
ez-tree
|
||||
</a>{' '}
|
||||
by{' '}
|
||||
<a
|
||||
className="underline decoration-dotted underline-offset-2 hover:text-sidebar-foreground/70"
|
||||
href="https://x.com/dangreenheck"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Daniel Greenheck
|
||||
</a>{' '}
|
||||
(MIT).
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TreesSection({ arming }: { arming: boolean }) {
|
||||
const selected = useTreesStore((s) => s.preset)
|
||||
const size = useTreesStore((s) => s.size)
|
||||
const height = useTreesStore((s) => s.height)
|
||||
const foliageDensity = useTreesStore((s) => s.foliageDensity)
|
||||
const trunkThickness = useTreesStore((s) => s.trunkThickness)
|
||||
const leafless = useTreesStore((s) => s.leafless)
|
||||
|
||||
const activate = (preset: TreePreset) => {
|
||||
useTreesStore.getState().setPreset(preset)
|
||||
useEditor.getState().setTool('trees:tree')
|
||||
useEditor.getState().setMode('build')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PresetGrid items={TREE_PRESET_LIST} onPick={activate} selected={arming ? selected : null} />
|
||||
{selected !== 'trellis' && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SegmentedControl
|
||||
onChange={useTreesStore.getState().setSize}
|
||||
options={[
|
||||
{ label: 'S', value: 'small' },
|
||||
{ label: 'M', value: 'medium' },
|
||||
{ label: 'L', value: 'large' },
|
||||
]}
|
||||
value={size}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={15}
|
||||
min={1}
|
||||
onChange={useTreesStore.getState().setHeight}
|
||||
precision={1}
|
||||
restoreOnCommit={false}
|
||||
step={0.5}
|
||||
unit="m"
|
||||
value={height}
|
||||
/>
|
||||
{!leafless && (
|
||||
<SliderControl
|
||||
label="Foliage"
|
||||
max={1.5}
|
||||
min={0}
|
||||
onChange={useTreesStore.getState().setFoliageDensity}
|
||||
precision={1}
|
||||
restoreOnCommit={false}
|
||||
step={0.1}
|
||||
value={foliageDensity}
|
||||
/>
|
||||
)}
|
||||
<SliderControl
|
||||
label="Trunk"
|
||||
max={2.5}
|
||||
min={0.3}
|
||||
onChange={useTreesStore.getState().setTrunkThickness}
|
||||
precision={1}
|
||||
restoreOnCommit={false}
|
||||
step={0.1}
|
||||
value={trunkThickness}
|
||||
/>
|
||||
<ToggleControl
|
||||
checked={leafless}
|
||||
label="Bare (leafless)"
|
||||
onChange={useTreesStore.getState().setLeafless}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FlowersSection({ arming }: { arming: boolean }) {
|
||||
const selected = useTreesStore((s) => s.flowerPreset)
|
||||
const height = useTreesStore((s) => s.flowerHeight)
|
||||
|
||||
const activate = (preset: FlowerPreset) => {
|
||||
useTreesStore.getState().setFlowerPreset(preset)
|
||||
useEditor.getState().setTool('trees:flower')
|
||||
useEditor.getState().setMode('build')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PresetGrid
|
||||
items={FLOWER_PRESET_LIST}
|
||||
onPick={activate}
|
||||
selected={arming ? selected : null}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={2}
|
||||
min={0.2}
|
||||
onChange={useTreesStore.getState().setFlowerHeight}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={height}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GrassSection({ arming }: { arming: boolean }) {
|
||||
const selected = useTreesStore((s) => s.grassPreset)
|
||||
const height = useTreesStore((s) => s.grassHeight)
|
||||
|
||||
const activate = (preset: GrassPreset) => {
|
||||
useTreesStore.getState().setGrassPreset(preset)
|
||||
useEditor.getState().setTool('trees:grass')
|
||||
useEditor.getState().setMode('build')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PresetGrid items={GRASS_PRESET_LIST} onPick={activate} selected={arming ? selected : null} />
|
||||
<SliderControl
|
||||
label="Height"
|
||||
max={2}
|
||||
min={0.1}
|
||||
onChange={useTreesStore.getState().setGrassHeight}
|
||||
precision={2}
|
||||
restoreOnCommit={false}
|
||||
step={0.05}
|
||||
unit="m"
|
||||
value={height}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetGrid<T extends string>({
|
||||
items,
|
||||
selected,
|
||||
onPick,
|
||||
}: {
|
||||
items: ReadonlyArray<{ id: T; label: string; thumbnail: string }>
|
||||
selected: T | null
|
||||
onPick: (id: T) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{items.map((item) => {
|
||||
const isSelected = selected === item.id
|
||||
return (
|
||||
<button
|
||||
className={`group relative flex flex-col gap-2 rounded-xl border p-2 transition-all ${
|
||||
isSelected
|
||||
? 'border-sidebar-ring bg-sidebar-accent shadow-sm'
|
||||
: 'border-sidebar-border hover:border-sidebar-ring/50 hover:bg-sidebar-accent/40'
|
||||
}`}
|
||||
key={item.id}
|
||||
onClick={() => onPick(item.id)}
|
||||
type="button"
|
||||
>
|
||||
<img
|
||||
alt=""
|
||||
aria-hidden
|
||||
className="aspect-square w-full rounded-lg bg-[#f3f4f6] object-cover ring-1 ring-black/10 transition-transform group-hover:scale-[1.02]"
|
||||
src={item.thumbnail}
|
||||
/>
|
||||
<span className="pl-0.5 font-medium text-xs">{item.label}</span>
|
||||
{isSelected && (
|
||||
<span className="absolute top-3 right-3 h-2 w-2 rounded-full bg-sidebar-ring ring-2 ring-sidebar-accent" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import { TREE_ART } from './art'
|
||||
import type { TreePreset, TreeSize } from './schema'
|
||||
|
||||
/**
|
||||
* Per-species config: the ez-tree preset name for each size, a default placement
|
||||
* height per size (metres), a swatch colour, and a card `thumbnail` (a
|
||||
* replaceable placeholder image — see `thumbnails.ts`). Pure data — no three.js,
|
||||
* no React — shared by the panel grid and the instanced renderer so they stay in
|
||||
* lockstep. `ez[size]` is the exact ez-tree preset name passed to
|
||||
* `tree.loadPreset(...)`, exposing all of ez-tree's built-in presets through a
|
||||
* clean species × size model. `trellis` has a single preset (size ignored).
|
||||
*/
|
||||
export type TreePresetSpec = {
|
||||
id: TreePreset
|
||||
label: string
|
||||
/** ez-tree preset name keyed by size. */
|
||||
ez: Record<TreeSize, string>
|
||||
/** Default placement height keyed by size. */
|
||||
height: Record<TreeSize, number>
|
||||
/** Whether the size control applies (false for `trellis`). */
|
||||
sized: boolean
|
||||
swatch: string
|
||||
thumbnail: string
|
||||
}
|
||||
|
||||
function ezSizes(family: string): Record<TreeSize, string> {
|
||||
return { small: `${family} Small`, medium: `${family} Medium`, large: `${family} Large` }
|
||||
}
|
||||
|
||||
export const TREE_PRESETS: Record<TreePreset, TreePresetSpec> = {
|
||||
oak: {
|
||||
id: 'oak',
|
||||
label: 'Oak',
|
||||
ez: ezSizes('Oak'),
|
||||
height: { small: 5, medium: 7, large: 11 },
|
||||
sized: true,
|
||||
swatch: '#4f7942',
|
||||
thumbnail: TREE_ART.oak,
|
||||
},
|
||||
pine: {
|
||||
id: 'pine',
|
||||
label: 'Pine',
|
||||
ez: ezSizes('Pine'),
|
||||
height: { small: 6, medium: 9, large: 14 },
|
||||
sized: true,
|
||||
swatch: '#2f5d3a',
|
||||
thumbnail: TREE_ART.pine,
|
||||
},
|
||||
aspen: {
|
||||
id: 'aspen',
|
||||
label: 'Aspen',
|
||||
ez: ezSizes('Aspen'),
|
||||
height: { small: 5, medium: 8, large: 12 },
|
||||
sized: true,
|
||||
swatch: '#8fae5d',
|
||||
thumbnail: TREE_ART.aspen,
|
||||
},
|
||||
ash: {
|
||||
id: 'ash',
|
||||
label: 'Ash',
|
||||
ez: ezSizes('Ash'),
|
||||
height: { small: 5, medium: 8, large: 12 },
|
||||
sized: true,
|
||||
swatch: '#6f9457',
|
||||
thumbnail: TREE_ART.ash,
|
||||
},
|
||||
bush: {
|
||||
id: 'bush',
|
||||
label: 'Bush',
|
||||
ez: { small: 'Bush 1', medium: 'Bush 2', large: 'Bush 3' },
|
||||
height: { small: 1.2, medium: 1.5, large: 1.8 },
|
||||
sized: true,
|
||||
swatch: '#5c8a4a',
|
||||
thumbnail: TREE_ART.bush,
|
||||
},
|
||||
trellis: {
|
||||
id: 'trellis',
|
||||
label: 'Trellis',
|
||||
ez: { small: 'Trellis', medium: 'Trellis', large: 'Trellis' },
|
||||
height: { small: 3, medium: 3, large: 3 },
|
||||
sized: false,
|
||||
swatch: '#8b6b45',
|
||||
thumbnail: TREE_ART.trellis,
|
||||
},
|
||||
}
|
||||
|
||||
export const TREE_PRESET_LIST: TreePresetSpec[] = Object.values(TREE_PRESETS)
|
||||
|
||||
/** The ez-tree preset name for a species + size (size ignored for `trellis`). */
|
||||
export function ezPresetOf(preset: TreePreset, size: TreeSize): string {
|
||||
return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).ez[size]
|
||||
}
|
||||
|
||||
/** Default placement height for a species + size. */
|
||||
export function defaultHeightOf(preset: TreePreset, size: TreeSize): number {
|
||||
return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).height[size]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded seed pool. The placement tool and the Randomize action pick from
|
||||
* this set so trees share geometry variants — that sharing is what makes
|
||||
* instancing pay off. A power user can still type an arbitrary seed in the
|
||||
* inspector; that tree just renders as its own single-instance variant.
|
||||
*/
|
||||
export const TREE_SEED_POOL = [1, 7, 13, 21, 34, 55, 89, 144]
|
||||
|
||||
/** Pick a seed from the pool, varied by an index so it stays deterministic
|
||||
* (no Math.random in schema-importable code paths). */
|
||||
export function seedFromPool(index: number): number {
|
||||
return TREE_SEED_POOL[Math.abs(index) % TREE_SEED_POOL.length] ?? 1
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { EDITOR_LAYER } from '@pascal-app/editor'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import type { Material } from 'three'
|
||||
import { generateTree, treeSpecOf } from './geometry'
|
||||
import type { TreeNode } from './schema'
|
||||
import { naturalHeight } from './variant-utils'
|
||||
|
||||
/**
|
||||
* Translucent placement ghost — a single ez-tree (not instanced) scaled to the
|
||||
* node's height, following the cursor. Clones each material for the see-through
|
||||
* look and disables raycast so the ghost never intercepts the cursor ray (which
|
||||
* would freeze `grid:move`).
|
||||
*/
|
||||
export default function TreePreview({ node }: { node: TreeNode }) {
|
||||
const built = useMemo(() => {
|
||||
const tree = generateTree(treeSpecOf(node))
|
||||
tree.scale.setScalar(node.height / naturalHeight(tree))
|
||||
// Overlay layer keeps the ghost out of export/snapshot passes. Layers
|
||||
// don't inherit, so every object in the built tree needs it.
|
||||
tree.traverse((obj) => obj.layers.set(EDITOR_LAYER))
|
||||
return tree
|
||||
}, [node])
|
||||
|
||||
useEffect(() => {
|
||||
const cloned: Material[] = []
|
||||
built.traverse((obj) => {
|
||||
;(obj as unknown as { raycast: () => void }).raycast = () => {}
|
||||
const mesh = obj as { material?: Material | Material[] }
|
||||
if (!mesh.material) return
|
||||
const ghost = (mat: Material): Material => {
|
||||
const c = mat.clone()
|
||||
c.transparent = true
|
||||
c.opacity = 0.5
|
||||
c.depthWrite = false
|
||||
cloned.push(c)
|
||||
return c
|
||||
}
|
||||
mesh.material = Array.isArray(mesh.material) ? mesh.material.map(ghost) : ghost(mesh.material)
|
||||
})
|
||||
return () => {
|
||||
for (const c of cloned) c.dispose()
|
||||
}
|
||||
}, [built])
|
||||
|
||||
return <primitive object={built} />
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getVariantData, treeSpecOf } from './geometry'
|
||||
import { KindProxy } from './instanced'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node))
|
||||
const colliderRadius = (node: TreeNode) => Math.max(0.4, (node.height ?? 5) * 0.18)
|
||||
|
||||
/**
|
||||
* Per-node selection proxy for the instanced trees — a thin binding of the
|
||||
* generic {@link KindProxy} to this kind's geometry + collider size.
|
||||
*/
|
||||
export default function TreeProxyRenderer({ node }: { node: TreeNode }) {
|
||||
return <KindProxy colliderRadius={colliderRadius} getVariant={getVariant} node={node} />
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { BaseNode, nodeType, objectId } from '@pascal-app/core'
|
||||
import { z } from 'zod'
|
||||
|
||||
/** Tree species the plugin can place, each backed by an ez-tree preset family.
|
||||
* The string persists in scene JSON. */
|
||||
export const TreePreset = z.enum(['oak', 'pine', 'aspen', 'ash', 'bush', 'trellis'])
|
||||
export type TreePreset = z.infer<typeof TreePreset>
|
||||
|
||||
/** Preset size variant. Maps to ez-tree's Small/Medium/Large presets (and
|
||||
* Bush 1/2/3); ignored for `trellis`, which has a single preset. */
|
||||
export const TreeSize = z.enum(['small', 'medium', 'large'])
|
||||
export type TreeSize = z.infer<typeof TreeSize>
|
||||
|
||||
/** ez-tree's two growth models — deciduous (spreading) vs evergreen (conical). */
|
||||
export const TreeType = z.enum(['deciduous', 'evergreen'])
|
||||
export type TreeType = z.infer<typeof TreeType>
|
||||
|
||||
/**
|
||||
* Schema for a placed tree. Composed from the public `BaseNode` exactly the way
|
||||
* built-in node kinds are — `objectId`/`nodeType` come from `@pascal-app/core`,
|
||||
* so a plugin needs no private host internals to mint a persistable node.
|
||||
*
|
||||
* `type` is the namespaced kind `trees:tree`. Every geometry-relevant field
|
||||
* (preset/size/treeType/seed/foliage/trunk/leafless/leafColor/branchColor) is
|
||||
* folded into the instancing variant key; `height`/`position`/`rotation` are
|
||||
* cheap per-instance transforms.
|
||||
*/
|
||||
export const TreeNode = BaseNode.extend({
|
||||
id: objectId('tree'),
|
||||
type: nodeType('trees:tree'),
|
||||
position: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
rotation: z.tuple([z.number(), z.number(), z.number()]).default([0, 0, 0]),
|
||||
preset: TreePreset.default('oak'),
|
||||
size: TreeSize.default('medium'),
|
||||
// Overrides — all optional so an unset field inherits the ez-tree preset (its
|
||||
// own seed/type/tints), exactly like flower `petalColor`. Placing a tree stores
|
||||
// none of these, so a fresh tree is the pure preset; the inspector sets them.
|
||||
/** Growth-model override. Unset ⇒ inherit the preset (oak → deciduous, pine → evergreen). */
|
||||
treeType: TreeType.optional(),
|
||||
height: z.number().positive().default(7),
|
||||
/** Geometry-seed override. Unset ⇒ the preset's own seed (its canonical silhouette);
|
||||
* set (e.g. via Randomize) to vary the tree. */
|
||||
seed: z.number().int().optional(),
|
||||
// Curated geometry params (folded into the instancing variant key):
|
||||
/** Leaf-count multiplier vs the preset (1 = preset default). */
|
||||
foliageDensity: z.number().min(0).max(1.5).default(1),
|
||||
/** Branch-radius multiplier (1 = preset default). */
|
||||
trunkThickness: z.number().min(0.3).max(2.5).default(1),
|
||||
/** Strip all leaves — a bare winter silhouette. */
|
||||
leafless: z.boolean().default(false),
|
||||
/** Leaf tint override (hex). Unset ⇒ the preset's leaf tint. */
|
||||
leafColor: z.string().optional(),
|
||||
/** Bark/branch tint override (hex). Unset ⇒ the preset's bark tint. */
|
||||
branchColor: z.string().optional(),
|
||||
})
|
||||
|
||||
export type TreeNode = z.infer<typeof TreeNode>
|
||||
@@ -1,20 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getVariantData, treeSpecOf, treeVariantKey } from './geometry'
|
||||
import { InstancedNodes } from './instanced'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
const variantKeyOf = (node: TreeNode) => treeVariantKey(treeSpecOf(node))
|
||||
const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node))
|
||||
|
||||
/**
|
||||
* Collective renderer for the baked `/viewer` (`bakeReplaceRenderer`): one baked
|
||||
* level's trees, instanced in level-local space (the viewer portals this into
|
||||
* that level's `Object3D`). Same instancing as the editor `system`, so wind
|
||||
* phase varies per tree via `instanceIndex` and a forest is a few draw calls.
|
||||
*/
|
||||
export default function TreeReplaceInstances({ nodes }: { nodes: TreeNode[] }) {
|
||||
return (
|
||||
<InstancedNodes getVariant={getVariant} localSpace nodes={nodes} variantKeyOf={variantKeyOf} />
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { FLOWER_PRESETS } from './flower-presets'
|
||||
import type { FlowerPreset } from './flower-schema'
|
||||
import { GRASS_PRESETS } from './grass-presets'
|
||||
import type { GrassPreset } from './grass-schema'
|
||||
import { defaultHeightOf, TREE_PRESETS } from './presets'
|
||||
import type { TreePreset, TreeSize } from './schema'
|
||||
|
||||
/**
|
||||
* The plugin's own module-level state — the example of "plugins self-manage
|
||||
* runtime state with module-level stores" from the plugin-authoring contract.
|
||||
* It holds the placement "brush": what the next planted tree/flower looks like.
|
||||
* The presets panel writes it; the placement tool reads it. No host lifecycle
|
||||
* slot. Colours are intentionally absent — they're edit-only (inspector).
|
||||
*/
|
||||
/** Which section of the Nature panel is showing. */
|
||||
export type TreesPanelMode = 'trees' | 'flowers' | 'grass'
|
||||
|
||||
type TreesStore = {
|
||||
/** Active panel section — in the store (not panel-local state) so the host's
|
||||
* "find in catalog" can land on the right section (see `find-sync.ts`). */
|
||||
mode: TreesPanelMode
|
||||
setMode: (mode: TreesPanelMode) => void
|
||||
preset: TreePreset
|
||||
size: TreeSize
|
||||
/** Height (m) of the next tree — a per-instance scale, never affects placed trees. */
|
||||
height: number
|
||||
/** Leaf-count multiplier vs the preset (folded into the instancing variant). */
|
||||
foliageDensity: number
|
||||
/** Branch-radius multiplier (folded into the instancing variant). */
|
||||
trunkThickness: number
|
||||
/** Plant bare (leafless) trees. */
|
||||
leafless: boolean
|
||||
setPreset: (preset: TreePreset) => void
|
||||
setSize: (size: TreeSize) => void
|
||||
setHeight: (height: number) => void
|
||||
setFoliageDensity: (value: number) => void
|
||||
setTrunkThickness: (value: number) => void
|
||||
setLeafless: (value: boolean) => void
|
||||
// Flower brush (sibling kind).
|
||||
flowerPreset: FlowerPreset
|
||||
flowerHeight: number
|
||||
setFlowerPreset: (preset: FlowerPreset) => void
|
||||
setFlowerHeight: (height: number) => void
|
||||
// Grass brush (sibling kind).
|
||||
grassPreset: GrassPreset
|
||||
grassHeight: number
|
||||
setGrassPreset: (preset: GrassPreset) => void
|
||||
setGrassHeight: (height: number) => void
|
||||
}
|
||||
|
||||
export const useTreesStore = create<TreesStore>((set, get) => ({
|
||||
mode: 'trees',
|
||||
setMode: (mode) => set({ mode }),
|
||||
preset: 'oak',
|
||||
size: 'medium',
|
||||
height: TREE_PRESETS.oak.height.medium,
|
||||
foliageDensity: 1,
|
||||
trunkThickness: 1,
|
||||
leafless: false,
|
||||
// Switching preset/size re-seeds the height to that combo's natural default;
|
||||
// the foliage/trunk brush settings carry over. Growth model comes from the
|
||||
// preset (oak → deciduous, pine → evergreen); override per-tree in the inspector.
|
||||
setPreset: (preset) => set({ preset, height: defaultHeightOf(preset, get().size) }),
|
||||
setSize: (size) => set({ size, height: defaultHeightOf(get().preset, size) }),
|
||||
setHeight: (height) => set({ height }),
|
||||
setFoliageDensity: (foliageDensity) => set({ foliageDensity }),
|
||||
setTrunkThickness: (trunkThickness) => set({ trunkThickness }),
|
||||
setLeafless: (leafless) => set({ leafless }),
|
||||
flowerPreset: 'daisy',
|
||||
flowerHeight: FLOWER_PRESETS.daisy.defaultHeight,
|
||||
setFlowerPreset: (flowerPreset) =>
|
||||
set({ flowerPreset, flowerHeight: FLOWER_PRESETS[flowerPreset].defaultHeight }),
|
||||
setFlowerHeight: (flowerHeight) => set({ flowerHeight }),
|
||||
grassPreset: 'meadow',
|
||||
grassHeight: GRASS_PRESETS.meadow.defaultHeight,
|
||||
setGrassPreset: (grassPreset) =>
|
||||
set({ grassPreset, grassHeight: GRASS_PRESETS[grassPreset].defaultHeight }),
|
||||
setGrassHeight: (grassHeight) => set({ grassHeight }),
|
||||
}))
|
||||
@@ -1,25 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { getVariantData, treeSpecOf, treeVariantKey } from './geometry'
|
||||
import { InstancedKindSystem } from './instanced'
|
||||
import type { TreeNode } from './schema'
|
||||
|
||||
// Module-scope so identities stay stable (the system memoises on them).
|
||||
const variantKeyOf = (node: TreeNode) => treeVariantKey(treeSpecOf(node))
|
||||
const getVariant = (node: TreeNode) => getVariantData(treeSpecOf(node))
|
||||
|
||||
/**
|
||||
* Collective instanced renderer for every placed tree — contributed via
|
||||
* `def.system`. Buckets trees by their geometry variant and draws each variant
|
||||
* as one InstancedMesh per ez-tree sub-mesh, so a forest is a handful of draw
|
||||
* calls. Selection/outline come from the per-node proxy renderer.
|
||||
*/
|
||||
export default function TreesSystem() {
|
||||
return (
|
||||
<InstancedKindSystem<TreeNode>
|
||||
getVariant={getVariant}
|
||||
kind="trees:tree"
|
||||
variantKeyOf={variantKeyOf}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, type AnyNodeId, useScene } from '@pascal-app/core'
|
||||
import { EDITOR_LAYER, triggerSFX } from '@pascal-app/editor'
|
||||
import { useViewer } from '@pascal-app/viewer'
|
||||
import { useMemo } from 'react'
|
||||
import { usePlacement } from './placement'
|
||||
import TreePreview from './preview'
|
||||
import { TreeNode } from './schema'
|
||||
import { useTreesStore } from './store'
|
||||
|
||||
/**
|
||||
* The trees placement tool. Mounted by the host's registry-first `ToolManager`
|
||||
* whenever `tool === 'trees:tree'` — no host edit per kind. Reads the panel
|
||||
* brush from the plugin store, ghosts a preview at the snapped cursor, and
|
||||
* commits a tree on click. Snapping + level conversion live in `usePlacement`.
|
||||
*/
|
||||
export default function TreeTool() {
|
||||
const activeLevelId = useViewer((s) => s.selection.levelId)
|
||||
const preset = useTreesStore((s) => s.preset)
|
||||
const size = useTreesStore((s) => s.size)
|
||||
const height = useTreesStore((s) => s.height)
|
||||
const foliageDensity = useTreesStore((s) => s.foliageDensity)
|
||||
const trunkThickness = useTreesStore((s) => s.trunkThickness)
|
||||
const leafless = useTreesStore((s) => s.leafless)
|
||||
|
||||
const previewNode = useMemo(
|
||||
() =>
|
||||
TreeNode.parse({
|
||||
preset,
|
||||
size,
|
||||
height,
|
||||
foliageDensity,
|
||||
trunkThickness,
|
||||
leafless,
|
||||
// seed/treeType left unset → the ghost shows the pure preset, as placed.
|
||||
position: [0, 0, 0],
|
||||
rotation: [0, 0, 0],
|
||||
}),
|
||||
[preset, size, height, foliageDensity, trunkThickness, leafless],
|
||||
)
|
||||
|
||||
const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => {
|
||||
if (!activeLevelId) return
|
||||
const s = useTreesStore.getState()
|
||||
const tree = TreeNode.parse({
|
||||
preset: s.preset,
|
||||
size: s.size,
|
||||
height: s.height,
|
||||
foliageDensity: s.foliageDensity,
|
||||
trunkThickness: s.trunkThickness,
|
||||
leafless: s.leafless,
|
||||
// seed/treeType unset → the pure ez-tree preset (its canonical seed + type).
|
||||
// All same-preset trees then share one instancing variant; a random Y
|
||||
// rotation keeps a planted row from looking cloned. Use Randomize (inspector)
|
||||
// to vary a tree's seed.
|
||||
position,
|
||||
rotation: [0, (Math.floor(Math.random() * 8) * Math.PI) / 4, 0],
|
||||
})
|
||||
useScene.getState().createNode(tree as unknown as AnyNode, activeLevelId as AnyNodeId)
|
||||
useViewer.getState().setSelection({ selectedIds: [tree.id as AnyNodeId] })
|
||||
triggerSFX('sfx:item-place')
|
||||
})
|
||||
|
||||
if (!activeLevelId) return null
|
||||
|
||||
return (
|
||||
<group layers={EDITOR_LAYER} ref={cursorRef} visible={cursorVisible}>
|
||||
<TreePreview node={previewNode} />
|
||||
</group>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Box3, type Object3D } from 'three'
|
||||
|
||||
// Pure helpers shared by the tree/flower/grass variant builders. They live
|
||||
// apart from `geometry.ts` because that module imports ez-tree, which loads
|
||||
// its inlined textures at module scope (needs `document`) and therefore must
|
||||
// never sit on an eagerly-imported path (index → definitions → floorplan) —
|
||||
// SSR/prerender would crash. Only lazy client modules may import `geometry.ts`.
|
||||
|
||||
/** Natural (unscaled) height of a generated plant, so the renderer can scale
|
||||
* each instance to the node's `height`. */
|
||||
export function naturalHeight(obj: Object3D): number {
|
||||
const box = new Box3().setFromObject(obj)
|
||||
return Math.max(0.001, box.max.y - box.min.y)
|
||||
}
|
||||
|
||||
/** Deterministic 32-bit RNG (mulberry32) — same seed ⇒ same geometry. Shared by
|
||||
* the procedural flower/grass builders so a variant is stable across instances. */
|
||||
export function mulberry32(seed: number): () => number {
|
||||
let a = seed || 1
|
||||
return () => {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import type { Color, Material, Side, Texture } from 'three'
|
||||
import { cos, Fn, float, instanceIndex, positionLocal, sin, time, uv } from 'three/tsl'
|
||||
import { MeshStandardNodeMaterial, type Node, type NodeBuilder } from 'three/webgpu'
|
||||
|
||||
/**
|
||||
* Always-on wind for the plant kinds, done in TSL so it runs on the editor's
|
||||
* WebGPU renderer (which ignores WebGL's `onBeforeCompile`). Two motions:
|
||||
*
|
||||
* - **Leaf flutter** (`LEAF_FLUTTER`) — ez-tree's own approach: the sway scales
|
||||
* with the leaf card's `uv.y`, so each leaf swings from its attachment while
|
||||
* the trunk and branches stay put. A whole-tree height-based bend, by contrast,
|
||||
* is a rigid rotation about the base and reads as the tree spinning in place.
|
||||
* Multi-frequency for a natural gust; phased per instance + per leaf.
|
||||
* - **Stem bend** (`STEM_BEND`) — for the small procedural kinds (flowers,
|
||||
* grass), a gentle whole-plant lean proportional to height reads fine.
|
||||
*
|
||||
* ez-tree isn't touched: its generated materials are re-created as node materials
|
||||
* carrying the texture/tint (`toWindMaterial`) — only the `leaves` material gets
|
||||
* the flutter node; bark stays static. `time` is advanced by the renderer.
|
||||
*/
|
||||
|
||||
// ── Leaf flutter (tree leaves) ───────────────────────────────────────────────
|
||||
const LEAF_FREQUENCY = 1.2
|
||||
const LEAF_STRENGTH = 0.3
|
||||
|
||||
const leafFlutter = Fn(() => {
|
||||
const p = positionLocal.toVar()
|
||||
const offset = float(instanceIndex).mul(0.7).add(p.x.add(p.z).mul(0.3))
|
||||
const t = time.mul(LEAF_FREQUENCY)
|
||||
const wave = sin(t.add(offset))
|
||||
.mul(0.5)
|
||||
.add(sin(t.mul(2).add(offset.mul(1.3))).mul(0.3))
|
||||
.add(sin(t.mul(5).add(offset.mul(1.5))).mul(0.2))
|
||||
const sway = uv().y.mul(LEAF_STRENGTH).mul(wave)
|
||||
p.x.addAssign(sway)
|
||||
p.z.addAssign(sway)
|
||||
return p
|
||||
})
|
||||
const LEAF_FLUTTER = leafFlutter()
|
||||
|
||||
// ── Stem bend (flowers, grass) ───────────────────────────────────────────────
|
||||
const STEM_FREQUENCY = 1.3
|
||||
const STEM_STRENGTH = 0.05
|
||||
|
||||
const stemBend = Fn(() => {
|
||||
const p = positionLocal.toVar()
|
||||
const h = p.y.max(0)
|
||||
const phase = float(instanceIndex).mul(0.618)
|
||||
const t = time.mul(STEM_FREQUENCY).add(phase)
|
||||
p.x.addAssign(h.mul(STEM_STRENGTH).mul(sin(t)))
|
||||
p.z.addAssign(h.mul(STEM_STRENGTH).mul(cos(t.mul(1.1))))
|
||||
return p
|
||||
})
|
||||
const STEM_BEND = stemBend()
|
||||
|
||||
/**
|
||||
* Standard node material whose wind displacement runs **before** the instance
|
||||
* transform. The wind nodes read `positionLocal` expecting raw geometry-local
|
||||
* coordinates — leaf phase from the card's position inside its own tree, stem
|
||||
* height from the plant's own base — with the per-instance scale/rotation/
|
||||
* translation applied on top. three r184 happened to emit `positionNode`
|
||||
* statements in exactly that order; r185 fixed the emission order so
|
||||
* `positionNode` now runs *after* instancing, which put the wind in level
|
||||
* space: sway stopped scaling with the instance, leaf phase followed world
|
||||
* placement, and STEM_BEND's height term read the floor elevation, so plants
|
||||
* on upper levels slid around rigidly. Assigning `positionLocal` inside
|
||||
* `setupPosition` before `super` applies instancing restores the r184
|
||||
* (geometry-space) semantics on r185.
|
||||
*/
|
||||
class WindNodeMaterial extends MeshStandardNodeMaterial {
|
||||
windNode: Node | null = null
|
||||
|
||||
setupPosition(builder: NodeBuilder): Node {
|
||||
if (this.windNode !== null) positionLocal.assign(this.windNode)
|
||||
return super.setupPosition(builder)
|
||||
}
|
||||
|
||||
customProgramCacheKey(): string {
|
||||
return `${super.customProgramCacheKey()}|wind:${this.windNode ? this.windNode.id : 'none'}`
|
||||
}
|
||||
}
|
||||
|
||||
/** The classic-material fields we carry over — enough to reproduce ez-tree's
|
||||
* bark/leaf look (textured, tinted, alpha-cut billboards). */
|
||||
type ClassicMaterial = Material & {
|
||||
map?: Texture | null
|
||||
alphaMap?: Texture | null
|
||||
color?: Color
|
||||
side?: Side
|
||||
alphaTest?: number
|
||||
opacity?: number
|
||||
transparent?: boolean
|
||||
depthWrite?: boolean
|
||||
}
|
||||
|
||||
const cache = new WeakMap<Material, MeshStandardNodeMaterial>()
|
||||
|
||||
/** Re-create a generated (ez-tree) material as a `MeshStandardNodeMaterial`,
|
||||
* transferring its texture/tint explicitly (node materials don't pick these up
|
||||
* via `Material.copy()`). Only the `leaves` material flutters; bark stays static.
|
||||
* Cached per source so shared variant materials convert once. */
|
||||
export function toWindMaterial(material: Material): MeshStandardNodeMaterial {
|
||||
const cached = cache.get(material)
|
||||
if (cached) return cached
|
||||
const src = material as ClassicMaterial
|
||||
const node = new WindNodeMaterial({
|
||||
map: src.map ?? null,
|
||||
alphaMap: src.alphaMap ?? null,
|
||||
color: src.color,
|
||||
side: src.side,
|
||||
alphaTest: src.alphaTest ?? 0,
|
||||
transparent: src.transparent ?? false,
|
||||
opacity: src.opacity ?? 1,
|
||||
depthWrite: src.depthWrite ?? true,
|
||||
roughness: 1,
|
||||
metalness: 0,
|
||||
})
|
||||
if (material.name === 'leaves') node.windNode = LEAF_FLUTTER
|
||||
cache.set(material, node)
|
||||
return node
|
||||
}
|
||||
|
||||
/** Build a swaying node material for the procedural kinds (flowers/grass). */
|
||||
export function windStandardMaterial(
|
||||
params: ConstructorParameters<typeof MeshStandardNodeMaterial>[0],
|
||||
): MeshStandardNodeMaterial {
|
||||
const material = new WindNodeMaterial(params)
|
||||
material.windNode = STEM_BEND
|
||||
return material
|
||||
}
|
||||
|
||||
const staticCache = new WeakMap<Material, Material>()
|
||||
|
||||
/** Windless twin of a wind material — same look, no wind node. The outline
|
||||
* mask pass renders outlined meshes with a shared override material, so an
|
||||
* outline can never follow the GPU sway; the selection proxy renders this twin
|
||||
* instead, so the outlined silhouette and the visible mesh match exactly (the
|
||||
* plant simply holds still while hovered/selected). Built by explicit property
|
||||
* transfer, not `.clone()` — node-material clone drops `map`/`color` (same
|
||||
* pitfall as `toWindMaterial`). Cached per source. */
|
||||
export function toStaticMaterial(material: Material): Material {
|
||||
const cached = staticCache.get(material)
|
||||
if (cached) return cached
|
||||
const src = material as MeshStandardNodeMaterial
|
||||
const twin = new MeshStandardNodeMaterial({
|
||||
map: src.map ?? null,
|
||||
alphaMap: src.alphaMap ?? null,
|
||||
color: src.color,
|
||||
side: src.side,
|
||||
alphaTest: src.alphaTest ?? 0,
|
||||
transparent: src.transparent ?? false,
|
||||
opacity: src.opacity ?? 1,
|
||||
depthWrite: src.depthWrite ?? true,
|
||||
roughness: src.roughness,
|
||||
metalness: src.metalness,
|
||||
})
|
||||
staticCache.set(material, twin)
|
||||
return twin
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "@pascal/typescript-config/react-library.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"noEmit": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNode, nodeRegistry, type RendererSource, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNode,
|
||||
isNodeKindEnabled,
|
||||
nodeRegistry,
|
||||
type RendererSource,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { type ComponentType, lazy, Suspense } from 'react'
|
||||
import { ParametricNodeRenderer } from './parametric-node-renderer'
|
||||
|
||||
@@ -23,7 +29,9 @@ export function getRegistryRenderer(
|
||||
|
||||
export const NodeRenderer = ({ nodeId }: { nodeId: AnyNode['id'] }) => {
|
||||
const node = useScene((state) => state.nodes[nodeId])
|
||||
const installedPlugins = useScene((state) => state.installedPlugins)
|
||||
if (!node) return null
|
||||
if (!isNodeKindEnabled(node.type, installedPlugins)) return null
|
||||
const def = nodeRegistry.get(node.type)
|
||||
if (!def) return null
|
||||
// Two-checkbox dispatch (see wiki/architecture/node-definitions.md):
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
type AnyNode,
|
||||
bakePolicyOf,
|
||||
isNodeKindEnabled,
|
||||
nodeRegistry,
|
||||
type RendererSource,
|
||||
type SceneGraph,
|
||||
@@ -30,6 +31,7 @@ export function buildGlbReferenceNodes(
|
||||
const out: AnyNode[] = []
|
||||
for (const raw of Object.values(nodes)) {
|
||||
const node = raw as AnyNode
|
||||
if (!isNodeKindEnabled(node.type, sceneGraph?.installedPlugins)) continue
|
||||
if (bakePolicyOf(node.type) !== 'strip') continue
|
||||
if (node.type === 'scan' && !allow.scans) continue
|
||||
if (node.type === 'guide' && !allow.guides) continue
|
||||
@@ -52,6 +54,7 @@ export function buildGlbReplaceNodes(sceneGraph: SceneGraph | null | undefined):
|
||||
const out: AnyNode[] = []
|
||||
for (const raw of Object.values(nodes)) {
|
||||
const node = raw as AnyNode
|
||||
if (!isNodeKindEnabled(node.type, sceneGraph?.installedPlugins)) continue
|
||||
if (bakePolicyOf(node.type) === 'replace') out.push(node)
|
||||
}
|
||||
return out
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
'use client'
|
||||
|
||||
import { type AnyNodeDefinition, createSceneApi, nodeRegistry, useScene } from '@pascal-app/core'
|
||||
import {
|
||||
type AnyNodeDefinition,
|
||||
createSceneApi,
|
||||
isNodeKindEnabled,
|
||||
nodeRegistry,
|
||||
useScene,
|
||||
} from '@pascal-app/core'
|
||||
import { type ComponentType, lazy, Suspense, useMemo } from 'react'
|
||||
|
||||
const DEFAULT_PRIORITY = 5
|
||||
@@ -34,6 +40,7 @@ function loadSystem(def: AnyNodeDefinition): ComponentType<RegisteredSystemProps
|
||||
*/
|
||||
export function RegisteredSystems() {
|
||||
const sceneApi = useMemo(() => createSceneApi(useScene), [])
|
||||
const installedPlugins = useScene((state) => state.installedPlugins)
|
||||
const entries = useMemo(() => {
|
||||
return Array.from(nodeRegistry.entries())
|
||||
.filter(([, def]) => def.system != null)
|
||||
@@ -49,6 +56,7 @@ export function RegisteredSystems() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
{entries.map(([kind, def]) => {
|
||||
if (!isNodeKindEnabled(kind, installedPlugins)) return null
|
||||
const Comp = loadSystem(def)
|
||||
if (!Comp) return null
|
||||
return <Comp key={`registered-system:${kind}`} sceneApi={sceneApi} />
|
||||
|
||||