diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4b3039b29..89866a7e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,10 @@ bun check:fix # Auto-fix issues A key rule: **`packages/viewer` must never import from `apps/editor`**. The viewer is a standalone component; editor-specific behavior is injected via props/children. +### Building a plugin + +New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md) for the contract, and copy [`packages/plugin-trees`](packages/plugin-trees) as a worked example. + ## Submitting a PR 1. **Fork the repo** and create a branch from `main` diff --git a/README.md b/README.md index 10832baa6..3ba4f6f24 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,15 @@ Clears dirty flag --- +## Building a Plugin + +The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same `Plugin` manifest the built-ins use — there is no separate internal API. + +- **Contract reference** — [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md): the `Plugin` shape, panel contributions, discovery (`setPluginDiscovery`), lifecycle, and what's in/out of v1. +- **Worked example** — [`packages/plugin-trees`](packages/plugin-trees): a first-party plugin (procedural trees, flowers, grass + a presets panel) structurally identical to a third-party pack. Copy it as a starting point. + +--- + ## Technology Stack - **React 19** + **Next.js 16** diff --git a/apps/editor/lib/bootstrap.ts b/apps/editor/lib/bootstrap.ts index 10788771a..3238d8041 100644 --- a/apps/editor/lib/bootstrap.ts +++ b/apps/editor/lib/bootstrap.ts @@ -4,8 +4,10 @@ import { loadPlugin, nodeRegistry, registerNode, + setPluginDiscovery, } from '@pascal-app/core' import { builtinPlugin } from '@pascal-app/nodes' +import { treesPlugin } from '@pascal-app/plugin-trees' // Idempotency guards: HMR can reload this module, but `registerNode` // throws on duplicate kinds. Flags live in the module closure so they @@ -77,5 +79,10 @@ export async function loadExternalPlugins(): Promise { } } +// Register the first-party example plugin (trees node + presets rail panel) +// through the same discovery hook a third-party pack would use. Must be set +// before `loadExternalPlugins()` reads it below. +setPluginDiscovery(async () => [treesPlugin]) + loadBuiltinsSync() void loadExternalPlugins() diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 0120eaf4c..18fb18206 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -13,6 +13,8 @@ const nextConfig: NextConfig = { '@pascal-app/core', '@pascal-app/editor', '@pascal-app/mcp', + '@pascal-app/plugin-trees', + '@dgreenheck/ez-tree', ], turbopack: { resolveAlias: { diff --git a/apps/editor/package.json b/apps/editor/package.json index 5af999759..4f4b18b33 100644 --- a/apps/editor/package.json +++ b/apps/editor/package.json @@ -17,6 +17,7 @@ "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", + "@pascal-app/plugin-trees": "*", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", diff --git a/bun.lock b/bun.lock index 3eb9cb4e4..f402fa785 100644 --- a/bun.lock +++ b/bun.lock @@ -33,6 +33,7 @@ "@pascal-app/editor": "*", "@pascal-app/mcp": "*", "@pascal-app/nodes": "*", + "@pascal-app/plugin-trees": "*", "@pascal-app/viewer": "*", "@radix-ui/react-tooltip": "^1.2.8", "@react-three/drei": "^10.7.7", @@ -257,6 +258,38 @@ "zustand": "^5", }, }, + "packages/plugin-trees": { + "name": "@pascal-app/plugin-trees", + "version": "0.1.0", + "dependencies": { + "@dgreenheck/ez-tree": "^1.1.0", + }, + "devDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@pascal/typescript-config": "*", + "@react-three/fiber": "^9", + "@types/node": "^22.19.12", + "@types/react": "^19.2.2", + "@types/three": "^0.184.0", + "react": "^19", + "three": "^0.184", + "typescript": "6.0.3", + "zod": "^4", + "zustand": "^5", + }, + "peerDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@react-three/fiber": "^9", + "react": "^18 || ^19", + "three": "^0.184", + "zod": "^4", + "zustand": "^5", + }, + }, "packages/typescript-config": { "name": "@repo/typescript-config", "version": "0.0.0", @@ -371,6 +404,8 @@ "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], + "@dgreenheck/ez-tree": ["@dgreenheck/ez-tree@1.1.0", "", { "peerDependencies": { "three": ">=0.167" } }, "sha512-6pvS6hD6B6h00dm0SnkgYeT4ABU5Y1Z9M44p1tXiV5C0eKrQy2sKECXshoaUv0qAOqYVL68w/PwadUxDFDiHUg=="], + "@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="], "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], @@ -683,6 +718,8 @@ "@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"], + "@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@workspace:packages/plugin-trees"], + "@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"], "@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"], diff --git a/packages/core/src/registry/index.ts b/packages/core/src/registry/index.ts index 62107fc2a..d239acd86 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -16,6 +16,7 @@ export type { TranslateHandle, } from './handles' export { + bakePolicyOf, discoverPlugins, getHostRefFields, getSelectableKinds, @@ -26,10 +27,12 @@ export { isPresettableKind, isRegistryMovable, isRegistrySelectable, + kindsWithBakePolicy, kindsWithFloorplanScope, loadPlugin, nodeRegistry, type PluginDiscovery, + panelRegistry, registerNode, resolveFacingIndicator, setPluginDiscovery, @@ -55,6 +58,8 @@ export type { AlignmentFootprintConfig, AnyNodeDefinition, AssetRef, + BakePolicy, + BakeReplaceRenderer, Capabilities, CapabilityCtx, CuttableConfig, @@ -95,11 +100,13 @@ export type { PaintPatchArgs, PaintPreviewArgs, PaintResolveArgs, + PanelWorkspace, ParamAction, ParametricDescriptor, ParamField, ParamGroup, Plugin, + PluginPanel, Presentation, Relations, RendererSource, diff --git a/packages/core/src/registry/registry.ts b/packages/core/src/registry/registry.ts index 2455c9675..093a9df31 100644 --- a/packages/core/src/registry/registry.ts +++ b/packages/core/src/registry/registry.ts @@ -1,5 +1,5 @@ import type { ZodObject } from 'zod' -import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types' +import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin, PluginPanel } from './types' const HOST_API_VERSION = 1 as const @@ -86,6 +86,78 @@ export function registerNode(def: AnyNodeDefinition): void { nodeRegistry._register(def) } +/** + * Registry of left-rail panels contributed by plugins. Same add-only, + * duplicate-id-throws semantics as the node registry, with one difference: it + * is *observable*. Plugin discovery runs asynchronously after the first React + * render (see `loadExternalPlugins`), so the sidebar subscribes via + * {@link PanelRegistryImpl.subscribe} / {@link PanelRegistryImpl.getSnapshot} + * (the `useSyncExternalStore` contract) and re-renders when a panel lands. + * `getSnapshot` returns a stable array reference between registrations so the + * subscribing component doesn't re-render in a loop. + */ +class PanelRegistryImpl { + private readonly panels = new Map() + private readonly listeners = new Set<() => void>() + private cached: PluginPanel[] = [] + // node kind → the (namespaced) panel id of the plugin that shipped it. + // Populated by `loadPlugin`; lets a host's "find in catalog" open the + // panel that places a kind without hardcoding per-plugin knowledge. + private readonly kindPanels = new Map() + + subscribe = (onChange: () => void): (() => void) => { + this.listeners.add(onChange) + return () => { + this.listeners.delete(onChange) + } + } + + getSnapshot = (): PluginPanel[] => this.cached + + _register(panel: PluginPanel): void { + if (typeof panel.id !== 'string' || panel.id.length === 0) { + throw new Error('[registry] PluginPanel.id must be a non-empty string') + } + if (this.panels.has(panel.id)) { + if (isDevMode()) { + console.warn(`[registry] re-registering plugin panel "${panel.id}" (HMR)`) + } else { + throw new Error(`[registry] duplicate plugin panel id: "${panel.id}" already registered`) + } + } + this.panels.set(panel.id, panel) + this.emit() + } + + _associateKind(kind: string, panelId: string): void { + this.kindPanels.set(kind, panelId) + } + + /** The (namespaced) panel id of the plugin that registered `kind`, if any. */ + panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind) + + // Test-only — clears the registry. Not exported from the package barrel. + _reset(): void { + this.panels.clear() + this.kindPanels.clear() + this.emit() + } + + private emit(): void { + this.cached = Array.from(this.panels.values()) + for (const fn of this.listeners) fn() + } +} + +export const panelRegistry: { + subscribe: (onChange: () => void) => () => void + getSnapshot: () => PluginPanel[] + panelForKind: (kind: string) => string | undefined + _register: (panel: PluginPanel) => void + _associateKind: (kind: string, panelId: string) => void + _reset: () => void +} = new PanelRegistryImpl() + /** * Returns the set of registered kinds whose definition declares the * `selectable` capability. Callers that maintain hardcoded "selectable kinds" @@ -130,6 +202,26 @@ export function kindsWithFloorplanScope(scope: 'level' | 'building'): string[] { return result } +/** + * A kind's {@link BakePolicy} from the registry, defaulting to `'static'` for + * kinds that don't declare one (or aren't registered). The bake and the baked + * `/viewer` consult this instead of hardcoding kind names — see + * plans/editor-plugin-trees-example.md → Part D. + */ +export function bakePolicyOf(kind: string): BakePolicy { + return nodeRegistry.get(kind)?.bake ?? 'static' +} + +/** Registered kinds whose {@link BakePolicy} matches `policy`. `'static'` is the + * default, so `kindsWithBakePolicy('static')` includes kinds that didn't set it. */ +export function kindsWithBakePolicy(policy: BakePolicy): string[] { + const result: string[] = [] + for (const [kind, def] of nodeRegistry.entries()) { + if ((def.bake ?? 'static') === policy) result.push(kind) + } + return result +} + /** * Returns true when the kind is movable from a 2D floor-plan handle — * either via `capabilities.movable`, an explicit @@ -225,6 +317,20 @@ export async function loadPlugin(plugin: Plugin): Promise { for (const def of plugin.nodes ?? []) { registerNode(def) } + for (const panel of plugin.panels ?? []) { + // Namespace the panel id by its owning plugin so two plugins can each + // ship a panel id of `'main'`. The namespaced id is what the sidebar + // uses as `activeSidebarPanel`. + panelRegistry._register({ ...panel, id: `${plugin.id}:${panel.id}` }) + } + // Associate every node kind with the plugin's first panel — the panel a + // host's "find in catalog" should open to place more of that kind. + const firstPanel = plugin.panels?.[0] + if (firstPanel) { + for (const def of plugin.nodes ?? []) { + panelRegistry._associateKind(def.kind, `${plugin.id}:${firstPanel.id}`) + } + } } /** @@ -235,8 +341,8 @@ export async function loadPlugin(plugin: Plugin): Promise { * {@link setPluginDiscovery} before the bootstrap module runs. * * Kept async so a future loader can fetch over the network without - * changing the contract. See `wiki/editor-plugin-authoring.md` for the - * plugin author surface this enables. + * changing the contract. See `wiki/architecture/plugin-authoring.md` for + * the plugin author surface this enables. */ export type PluginDiscovery = () => Promise diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index db3b10eff..4cd973b6e 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -695,10 +695,36 @@ export type FloorplanMoveTarget = (args: { // ─── Plugin manifest ───────────────────────────────────────────────── +/** + * A left-rail panel contributed by a plugin. The host adds `icon` to the + * sidebar icon rail; clicking it mounts `component` (lazy-loaded, behind a + * per-panel error boundary) in the sidebar content area. `id` is namespaced + * by the owning plugin id at load time, so two plugins may each declare a + * panel id of `'main'` without colliding. `icon` reuses {@link IconRef} so a + * plugin contributes a mark the same way a node's presentation does, with no + * React rendering in core. + */ +/** Host workspace a panel belongs to — `edit` (the build workspace) or + * `studio` (the render workspace). Manifest metadata, not core logic: the + * host's sidebar filters by its current workspace mode. */ +export type PanelWorkspace = 'edit' | 'studio' + +export type PluginPanel = { + id: string + label: string + icon: IconRef + component: LazyComponent + /** Workspaces that surface this panel. Default `['edit']` — a placement / + * authoring panel has no business in the clean render workspace; a plugin + * that ships studio tooling opts in explicitly. */ + workspaces?: readonly PanelWorkspace[] +} + export type Plugin = { id: string apiVersion: 1 nodes?: AnyNodeDefinition[] + panels?: PluginPanel[] } // ─── NodeDefinition ────────────────────────────────────────────────── @@ -726,6 +752,19 @@ export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment' */ export type SnapProfile = 'item' | 'structural' +/** + * How a kind is treated by the GLB bake and the baked `/viewer`. See + * plans/editor-plugin-trees-example.md → Part D. + * - `'static'` (default) — baked as geometry; the viewer shows the baked mesh. + * - `'strip'` — excluded from the bake; the viewer rebuilds it live from + * `scene_graph` via the registry renderer (heavy reference assets: scans, guides). + * - `'replace'` — baked as *static* geometry (a plain glTF viewer still shows it), + * but our viewer removes the baked meshes for this kind and re-renders the node + * live from `scene_graph` — for dynamic content whose runtime look differs from a + * frozen snapshot (shader wind, interactivity), rendered through its own path. + */ +export type BakePolicy = 'static' | 'strip' | 'replace' + export type NodeDefinition> = { kind: string schemaVersion: number @@ -785,6 +824,9 @@ export type NodeDefinition> = { */ dirtyTracking?: boolean + /** GLB bake treatment for this kind (default `'static'`). See {@link BakePolicy}. */ + bake?: BakePolicy + /** * Renderer for this kind. Optional under the three-checkbox composition * model (see `wiki/architecture/node-definitions.md`): when omitted, the @@ -797,6 +839,17 @@ export type NodeDefinition> = { * already null-guard on `def.renderer` so omitting it is safe. */ renderer?: RendererSource> + /** + * Collective renderer the baked `/viewer` uses to re-render this kind live when + * `bake === 'replace'`. It receives every node of this kind under one baked + * level and is portaled into that level's `Object3D`, so an instanced kind can + * draw them as instanced meshes in level-local space (riding level stacking for + * free) instead of the frozen baked meshes (which the viewer hides). Needed when + * the normal per-node `renderer` can't stand alone in a baked scene (e.g. an + * instanced kind whose `renderer` is an invisible selection proxy and whose real + * geometry comes from a `system`). See plans/editor-plugin-trees-example.md → Part D. + */ + bakeReplaceRenderer?: BakeReplaceRenderer> /** * Pure geometry builder. When set, the framework's generic * `` calls this on every dirty mark — `nodes` keyed by @@ -1136,6 +1189,15 @@ export type RendererSource = | { kind: 'glb'; getAsset: (n: N) => AssetRef } | { kind: 'instanced-glb'; getAsset: (n: N) => AssetRef } +/** + * A collective renderer for the baked `/viewer` (see `NodeDefinition.bakeReplaceRenderer`): + * a lazy module whose default export takes all of one level's `replace` nodes and + * is portaled into that baked level. Three-free indirection, same as `system`. + */ +export type BakeReplaceRenderer = { + module: () => Promise<{ default: ComponentType<{ nodes: N[] }> }> +} + export type AssetRef = { id: string src: string diff --git a/packages/editor/src/components/editor/bake-exporter.tsx b/packages/editor/src/components/editor/bake-exporter.tsx index 5771adee7..1a188af93 100644 --- a/packages/editor/src/components/editor/bake-exporter.tsx +++ b/packages/editor/src/components/editor/bake-exporter.tsx @@ -1,10 +1,20 @@ 'use client' import { useScene } from '@pascal-app/core' +import { useViewer } from '@pascal-app/viewer' import { useThree } from '@react-three/fiber' import { useEffect, useRef } from 'react' import { exportSceneToGlb } from '../../lib/glb-export' +/** Resolve after the next couple of animation frames, giving React/R3F time to + * commit and mount export-only geometry (e.g. instanced kinds' real meshes) + * before the exporter clones the scene graph. */ +function nextFrames(): Promise { + return new Promise((resolve) => { + requestAnimationFrame(() => requestAnimationFrame(() => resolve())) + }) +} + export function BakeExporter({ active, onComplete, @@ -21,12 +31,19 @@ export function BakeExporter({ doneRef.current = true const run = async () => { try { + // Signal export so instanced kinds (trees/flowers/grass) swap their + // invisible proxy for real, exportable geometry, then wait for the + // commit before cloning the scene graph. + useViewer.getState().setExporting(true) + await nextFrames() const sceneGroup = scene.getObjectByName('scene-renderer') if (!sceneGroup) throw new Error('scene-renderer group not found') const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes) onComplete(buffer) } catch (err) { onError(err instanceof Error ? err.message : String(err)) + } finally { + useViewer.getState().setExporting(false) } } void run() diff --git a/packages/editor/src/components/editor/index.tsx b/packages/editor/src/components/editor/index.tsx index 508094e37..20513fca4 100644 --- a/packages/editor/src/components/editor/index.tsx +++ b/packages/editor/src/components/editor/index.tsx @@ -55,6 +55,7 @@ import type { ExtraPanel } from '../ui/sidebar/icon-rail' import { SettingsPanel, type SettingsPanelProps } from '../ui/sidebar/panels/settings-panel' import { SitePanel, type SitePanelProps } from '../ui/sidebar/panels/site-panel' import type { SidebarTab } from '../ui/sidebar/tab-bar' +import { usePluginPanels } from '../ui/sidebar/use-plugin-panels' import { CustomCameraControls } from './custom-camera-controls' import { EditorLayoutV2 } from './editor-layout-v2' import { ExportManager } from './export-manager' @@ -1113,6 +1114,12 @@ export default function Editor({ const sidebarWidth = useSidebarStore((s) => s.width) const isSidebarCollapsed = useSidebarStore((s) => s.isCollapsed) + // Plugin-contributed rail panels (registry-only). Called unconditionally so + // hook order is stable across the v1 / v2 layout branches below; the v1 + // AppSidebar path merges its own copy internally, the v2 path merges these + // into its tab bar. + const pluginRailPanels = usePluginPanels() + useEffect(() => { const teardown = initializeEditorRuntime() return teardown @@ -1284,7 +1291,17 @@ export default function Editor({ // ── V2 layout ── if (layoutVersion === 'v2') { - const tabMap = new Map(sidebarTabs?.map((t) => [t.id, t]) ?? []) + // Plugin panels join the host's `sidebarTabs` as first-class tabs. Host + // tabs keep precedence (already in the map first); a plugin panel id can't + // collide with a host tab because it's namespaced by plugin id. + const tabMap = new Map( + sidebarTabs?.map((t) => [t.id, t]) ?? [], + ) + for (const p of pluginRailPanels) { + if (!tabMap.has(p.id)) { + tabMap.set(p.id, { id: p.id, label: p.label, icon: p.icon, component: p.component }) + } + } const renderTabContent = (tabId: string) => { // Built-in panels @@ -1301,14 +1318,24 @@ export default function Editor({ return } - const tabBarTabs = - sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({ + const tabBarTabs = [ + ...(sidebarTabs?.map(({ id, label, mobileDefaultSnap, mobileIcon, icon }) => ({ id, label, mobileDefaultSnap, mobileIcon, icon, - })) ?? [] + })) ?? []), + // Plugin panels appear after the host's tabs in the rail. The icon + // doubles as the mobile icon; a half-height sheet is a sensible default. + ...pluginRailPanels.map((p) => ({ + id: p.id, + label: p.label, + mobileDefaultSnap: 0.5, + mobileIcon: p.icon, + icon: p.icon, + })), + ] return ( <> diff --git a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx index 383d28675..99f756e01 100644 --- a/packages/editor/src/components/ui/sidebar/app-sidebar.tsx +++ b/packages/editor/src/components/ui/sidebar/app-sidebar.tsx @@ -16,6 +16,7 @@ import useEditor from './../../../store/use-editor' import { type ExtraPanel, IconRail } from './icon-rail' import { SettingsPanel, type SettingsPanelProps } from './panels/settings-panel' import { SitePanel, type SitePanelProps } from './panels/site-panel' +import { usePluginPanels } from './use-plugin-panels' interface AppSidebarProps { appMenuButton?: ReactNode @@ -31,9 +32,12 @@ export function AppSidebar({ sidebarTop, settingsPanelProps, sitePanelProps, - extraPanels, + extraPanels: hostExtraPanels, commandPaletteEmptyAction, }: AppSidebarProps) { + // Host-provided panels merged with plugin-contributed panels from the + // registry — the icon rail and content area treat both identically. + const extraPanels = usePluginPanels(hostExtraPanels) const activePanel = useEditor((s) => s.activeSidebarPanel) const setActivePanel = useEditor((s) => s.setActiveSidebarPanel) const hasActivePanel = diff --git a/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx new file mode 100644 index 000000000..f009b72d8 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx @@ -0,0 +1,97 @@ +'use client' + +import { Icon } from '@iconify/react' +import { type IconRef, panelRegistry, type PluginPanel } from '@pascal-app/core' +import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react' +import useEditor from '../../../store/use-editor' +import { ErrorBoundary } from '../primitives/error-boundary' +import type { ExtraPanel } from './icon-rail' + +/** Resolve a plugin's {@link IconRef} into a rail-sized React node. Mirrors the + * inspector's `renderIcon`, sized for the 24px icon-rail button. */ +function renderIconRef(ref: IconRef): ReactNode { + if (ref.kind === 'url') { + return + } + if (ref.kind === 'iconify') { + return + } + if (ref.kind === 'svg') { + return ( + + + + ) + } + const LazyIcon = lazy(ref.module) + return ( + + + + ) +} + +function PluginPanelCrashed({ label }: { label: string }) { + return ( +
+

"{label}" plugin crashed

+

+ This panel hit an error and was unloaded for this session. The rest of the editor is + unaffected — reload to try again. +

+
+ ) +} + +// `React.lazy` must be called once per loader so the resolved component keeps a +// stable identity across renders (otherwise switching panels remounts the tree +// every time the sidebar re-renders). Cache the wrapped component by its loader. +const wrappedPanelCache = new WeakMap() + +function resolvePanelComponent(panel: PluginPanel): ComponentType { + const cached = wrappedPanelCache.get(panel.component) + if (cached) return cached + const Lazy = lazy(panel.component) + const Wrapped: ComponentType = () => ( + }> + Loading…}> + + + + ) + Wrapped.displayName = `PluginPanel(${panel.id})` + wrappedPanelCache.set(panel.component, Wrapped) + return Wrapped +} + +/** + * Merge plugin-contributed panels (from the observable {@link panelRegistry}) + * with the host's `extraPanels`, returning the combined list the icon rail and + * panel-content area render. Host panels keep their leading order and win on id + * collisions. Subscribes to the registry so a panel that registers after the + * first render (plugin discovery is async) makes the rail re-render. + * + * Panels are filtered by the current workspace: a panel surfaces only in the + * workspaces it declares (`PluginPanel.workspaces`, default `['edit']`), so an + * authoring panel like Nature doesn't ride into the studio rail. + */ +export function usePluginPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { + const registered = useSyncExternalStore( + panelRegistry.subscribe, + panelRegistry.getSnapshot, + panelRegistry.getSnapshot, + ) + const workspaceMode = useEditor((s) => s.workspaceMode) + const hostIds = new Set(hostPanels?.map((p) => p.id)) + const fromRegistry = registered + .filter((p) => !hostIds.has(p.id) && (p.workspaces ?? ['edit']).includes(workspaceMode)) + .map( + (p): ExtraPanel => ({ + id: p.id, + label: p.label, + icon: renderIconRef(p.icon), + component: resolvePanelComponent(p), + }), + ) + return [...(hostPanels ?? []), ...fromRegistry] +} diff --git a/packages/editor/src/lib/glb-export.ts b/packages/editor/src/lib/glb-export.ts index 31594338a..8ba187b61 100644 --- a/packages/editor/src/lib/glb-export.ts +++ b/packages/editor/src/lib/glb-export.ts @@ -1,5 +1,6 @@ import { type AnyNode, + bakePolicyOf, type DoorNode, emitter, getLevelDisplayName, @@ -104,14 +105,16 @@ export function prepareSceneForExport( const scene = source.clone(true) const cloneByOriginal = pairClones(source, scene) - // Scans (LiDAR meshes) and guides (floorplan images) are heavy reference - // assets stored elsewhere and aren't part of the compiled building. Drop them - // from the artifact entirely — `/viewer` re-adds them from the scene graph, - // gated by the project's public-visibility flags, so they never bloat the - // shared GLB nor slip past those flags into a static public file. + // Kinds with `def.bake === 'strip'` (scans/LiDAR, guides/floorplan) are heavy + // reference assets stored elsewhere and aren't part of the compiled building. + // Drop them from the artifact entirely — `/viewer` re-adds them from the scene + // graph, gated by the project's public-visibility flags, so they never bloat + // the shared GLB nor slip past those flags into a static public file. + // (`'replace'` kinds are *kept*: static for portability, the viewer swaps them + // for a live render.) for (const [id, original] of sceneRegistry.nodes) { const node = nodes[id] - if (node?.type === 'scan' || node?.type === 'guide') { + if (node && bakePolicyOf(node.type) === 'strip') { cloneByOriginal.get(original)?.removeFromParent() } } diff --git a/packages/editor/src/store/use-editor.tsx b/packages/editor/src/store/use-editor.tsx index 87cdbbe69..8e6d3ea9f 100644 --- a/packages/editor/src/store/use-editor.tsx +++ b/packages/editor/src/store/use-editor.tsx @@ -172,8 +172,14 @@ export type NavigationSyncPose = { export type NavigationSyncPoseInput = Omit -// Combined tool type -export type Tool = SiteTool | StructureTool | FurnishTool +// Combined tool type. Known literals keep autocomplete; the `(string & {})` +// arm lets plugin-contributed tool ids (e.g. `'trees:tree'`) typecheck without +// the host enumerating every plugin kind. The runtime dispatch is already +// registry-first — `tool-manager` resolves `nodeRegistry.get(tool)?.tool` — so +// an unknown-to-the-host tool string flows straight through to the plugin's +// placement component. +export type KnownTool = SiteTool | StructureTool | FurnishTool +export type Tool = KnownTool | (string & {}) /** * Starting parameters seeded into a draw tool before it mints a node. diff --git a/packages/nodes/src/guide/definition.ts b/packages/nodes/src/guide/definition.ts index cd2c546b6..a60c5126a 100644 --- a/packages/nodes/src/guide/definition.ts +++ b/packages/nodes/src/guide/definition.ts @@ -10,6 +10,9 @@ import { GuideNode } from './schema' */ export const guideDefinition: NodeDefinition = { kind: 'guide', + // Floorplan image reference: stripped from the bake, re-added live from + // scene_graph in the viewer (see plans → Part D; glb-reference-nodes.tsx). + bake: 'strip', schemaVersion: 1, schema: GuideNode, category: 'site', diff --git a/packages/nodes/src/scan/definition.ts b/packages/nodes/src/scan/definition.ts index 64f1557eb..bc6cc2f70 100644 --- a/packages/nodes/src/scan/definition.ts +++ b/packages/nodes/src/scan/definition.ts @@ -9,6 +9,9 @@ import { ScanNode } from './schema' */ export const scanDefinition: NodeDefinition = { kind: 'scan', + // Heavy LiDAR asset: stripped from the bake, re-added live from scene_graph + // in the viewer (see plans → Part D; glb-reference-nodes.tsx). + bake: 'strip', schemaVersion: 1, schema: ScanNode, category: 'site', diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md new file mode 100644 index 000000000..100fea15f --- /dev/null +++ b/packages/plugin-trees/README.md @@ -0,0 +1,92 @@ +# @pascal-app/plugin-trees + +The first-party **example plugin** for the Pascal editor. It contributes a +procedural _trees_ node and a left-rail _presets_ panel, and exists to prove — +and document — the minimal host 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 a plugin has: + +1. **Left-rail panel** — `panels` in the 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`) and one panel (`Trees`), loaded through the same `loadPlugin` +path as the built-ins. + +## Notes / known gaps + +- `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. diff --git a/packages/plugin-trees/package.json b/packages/plugin-trees/package.json new file mode 100644 index 000000000..e1a6a7143 --- /dev/null +++ b/packages/plugin-trees/package.json @@ -0,0 +1,56 @@ +{ + "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.184", + "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.184", + "typescript": "6.0.3", + "zod": "^4", + "zustand": "^5" + }, + "keywords": [ + "pascal", + "3d", + "editor", + "plugin" + ], + "license": "MIT" +} diff --git a/packages/plugin-trees/src/art.ts b/packages/plugin-trees/src/art.ts new file mode 100644 index 000000000..43b3edeba --- /dev/null +++ b/packages/plugin-trees/src/art.ts @@ -0,0 +1,48 @@ +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 ``. + */ +const url = (asset: { src: string }): string => asset.src + +export const TREE_ART: Record = { + oak: url(oak), + pine: url(pine), + aspen: url(aspen), + ash: url(ash), + bush: url(bush), + trellis: url(trellis), +} + +export const FLOWER_ART: Record = { + daisy: url(daisy), + tulip: url(tulip), + lavender: url(lavender), +} + +export const GRASS_ART: Record = { + meadow: url(meadow), + fescue: url(fescue), + reed: url(reed), +} + +/** The Nature panel / section icon. */ +export const NATURE_ICON = url(natureIcon) diff --git a/packages/plugin-trees/src/assets.d.ts b/packages/plugin-trees/src/assets.d.ts new file mode 100644 index 000000000..ea56a5b57 --- /dev/null +++ b/packages/plugin-trees/src/assets.d.ts @@ -0,0 +1,8 @@ +// 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 +} diff --git a/packages/plugin-trees/src/assets/ash.webp b/packages/plugin-trees/src/assets/ash.webp new file mode 100644 index 000000000..42a4289f3 Binary files /dev/null and b/packages/plugin-trees/src/assets/ash.webp differ diff --git a/packages/plugin-trees/src/assets/aspen.webp b/packages/plugin-trees/src/assets/aspen.webp new file mode 100644 index 000000000..4fb0f79df Binary files /dev/null and b/packages/plugin-trees/src/assets/aspen.webp differ diff --git a/packages/plugin-trees/src/assets/bush.webp b/packages/plugin-trees/src/assets/bush.webp new file mode 100644 index 000000000..237258109 Binary files /dev/null and b/packages/plugin-trees/src/assets/bush.webp differ diff --git a/packages/plugin-trees/src/assets/daisy.webp b/packages/plugin-trees/src/assets/daisy.webp new file mode 100644 index 000000000..ce1f25240 Binary files /dev/null and b/packages/plugin-trees/src/assets/daisy.webp differ diff --git a/packages/plugin-trees/src/assets/fescue.webp b/packages/plugin-trees/src/assets/fescue.webp new file mode 100644 index 000000000..0c8c1969b Binary files /dev/null and b/packages/plugin-trees/src/assets/fescue.webp differ diff --git a/packages/plugin-trees/src/assets/lavender.webp b/packages/plugin-trees/src/assets/lavender.webp new file mode 100644 index 000000000..1a7be2b56 Binary files /dev/null and b/packages/plugin-trees/src/assets/lavender.webp differ diff --git a/packages/plugin-trees/src/assets/meadow.webp b/packages/plugin-trees/src/assets/meadow.webp new file mode 100644 index 000000000..056a463cc Binary files /dev/null and b/packages/plugin-trees/src/assets/meadow.webp differ diff --git a/packages/plugin-trees/src/assets/nature-icon.webp b/packages/plugin-trees/src/assets/nature-icon.webp new file mode 100644 index 000000000..89772af4d Binary files /dev/null and b/packages/plugin-trees/src/assets/nature-icon.webp differ diff --git a/packages/plugin-trees/src/assets/oak.webp b/packages/plugin-trees/src/assets/oak.webp new file mode 100644 index 000000000..5be2a1263 Binary files /dev/null and b/packages/plugin-trees/src/assets/oak.webp differ diff --git a/packages/plugin-trees/src/assets/pine.webp b/packages/plugin-trees/src/assets/pine.webp new file mode 100644 index 000000000..f12ce0319 Binary files /dev/null and b/packages/plugin-trees/src/assets/pine.webp differ diff --git a/packages/plugin-trees/src/assets/reed.webp b/packages/plugin-trees/src/assets/reed.webp new file mode 100644 index 000000000..4fcbb04ab Binary files /dev/null and b/packages/plugin-trees/src/assets/reed.webp differ diff --git a/packages/plugin-trees/src/assets/trellis.webp b/packages/plugin-trees/src/assets/trellis.webp new file mode 100644 index 000000000..ed3cac929 Binary files /dev/null and b/packages/plugin-trees/src/assets/trellis.webp differ diff --git a/packages/plugin-trees/src/assets/tulip.webp b/packages/plugin-trees/src/assets/tulip.webp new file mode 100644 index 000000000..3572715b9 Binary files /dev/null and b/packages/plugin-trees/src/assets/tulip.webp differ diff --git a/packages/plugin-trees/src/definition.ts b/packages/plugin-trees/src/definition.ts new file mode 100644 index 000000000..fb9dedc99 --- /dev/null +++ b/packages/plugin-trees/src/definition.ts @@ -0,0 +1,144 @@ +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 { + 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 = { + 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.', + }, +} diff --git a/packages/plugin-trees/src/find-sync.ts b/packages/plugin-trees/src/find-sync.ts new file mode 100644 index 000000000..2b8670524 --- /dev/null +++ b/packages/plugin-trees/src/find-sync.ts @@ -0,0 +1,40 @@ +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 (via + * `panelRegistry.panelForKind`) — 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 = { + 'trees:tree': 'trees', + 'trees:flower': 'flowers', + 'trees:grass': 'grass', +} + +emitter.on( + 'selection:find-node' as never, + ((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') + } + }) as never, +) diff --git a/packages/plugin-trees/src/floorplan.ts b/packages/plugin-trees/src/floorplan.ts new file mode 100644 index 000000000..fed329699 --- /dev/null +++ b/packages/plugin-trees/src/floorplan.ts @@ -0,0 +1,125 @@ +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 } +} diff --git a/packages/plugin-trees/src/flower-definition.ts b/packages/plugin-trees/src/flower-definition.ts new file mode 100644 index 000000000..4c699b1f3 --- /dev/null +++ b/packages/plugin-trees/src/flower-definition.ts @@ -0,0 +1,83 @@ +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 = { + 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.', + }, +} diff --git a/packages/plugin-trees/src/flower-geometry.ts b/packages/plugin-trees/src/flower-geometry.ts new file mode 100644 index 000000000..56960b697 --- /dev/null +++ b/packages/plugin-trees/src/flower-geometry.ts @@ -0,0 +1,115 @@ +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() + +/** 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 +} diff --git a/packages/plugin-trees/src/flower-parametrics.ts b/packages/plugin-trees/src/flower-parametrics.ts new file mode 100644 index 000000000..b81dda986 --- /dev/null +++ b/packages/plugin-trees/src/flower-parametrics.ts @@ -0,0 +1,22 @@ +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 = { + 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' }], + }, + ], +} diff --git a/packages/plugin-trees/src/flower-presets.ts b/packages/plugin-trees/src/flower-presets.ts new file mode 100644 index 000000000..b7fb7e3d4 --- /dev/null +++ b/packages/plugin-trees/src/flower-presets.ts @@ -0,0 +1,54 @@ +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 = { + 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] diff --git a/packages/plugin-trees/src/flower-preview.tsx b/packages/plugin-trees/src/flower-preview.tsx new file mode 100644 index 000000000..9bb01f33e --- /dev/null +++ b/packages/plugin-trees/src/flower-preview.tsx @@ -0,0 +1,51 @@ +'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 ( + + {data.subMeshes.map((sub, i) => ( + + ))} + + ) +} diff --git a/packages/plugin-trees/src/flower-proxy-renderer.tsx b/packages/plugin-trees/src/flower-proxy-renderer.tsx new file mode 100644 index 000000000..27eb5e33a --- /dev/null +++ b/packages/plugin-trees/src/flower-proxy-renderer.tsx @@ -0,0 +1,13 @@ +'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 +} diff --git a/packages/plugin-trees/src/flower-schema.ts b/packages/plugin-trees/src/flower-schema.ts new file mode 100644 index 000000000..0edc0b0e4 --- /dev/null +++ b/packages/plugin-trees/src/flower-schema.ts @@ -0,0 +1,23 @@ +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 + +/** 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 diff --git a/packages/plugin-trees/src/flower-static-renderer.tsx b/packages/plugin-trees/src/flower-static-renderer.tsx new file mode 100644 index 000000000..99394a9bf --- /dev/null +++ b/packages/plugin-trees/src/flower-static-renderer.tsx @@ -0,0 +1,16 @@ +'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 ( + + ) +} diff --git a/packages/plugin-trees/src/flower-system.tsx b/packages/plugin-trees/src/flower-system.tsx new file mode 100644 index 000000000..0572acc24 --- /dev/null +++ b/packages/plugin-trees/src/flower-system.tsx @@ -0,0 +1,20 @@ +'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 ( + + getVariant={getVariant} + kind="trees:flower" + variantKeyOf={variantKeyOf} + /> + ) +} diff --git a/packages/plugin-trees/src/flower-tool.tsx b/packages/plugin-trees/src/flower-tool.tsx new file mode 100644 index 000000000..3538d2fff --- /dev/null +++ b/packages/plugin-trees/src/flower-tool.tsx @@ -0,0 +1,57 @@ +'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 ( + + + + ) +} diff --git a/packages/plugin-trees/src/geometry.ts b/packages/plugin-trees/src/geometry.ts new file mode 100644 index 000000000..f559986a4 --- /dev/null +++ b/packages/plugin-trees/src/geometry.ts @@ -0,0 +1,148 @@ +// 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 + 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() + +/** + * 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 + 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 +} diff --git a/packages/plugin-trees/src/grass-definition.ts b/packages/plugin-trees/src/grass-definition.ts new file mode 100644 index 000000000..ff48957c5 --- /dev/null +++ b/packages/plugin-trees/src/grass-definition.ts @@ -0,0 +1,83 @@ +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 = { + 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.', + }, +} diff --git a/packages/plugin-trees/src/grass-geometry.ts b/packages/plugin-trees/src/grass-geometry.ts new file mode 100644 index 000000000..6bca0aef5 --- /dev/null +++ b/packages/plugin-trees/src/grass-geometry.ts @@ -0,0 +1,55 @@ +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() + +/** 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 +} diff --git a/packages/plugin-trees/src/grass-parametrics.ts b/packages/plugin-trees/src/grass-parametrics.ts new file mode 100644 index 000000000..8b8b3eceb --- /dev/null +++ b/packages/plugin-trees/src/grass-parametrics.ts @@ -0,0 +1,22 @@ +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 = { + 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' }], + }, + ], +} diff --git a/packages/plugin-trees/src/grass-presets.ts b/packages/plugin-trees/src/grass-presets.ts new file mode 100644 index 000000000..cf5b6c0d9 --- /dev/null +++ b/packages/plugin-trees/src/grass-presets.ts @@ -0,0 +1,50 @@ +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 = { + 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] diff --git a/packages/plugin-trees/src/grass-preview.tsx b/packages/plugin-trees/src/grass-preview.tsx new file mode 100644 index 000000000..fece97ec8 --- /dev/null +++ b/packages/plugin-trees/src/grass-preview.tsx @@ -0,0 +1,51 @@ +'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 ( + + {data.subMeshes.map((sub, i) => ( + + ))} + + ) +} diff --git a/packages/plugin-trees/src/grass-proxy-renderer.tsx b/packages/plugin-trees/src/grass-proxy-renderer.tsx new file mode 100644 index 000000000..1fa1c5bab --- /dev/null +++ b/packages/plugin-trees/src/grass-proxy-renderer.tsx @@ -0,0 +1,13 @@ +'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 +} diff --git a/packages/plugin-trees/src/grass-schema.ts b/packages/plugin-trees/src/grass-schema.ts new file mode 100644 index 000000000..e6cf9ee53 --- /dev/null +++ b/packages/plugin-trees/src/grass-schema.ts @@ -0,0 +1,23 @@ +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 + +/** 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 diff --git a/packages/plugin-trees/src/grass-static-renderer.tsx b/packages/plugin-trees/src/grass-static-renderer.tsx new file mode 100644 index 000000000..0962aa261 --- /dev/null +++ b/packages/plugin-trees/src/grass-static-renderer.tsx @@ -0,0 +1,15 @@ +'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 ( + + ) +} diff --git a/packages/plugin-trees/src/grass-system.tsx b/packages/plugin-trees/src/grass-system.tsx new file mode 100644 index 000000000..918885ee5 --- /dev/null +++ b/packages/plugin-trees/src/grass-system.tsx @@ -0,0 +1,19 @@ +'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 ( + + getVariant={getVariant} + kind="trees:grass" + variantKeyOf={variantKeyOf} + /> + ) +} diff --git a/packages/plugin-trees/src/grass-tool.tsx b/packages/plugin-trees/src/grass-tool.tsx new file mode 100644 index 000000000..f04f86692 --- /dev/null +++ b/packages/plugin-trees/src/grass-tool.tsx @@ -0,0 +1,57 @@ +'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 ( + + + + ) +} diff --git a/packages/plugin-trees/src/index.ts b/packages/plugin-trees/src/index.ts new file mode 100644 index 000000000..07a6f3a98 --- /dev/null +++ b/packages/plugin-trees/src/index.ts @@ -0,0 +1,43 @@ +import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' +// 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, + ], + panels: [ + { + id: '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' diff --git a/packages/plugin-trees/src/instanced.tsx b/packages/plugin-trees/src/instanced.tsx new file mode 100644 index 000000000..511bdf2ec --- /dev/null +++ b/packages/plugin-trees/src/instanced.tsx @@ -0,0 +1,308 @@ +'use client' + +import { + type AnyNodeId, + sceneRegistry, + useLiveNodeOverrides, + useLiveTransforms, + useRegistry, + useScene, +} from '@pascal-app/core' +import { useNodeEvents, useViewer } from '@pascal-app/viewer' +import { 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({ + 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]) + return +} + +/** + * 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({ + nodes, + variantKeyOf, + getVariant, + localSpace = false, +}: { + nodes: N[] + variantKeyOf: (node: N) => string + getVariant: (node: N) => VariantData + localSpace?: boolean +}) { + const buckets = useMemo(() => { + const map = new Map() + 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) => ( + + ))} + + ) +} + +function Variant({ + 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) => ( + + ))} + + ) +} + +function InstancedSubMesh({ + subMesh, + nodes, + naturalHeight, + localSpace, +}: { + subMesh: SubMesh + nodes: N[] + naturalHeight: number + localSpace: boolean +}) { + const ref = useRef(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) + + useLayoutEffect(() => { + const mesh = ref.current + if (!mesh) return + 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) { + parent.updateWorldMatrix(true, false) + 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]) + + return ( + + ) +} + +// ── 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({ + node, + getVariant, + colliderRadius, +}: { + node: N + getVariant: (node: N) => VariantData + colliderRadius: (node: N) => number +}) { + const registeredRef = useRef(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 ( + + {!isExporting && ( + + + + + )} + + {variant && ( + + {variant.subMeshes.map((subMesh, i) => ( + + ))} + + )} + + + ) +} diff --git a/packages/plugin-trees/src/parametrics.ts b/packages/plugin-trees/src/parametrics.ts new file mode 100644 index 000000000..75ebc27c8 --- /dev/null +++ b/packages/plugin-trees/src/parametrics.ts @@ -0,0 +1,91 @@ +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 = { + 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 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 as never, + ), + }, + ], +} diff --git a/packages/plugin-trees/src/placement.tsx b/packages/plugin-trees/src/placement.tsx new file mode 100644 index 000000000..fc64fd52a --- /dev/null +++ b/packages/plugin-trees/src/placement.tsx @@ -0,0 +1,81 @@ +'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(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 } +} diff --git a/packages/plugin-trees/src/presets-panel.tsx b/packages/plugin-trees/src/presets-panel.tsx new file mode 100644 index 000000000..b095d43c9 --- /dev/null +++ b/packages/plugin-trees/src/presets-panel.tsx @@ -0,0 +1,267 @@ +'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 = { + trees: 'trees:tree', + flowers: 'trees:flower', + grass: 'trees:grass', +} +const NOUN: Record = { 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 ( +
+
+
+

Nature

+ + {count} planted + +
+ +

+ {arming + ? 'Click the ground to plant. Press Esc to stop.' + : `Pick ${mode === 'grass' ? 'a grass' : `a ${NOUN[mode]}`}, then click the ground.`} +

+
+ + {mode === 'trees' && } + {mode === 'flowers' && } + {mode === 'grass' && } + + +
+ ) +} + +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 ( + <> + + {selected !== 'trellis' && ( +
+ +
+ )} +
+ + {!leafless && ( + + )} + + +
+ + ) +} + +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 ( + <> + + + + ) +} + +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 ( + <> + + + + ) +} + +function PresetGrid({ + items, + selected, + onPick, +}: { + items: ReadonlyArray<{ id: T; label: string; thumbnail: string }> + selected: T | null + onPick: (id: T) => void +}) { + return ( +
+ {items.map((item) => { + const isSelected = selected === item.id + return ( + + ) + })} +
+ ) +} diff --git a/packages/plugin-trees/src/presets.ts b/packages/plugin-trees/src/presets.ts new file mode 100644 index 000000000..a43fe5888 --- /dev/null +++ b/packages/plugin-trees/src/presets.ts @@ -0,0 +1,111 @@ +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 + /** Default placement height keyed by size. */ + height: Record + /** Whether the size control applies (false for `trellis`). */ + sized: boolean + swatch: string + thumbnail: string +} + +function ezSizes(family: string): Record { + return { small: `${family} Small`, medium: `${family} Medium`, large: `${family} Large` } +} + +export const TREE_PRESETS: Record = { + 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 +} diff --git a/packages/plugin-trees/src/preview.tsx b/packages/plugin-trees/src/preview.tsx new file mode 100644 index 000000000..7992d9d27 --- /dev/null +++ b/packages/plugin-trees/src/preview.tsx @@ -0,0 +1,44 @@ +'use client' + +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)) + 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 +} diff --git a/packages/plugin-trees/src/proxy-renderer.tsx b/packages/plugin-trees/src/proxy-renderer.tsx new file mode 100644 index 000000000..18f8f9c06 --- /dev/null +++ b/packages/plugin-trees/src/proxy-renderer.tsx @@ -0,0 +1,16 @@ +'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 +} diff --git a/packages/plugin-trees/src/schema.ts b/packages/plugin-trees/src/schema.ts new file mode 100644 index 000000000..bf09708bd --- /dev/null +++ b/packages/plugin-trees/src/schema.ts @@ -0,0 +1,57 @@ +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 + +/** 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 + +/** ez-tree's two growth models — deciduous (spreading) vs evergreen (conical). */ +export const TreeType = z.enum(['deciduous', 'evergreen']) +export type TreeType = z.infer + +/** + * 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 diff --git a/packages/plugin-trees/src/static-renderer.tsx b/packages/plugin-trees/src/static-renderer.tsx new file mode 100644 index 000000000..36d657241 --- /dev/null +++ b/packages/plugin-trees/src/static-renderer.tsx @@ -0,0 +1,20 @@ +'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 ( + + ) +} diff --git a/packages/plugin-trees/src/store.ts b/packages/plugin-trees/src/store.ts new file mode 100644 index 000000000..f07e4c9a2 --- /dev/null +++ b/packages/plugin-trees/src/store.ts @@ -0,0 +1,80 @@ +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((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 }), +})) diff --git a/packages/plugin-trees/src/system.tsx b/packages/plugin-trees/src/system.tsx new file mode 100644 index 000000000..cbdfa4753 --- /dev/null +++ b/packages/plugin-trees/src/system.tsx @@ -0,0 +1,25 @@ +'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 ( + + getVariant={getVariant} + kind="trees:tree" + variantKeyOf={variantKeyOf} + /> + ) +} diff --git a/packages/plugin-trees/src/tool.tsx b/packages/plugin-trees/src/tool.tsx new file mode 100644 index 000000000..02089da40 --- /dev/null +++ b/packages/plugin-trees/src/tool.tsx @@ -0,0 +1,74 @@ +'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 { 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 brush = useTreesStore() + + const previewNode = useMemo( + () => + TreeNode.parse({ + preset: brush.preset, + size: brush.size, + height: brush.height, + foliageDensity: brush.foliageDensity, + trunkThickness: brush.trunkThickness, + leafless: brush.leafless, + // seed/treeType left unset → the ghost shows the pure preset, as placed. + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + [ + brush.preset, + brush.size, + brush.height, + brush.foliageDensity, + brush.trunkThickness, + brush.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 ( + + + + ) +} diff --git a/packages/plugin-trees/src/variant-utils.ts b/packages/plugin-trees/src/variant-utils.ts new file mode 100644 index 000000000..b46c7cb3d --- /dev/null +++ b/packages/plugin-trees/src/variant-utils.ts @@ -0,0 +1,27 @@ +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 + } +} diff --git a/packages/plugin-trees/src/wind-node.ts b/packages/plugin-trees/src/wind-node.ts new file mode 100644 index 000000000..4b4a0fb12 --- /dev/null +++ b/packages/plugin-trees/src/wind-node.ts @@ -0,0 +1,132 @@ +import type { Color, Material, Side, Texture } from 'three' +import { cos, Fn, float, instanceIndex, positionLocal, sin, time, uv } from 'three/tsl' +import { MeshStandardNodeMaterial } 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() + +/** 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() + +/** 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 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: 1, + metalness: 0, + }) + if (material.name === 'leaves') node.positionNode = LEAF_FLUTTER + cache.set(material, node) + return node +} + +/** Build a swaying node material for the procedural kinds (flowers/grass). */ +export function windStandardMaterial( + params: ConstructorParameters[0], +): MeshStandardNodeMaterial { + const material = new MeshStandardNodeMaterial(params) + material.positionNode = STEM_BEND + return material +} + +const staticCache = new WeakMap() + +/** Windless twin of a wind material — same look, no `positionNode`. 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 +} diff --git a/packages/plugin-trees/tsconfig.json b/packages/plugin-trees/tsconfig.json new file mode 100644 index 000000000..7eb9c7ec7 --- /dev/null +++ b/packages/plugin-trees/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@pascal/typescript-config/react-library.json", + "compilerOptions": { + "rootDir": "src", + "noEmit": true, + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules"] +} diff --git a/packages/viewer/src/components/viewer/glb-reference-nodes.tsx b/packages/viewer/src/components/viewer/glb-reference-nodes.tsx index f1814a2d2..3937affdf 100644 --- a/packages/viewer/src/components/viewer/glb-reference-nodes.tsx +++ b/packages/viewer/src/components/viewer/glb-reference-nodes.tsx @@ -1,19 +1,25 @@ 'use client' -import { type AnyNode, nodeRegistry, type RendererSource, type SceneGraph } from '@pascal-app/core' +import { + type AnyNode, + bakePolicyOf, + nodeRegistry, + type RendererSource, + type SceneGraph, +} from '@pascal-app/core' import { createPortal } from '@react-three/fiber' -import { Suspense } from 'react' +import { memo, Suspense } from 'react' import type { Object3D } from 'three' import { getRegistryRenderer } from '../renderers/node-renderer' /** - * Scans (LiDAR meshes) and guides (floorplan images) are stripped from the - * baked GLB — they're heavy reference assets stored elsewhere. The GLB viewer - * re-adds them at runtime from the scene graph, portaled into their parent + * Kinds with `def.bake === 'strip'` (scans/LiDAR, guides/floorplan images) are + * excluded from the baked GLB — heavy reference assets stored elsewhere. The GLB + * viewer re-adds them at runtime from the scene graph, portaled into their parent * level's baked node so they ride level stacking, using the same registry - * renderers as the parametric viewer. Privacy is enforced upstream: the page - * only includes nodes whose `show_*_public` flag (or owner/admin) allows it, so - * a disallowed asset is never even fetched. + * renderers as the parametric viewer. Selection is registry-driven; scan/guide + * privacy is enforced upstream (`show_*_public`), so a disallowed asset is never + * even fetched — we still honour the flags here as a second gate. */ export function buildGlbReferenceNodes( sceneGraph: SceneGraph | null | undefined, @@ -24,13 +30,40 @@ export function buildGlbReferenceNodes( const out: AnyNode[] = [] for (const raw of Object.values(nodes)) { const node = raw as AnyNode - if (node.type === 'scan' && allow.scans) out.push(node) - else if (node.type === 'guide' && allow.guides) out.push(node) + if (bakePolicyOf(node.type) !== 'strip') continue + if (node.type === 'scan' && !allow.scans) continue + if (node.type === 'guide' && !allow.guides) continue + out.push(node) } return out } -export function GlbReferenceNodes({ +/** + * Kinds with `def.bake === 'replace'` — baked as static geometry (so plain glTF + * viewers still show them) but re-rendered live here, since their runtime look + * differs from a frozen snapshot (shader wind, interactivity). `GlbScene` hides + * the baked meshes for these kinds; this feeds them back through the same + * portal-into-level path as reference nodes. Registry-driven; no privacy gate + * (dynamic scene content, not user uploads). + */ +export function buildGlbReplaceNodes(sceneGraph: SceneGraph | null | undefined): AnyNode[] { + const nodes = sceneGraph?.nodes + if (!nodes) return [] + const out: AnyNode[] = [] + for (const raw of Object.values(nodes)) { + const node = raw as AnyNode + if (bakePolicyOf(node.type) === 'replace') out.push(node) + } + return out +} + +// Memoized: `GlbScene` re-renders every frame during camera movement (hover +// raycast, walkthrough HUD). Without memo, all rebuilt nodes reconcile each +// frame — negligible for one or two scans/guides, but a `replace` kind can put +// dozens of nodes here (e.g. a forest), so each frame reconciles dozens of +// portals + submeshes. `nodes` and `identity` are stable references (page-level +// `useState` / `useMemo([gltf.scene])`), so memo short-circuits cleanly. +export const GlbReferenceNodes = memo(function GlbReferenceNodes({ nodes, identity, }: { @@ -45,12 +78,20 @@ export function GlbReferenceNodes({ })} ) -} +}) -/** Render one scan/guide via its registry renderer, portaled into its parent - * level's baked Object3D (so the node's level-local transform resolves to the - * same world pose as the parametric scene). */ -function GlbReferenceNode({ node, anchor }: { node: AnyNode; anchor: Object3D }) { +/** Render one `strip` node (scan/guide) via its registry `renderer`, portaled + * into its parent level's baked Object3D (so the node's level-local transform + * resolves to the same world pose as the parametric scene). Memoized so an + * unchanged `(node, anchor)` skips the subtree when the parent re-renders on + * camera move. (`replace` kinds use the collective `GlbReplaceInstances` path.) */ +const GlbReferenceNode = memo(function GlbReferenceNode({ + node, + anchor, +}: { + node: AnyNode + anchor: Object3D +}) { const source = nodeRegistry.get(node.type)?.renderer const Renderer = source ? getRegistryRenderer(source as RendererSource) : null if (!Renderer) return null @@ -60,4 +101,4 @@ function GlbReferenceNode({ node, anchor }: { node: AnyNode; anchor: Object3D }) , anchor, ) -} +}) diff --git a/packages/viewer/src/components/viewer/glb-replace-instances.tsx b/packages/viewer/src/components/viewer/glb-replace-instances.tsx new file mode 100644 index 000000000..7223b2efa --- /dev/null +++ b/packages/viewer/src/components/viewer/glb-replace-instances.tsx @@ -0,0 +1,79 @@ +'use client' + +import { type AnyNode, type BakeReplaceRenderer, nodeRegistry } from '@pascal-app/core' +import { createPortal } from '@react-three/fiber' +import { type ComponentType, Fragment, lazy, memo, type ReactNode, Suspense, useMemo } from 'react' +import type { Object3D } from 'three' + +// Lazy components cached by their source so React.lazy isn't re-invoked per render. +const lazyCache = new WeakMap, ComponentType<{ nodes: AnyNode[] }>>() + +function getReplaceRenderer( + source: BakeReplaceRenderer, +): ComponentType<{ nodes: AnyNode[] }> { + const cached = lazyCache.get(source) + if (cached) return cached + const Comp = lazy(source.module) as unknown as ComponentType<{ nodes: AnyNode[] }> + lazyCache.set(source, Comp) + return Comp +} + +/** + * Re-renders `bake: 'replace'` kinds (e.g. plugin trees) live over the baked GLB. + * The baked static meshes are hidden by `GlbScene`; these nodes are grouped by + * `(parent level, kind)` and each group is handed to the kind's collective + * `bakeReplaceRenderer` (an instanced renderer), portaled into that level's baked + * `Object3D`. Local-space instances therefore ride level stacking/explode for + * free, and a forest stays a few instanced draw calls. + * + * Memoized: `GlbScene` re-renders each frame on camera move; `nodes` and + * `identity` are stable refs, so this whole subtree short-circuits. + */ +export const GlbReplaceInstances = memo(function GlbReplaceInstances({ + nodes, + identity, +}: { + nodes: AnyNode[] + identity: Map +}) { + const byLevel = useMemo(() => { + const levels = new Map>() + for (const node of nodes) { + const parentId = node.parentId + if (!parentId) continue + let byKind = levels.get(parentId) + if (!byKind) { + byKind = new Map() + levels.set(parentId, byKind) + } + const list = byKind.get(node.type) + if (list) list.push(node) + else byKind.set(node.type, [node]) + } + return levels + }, [nodes]) + + const portals: ReactNode[] = [] + byLevel.forEach((byKind, parentId) => { + const anchor = identity.get(parentId) + if (!anchor) return + byKind.forEach((kindNodes, kind) => { + const source = nodeRegistry.get(kind)?.bakeReplaceRenderer as + | BakeReplaceRenderer + | undefined + if (!source) return + const Renderer = getReplaceRenderer(source) + portals.push( + + {createPortal( + + + , + anchor, + )} + , + ) + }) + }) + return <>{portals} +}) diff --git a/packages/viewer/src/components/viewer/glb-scene.tsx b/packages/viewer/src/components/viewer/glb-scene.tsx index 3383b5998..72ffa64be 100644 --- a/packages/viewer/src/components/viewer/glb-scene.tsx +++ b/packages/viewer/src/components/viewer/glb-scene.tsx @@ -1,6 +1,6 @@ 'use client' -import type { AnyNode, SurfaceRole } from '@pascal-app/core' +import { type AnyNode, bakePolicyOf, type SurfaceRole } from '@pascal-app/core' import { Html, useAnimations } from '@react-three/drei' import { type ThreeEvent, useFrame, useThree } from '@react-three/fiber' import { useCallback, useEffect, useMemo, useRef } from 'react' @@ -8,12 +8,14 @@ import * as THREE from 'three' import { lerp } from 'three/src/math/MathUtils.js' import { color, float, uniform, uv } from 'three/tsl' import { MeshBasicNodeMaterial } from 'three/webgpu' +import { acceleratedRaycast, computeBoundsTree, disposeBoundsTree } from 'three-mesh-bvh' import { useGLTFKTX2 } from '../../hooks/use-gltf-ktx2' import { ZONE_LAYER } from '../../lib/layers' import { createSurfaceRoleMaterial } from '../../lib/materials' import useViewer from '../../store/use-viewer' import { GlbInteractive, type GlbInteractiveItem } from './glb-interactive' import { GlbReferenceNodes } from './glb-reference-nodes' +import { GlbReplaceInstances } from './glb-replace-instances' /** Vertical gap added per floor in `exploded` level mode (matches LevelSystem). */ const EXPLODED_GAP = 5 @@ -283,6 +285,7 @@ export function GlbScene({ url, interactiveItems, referenceNodes, + replaceNodes, onLevelsChange, onIdentityChange, onHoverChange, @@ -295,6 +298,9 @@ export function GlbScene({ /** Scan / guide nodes from the scene graph, re-added at runtime (they're * stripped from the bake). Already filtered by the privacy flags upstream. */ referenceNodes?: AnyNode[] + /** `bake: 'replace'` nodes (e.g. plugin trees): baked static but re-rendered + * live here via their `bakeReplaceRenderer`; the baked meshes are hidden. */ + replaceNodes?: AnyNode[] onLevelsChange?: (levels: GlbLevel[]) => void onIdentityChange?: (identity: GlbIdentity) => void onHoverChange?: (hover: GlbHover) => void @@ -333,6 +339,45 @@ export function GlbScene({ }) }, [gltf.scene, textures, sceneTheme]) + // The baked scene isn't wrapped in (the community viewer runs with + // useBvh={false}), so hover/pick raycasts against the baked building were + // brute-force triangle tests — dozens of ms per pointer move on a dense scene, + // and far worse once a `replace` forest is portaled in. Give each baked mesh a + // BVH so those raycasts are accelerated. `replace` instances are NO_RAYCAST, so + // the `raycast === Mesh.prototype.raycast` guard skips them (mirrors SceneBvh). + useEffect(() => { + const accelerated = new Set() + const computed = new Set() + gltf.scene.traverse((child) => { + const mesh = child as THREE.Mesh + if (!mesh.isMesh || mesh.raycast !== THREE.Mesh.prototype.raycast) return + mesh.raycast = acceleratedRaycast + accelerated.add(mesh) + const geometry = mesh.geometry + if (geometry.boundsTree || !geometry.getAttribute('position')) return + try { + // three-mesh-bvh + @types/three disagree on the helper signatures; cast + // through unknown like SceneBvh does — the runtime call is correct. + ;(geometry as { computeBoundsTree?: unknown }).computeBoundsTree = + computeBoundsTree as unknown as typeof geometry.computeBoundsTree + ;(geometry as { disposeBoundsTree?: unknown }).disposeBoundsTree = + disposeBoundsTree as unknown as typeof geometry.disposeBoundsTree + geometry.computeBoundsTree() + computed.add(geometry) + } catch (error) { + console.warn('[viewer] skipping BVH for baked mesh geometry', error) + } + }) + return () => { + for (const geometry of computed) { + if (geometry.boundsTree) geometry.disposeBoundsTree() + } + for (const mesh of accelerated) { + if (mesh.raycast === acceleratedRaycast) mesh.raycast = THREE.Mesh.prototype.raycast + } + } + }, [gltf.scene]) + // One pass over the artifact: identity objects (id → Object3D), ordered floors, // and zone polygons. Levels stay out of `sceneRegistry` so the parametric // LevelSystem never re-stacks them. @@ -357,6 +402,11 @@ export function GlbScene({ } if (!extras.pascalId) return objects.set(extras.pascalId, object) + // `bake: 'replace'` kinds are baked as static geometry (portable), but a + // loaded plugin re-renders them live from the scene graph — hide the frozen + // baked mesh so only the live one shows. Gated on the registry, so with no + // plugin loaded the policy is `'static'` and the baked mesh stays. + if (bakePolicyOf(extras.kind ?? '') === 'replace') object.visible = false if (extras.kind === 'building') buildingNode = object else if (extras.kind === 'site') siteNode = object if (extras.kind === 'ceiling' || extras.kind === 'roof') occluderNodes.push(object) @@ -1011,6 +1061,11 @@ export function GlbScene({ {referenceNodes?.length ? ( ) : null} + {/* `bake: 'replace'` nodes (plugin trees): baked meshes hidden above, the + live instanced render portaled per level via each kind's bakeReplaceRenderer. */} + {replaceNodes?.length ? ( + + ) : null} {/* Floating room labels. Each group's matrix is synced to its zone node every frame (above) so the label rides level stacking; the div fades with the room fill via a CSS transition. */} diff --git a/packages/viewer/src/index.ts b/packages/viewer/src/index.ts index 761e4ce54..150c93246 100644 --- a/packages/viewer/src/index.ts +++ b/packages/viewer/src/index.ts @@ -23,7 +23,10 @@ export { GlbInteractive, type GlbInteractiveItem, } from './components/viewer/glb-interactive' -export { buildGlbReferenceNodes } from './components/viewer/glb-reference-nodes' +export { + buildGlbReferenceNodes, + buildGlbReplaceNodes, +} from './components/viewer/glb-reference-nodes' export { type GlbHover, type GlbIdentity, diff --git a/packages/viewer/src/store/use-viewer.ts b/packages/viewer/src/store/use-viewer.ts index 58e0d052c..ade93265f 100644 --- a/packages/viewer/src/store/use-viewer.ts +++ b/packages/viewer/src/store/use-viewer.ts @@ -41,6 +41,14 @@ type ViewerState = { renderContext: RenderContext setRenderContext: (context: RenderContext) => void + /** True during a GLB bake/export pass. Renderers that normally draw via a + * collective InstancedMesh (`def.system`) and mount only an invisible per-node + * proxy can watch this to emit real, visible geometry so the exporter — which + * clones only the `scene-renderer` subtree — captures them. Transient (never + * persisted). */ + isExporting: boolean + setExporting: (value: boolean) => void + shading: RenderShading shadingByContext: Partial> setShading: (shading: RenderShading) => void @@ -222,6 +230,9 @@ const useViewer = create()( renderContext: 'editor', setRenderContext: (context) => set({ renderContext: context }), + isExporting: false, + setExporting: (value) => set({ isExporting: value }), + shading: 'rendered', shadingByContext: {}, setShading: (shading) => diff --git a/wiki/architecture/plugin-authoring.md b/wiki/architecture/plugin-authoring.md index 41abc1b41..79518bb11 100644 --- a/wiki/architecture/plugin-authoring.md +++ b/wiki/architecture/plugin-authoring.md @@ -21,6 +21,10 @@ export const myPlugin: Plugin = { armchairDefinition, // ... ], + panels: [ + { id: 'catalog', label: 'Catalog', icon: { kind: 'iconify', name: 'lucide:sofa' }, + component: () => import('./catalog-panel') }, + ], } ``` @@ -28,7 +32,10 @@ export const myPlugin: Plugin = { |---|---|---| | `id` | yes | Globally unique. Use `vendor:pack-name` to avoid collisions. The host treats it as opaque. | | `apiVersion` | yes | Currently `1`. The host throws on mismatch — bumping breaks plugins, intentionally. | -| `nodes` | optional | Array of `AnyNodeDefinition`. May be empty for a pure-resource plugin (future). | +| `nodes` | optional | Array of `AnyNodeDefinition`. May be empty for a pure-panel plugin. | +| `panels` | optional | Array of `PluginPanel` — left-rail panels (see [Panel contributions](#panel-contributions)). | + +The first-party [`@pascal-app/plugin-trees`](../../packages/plugin-trees) package is the worked example: one node kind + one panel. Copy it as a starting point. The same shape powers the built-in `pascal:core` plugin in `@pascal-app/nodes` — there's no "internal" plugin format. Whatever works for built-ins works for third parties. @@ -51,6 +58,39 @@ A plugin's `nodes` array is the only meaningful contribution point in v1. Each e See [`node-definitions.md`](node-definitions.md) for the three-checkbox composition model that ties these together. +## Panel contributions + +A plugin can add its own panel to the sidebar **icon rail** via `plugin.panels`. Each entry: + +```ts +export type PluginPanel = { + id: string // unique within the plugin; host namespaces it as `${plugin.id}:${id}` + label: string // rail tooltip + panel header + icon: IconRef // same icon union node presentation uses (iconify / url / svg / component) + component: LazyComponent // () => import('./panel') — a default-exported React component +} +``` + +Behaviour: + +- **In-process React, no iframe.** The component mounts directly in the host React tree, lazily on first open. It can use the host stores (`useScene`, `useEditor`, `useViewer`) and its own Zustand stores freely. +- **Error-isolated.** Every plugin panel is wrapped in an error boundary with a "this plugin crashed" fallback. A throwing panel degrades to that fallback for the session instead of taking down the sidebar — other panels keep working. +- **Namespaced.** `loadPlugin` rewrites the panel id to `${plugin.id}:${panel.id}`, so two plugins can both ship `id: 'main'`. Host-provided panels (the app's own `extraSidebarPanels`) keep precedence on an id collision. +- **Styling.** Scope your CSS — no globals. Use the host sidebar CSS variables (`--sidebar`, `--sidebar-foreground`, `--sidebar-accent`, `--sidebar-border`, `--sidebar-ring`) so the panel reads as native in light/dark. + +The registry that backs this (`panelRegistry` in `@pascal-app/core`) is observable — discovery is async, so the sidebar subscribes and the rail icon appears as soon as the plugin loads. + +### Driving a tool from a panel + +A panel typically writes a choice into the plugin's own store, then arms placement: + +```ts +useEditor.getState().setTool('trees:tree') // a plugin tool id +useEditor.getState().setMode('build') +``` + +`Tool` is `KnownTool | (string & {})`, so a plugin's namespaced tool id (the node `kind`) typechecks without the host enumerating it. Dispatch is registry-first — `ToolManager` resolves `nodeRegistry.get(tool)?.tool` — so an unknown-to-the-host tool string flows straight through to the plugin's `def.tool` component. The placement tool reads the plugin store for *what* to place. That round-trip — panel → store → `def.tool` → `SceneApi` → scene → reactive `useScene` read-back in the panel — is the full plugin communication path; `plugin-trees` demonstrates it end to end. + ## Importing host packages A plugin imports from the published `@pascal-app/*` packages — same surface the built-ins use, peer-dependency-style: