From 74d9b8db9dc7157fe963cffb15cb393bbf677f62 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 30 Jun 2026 15:27:55 -0400 Subject: [PATCH 01/36] feat(plugins): trees tracer-bullet plugin + minimal host panel surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship one real first-party plugin and only the host surface it forces into existence, proving the plugin contribution paths every future plugin (mint.gg generator, Home Assistant, environments, room volume) will reuse. Host surface (core + editor): - core: Plugin.panels + observable panelRegistry; loadPlugin routes panels (namespaced by plugin id, dup-throws like nodes). - editor: AppSidebar merges panelRegistry into the icon rail via useSyncExternalStore; each plugin panel lazy-loaded behind an error boundary; host extraPanels keep precedence. - editor: widen Tool to `KnownTool | (string & {})` so plugin tool ids (e.g. 'trees:tree') typecheck; dispatch already registry-first. Trees plugin (packages/plugin-trees), structurally a third-party pack (peer-deps on @pascal-app/*): - trees:tree node — procedural low-poly geometry (oak/pine/birch/palm), free parametric inspector (preset/height/seed + Randomize), placement tool + ghost preview built from public primitives only. - presets rail panel — panel -> plugin store -> def.tool -> SceneApi -> scene -> reactive read-back ("N planted"). Loading + docs: - apps/editor: setPluginDiscovery([treesPlugin]) in bootstrap; transpilePackages + dep. - wiki/architecture/plugin-authoring.md: panels field, error-boundary contract, styling, tool-from-panel note. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/editor/lib/bootstrap.ts | 7 ++ apps/editor/next.config.ts | 1 + apps/editor/package.json | 1 + bun.lock | 30 +++++ packages/core/src/registry/index.ts | 2 + packages/core/src/registry/registry.ts | 66 +++++++++- packages/core/src/registry/types.ts | 17 +++ .../src/components/ui/sidebar/app-sidebar.tsx | 6 +- .../ui/sidebar/use-plugin-panels.tsx | 91 ++++++++++++++ packages/editor/src/store/use-editor.tsx | 10 +- packages/plugin-trees/README.md | 48 ++++++++ packages/plugin-trees/package.json | 51 ++++++++ packages/plugin-trees/src/definition.ts | 80 ++++++++++++ packages/plugin-trees/src/geometry.ts | 110 +++++++++++++++++ packages/plugin-trees/src/index.ts | 27 ++++ packages/plugin-trees/src/parametrics.ts | 40 ++++++ packages/plugin-trees/src/presets-panel.tsx | 70 +++++++++++ packages/plugin-trees/src/presets.ts | 52 ++++++++ packages/plugin-trees/src/preview.tsx | 44 +++++++ packages/plugin-trees/src/schema.ts | 28 +++++ packages/plugin-trees/src/store.ts | 18 +++ packages/plugin-trees/src/tool.tsx | 115 ++++++++++++++++++ packages/plugin-trees/tsconfig.json | 10 ++ wiki/architecture/plugin-authoring.md | 42 ++++++- 24 files changed, 961 insertions(+), 5 deletions(-) create mode 100644 packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx create mode 100644 packages/plugin-trees/README.md create mode 100644 packages/plugin-trees/package.json create mode 100644 packages/plugin-trees/src/definition.ts create mode 100644 packages/plugin-trees/src/geometry.ts create mode 100644 packages/plugin-trees/src/index.ts create mode 100644 packages/plugin-trees/src/parametrics.ts create mode 100644 packages/plugin-trees/src/presets-panel.tsx create mode 100644 packages/plugin-trees/src/presets.ts create mode 100644 packages/plugin-trees/src/preview.tsx create mode 100644 packages/plugin-trees/src/schema.ts create mode 100644 packages/plugin-trees/src/store.ts create mode 100644 packages/plugin-trees/src/tool.tsx create mode 100644 packages/plugin-trees/tsconfig.json 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..76aa09af9 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -13,6 +13,7 @@ const nextConfig: NextConfig = { '@pascal-app/core', '@pascal-app/editor', '@pascal-app/mcp', + '@pascal-app/plugin-trees', ], 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..0bbc244df 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,33 @@ "zustand": "^5", }, }, + "packages/plugin-trees": { + "name": "@pascal-app/plugin-trees", + "version": "0.1.0", + "devDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@pascal/typescript-config": "*", + "@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": "^18 || ^19", + "three": "^0.184", + "zod": "^4", + "zustand": "^5", + }, + }, "packages/typescript-config": { "name": "@repo/typescript-config", "version": "0.0.0", @@ -683,6 +711,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..c0adf8457 100644 --- a/packages/core/src/registry/index.ts +++ b/packages/core/src/registry/index.ts @@ -30,6 +30,7 @@ export { loadPlugin, nodeRegistry, type PluginDiscovery, + panelRegistry, registerNode, resolveFacingIndicator, setPluginDiscovery, @@ -100,6 +101,7 @@ export type { 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..273a677ed 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, NodeRegistry, Plugin, PluginPanel } from './types' const HOST_API_VERSION = 1 as const @@ -86,6 +86,64 @@ 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[] = [] + + 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() + } + + // Test-only — clears the registry. Not exported from the package barrel. + _reset(): void { + this.panels.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[] + _register: (panel: PluginPanel) => void + _reset: () => void +} = new PanelRegistryImpl() + /** * Returns the set of registered kinds whose definition declares the * `selectable` capability. Callers that maintain hardcoded "selectable kinds" @@ -225,6 +283,12 @@ 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}` }) + } } /** diff --git a/packages/core/src/registry/types.ts b/packages/core/src/registry/types.ts index db3b10eff..427f3382b 100644 --- a/packages/core/src/registry/types.ts +++ b/packages/core/src/registry/types.ts @@ -695,10 +695,27 @@ 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. + */ +export type PluginPanel = { + id: string + label: string + icon: IconRef + component: LazyComponent +} + export type Plugin = { id: string apiVersion: 1 nodes?: AnyNodeDefinition[] + panels?: PluginPanel[] } // ─── NodeDefinition ────────────────────────────────────────────────── 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..731c34092 --- /dev/null +++ b/packages/editor/src/components/ui/sidebar/use-plugin-panels.tsx @@ -0,0 +1,91 @@ +'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 { 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. + */ +export function usePluginPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] { + const registered = useSyncExternalStore( + panelRegistry.subscribe, + panelRegistry.getSnapshot, + panelRegistry.getSnapshot, + ) + const hostIds = new Set(hostPanels?.map((p) => p.id)) + const fromRegistry = registered + .filter((p) => !hostIds.has(p.id)) + .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/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/plugin-trees/README.md b/packages/plugin-trees/README.md new file mode 100644 index 000000000..45843864b --- /dev/null +++ b/packages/plugin-trees/README.md @@ -0,0 +1,48 @@ +# @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`/`zustand`) and imports +nothing private. Copy this folder as the starting point for a new plugin. + +## What it demonstrates + +The three 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. **3D render + placement for free** — `def.geometry` (`geometry.ts`, a pure + `three` builder) and `def.tool`/`def.preview` (`tool.tsx`, `preview.tsx`). + +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). + +## Manifest + +```ts +import { treesPlugin } from '@pascal-app/plugin-trees' +// host: +setPluginDiscovery(async () => [treesPlugin]) +``` + +`treesPlugin` exports one node kind (`trees:tree`) 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. + +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..a4d2297c3 --- /dev/null +++ b/packages/plugin-trees/package.json @@ -0,0 +1,51 @@ +{ + "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" + }, + "peerDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "react": "^18 || ^19", + "three": "^0.184", + "zod": "^4", + "zustand": "^5" + }, + "devDependencies": { + "@pascal-app/core": "*", + "@pascal-app/editor": "*", + "@pascal-app/viewer": "*", + "@pascal/typescript-config": "*", + "@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/definition.ts b/packages/plugin-trees/src/definition.ts new file mode 100644 index 000000000..58c9959f4 --- /dev/null +++ b/packages/plugin-trees/src/definition.ts @@ -0,0 +1,80 @@ +import type { NodeDefinition } from '@pascal-app/core' +import { buildTreeGeometry } from './geometry' +import { treeParametrics } from './parametrics' +import { TreeNode } from './schema' + +/** + * The tree node definition. Three-checkbox composition like the built-ins: + * `geometry` for 3D, `parametrics` for the inspector, `tool`/`preview` for + * placement — all pure/lazy, no host dispatch code. The host renders, inspects, + * moves, and persists trees purely from this descriptor. + */ +export const treeDefinition: NodeDefinition = { + kind: 'trees:tree', + 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', + height: 5, + seed: 1, + }), + + capabilities: { + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { axes: ['y'], snapAngles: [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2] }, + selectable: { hitVolume: 'bbox' }, + duplicable: true, + deletable: true, + groupable: true, + snappable: {}, + floorPlaced: { + // `footprint` receives the host's `AnyNode`; cast to our schema type the + // same way built-in kinds do (`node as ShelfNode`). + footprint: (node) => { + const tree = node as unknown as TreeNode + const radius = Math.max(0.5, tree.height * 0.28) + return { + dimensions: [radius * 2, tree.height, radius * 2] as [number, number, number], + rotation: tree.rotation, + } + }, + collides: false, + }, + }, + + parametrics: treeParametrics, + + // Pure builder + a cache key over only the geometry-relevant fields, so a + // move or reparent never rebuilds the mesh. + geometry: (node) => buildTreeGeometry(node), + geometryKey: (n) => JSON.stringify([n.preset, n.height, n.seed]), + + preview: () => import('./preview'), + tool: () => import('./tool'), + toolHints: [ + { key: 'Left click', label: 'Plant tree' }, + { key: 'Esc', label: 'Stop' }, + ], + + presentation: { + label: 'Tree', + description: 'A procedural low-poly tree. Oak, pine, birch, or palm.', + icon: { kind: 'iconify', name: 'lucide:trees' }, + paletteSection: 'furnish', + hidden: true, + }, + + mcp: { + description: + 'A procedural tree (example plugin node). Four presets — oak, pine, birch, palm — with adjustable height and a seed for canopy variation.', + }, +} diff --git a/packages/plugin-trees/src/geometry.ts b/packages/plugin-trees/src/geometry.ts new file mode 100644 index 000000000..1461ce7a9 --- /dev/null +++ b/packages/plugin-trees/src/geometry.ts @@ -0,0 +1,110 @@ +import { + ConeGeometry, + CylinderGeometry, + Group, + IcosahedronGeometry, + Mesh, + MeshStandardMaterial, + SphereGeometry, +} from 'three' +import { TREE_PRESETS } from './presets' +import type { TreeNode } from './schema' + +/** + * Pure procedural tree builder. Returns a `Group` of low-poly meshes in local + * space with the base at y=0 growing along +Y — the framework's renderer + * applies the node's position/rotation, so this never bakes world placement in. + * Depends only on `three` + the node's own `preset`/`height`/`seed`, which is + * why the definition's `geometryKey` can skip rebuilds on move/reparent. + * + * `seed` drives a tiny deterministic RNG so two oaks with different seeds get + * subtly different canopies without persisting per-vertex data. + */ +export function buildTreeGeometry(node: TreeNode): Group { + const group = new Group() + const spec = TREE_PRESETS[node.preset] ?? TREE_PRESETS.oak + const height = Math.max(0.5, node.height) + const rng = mulberry32(node.seed >>> 0) + + const trunkHeight = height * spec.trunkFraction + const trunkRadius = Math.max(0.05, height * 0.04) + const trunkMat = new MeshStandardMaterial({ color: spec.trunkColor, roughness: 0.9 }) + const foliageMat = new MeshStandardMaterial({ color: spec.foliageColor, roughness: 0.8 }) + + const trunk = new Mesh( + new CylinderGeometry(trunkRadius * 0.7, trunkRadius, trunkHeight, 7), + trunkMat, + ) + trunk.position.y = trunkHeight / 2 + trunk.name = 'tree-trunk' + group.add(trunk) + + const canopyHeight = height - trunkHeight + const canopyBase = trunkHeight + + if (node.preset === 'pine') { + // Three stacked cones, narrowing toward the top. + const tiers = 3 + for (let i = 0; i < tiers; i++) { + const t = i / tiers + const radius = height * 0.26 * (1 - t * 0.55) * (0.92 + rng() * 0.16) + const tierHeight = (canopyHeight / tiers) * 1.5 + const cone = new Mesh(new ConeGeometry(radius, tierHeight, 8), foliageMat) + cone.position.y = canopyBase + canopyHeight * t + tierHeight * 0.25 + cone.name = `tree-canopy-${i}` + group.add(cone) + } + } else if (node.preset === 'palm') { + // A crown of angled cone fronds at the top of a tall bare trunk. + const fronds = 6 + const frondLength = height * 0.4 + for (let i = 0; i < fronds; i++) { + const angle = (i / fronds) * Math.PI * 2 + rng() * 0.4 + const frond = new Mesh(new ConeGeometry(height * 0.05, frondLength, 5), foliageMat) + frond.position.set( + Math.cos(angle) * frondLength * 0.35, + canopyBase + frondLength * 0.2, + Math.sin(angle) * frondLength * 0.35, + ) + frond.rotation.z = Math.PI / 2.4 + frond.rotation.y = -angle + frond.name = `tree-frond-${i}` + group.add(frond) + } + } else { + // oak / birch — a rounded canopy: a low-poly icosahedron plus a couple of + // smaller offset spheres for a hand-clustered look. + const canopyRadius = height * 0.28 + const crown = new Mesh(new IcosahedronGeometry(canopyRadius, 0), foliageMat) + crown.position.y = canopyBase + canopyHeight * 0.5 + crown.name = 'tree-canopy' + group.add(crown) + + const blobs = 2 + for (let i = 0; i < blobs; i++) { + const blob = new Mesh(new SphereGeometry(canopyRadius * 0.6, 6, 5), foliageMat) + const angle = rng() * Math.PI * 2 + blob.position.set( + Math.cos(angle) * canopyRadius * 0.6, + canopyBase + canopyHeight * (0.4 + rng() * 0.4), + Math.sin(angle) * canopyRadius * 0.6, + ) + blob.name = `tree-canopy-blob-${i}` + group.add(blob) + } + } + + return group +} + +/** Deterministic 32-bit RNG (mulberry32) — same seed ⇒ same canopy. */ +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/index.ts b/packages/plugin-trees/src/index.ts new file mode 100644 index 000000000..7d17c5e38 --- /dev/null +++ b/packages/plugin-trees/src/index.ts @@ -0,0 +1,27 @@ +import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' +import { treeDefinition } from './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: one node kind + * (`trees:tree`) 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], + panels: [ + { + id: 'trees', + label: 'Trees', + icon: { kind: 'iconify', name: 'lucide:trees' }, + component: () => import('./presets-panel'), + }, + ], +} + +export { treeDefinition } from './definition' +export { buildTreeGeometry } from './geometry' +export { TreeNode, TreePreset } from './schema' diff --git a/packages/plugin-trees/src/parametrics.ts b/packages/plugin-trees/src/parametrics.ts new file mode 100644 index 000000000..f6e7aec21 --- /dev/null +++ b/packages/plugin-trees/src/parametrics.ts @@ -0,0 +1,40 @@ +import { type AnyNodeId, type ParametricDescriptor, useScene } from '@pascal-app/core' +import type { TreeNode } from './schema' + +/** + * The tree's right-hand inspector. This descriptor is the entire inspector — + * the host's `ParametricInspector` renders the preset select, height slider, + * seed field, 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. + */ +export const treeParametrics: ParametricDescriptor = { + groups: [ + { + label: 'Tree', + fields: [ + { key: 'preset', kind: 'enum', options: ['oak', 'pine', 'birch', 'palm'] }, + { key: 'height', kind: 'number', unit: 'm', min: 1, max: 15, step: 0.5 }, + { key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 }, + ], + }, + { + label: 'Position', + fields: [{ key: 'position', kind: 'vec3' }], + }, + ], + actions: [ + { + label: 'Randomize', + // The action receives the live node; writing a new seed re-runs the + // geometry builder (seed is part of the definition's geometryKey). + onClick: (n) => + useScene.getState().updateNode( + n.id as AnyNodeId, + { + seed: Math.floor(Math.random() * 10000), + } as Partial as never, + ), + }, + ], +} diff --git a/packages/plugin-trees/src/presets-panel.tsx b/packages/plugin-trees/src/presets-panel.tsx new file mode 100644 index 000000000..497977076 --- /dev/null +++ b/packages/plugin-trees/src/presets-panel.tsx @@ -0,0 +1,70 @@ +'use client' + +import { useScene } from '@pascal-app/core' +import { useEditor } from '@pascal-app/editor' +import { TREE_PRESET_LIST } from './presets' +import type { TreePreset } from './schema' +import { useTreesStore } from './store' + +/** + * The plugin's own left-rail panel. Clicking a preset card writes the choice + * into the plugin store and arms placement (`setTool('trees:tree')` + + * `setMode('build')`) — mirroring how the community catalog activates the item + * tool. The "N planted" counter reads the scene reactively, closing the + * communication triangle: panel → store → tool → scene → panel. + * + * Styling is scoped + uses the host's sidebar CSS variables so it looks native + * without leaking globals. + */ +export default function TreesPanel() { + const selected = useTreesStore((s) => s.preset) + const setPreset = useTreesStore((s) => s.setPreset) + const activeTool = useEditor((s) => s.tool) + const treeCount = useScene( + (s) => Object.values(s.nodes).filter((n) => (n.type as string) === 'trees:tree').length, + ) + + const activate = (preset: TreePreset) => { + setPreset(preset) + useEditor.getState().setTool('trees:tree') + useEditor.getState().setMode('build') + } + + const arming = activeTool === 'trees:tree' + + return ( +
+
+

Trees

+ {treeCount} planted +
+

+ {arming ? 'Click the ground to plant. Esc to stop.' : 'Pick a tree, then click the ground.'} +

+
+ {TREE_PRESET_LIST.map((spec) => { + const isSelected = selected === spec.id && arming + return ( + + ) + })} +
+
+ ) +} diff --git a/packages/plugin-trees/src/presets.ts b/packages/plugin-trees/src/presets.ts new file mode 100644 index 000000000..fdbba3be4 --- /dev/null +++ b/packages/plugin-trees/src/presets.ts @@ -0,0 +1,52 @@ +import type { TreePreset } from './schema' + +/** Per-preset appearance + proportions read by the geometry builder and the + * presets panel. Pure data — no Three.js, no React — so both the panel grid and + * the mesh builder stay in lockstep. */ +export type TreePresetSpec = { + id: TreePreset + label: string + /** Default overall height in metres when this preset is first placed. */ + defaultHeight: number + trunkColor: string + foliageColor: string + /** Fraction of total height taken by the bare trunk before foliage starts. */ + trunkFraction: number +} + +export const TREE_PRESETS: Record = { + oak: { + id: 'oak', + label: 'Oak', + defaultHeight: 5, + trunkColor: '#6b4f3a', + foliageColor: '#4f7942', + trunkFraction: 0.4, + }, + pine: { + id: 'pine', + label: 'Pine', + defaultHeight: 6, + trunkColor: '#5c4433', + foliageColor: '#2f5d3a', + trunkFraction: 0.18, + }, + birch: { + id: 'birch', + label: 'Birch', + defaultHeight: 5.5, + trunkColor: '#d8d2c4', + foliageColor: '#7aa760', + trunkFraction: 0.5, + }, + palm: { + id: 'palm', + label: 'Palm', + defaultHeight: 6.5, + trunkColor: '#9c7a4d', + foliageColor: '#3f8f5a', + trunkFraction: 0.82, + }, +} + +export const TREE_PRESET_LIST: TreePresetSpec[] = Object.values(TREE_PRESETS) diff --git a/packages/plugin-trees/src/preview.tsx b/packages/plugin-trees/src/preview.tsx new file mode 100644 index 000000000..8fd8f6d19 --- /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 { buildTreeGeometry } from './geometry' +import type { TreeNode } from './schema' + +/** + * Translucent placement ghost. Defers to `buildTreeGeometry` so the preview is + * always exactly what the commit will create, then clones each material for a + * see-through look and disables raycast so the ghost never intercepts the + * cursor ray (which would freeze `grid:move`). Same contract as the built-in + * shelf preview. + */ +export default function TreePreview({ node }: { node: TreeNode }) { + const built = useMemo(() => buildTreeGeometry(node), [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.traverse((obj) => { + const mesh = obj as { geometry?: { dispose: () => void } } + mesh.geometry?.dispose() + }) + } + }, [built]) + + return +} diff --git a/packages/plugin-trees/src/schema.ts b/packages/plugin-trees/src/schema.ts new file mode 100644 index 000000000..99e69c833 --- /dev/null +++ b/packages/plugin-trees/src/schema.ts @@ -0,0 +1,28 @@ +import { BaseNode, nodeType, objectId } from '@pascal-app/core' +import { z } from 'zod' + +/** Tree silhouettes the plugin can place. The string persists in scene JSON. */ +export const TreePreset = z.enum(['oak', 'pine', 'birch', 'palm']) +export type TreePreset = 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` — the same string the registry, + * the tool id, and the scene JSON all key on. `height`/`seed`/`preset` are the + * only geometry-relevant fields, so the definition's `geometryKey` is built + * from them. + */ +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'), + height: z.number().positive().default(4), + seed: z.number().int().default(1), +}) + +export type TreeNode = z.infer diff --git a/packages/plugin-trees/src/store.ts b/packages/plugin-trees/src/store.ts new file mode 100644 index 000000000..66f74d384 --- /dev/null +++ b/packages/plugin-trees/src/store.ts @@ -0,0 +1,18 @@ +import { create } from 'zustand' +import type { TreePreset } 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. + * The presets panel writes the chosen preset here; the placement tool reads it + * to know which tree to drop. No host lifecycle slot is involved. + */ +type TreesStore = { + preset: TreePreset + setPreset: (preset: TreePreset) => void +} + +export const useTreesStore = create((set) => ({ + preset: 'oak', + setPreset: (preset) => set({ preset }), +})) diff --git a/packages/plugin-trees/src/tool.tsx b/packages/plugin-trees/src/tool.tsx new file mode 100644 index 000000000..eca011fcf --- /dev/null +++ b/packages/plugin-trees/src/tool.tsx @@ -0,0 +1,115 @@ +'use client' + +import { + type AnyNode, + type AnyNodeId, + emitter, + type GridEvent, + sceneRegistry, + useScene, +} from '@pascal-app/core' +import { triggerSFX } from '@pascal-app/editor' +import { useViewer } from '@pascal-app/viewer' +import { useEffect, useMemo, useRef, useState } from 'react' +import { type Group, Vector3 } from 'three' +import { TREE_PRESETS } from './presets' +import TreePreview from './preview' +import { TreeNode, type TreePreset } from './schema' +import { useTreesStore } from './store' + +const worldVec = new Vector3() + +/** Default height for a preset, guarded against the noUncheckedIndexedAccess + * `| undefined` on the record lookup. */ +function presetHeight(preset: TreePreset): number { + return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).defaultHeight +} + +/** + * Convert a world-space grid hit into the active level's local frame, the way + * the host stores node positions. Re-derived here from the public + * `sceneRegistry` because the built-in `floor-placement` helpers aren't part of + * the public `@pascal-app/*` surface yet — a candidate for the future + * `@pascal-app/plugin-api` package. + */ +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] +} + +/** + * The trees placement tool. Mounted by the host's registry-first `ToolManager` + * whenever `tool === 'trees:tree'` — no host edit per kind. Reads the chosen + * preset from the plugin's own store, ghosts a preview at the cursor on + * `grid:move`, and commits a tree on `grid:click`. This is the third leg of the + * plugin surface: 3D rendering + placement from `def.geometry`/`def.tool`. + */ +export default function TreeTool() { + const activeLevelId = useViewer((s) => s.selection.levelId) + const preset = useTreesStore((s) => s.preset) + const cursorRef = useRef(null) + const [cursorVisible, setCursorVisible] = useState(false) + + // Preview tree shaped by the currently-selected preset. + const previewNode = useMemo( + () => + TreeNode.parse({ + preset, + height: presetHeight(preset), + seed: 1, + position: [0, 0, 0], + rotation: [0, 0, 0], + }), + [preset], + ) + + useEffect(() => { + if (!activeLevelId) return + setCursorVisible(false) + let lastWorld: [number, number, number] | null = null + + const onGridMove = (event: GridEvent) => { + setCursorVisible(true) + // The tool mounts inside the host's building-local group, so positioning + // the ghost with the building-local hit keeps it under the cursor. + const [lx, , lz] = event.localPosition + cursorRef.current?.position.set(lx, 0, lz) + lastWorld = event.position + } + + const onGridClick = (event: GridEvent) => { + const world = lastWorld ?? event.position + const position = toLevelLocal(activeLevelId, world) + const tree = TreeNode.parse({ + preset, + height: presetHeight(preset), + seed: Math.floor(Math.random() * 10000), + position, + rotation: [0, 0, 0], + }) + useScene.getState().createNode(tree as unknown as AnyNode, activeLevelId as AnyNodeId) + useViewer.getState().setSelection({ selectedIds: [tree.id as AnyNodeId] }) + triggerSFX('sfx:item-place') + // Stay active for rapid planting; Esc / a tool switch unmounts us. + } + + emitter.on('grid:move', onGridMove) + emitter.on('grid:click', onGridClick) + return () => { + emitter.off('grid:move', onGridMove) + emitter.off('grid:click', onGridClick) + } + }, [activeLevelId, preset]) + + if (!activeLevelId) return null + + return ( + + + + ) +} 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/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: From 78b0a0c6df1945df8decab60cf4766fa9f435eed Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 30 Jun 2026 15:45:26 -0400 Subject: [PATCH 02/36] feat(plugins): merge plugin panels into the v2 sidebar layout too The community shell renders , a separate rail from AppSidebar. Merge registry panels into the v2 tabMap + tab bar (and thus mobile) so plugin panels show up there as well, not only in the v1 AppSidebar. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../editor/src/components/editor/index.tsx | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) 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 ( <> From 19059d9e892653ba3a788e0f6310dc2729e1d88e Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 30 Jun 2026 16:22:27 -0400 Subject: [PATCH 03/36] feat(plugin-trees): ez-tree geometry + instanced rendering + snapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the placeholder low-poly geometry with dgreenheck/ez-tree (self-contained — bark/leaf textures inlined as base64, no assets to host) and render the forest with true GPU instancing. Rendering: - def.system (system.tsx): one collective renderer that groups every trees:tree node by (preset, seed) variant and draws each variant as one InstancedMesh per ez-tree sub-mesh, composing the parent level's world matrix into per-instance matrices. ~1 draw call per variant. - def.renderer (proxy-renderer.tsx): an invisible, raycastable per-node proxy so the host's existing selection / outline / zone machinery keeps working — no instanceId bookkeeping. (Outline highlights the proxy bbox; documented.) - geometry per variant generated once by ez-tree and cached; seeds drawn from a bounded pool so trees actually share variants. Presets remapped to ez-tree built-ins (oak/pine/aspen/ash/bush). Placement now respects the active snap mode — isGridSnapActive() + gridSnapStep + snapPointToGrid, like the built-in item/shelf tools. transpilePackages += @dgreenheck/ez-tree in the editor app. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/editor/next.config.ts | 1 + bun.lock | 7 + packages/plugin-trees/README.md | 31 +++- packages/plugin-trees/package.json | 5 + packages/plugin-trees/src/definition.ts | 21 +-- packages/plugin-trees/src/geometry.ts | 162 ++++++++----------- packages/plugin-trees/src/index.ts | 2 +- packages/plugin-trees/src/parametrics.ts | 10 +- packages/plugin-trees/src/presets-panel.tsx | 2 +- packages/plugin-trees/src/presets.ts | 72 ++++----- packages/plugin-trees/src/preview.tsx | 21 ++- packages/plugin-trees/src/proxy-renderer.tsx | 48 ++++++ packages/plugin-trees/src/schema.ts | 5 +- packages/plugin-trees/src/system.tsx | 126 +++++++++++++++ packages/plugin-trees/src/tool.tsx | 27 +++- 15 files changed, 365 insertions(+), 175 deletions(-) create mode 100644 packages/plugin-trees/src/proxy-renderer.tsx create mode 100644 packages/plugin-trees/src/system.tsx diff --git a/apps/editor/next.config.ts b/apps/editor/next.config.ts index 76aa09af9..18fb18206 100644 --- a/apps/editor/next.config.ts +++ b/apps/editor/next.config.ts @@ -14,6 +14,7 @@ const nextConfig: NextConfig = { '@pascal-app/editor', '@pascal-app/mcp', '@pascal-app/plugin-trees', + '@dgreenheck/ez-tree', ], turbopack: { resolveAlias: { diff --git a/bun.lock b/bun.lock index 0bbc244df..f402fa785 100644 --- a/bun.lock +++ b/bun.lock @@ -261,11 +261,15 @@ "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", @@ -279,6 +283,7 @@ "@pascal-app/core": "*", "@pascal-app/editor": "*", "@pascal-app/viewer": "*", + "@react-three/fiber": "^9", "react": "^18 || ^19", "three": "^0.184", "zod": "^4", @@ -399,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=="], diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md index 45843864b..cbcc511f1 100644 --- a/packages/plugin-trees/README.md +++ b/packages/plugin-trees/README.md @@ -5,25 +5,42 @@ 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`/`zustand`) and imports +`@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 three contribution paths a plugin has: +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. **3D render + placement for free** — `def.geometry` (`geometry.ts`, a pure - `three` builder) and `def.tool`/`def.preview` (`tool.tsx`, `preview.tsx`). +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** — instead of the per-node `def.geometry` path, trees + render via two pieces: + - `def.system` (`system.tsx`) — a collective renderer mounted once that + groups every `trees:tree` node by `(preset, seed)` variant and draws each + variant as one `InstancedMesh` per sub-mesh. A forest of N trees is a + handful of draw calls. Geometry per variant is generated once by ez-tree + (`geometry.ts`) and cached. + - `def.renderer` (`proxy-renderer.tsx`) — a featherweight invisible, + raycastable proxy per node so the host's existing selection / outline / + zone machinery keeps working with no instanceId bookkeeping. 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 @@ -44,5 +61,11 @@ loaded through the same `loadPlugin` path as the built-ins. `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. +- **Instanced selection highlight** outlines the proxy's bounding box, not the + tree silhouette, because the host's outline pass reads one `Object3D` per node + and the visible pixels live in a shared `InstancedMesh`. Per-instance + silhouette outlining would need host support. +- The instance matrices fold in the parent level's world transform; a building + move while trees are static won't refresh until a tree node next changes. See `wiki/architecture/plugin-authoring.md` for the full contract. diff --git a/packages/plugin-trees/package.json b/packages/plugin-trees/package.json index a4d2297c3..e1a6a7143 100644 --- a/packages/plugin-trees/package.json +++ b/packages/plugin-trees/package.json @@ -18,10 +18,14 @@ "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", @@ -32,6 +36,7 @@ "@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", diff --git a/packages/plugin-trees/src/definition.ts b/packages/plugin-trees/src/definition.ts index 58c9959f4..0d21c6b21 100644 --- a/packages/plugin-trees/src/definition.ts +++ b/packages/plugin-trees/src/definition.ts @@ -1,13 +1,14 @@ import type { NodeDefinition } from '@pascal-app/core' -import { buildTreeGeometry } from './geometry' import { treeParametrics } from './parametrics' import { TreeNode } from './schema' /** - * The tree node definition. Three-checkbox composition like the built-ins: - * `geometry` for 3D, `parametrics` for the inspector, `tool`/`preview` for - * placement — all pure/lazy, no host dispatch code. The host renders, inspects, - * moves, and persists trees purely from this descriptor. + * 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', @@ -24,7 +25,7 @@ export const treeDefinition: NodeDefinition = { position: [0, 0, 0], rotation: [0, 0, 0], preset: 'oak', - height: 5, + height: 7, seed: 1, }), @@ -53,10 +54,10 @@ export const treeDefinition: NodeDefinition = { parametrics: treeParametrics, - // Pure builder + a cache key over only the geometry-relevant fields, so a - // move or reparent never rebuilds the mesh. - geometry: (node) => buildTreeGeometry(node), - geometryKey: (n) => JSON.stringify([n.preset, n.height, n.seed]), + // 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 }, preview: () => import('./preview'), tool: () => import('./tool'), diff --git a/packages/plugin-trees/src/geometry.ts b/packages/plugin-trees/src/geometry.ts index 1461ce7a9..33850c219 100644 --- a/packages/plugin-trees/src/geometry.ts +++ b/packages/plugin-trees/src/geometry.ts @@ -1,110 +1,80 @@ -import { - ConeGeometry, - CylinderGeometry, - Group, - IcosahedronGeometry, - Mesh, - MeshStandardMaterial, - SphereGeometry, -} from 'three' +import { Tree } from '@dgreenheck/ez-tree' +import { Box3, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three' import { TREE_PRESETS } from './presets' -import type { TreeNode } from './schema' +import type { TreePreset } from './schema' /** - * Pure procedural tree builder. Returns a `Group` of low-poly meshes in local - * space with the base at y=0 growing along +Y — the framework's renderer - * applies the node's position/rotation, so this never bakes world placement in. - * Depends only on `three` + the node's own `preset`/`height`/`seed`, which is - * why the definition's `geometryKey` can skip rebuilds on move/reparent. - * - * `seed` drives a tiny deterministic RNG so two oaks with different seeds get - * subtly different canopies without persisting per-vertex data. + * Generate an ez-tree for a (preset, seed). ez-tree's `Tree` is a `THREE.Group` + * whose children are the bark + leaf meshes; textures are inlined in the + * library (no asset hosting). Pure given its inputs — same (preset, seed) ⇒ the + * same tree — which is what lets the renderer cache one generation per variant + * and instance it across every placed tree. */ -export function buildTreeGeometry(node: TreeNode): Group { - const group = new Group() - const spec = TREE_PRESETS[node.preset] ?? TREE_PRESETS.oak - const height = Math.max(0.5, node.height) - const rng = mulberry32(node.seed >>> 0) +export function generateTree(preset: TreePreset, seed: number): Tree { + const spec = TREE_PRESETS[preset] ?? TREE_PRESETS.oak + const tree = new Tree() + tree.loadPreset(spec.ezPreset) + // Set the seed AFTER the preset (the preset carries its own seed) and + // regenerate — ez-tree requires generate() after any option change. + tree.options.seed = seed + tree.generate() + return tree +} - const trunkHeight = height * spec.trunkFraction - const trunkRadius = Math.max(0.05, height * 0.04) - const trunkMat = new MeshStandardMaterial({ color: spec.trunkColor, roughness: 0.9 }) - const foliageMat = new MeshStandardMaterial({ color: spec.foliageColor, roughness: 0.8 }) +/** 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[] } - const trunk = new Mesh( - new CylinderGeometry(trunkRadius * 0.7, trunkRadius, trunkHeight, 7), - trunkMat, - ) - trunk.position.y = trunkHeight / 2 - trunk.name = 'tree-trunk' - group.add(trunk) +/** Geometry + height for one tree variant, generated once and shared across + * every instance of that (preset, seed). */ +export type TreeVariantData = { subMeshes: TreeSubMesh[]; naturalHeight: number } - const canopyHeight = height - trunkHeight - const canopyBase = trunkHeight +/** Stable variant id. Trees with the same key share one set of InstancedMeshes. */ +export function variantKey(preset: TreePreset, seed: number): string { + return `${preset}:${seed}` +} - if (node.preset === 'pine') { - // Three stacked cones, narrowing toward the top. - const tiers = 3 - for (let i = 0; i < tiers; i++) { - const t = i / tiers - const radius = height * 0.26 * (1 - t * 0.55) * (0.92 + rng() * 0.16) - const tierHeight = (canopyHeight / tiers) * 1.5 - const cone = new Mesh(new ConeGeometry(radius, tierHeight, 8), foliageMat) - cone.position.y = canopyBase + canopyHeight * t + tierHeight * 0.25 - cone.name = `tree-canopy-${i}` - group.add(cone) - } - } else if (node.preset === 'palm') { - // A crown of angled cone fronds at the top of a tall bare trunk. - const fronds = 6 - const frondLength = height * 0.4 - for (let i = 0; i < fronds; i++) { - const angle = (i / fronds) * Math.PI * 2 + rng() * 0.4 - const frond = new Mesh(new ConeGeometry(height * 0.05, frondLength, 5), foliageMat) - frond.position.set( - Math.cos(angle) * frondLength * 0.35, - canopyBase + frondLength * 0.2, - Math.sin(angle) * frondLength * 0.35, - ) - frond.rotation.z = Math.PI / 2.4 - frond.rotation.y = -angle - frond.name = `tree-frond-${i}` - group.add(frond) - } - } else { - // oak / birch — a rounded canopy: a low-poly icosahedron plus a couple of - // smaller offset spheres for a hand-clustered look. - const canopyRadius = height * 0.28 - const crown = new Mesh(new IcosahedronGeometry(canopyRadius, 0), foliageMat) - crown.position.y = canopyBase + canopyHeight * 0.5 - crown.name = 'tree-canopy' - group.add(crown) +const variantCache = new Map() - const blobs = 2 - for (let i = 0; i < blobs; i++) { - const blob = new Mesh(new SphereGeometry(canopyRadius * 0.6, 6, 5), foliageMat) - const angle = rng() * Math.PI * 2 - blob.position.set( - Math.cos(angle) * canopyRadius * 0.6, - canopyBase + canopyHeight * (0.4 + rng() * 0.4), - Math.sin(angle) * canopyRadius * 0.6, - ) - blob.name = `tree-canopy-blob-${i}` - group.add(blob) - } +/** + * Cached geometry for a variant. ez-tree's `generate()` is heavy, so it runs + * once per (preset, seed); 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(preset: TreePreset, seed: number): TreeVariantData { + const key = variantKey(preset, seed) + const cached = variantCache.get(key) + if (cached) return cached + const tree = generateTree(preset, seed) + const data: TreeVariantData = { + subMeshes: extractSubMeshes(tree), + naturalHeight: naturalHeight(tree), } + variantCache.set(key, data) + return data +} - return group +/** 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) + out.push({ geometry, material: mesh.material }) + } + }) + return out } -/** Deterministic 32-bit RNG (mulberry32) — same seed ⇒ same canopy. */ -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 - } +/** Natural (unscaled) height of a generated tree, 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) } diff --git a/packages/plugin-trees/src/index.ts b/packages/plugin-trees/src/index.ts index 7d17c5e38..70faf82f6 100644 --- a/packages/plugin-trees/src/index.ts +++ b/packages/plugin-trees/src/index.ts @@ -23,5 +23,5 @@ export const treesPlugin: Plugin = { } export { treeDefinition } from './definition' -export { buildTreeGeometry } from './geometry' +export { generateTree } from './geometry' export { TreeNode, TreePreset } from './schema' diff --git a/packages/plugin-trees/src/parametrics.ts b/packages/plugin-trees/src/parametrics.ts index f6e7aec21..1c2ccc577 100644 --- a/packages/plugin-trees/src/parametrics.ts +++ b/packages/plugin-trees/src/parametrics.ts @@ -1,4 +1,5 @@ import { type AnyNodeId, type ParametricDescriptor, useScene } from '@pascal-app/core' +import { TREE_SEED_POOL } from './presets' import type { TreeNode } from './schema' /** @@ -13,7 +14,7 @@ export const treeParametrics: ParametricDescriptor = { { label: 'Tree', fields: [ - { key: 'preset', kind: 'enum', options: ['oak', 'pine', 'birch', 'palm'] }, + { key: 'preset', kind: 'enum', options: ['oak', 'pine', 'aspen', 'ash', 'bush'] }, { key: 'height', kind: 'number', unit: 'm', min: 1, max: 15, step: 0.5 }, { key: 'seed', kind: 'number', min: 0, max: 9999, step: 1 }, ], @@ -26,13 +27,14 @@ export const treeParametrics: ParametricDescriptor = { actions: [ { label: 'Randomize', - // The action receives the live node; writing a new seed re-runs the - // geometry builder (seed is part of the definition's geometryKey). + // 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: Math.floor(Math.random() * 10000), + seed: TREE_SEED_POOL[Math.floor(Math.random() * TREE_SEED_POOL.length)] ?? 1, } as Partial as never, ), }, diff --git a/packages/plugin-trees/src/presets-panel.tsx b/packages/plugin-trees/src/presets-panel.tsx index 497977076..ce845c122 100644 --- a/packages/plugin-trees/src/presets-panel.tsx +++ b/packages/plugin-trees/src/presets-panel.tsx @@ -58,7 +58,7 @@ export default function TreesPanel() { {spec.label} diff --git a/packages/plugin-trees/src/presets.ts b/packages/plugin-trees/src/presets.ts index fdbba3be4..8c320b05a 100644 --- a/packages/plugin-trees/src/presets.ts +++ b/packages/plugin-trees/src/presets.ts @@ -1,52 +1,46 @@ import type { TreePreset } from './schema' -/** Per-preset appearance + proportions read by the geometry builder and the - * presets panel. Pure data — no Three.js, no React — so both the panel grid and - * the mesh builder stay in lockstep. */ +/** + * Per-preset config: which ez-tree built-in preset to generate, a swatch colour + * for the panel card, and a default placement height (metres). Pure data — no + * three.js, no React — shared by the panel grid and the instanced renderer so + * they stay in lockstep. `ezPreset` is the exact ez-tree preset name passed to + * `tree.loadPreset(...)`. + */ export type TreePresetSpec = { id: TreePreset label: string - /** Default overall height in metres when this preset is first placed. */ + ezPreset: string defaultHeight: number - trunkColor: string - foliageColor: string - /** Fraction of total height taken by the bare trunk before foliage starts. */ - trunkFraction: number + swatch: string } export const TREE_PRESETS: Record = { - oak: { - id: 'oak', - label: 'Oak', - defaultHeight: 5, - trunkColor: '#6b4f3a', - foliageColor: '#4f7942', - trunkFraction: 0.4, - }, - pine: { - id: 'pine', - label: 'Pine', - defaultHeight: 6, - trunkColor: '#5c4433', - foliageColor: '#2f5d3a', - trunkFraction: 0.18, - }, - birch: { - id: 'birch', - label: 'Birch', - defaultHeight: 5.5, - trunkColor: '#d8d2c4', - foliageColor: '#7aa760', - trunkFraction: 0.5, - }, - palm: { - id: 'palm', - label: 'Palm', - defaultHeight: 6.5, - trunkColor: '#9c7a4d', - foliageColor: '#3f8f5a', - trunkFraction: 0.82, + oak: { id: 'oak', label: 'Oak', ezPreset: 'Oak Medium', defaultHeight: 7, swatch: '#4f7942' }, + pine: { id: 'pine', label: 'Pine', ezPreset: 'Pine Medium', defaultHeight: 9, swatch: '#2f5d3a' }, + aspen: { + id: 'aspen', + label: 'Aspen', + ezPreset: 'Aspen Medium', + defaultHeight: 8, + swatch: '#8fae5d', }, + ash: { id: 'ash', label: 'Ash', ezPreset: 'Ash Medium', defaultHeight: 8, swatch: '#6f9457' }, + bush: { id: 'bush', label: 'Bush', ezPreset: 'Bush 1', defaultHeight: 1.5, swatch: '#5c8a4a' }, } export const TREE_PRESET_LIST: TreePresetSpec[] = Object.values(TREE_PRESETS) + +/** + * Bounded seed pool. The placement tool and the Randomize action pick from + * this set so trees share geometry variants (preset × seed) — 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 index 8fd8f6d19..82e428bdf 100644 --- a/packages/plugin-trees/src/preview.tsx +++ b/packages/plugin-trees/src/preview.tsx @@ -2,18 +2,21 @@ import { useEffect, useMemo } from 'react' import type { Material } from 'three' -import { buildTreeGeometry } from './geometry' +import { generateTree, naturalHeight } from './geometry' import type { TreeNode } from './schema' /** - * Translucent placement ghost. Defers to `buildTreeGeometry` so the preview is - * always exactly what the commit will create, then clones each material for a - * see-through look and disables raycast so the ghost never intercepts the - * cursor ray (which would freeze `grid:move`). Same contract as the built-in - * shelf preview. + * 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(() => buildTreeGeometry(node), [node]) + const built = useMemo(() => { + const tree = generateTree(node.preset, node.seed) + tree.scale.setScalar(node.height / naturalHeight(tree)) + return tree + }, [node.preset, node.seed, node.height]) useEffect(() => { const cloned: Material[] = [] @@ -33,10 +36,6 @@ export default function TreePreview({ node }: { node: TreeNode }) { }) return () => { for (const c of cloned) c.dispose() - built.traverse((obj) => { - const mesh = obj as { geometry?: { dispose: () => void } } - mesh.geometry?.dispose() - }) } }, [built]) diff --git a/packages/plugin-trees/src/proxy-renderer.tsx b/packages/plugin-trees/src/proxy-renderer.tsx new file mode 100644 index 000000000..c0a23eac6 --- /dev/null +++ b/packages/plugin-trees/src/proxy-renderer.tsx @@ -0,0 +1,48 @@ +'use client' + +import { type AnyNodeId, useRegistry } from '@pascal-app/core' +import { useNodeEvents } from '@pascal-app/viewer' +import { useRef } from 'react' +import type { Group } from 'three' +import type { TreeNode } from './schema' + +/** + * Per-node selection proxy. The visible tree pixels come from the instanced + * `def.system`; this renderer mounts an INVISIBLE, raycastable collider per + * node so the host's existing selection machinery keeps working unchanged: + * + * - `useRegistry(id, type, ref)` makes `sceneRegistry.get(id)` return a real + * per-node Object3D, which the outline pass and zone-containment tests read. + * - `useNodeEvents(node, type)` wires the same `trees:tree:click/enter/leave` + * bus events every selectable kind uses — no instanceId bookkeeping. + * + * The collider writes neither colour nor depth, so it never paints, but + * `visible` stays true so R3F still raycasts it. Sized to the tree's rough + * canopy box. Mounted inside the parent level's group (like every node + * renderer), so its transform — and thus the picked position — is correct + * without composing the level matrix here. + */ +export default function TreeProxyRenderer({ node: tree }: { node: TreeNode }) { + const ref = useRef(null!) + // The bus event key is the kind literal at runtime; the cast is contained. + const handlers = useNodeEvents(tree as never, tree.type as never) + useRegistry(tree.id as AnyNodeId, tree.type, ref) + + const height = Math.max(0.5, tree.height ?? 5) + const radius = Math.max(0.4, height * 0.18) + + return ( + + + + + + + ) +} diff --git a/packages/plugin-trees/src/schema.ts b/packages/plugin-trees/src/schema.ts index 99e69c833..429c18d48 100644 --- a/packages/plugin-trees/src/schema.ts +++ b/packages/plugin-trees/src/schema.ts @@ -1,8 +1,9 @@ import { BaseNode, nodeType, objectId } from '@pascal-app/core' import { z } from 'zod' -/** Tree silhouettes the plugin can place. The string persists in scene JSON. */ -export const TreePreset = z.enum(['oak', 'pine', 'birch', 'palm']) +/** Tree silhouettes the plugin can place, each backed by an ez-tree preset. + * The string persists in scene JSON. */ +export const TreePreset = z.enum(['oak', 'pine', 'aspen', 'ash', 'bush']) export type TreePreset = z.infer /** diff --git a/packages/plugin-trees/src/system.tsx b/packages/plugin-trees/src/system.tsx new file mode 100644 index 000000000..ede652b96 --- /dev/null +++ b/packages/plugin-trees/src/system.tsx @@ -0,0 +1,126 @@ +'use client' + +import { sceneRegistry, useScene } from '@pascal-app/core' +import { useLayoutEffect, useMemo, useRef } from 'react' +import { type BufferGeometry, type InstancedMesh, type Material, Matrix4, Object3D } from 'three' +import { getVariantData, type TreeSubMesh } from './geometry' +import type { TreeNode, TreePreset } from './schema' + +/** + * Collective instanced renderer for every placed tree — contributed via + * `def.system`, mounted once by the viewer's `RegisteredSystems`. It groups all + * `trees:tree` nodes by (preset, seed) variant and draws each variant as one + * `InstancedMesh` per sub-mesh, so a forest of N trees is a handful of draw + * calls, not N. Selection/outline come from the per-node proxy renderer + * (`def.renderer`); this system only paints pixels. + */ +export default function TreesSystem() { + const nodes = useScene((s) => s.nodes) + + const buckets = useMemo(() => { + const map = new Map() + for (const raw of Object.values(nodes)) { + if ((raw.type as string) !== 'trees:tree') continue + const node = raw as unknown as TreeNode + const key = `${node.preset}:${node.seed}` + const bucket = map.get(key) + if (bucket) bucket.nodes.push(node) + else map.set(key, { preset: node.preset, seed: node.seed, nodes: [node] }) + } + return Array.from(map, ([key, value]) => ({ key, ...value })) + }, [nodes]) + + return ( + <> + {buckets.map((bucket) => ( + + ))} + + ) +} + +function TreeVariant({ + preset, + seed, + nodes, +}: { + preset: TreePreset + seed: number + nodes: TreeNode[] +}) { + // Generated once per variant and cached; shared across every instance. + const data = useMemo(() => getVariantData(preset, seed), [preset, seed]) + return ( + <> + {data.subMeshes.map((subMesh, i) => ( + + ))} + + ) +} + +const DUMMY = new Object3D() +const INSTANCE_MATRIX = new Matrix4() + +function InstancedSubMesh({ + subMesh, + nodes, + naturalHeight, +}: { + subMesh: TreeSubMesh + nodes: TreeNode[] + naturalHeight: number +}) { + const ref = useRef(null) + // Round capacity up so the InstancedMesh isn't recreated on every single + // 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() + // Instances live at the scene root, so fold in the parent level's world + // matrix — node positions are stored level-local. + const parent = 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]) + + return ( + + ) +} diff --git a/packages/plugin-trees/src/tool.tsx b/packages/plugin-trees/src/tool.tsx index eca011fcf..e4184d2e4 100644 --- a/packages/plugin-trees/src/tool.tsx +++ b/packages/plugin-trees/src/tool.tsx @@ -6,13 +6,14 @@ import { emitter, type GridEvent, sceneRegistry, + snapPointToGrid, useScene, } from '@pascal-app/core' -import { triggerSFX } from '@pascal-app/editor' +import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { type Group, Vector3 } from 'three' -import { TREE_PRESETS } from './presets' +import { TREE_PRESETS, TREE_SEED_POOL } from './presets' import TreePreview from './preview' import { TreeNode, type TreePreset } from './schema' import { useTreesStore } from './store' @@ -25,6 +26,14 @@ function presetHeight(preset: TreePreset): number { return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).defaultHeight } +/** Snap a planar position to the grid when grid snapping is the active mode — + * reading the same `isGridSnapActive()` toggle and `gridSnapStep` the built-in + * item/shelf tools use, so trees honour the snap mode like every other item. */ +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 here from the public @@ -77,19 +86,23 @@ export default function TreeTool() { // The tool mounts inside the host's building-local group, so positioning // the ghost with the building-local hit keeps it under the cursor. const [lx, , lz] = event.localPosition - cursorRef.current?.position.set(lx, 0, lz) + const [sx, sz] = snapXZ(lx, lz) + cursorRef.current?.position.set(sx, 0, sz) lastWorld = event.position } const onGridClick = (event: GridEvent) => { const world = lastWorld ?? event.position - const position = toLevelLocal(activeLevelId, world) + const [lx, , lz] = toLevelLocal(activeLevelId, world) + const [sx, sz] = snapXZ(lx, lz) const tree = TreeNode.parse({ preset, height: presetHeight(preset), - seed: Math.floor(Math.random() * 10000), - position, - rotation: [0, 0, 0], + // Pick from the bounded pool so placed trees share instancing variants; + // a small random Y rotation keeps a planted row from looking cloned. + seed: TREE_SEED_POOL[Math.floor(Math.random() * TREE_SEED_POOL.length)] ?? 1, + position: [sx, 0, sz], + rotation: [0, Math.random() * Math.PI * 2, 0], }) useScene.getState().createNode(tree as unknown as AnyNode, activeLevelId as AnyNodeId) useViewer.getState().setSelection({ selectedIds: [tree.id as AnyNodeId] }) From 2781ac533671da19dc71c041273aa6730be95fbe Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 30 Jun 2026 16:43:34 -0400 Subject: [PATCH 04/36] feat(plugin-trees): true-silhouette hover/select + richer presets panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selection/hover now outlines the real tree shape, not the bbox. The proxy splits into an outer group (stable invisible box collider + pointer handlers, the raycast target) and an inner registered group that mounts the real ez-tree geometry (invisible, non-raycasting) only while the node is hovered or selected. The host outline pass reads the registered inner group, so it traces the true silhouette; picking stays on the steady box. Geometry for the highlight reuses the cached variant. Panel: redesigned cards with gradient swatches + selected state, a "planted" count chip, and a height slider that seeds the next tree's height (a per-instance scale — never touches placed trees or instancing). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugin-trees/src/presets-panel.tsx | 82 +++++++++++++------- packages/plugin-trees/src/proxy-renderer.tsx | 76 +++++++++++++----- packages/plugin-trees/src/store.ts | 14 +++- packages/plugin-trees/src/tool.tsx | 20 ++--- 4 files changed, 130 insertions(+), 62 deletions(-) diff --git a/packages/plugin-trees/src/presets-panel.tsx b/packages/plugin-trees/src/presets-panel.tsx index ce845c122..6b61533ac 100644 --- a/packages/plugin-trees/src/presets-panel.tsx +++ b/packages/plugin-trees/src/presets-panel.tsx @@ -7,64 +7,92 @@ import type { TreePreset } from './schema' import { useTreesStore } from './store' /** - * The plugin's own left-rail panel. Clicking a preset card writes the choice - * into the plugin store and arms placement (`setTool('trees:tree')` + - * `setMode('build')`) — mirroring how the community catalog activates the item - * tool. The "N planted" counter reads the scene reactively, closing the - * communication triangle: panel → store → tool → scene → panel. + * The plugin's own left-rail panel. Picking a preset arms placement + * (`setTool('trees:tree')` + `setMode('build')`); the height slider seeds the + * next tree's height. The "N planted" counter reads the scene reactively, + * closing the triangle: panel → store → tool → scene → panel. * - * Styling is scoped + uses the host's sidebar CSS variables so it looks native + * Styling is scoped and uses the host sidebar CSS variables so it reads native * without leaking globals. */ export default function TreesPanel() { const selected = useTreesStore((s) => s.preset) - const setPreset = useTreesStore((s) => s.setPreset) + const height = useTreesStore((s) => s.height) + const setHeight = useTreesStore((s) => s.setHeight) const activeTool = useEditor((s) => s.tool) const treeCount = useScene( (s) => Object.values(s.nodes).filter((n) => (n.type as string) === 'trees:tree').length, ) + const arming = activeTool === 'trees:tree' + const activate = (preset: TreePreset) => { - setPreset(preset) + useTreesStore.getState().setPreset(preset) useEditor.getState().setTool('trees:tree') useEditor.getState().setMode('build') } - const arming = activeTool === 'trees:tree' - return ( -
-
-

Trees

- {treeCount} planted -
-

- {arming ? 'Click the ground to plant. Esc to stop.' : 'Pick a tree, then click the ground.'} -

+
+
+
+

Trees

+ + {treeCount} planted + +
+

+ {arming + ? 'Click the ground to plant. Press Esc to stop.' + : 'Pick a tree, then click the ground to plant.'} +

+
+
{TREE_PRESET_LIST.map((spec) => { const isSelected = selected === spec.id && arming return ( ) })}
+ +
) } diff --git a/packages/plugin-trees/src/proxy-renderer.tsx b/packages/plugin-trees/src/proxy-renderer.tsx index c0a23eac6..0b6d71b68 100644 --- a/packages/plugin-trees/src/proxy-renderer.tsx +++ b/packages/plugin-trees/src/proxy-renderer.tsx @@ -1,48 +1,84 @@ 'use client' import { type AnyNodeId, useRegistry } from '@pascal-app/core' -import { useNodeEvents } from '@pascal-app/viewer' -import { useRef } from 'react' -import type { Group } from 'three' +import { useNodeEvents, useViewer } from '@pascal-app/viewer' +import { useMemo, useRef } from 'react' +import { type Group, MeshBasicMaterial } from 'three' +import { getVariantData } from './geometry' import type { TreeNode } from './schema' +// One shared invisible material for every silhouette mesh: writes neither +// colour nor depth, so it paints nothing in the main passes — but the host's +// outline pass renders it with its OWN depth-override material, so the true +// tree shape still outlines. +const INVISIBLE = new MeshBasicMaterial({ colorWrite: false, depthWrite: false }) + /** - * Per-node selection proxy. The visible tree pixels come from the instanced - * `def.system`; this renderer mounts an INVISIBLE, raycastable collider per - * node so the host's existing selection machinery keeps working unchanged: - * - * - `useRegistry(id, type, ref)` makes `sceneRegistry.get(id)` return a real - * per-node Object3D, which the outline pass and zone-containment tests read. - * - `useNodeEvents(node, type)` wires the same `trees:tree:click/enter/leave` - * bus events every selectable kind uses — no instanceId bookkeeping. + * Per-node selection proxy for the instanced trees. The visible pixels come + * from the `def.system` InstancedMeshes; this gives the host's existing + * selection machinery the per-node `Object3D` it needs: * - * The collider writes neither colour nor depth, so it never paints, but - * `visible` stays true so R3F still raycasts it. Sized to the tree's rough - * canopy box. Mounted inside the parent level's group (like every node - * renderer), so its transform — and thus the picked position — is correct - * without composing the level matrix here. + * - Outer group: carries the `useNodeEvents` pointer handlers and a cheap + * invisible BOX collider — the stable raycast target for hover/select. + * - Inner group (the one registered with `useRegistry`): empty until the tree + * is hovered/selected, then it holds the real ez-tree geometry (invisible, + * non-raycasting). The outline pass reads exactly this registered object, so + * the highlight traces the true silhouette — not the box — while picking + * still goes through the steady outer collider. */ export default function TreeProxyRenderer({ node: tree }: { node: TreeNode }) { - const ref = useRef(null!) + const outlineRef = useRef(null!) // The bus event key is the kind literal at runtime; the cast is contained. const handlers = useNodeEvents(tree as never, tree.type as never) - useRegistry(tree.id as AnyNodeId, tree.type, ref) + useRegistry(tree.id as AnyNodeId, tree.type, outlineRef) + + const active = useViewer( + (s) => s.hoveredId === tree.id || s.selection.selectedIds.includes(tree.id as never), + ) const height = Math.max(0.5, tree.height ?? 5) const radius = Math.max(0.4, height * 0.18) + // Real geometry for the silhouette, only built when actually highlighted. + const variant = useMemo( + () => (active ? getVariantData(tree.preset, tree.seed) : null), + [active, tree.preset, tree.seed], + ) + const silhouetteScale = variant ? height / variant.naturalHeight : 1 + return ( + {/* Stable invisible pick collider (NOT under the registered group, so it + is never outlined). */} - + + + {/* Registered outline target — real geometry appears only when active. */} + + {variant && ( + + {variant.subMeshes.map((subMesh, i) => ( + + ))} + + )} + ) } + +// Silhouette meshes must not steal pointer hits from the box collider. +const NO_RAYCAST = () => {} diff --git a/packages/plugin-trees/src/store.ts b/packages/plugin-trees/src/store.ts index 66f74d384..c06d09ee9 100644 --- a/packages/plugin-trees/src/store.ts +++ b/packages/plugin-trees/src/store.ts @@ -1,18 +1,26 @@ import { create } from 'zustand' +import { TREE_PRESETS } from './presets' import type { TreePreset } 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. - * The presets panel writes the chosen preset here; the placement tool reads it - * to know which tree to drop. No host lifecycle slot is involved. + * The presets panel writes the chosen preset + placement height here; the + * placement tool reads them. No host lifecycle slot is involved. */ type TreesStore = { preset: TreePreset + /** Height (m) applied to the next planted tree — a per-instance scale, so it + * never affects already-placed trees or instancing. */ + height: number setPreset: (preset: TreePreset) => void + setHeight: (height: number) => void } export const useTreesStore = create((set) => ({ preset: 'oak', - setPreset: (preset) => set({ preset }), + height: TREE_PRESETS.oak.defaultHeight, + // Switching preset re-seeds the height to that preset's natural default. + setPreset: (preset) => set({ preset, height: TREE_PRESETS[preset].defaultHeight }), + setHeight: (height) => set({ height }), })) diff --git a/packages/plugin-trees/src/tool.tsx b/packages/plugin-trees/src/tool.tsx index e4184d2e4..b4081efa5 100644 --- a/packages/plugin-trees/src/tool.tsx +++ b/packages/plugin-trees/src/tool.tsx @@ -13,19 +13,13 @@ import { isGridSnapActive, triggerSFX, useEditor } from '@pascal-app/editor' import { useViewer } from '@pascal-app/viewer' import { useEffect, useMemo, useRef, useState } from 'react' import { type Group, Vector3 } from 'three' -import { TREE_PRESETS, TREE_SEED_POOL } from './presets' +import { TREE_SEED_POOL } from './presets' import TreePreview from './preview' -import { TreeNode, type TreePreset } from './schema' +import { TreeNode } from './schema' import { useTreesStore } from './store' const worldVec = new Vector3() -/** Default height for a preset, guarded against the noUncheckedIndexedAccess - * `| undefined` on the record lookup. */ -function presetHeight(preset: TreePreset): number { - return (TREE_PRESETS[preset] ?? TREE_PRESETS.oak).defaultHeight -} - /** Snap a planar position to the grid when grid snapping is the active mode — * reading the same `isGridSnapActive()` toggle and `gridSnapStep` the built-in * item/shelf tools use, so trees honour the snap mode like every other item. */ @@ -60,20 +54,21 @@ function toLevelLocal(levelId: string, world: [number, number, number]): [number export default function TreeTool() { const activeLevelId = useViewer((s) => s.selection.levelId) const preset = useTreesStore((s) => s.preset) + const height = useTreesStore((s) => s.height) const cursorRef = useRef(null) const [cursorVisible, setCursorVisible] = useState(false) - // Preview tree shaped by the currently-selected preset. + // Preview tree shaped by the currently-selected preset + panel height. const previewNode = useMemo( () => TreeNode.parse({ preset, - height: presetHeight(preset), + height, seed: 1, position: [0, 0, 0], rotation: [0, 0, 0], }), - [preset], + [preset, height], ) useEffect(() => { @@ -97,7 +92,8 @@ export default function TreeTool() { const [sx, sz] = snapXZ(lx, lz) const tree = TreeNode.parse({ preset, - height: presetHeight(preset), + // Read height fresh so the slider applies without re-subscribing here. + height: useTreesStore.getState().height, // Pick from the bounded pool so placed trees share instancing variants; // a small random Y rotation keeps a planted row from looking cloned. seed: TREE_SEED_POOL[Math.floor(Math.random() * TREE_SEED_POOL.length)] ?? 1, From ff2bcdec364baa6aa43724da037cfcb6d2825257 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Tue, 30 Jun 2026 17:00:41 -0400 Subject: [PATCH 05/36] feat(plugin-trees): curated tree params + procedural flowers (sibling kind) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tree params: expose foliageDensity, trunkThickness, and a leafless toggle in the inspector and the panel brush. Each is folded into the instancing variant key and mapped onto ez-tree options (radius scale, leaf count), so editing one tree only re-buckets that tree — instancing degrades gracefully, never worse than per-node. Flowers: add a `trees:flower` sibling kind — simple procedural geometry (stem + petals + center, merged per material), presets daisy / tulip / lavender, instanced + selectable exactly like trees. Refactor: extract the instanced renderer + selection proxy into a generic `instanced.tsx` (InstancedKindSystem + KindProxy) and the snap/level/grid placement wiring into `placement.tsx` (usePlacement). Trees and flowers are now thin bindings — the template for future plant kinds. Panel: a Trees / Flowers toggle, gradient preset cards, per-kind planted count, and brush sliders. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/plugin-trees/README.md | 43 ++- packages/plugin-trees/src/definition.ts | 3 + .../plugin-trees/src/flower-definition.ts | 75 +++++ packages/plugin-trees/src/flower-geometry.ts | 120 ++++++++ .../plugin-trees/src/flower-parametrics.ts | 21 ++ packages/plugin-trees/src/flower-presets.ts | 48 ++++ packages/plugin-trees/src/flower-preview.tsx | 51 ++++ .../src/flower-proxy-renderer.tsx | 13 + packages/plugin-trees/src/flower-schema.ts | 20 ++ packages/plugin-trees/src/flower-system.tsx | 19 ++ packages/plugin-trees/src/flower-tool.tsx | 47 ++++ packages/plugin-trees/src/geometry.ts | 78 ++++-- packages/plugin-trees/src/index.ts | 16 +- packages/plugin-trees/src/instanced.tsx | 217 +++++++++++++++ packages/plugin-trees/src/parametrics.ts | 15 + packages/plugin-trees/src/placement.tsx | 81 ++++++ packages/plugin-trees/src/presets-panel.tsx | 260 ++++++++++++++---- packages/plugin-trees/src/preview.tsx | 6 +- packages/plugin-trees/src/proxy-renderer.tsx | 84 +----- packages/plugin-trees/src/schema.ts | 9 +- packages/plugin-trees/src/store.ts | 37 ++- packages/plugin-trees/src/system.tsx | 129 +-------- packages/plugin-trees/src/tool.tsx | 123 +++------ 23 files changed, 1119 insertions(+), 396 deletions(-) create mode 100644 packages/plugin-trees/src/flower-definition.ts create mode 100644 packages/plugin-trees/src/flower-geometry.ts create mode 100644 packages/plugin-trees/src/flower-parametrics.ts create mode 100644 packages/plugin-trees/src/flower-presets.ts create mode 100644 packages/plugin-trees/src/flower-preview.tsx create mode 100644 packages/plugin-trees/src/flower-proxy-renderer.tsx create mode 100644 packages/plugin-trees/src/flower-schema.ts create mode 100644 packages/plugin-trees/src/flower-system.tsx create mode 100644 packages/plugin-trees/src/flower-tool.tsx create mode 100644 packages/plugin-trees/src/instanced.tsx create mode 100644 packages/plugin-trees/src/placement.tsx diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md index cbcc511f1..5a70f7cdc 100644 --- a/packages/plugin-trees/README.md +++ b/packages/plugin-trees/README.md @@ -21,16 +21,29 @@ The contribution paths a plugin has: 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** — instead of the per-node `def.geometry` path, trees - render via two pieces: - - `def.system` (`system.tsx`) — a collective renderer mounted once that - groups every `trees:tree` node by `(preset, seed)` variant and draws each - variant as one `InstancedMesh` per sub-mesh. A forest of N trees is a - handful of draw calls. Geometry per variant is generated once by ez-tree - (`geometry.ts`) and cached. - - `def.renderer` (`proxy-renderer.tsx`) — a featherweight invisible, - raycastable proxy per node so the host's existing selection / outline / - zone machinery keeps working with no instanceId bookkeeping. +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. + +### Two kinds + +- **`trees:tree`** — ez-tree geometry (`geometry.ts`); presets Oak / Pine / + Aspen / Ash / Bush; curated inspector params (foliage density, trunk + thickness, leafless) folded into the variant key. +- **`trees:flower`** — simple procedural geometry (`flower-geometry.ts`, merged + per material); presets daisy / tulip / lavender. A sibling kind that reuses the + exact same instanced core + placement helper (`placement.tsx`) — 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 @@ -61,11 +74,11 @@ loaded through the same `loadPlugin` path as the built-ins. `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. -- **Instanced selection highlight** outlines the proxy's bounding box, not the - tree silhouette, because the host's outline pass reads one `Object3D` per node - and the visible pixels live in a shared `InstancedMesh`. Per-instance - silhouette outlining would need host support. - The instance matrices fold in the parent level's world transform; a building - move while trees are static won't refresh until a tree node next changes. + 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/src/definition.ts b/packages/plugin-trees/src/definition.ts index 0d21c6b21..c86949077 100644 --- a/packages/plugin-trees/src/definition.ts +++ b/packages/plugin-trees/src/definition.ts @@ -27,6 +27,9 @@ export const treeDefinition: NodeDefinition = { preset: 'oak', height: 7, seed: 1, + foliageDensity: 1, + trunkThickness: 1, + leafless: false, }), capabilities: { diff --git a/packages/plugin-trees/src/flower-definition.ts b/packages/plugin-trees/src/flower-definition.ts new file mode 100644 index 000000000..e59f9f260 --- /dev/null +++ b/packages/plugin-trees/src/flower-definition.ts @@ -0,0 +1,75 @@ +import type { NodeDefinition } from '@pascal-app/core' +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', + 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, + }), + + capabilities: { + movable: { axes: ['x', 'z'], gridSnap: true }, + rotatable: { axes: ['y'], snapAngles: [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2] }, + 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, + + renderer: { kind: 'parametric', module: () => import('./flower-proxy-renderer') }, + system: { module: () => import('./flower-system'), priority: 3 }, + + 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..f2f832b0b --- /dev/null +++ b/packages/plugin-trees/src/flower-geometry.ts @@ -0,0 +1,120 @@ +import { + type BufferGeometry, + ConeGeometry, + CylinderGeometry, + Group, + Mesh, + MeshStandardMaterial, + 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 { naturalHeight } from './geometry' +import type { SubMesh, VariantData } from './instanced' + +export function flowerVariantKey(preset: FlowerPreset, seed: number): string { + return `${preset}:${seed}` +} + +const variantCache = new Map() + +/** Cached procedural flower geometry for a (preset, seed). 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 key = flowerVariantKey(node.preset, node.seed) + const cached = variantCache.get(key) + if (cached) return cached + const group = buildFlower(node.preset, node.seed) + 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): Group { + const spec = FLOWER_PRESETS[preset] ?? FLOWER_PRESETS.daisy + const rng = mulberry32(seed >>> 0) + const group = new Group() + const stemMat = new MeshStandardMaterial({ color: spec.stemColor, roughness: 0.85 }) + const petalMat = new MeshStandardMaterial({ color: spec.petalColor, roughness: 0.7 }) + const centerMat = new MeshStandardMaterial({ 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 +} + +/** Deterministic 32-bit RNG (mulberry32) — same seed ⇒ same flower. */ +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/flower-parametrics.ts b/packages/plugin-trees/src/flower-parametrics.ts new file mode 100644 index 000000000..4a0221b69 --- /dev/null +++ b/packages/plugin-trees/src/flower-parametrics.ts @@ -0,0 +1,21 @@ +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: '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..7f50b93ac --- /dev/null +++ b/packages/plugin-trees/src/flower-presets.ts @@ -0,0 +1,48 @@ +import type { FlowerPreset } from './flower-schema' + +/** Per-flower colours + default height (metres). Pure data shared by the + * geometry builder and the panel swatches. */ +export type FlowerPresetSpec = { + id: FlowerPreset + label: string + petalColor: string + centerColor: string + stemColor: string + defaultHeight: number + swatch: string +} + +export const FLOWER_PRESETS: Record = { + daisy: { + id: 'daisy', + label: 'Daisy', + petalColor: '#fcfcf2', + centerColor: '#f4c430', + stemColor: '#4f7942', + defaultHeight: 0.5, + swatch: '#f4c430', + }, + tulip: { + id: 'tulip', + label: 'Tulip', + petalColor: '#e0457b', + centerColor: '#c43160', + stemColor: '#3f7a3a', + defaultHeight: 0.45, + swatch: '#e0457b', + }, + lavender: { + id: 'lavender', + label: 'Lavender', + petalColor: '#9b6fd4', + centerColor: '#7d52b8', + stemColor: '#5a7a4a', + defaultHeight: 0.6, + swatch: '#9b6fd4', + }, +} + +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..9873f7a00 --- /dev/null +++ b/packages/plugin-trees/src/flower-schema.ts @@ -0,0 +1,20 @@ +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), +}) + +export type FlowerNode = z.infer diff --git a/packages/plugin-trees/src/flower-system.tsx b/packages/plugin-trees/src/flower-system.tsx new file mode 100644 index 000000000..357440a06 --- /dev/null +++ b/packages/plugin-trees/src/flower-system.tsx @@ -0,0 +1,19 @@ +'use client' + +import { flowerVariantKey, getFlowerVariant } from './flower-geometry' +import type { FlowerNode } from './flower-schema' +import { InstancedKindSystem } from './instanced' + +const variantKeyOf = (node: FlowerNode) => flowerVariantKey(node.preset, node.seed) +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..eb0155c93 --- /dev/null +++ b/packages/plugin-trees/src/flower-tool.tsx @@ -0,0 +1,47 @@ +'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_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. */ +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, 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, + seed: FLOWER_SEED_POOL[Math.floor(Math.random() * FLOWER_SEED_POOL.length)] ?? 1, + position, + rotation: [0, Math.random() * Math.PI * 2, 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 index 33850c219..bd52a3c81 100644 --- a/packages/plugin-trees/src/geometry.ts +++ b/packages/plugin-trees/src/geometry.ts @@ -1,22 +1,55 @@ import { Tree } from '@dgreenheck/ez-tree' import { Box3, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three' import { TREE_PRESETS } from './presets' -import type { TreePreset } from './schema' +import type { TreeNode } from './schema' + +/** 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' | 'seed' | 'foliageDensity' | 'trunkThickness' | 'leafless' +> + +export function treeSpecOf(node: TreeNode): TreeSpec { + return { + preset: node.preset, + seed: node.seed, + foliageDensity: node.foliageDensity, + trunkThickness: node.trunkThickness, + leafless: node.leafless, + } +} + +/** Stable variant id. Trees with the same key share one set of InstancedMeshes. */ +export function treeVariantKey(spec: TreeSpec): string { + return `${spec.preset}:${spec.seed}:${spec.foliageDensity}:${spec.trunkThickness}:${spec.leafless}` +} /** - * Generate an ez-tree for a (preset, seed). ez-tree's `Tree` is a `THREE.Group` - * whose children are the bark + leaf meshes; textures are inlined in the - * library (no asset hosting). Pure given its inputs — same (preset, seed) ⇒ the - * same tree — which is what lets the renderer cache one generation per variant - * and instance it across every placed tree. + * Generate an ez-tree for a spec. ez-tree's `Tree` is a `THREE.Group`; textures + * are inlined in the library (no asset hosting). The curated inspector params + * map onto ez-tree options after the preset loads: trunk thickness scales every + * branch radius, foliage density scales the leaf count, and `leafless` zeroes + * it. Pure given its inputs — same spec ⇒ same tree — which is what lets the + * renderer cache one generation per variant and instance it everywhere. */ -export function generateTree(preset: TreePreset, seed: number): Tree { - const spec = TREE_PRESETS[preset] ?? TREE_PRESETS.oak +export function generateTree(spec: TreeSpec): Tree { + const preset = TREE_PRESETS[spec.preset] ?? TREE_PRESETS.oak const tree = new Tree() - tree.loadPreset(spec.ezPreset) - // Set the seed AFTER the preset (the preset carries its own seed) and - // regenerate — ez-tree requires generate() after any option change. - tree.options.seed = seed + tree.loadPreset(preset.ezPreset) + tree.options.seed = spec.seed + + 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 } + leaves.count = spec.leafless ? 0 : Math.round(leaves.count * spec.foliageDensity) + tree.generate() return tree } @@ -27,27 +60,22 @@ export function generateTree(preset: TreePreset, seed: number): Tree { export type TreeSubMesh = { geometry: BufferGeometry; material: Material | Material[] } /** Geometry + height for one tree variant, generated once and shared across - * every instance of that (preset, seed). */ + * every instance of that spec. */ export type TreeVariantData = { subMeshes: TreeSubMesh[]; naturalHeight: number } -/** Stable variant id. Trees with the same key share one set of InstancedMeshes. */ -export function variantKey(preset: TreePreset, seed: number): string { - return `${preset}:${seed}` -} - const variantCache = new Map() /** - * Cached geometry for a variant. ez-tree's `generate()` is heavy, so it runs - * once per (preset, seed); 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). + * 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(preset: TreePreset, seed: number): TreeVariantData { - const key = variantKey(preset, seed) +export function getVariantData(spec: TreeSpec): TreeVariantData { + const key = treeVariantKey(spec) const cached = variantCache.get(key) if (cached) return cached - const tree = generateTree(preset, seed) + const tree = generateTree(spec) const data: TreeVariantData = { subMeshes: extractSubMeshes(tree), naturalHeight: naturalHeight(tree), diff --git a/packages/plugin-trees/src/index.ts b/packages/plugin-trees/src/index.ts index 70faf82f6..260ed90e8 100644 --- a/packages/plugin-trees/src/index.ts +++ b/packages/plugin-trees/src/index.ts @@ -1,17 +1,21 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' import { treeDefinition } from './definition' +import { flowerDefinition } from './flower-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: one node kind - * (`trees:tree`) 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. + * loads it through the same `loadPlugin` path the built-ins use: two node kinds + * (`trees:tree`, `trees:flower`) 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], + nodes: [ + treeDefinition as unknown as AnyNodeDefinition, + flowerDefinition as unknown as AnyNodeDefinition, + ], panels: [ { id: 'trees', @@ -23,5 +27,7 @@ export const treesPlugin: Plugin = { } export { treeDefinition } from './definition' +export { flowerDefinition } from './flower-definition' +export { FlowerNode, FlowerPreset } from './flower-schema' export { generateTree } from './geometry' 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..eda67deb6 --- /dev/null +++ b/packages/plugin-trees/src/instanced.tsx @@ -0,0 +1,217 @@ +'use client' + +import { type AnyNodeId, sceneRegistry, 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, + MeshBasicMaterial, + Object3D, +} from 'three' + +/** + * 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 INVISIBLE = new MeshBasicMaterial({ colorWrite: false, depthWrite: false }) +const DUMMY = new Object3D() +const INSTANCE_MATRIX = new Matrix4() +const NO_RAYCAST = () => {} + +// ── Collective instanced renderer (a `def.system`) ─────────────────────────── + +export function InstancedKindSystem({ + kind, + variantKeyOf, + getVariant, +}: { + kind: string + variantKeyOf: (node: N) => string + getVariant: (node: N) => VariantData +}) { + const nodes = useScene((s) => s.nodes) + + const buckets = useMemo(() => { + const map = new Map() + for (const raw of Object.values(nodes)) { + if ((raw.type as string) !== kind) continue + const node = raw as unknown as N + 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, kind, variantKeyOf]) + + return ( + <> + {buckets.map((bucket) => ( + + ))} + + ) +} + +function Variant({ + sample, + nodes, + getVariant, +}: { + sample: N + nodes: N[] + getVariant: (node: N) => VariantData +}) { + const data = useMemo(() => getVariant(sample), [sample, getVariant]) + return ( + <> + {data.subMeshes.map((subMesh, i) => ( + + ))} + + ) +} + +function InstancedSubMesh({ + subMesh, + nodes, + naturalHeight, +}: { + subMesh: SubMesh + nodes: N[] + naturalHeight: number +}) { + 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() + // Instances live at the scene root, so fold in the parent level's world + // matrix — node positions are stored level-local. + const parent = 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]) + + return ( + + ) +} + +// ── Per-node selection proxy (a `def.renderer`) ────────────────────────────── + +/** + * Invisible per-node proxy that keeps the host's selection machinery working + * for an instanced kind. Outer group: a stable box collider (the raycast + * target) + pointer handlers. Inner registered group: the real geometry + * (invisible, non-raycasting) mounted only while hovered/selected, so the + * outline pass traces the true silhouette instead of the box. + */ +export function KindProxy({ + node, + getVariant, + colliderRadius, +}: { + node: N + getVariant: (node: N) => VariantData + colliderRadius: (node: N) => number +}) { + const outlineRef = useRef(null!) + const handlers = useNodeEvents(node as never, node.type as never) + useRegistry(node.id as AnyNodeId, node.type, outlineRef) + + const active = useViewer( + (s) => s.hoveredId === node.id || s.selection.selectedIds.includes(node.id as never), + ) + + const height = Math.max(0.2, node.height ?? 1) + const radius = colliderRadius(node) + const variant = useMemo(() => (active ? getVariant(node) : null), [active, node, getVariant]) + const silhouetteScale = variant ? height / variant.naturalHeight : 1 + + return ( + + + + + + + {variant && ( + + {variant.subMeshes.map((subMesh, i) => ( + + ))} + + )} + + + ) +} diff --git a/packages/plugin-trees/src/parametrics.ts b/packages/plugin-trees/src/parametrics.ts index 1c2ccc577..520d959ec 100644 --- a/packages/plugin-trees/src/parametrics.ts +++ b/packages/plugin-trees/src/parametrics.ts @@ -19,6 +19,21 @@ export const treeParametrics: ParametricDescriptor = { { 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 }, + ], + }, { label: 'Position', fields: [{ key: 'position', kind: 'vec3' }], 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 index 6b61533ac..42cb00210 100644 --- a/packages/plugin-trees/src/presets-panel.tsx +++ b/packages/plugin-trees/src/presets-panel.tsx @@ -2,97 +2,237 @@ import { useScene } from '@pascal-app/core' import { useEditor } from '@pascal-app/editor' +import { useState } from 'react' +import { FLOWER_PRESET_LIST } from './flower-presets' +import type { FlowerPreset } from './flower-schema' import { TREE_PRESET_LIST } from './presets' import type { TreePreset } from './schema' import { useTreesStore } from './store' +type Mode = 'trees' | 'flowers' + /** - * The plugin's own left-rail panel. Picking a preset arms placement - * (`setTool('trees:tree')` + `setMode('build')`); the height slider seeds the - * next tree's height. The "N planted" counter reads the scene reactively, - * closing the triangle: panel → store → tool → scene → panel. - * - * Styling is scoped and uses the host sidebar CSS variables so it reads native - * without leaking globals. + * The plugin's left-rail panel. A Trees / Flowers toggle switches the brush; + * picking a preset arms placement for that kind (`setTool('trees:tree' | + * 'trees:flower')` + build mode). The count chip reads the scene reactively, + * closing the triangle: panel → store → tool → scene → panel. Scoped styling + + * host sidebar CSS variables keep it native. */ export default function TreesPanel() { - const selected = useTreesStore((s) => s.preset) - const height = useTreesStore((s) => s.height) - const setHeight = useTreesStore((s) => s.setHeight) + const [mode, setMode] = useState('trees') const activeTool = useEditor((s) => s.tool) const treeCount = useScene( (s) => Object.values(s.nodes).filter((n) => (n.type as string) === 'trees:tree').length, ) + const flowerCount = useScene( + (s) => Object.values(s.nodes).filter((n) => (n.type as string) === 'trees:flower').length, + ) - const arming = activeTool === 'trees:tree' - - const activate = (preset: TreePreset) => { - useTreesStore.getState().setPreset(preset) - useEditor.getState().setTool('trees:tree') - useEditor.getState().setMode('build') - } + const arming = mode === 'trees' ? activeTool === 'trees:tree' : activeTool === 'trees:flower' + const count = mode === 'trees' ? treeCount : flowerCount return (
-
+
-

Trees

+

Plant

- {treeCount} planted + {count} planted
+
+ {(['trees', 'flowers'] as const).map((m) => ( + + ))} +

{arming ? 'Click the ground to plant. Press Esc to stop.' - : 'Pick a tree, then click the ground to plant.'} + : `Pick a ${mode === 'trees' ? 'tree' : 'flower'}, then click the ground.`}

-
- {TREE_PRESET_LIST.map((spec) => { - const isSelected = selected === spec.id && arming - return ( - - ) - })} -
+ {mode === 'trees' ? : } +
+ ) +} -
-
- {(['trees', 'flowers'] as const).map((m) => ( - - ))} -
+

{arming ? 'Click the ground to plant. Press Esc to stop.' @@ -62,6 +57,19 @@ export default function TreesPanel() { {mode === 'trees' ? : } + +

+ Trees generated with{' '} + + ez-tree + {' '} + by Daniel Greenheck (MIT). +
) } @@ -82,42 +90,45 @@ function TreesSection({ arming }: { arming: boolean }) { return ( <> -
- + - - + )} + - +
) @@ -140,13 +151,15 @@ function FlowersSection({ arming }: { arming: boolean }) { onPick={activate} selected={arming ? selected : null} /> - @@ -158,7 +171,7 @@ function PresetGrid({ selected, onPick, }: { - items: ReadonlyArray<{ id: T; label: string; swatch: string }> + items: ReadonlyArray<{ id: T; label: string; thumbnail: string }> selected: T | null onPick: (id: T) => void }) { @@ -168,7 +181,7 @@ function PresetGrid({ const isSelected = selected === item.id return ( ) @@ -194,45 +206,3 @@ function PresetGrid({ ) } - -function Slider({ - label, - value, - min, - max, - step, - suffix = '', - disabled = false, - onChange, -}: { - label: string - value: number - min: number - max: number - step: number - suffix?: string - disabled?: boolean - onChange: (value: number) => void -}) { - return ( - - ) -} diff --git a/packages/plugin-trees/src/presets.ts b/packages/plugin-trees/src/presets.ts index 8c320b05a..a57a2b8ad 100644 --- a/packages/plugin-trees/src/presets.ts +++ b/packages/plugin-trees/src/presets.ts @@ -1,11 +1,12 @@ import type { TreePreset } from './schema' +import { treeThumbnail } from './thumbnails' /** - * Per-preset config: which ez-tree built-in preset to generate, a swatch colour - * for the panel card, and a default placement height (metres). Pure data — no - * three.js, no React — shared by the panel grid and the instanced renderer so - * they stay in lockstep. `ezPreset` is the exact ez-tree preset name passed to - * `tree.loadPreset(...)`. + * Per-preset config: which ez-tree built-in preset to generate, a swatch colour, + * a card `thumbnail` (a replaceable placeholder image — see `thumbnails.ts`), and + * a default placement height (metres). Pure data — no three.js, no React — + * shared by the panel grid and the instanced renderer so they stay in lockstep. + * `ezPreset` is the exact ez-tree preset name passed to `tree.loadPreset(...)`. */ export type TreePresetSpec = { id: TreePreset @@ -13,20 +14,50 @@ export type TreePresetSpec = { ezPreset: string defaultHeight: number swatch: string + thumbnail: string } export const TREE_PRESETS: Record = { - oak: { id: 'oak', label: 'Oak', ezPreset: 'Oak Medium', defaultHeight: 7, swatch: '#4f7942' }, - pine: { id: 'pine', label: 'Pine', ezPreset: 'Pine Medium', defaultHeight: 9, swatch: '#2f5d3a' }, + oak: { + id: 'oak', + label: 'Oak', + ezPreset: 'Oak Medium', + defaultHeight: 7, + swatch: '#4f7942', + thumbnail: treeThumbnail('#4f7942'), + }, + pine: { + id: 'pine', + label: 'Pine', + ezPreset: 'Pine Medium', + defaultHeight: 9, + swatch: '#2f5d3a', + thumbnail: treeThumbnail('#2f5d3a'), + }, aspen: { id: 'aspen', label: 'Aspen', ezPreset: 'Aspen Medium', defaultHeight: 8, swatch: '#8fae5d', + thumbnail: treeThumbnail('#8fae5d'), + }, + ash: { + id: 'ash', + label: 'Ash', + ezPreset: 'Ash Medium', + defaultHeight: 8, + swatch: '#6f9457', + thumbnail: treeThumbnail('#6f9457'), + }, + bush: { + id: 'bush', + label: 'Bush', + ezPreset: 'Bush 1', + defaultHeight: 1.5, + swatch: '#5c8a4a', + thumbnail: treeThumbnail('#5c8a4a'), }, - ash: { id: 'ash', label: 'Ash', ezPreset: 'Ash Medium', defaultHeight: 8, swatch: '#6f9457' }, - bush: { id: 'bush', label: 'Bush', ezPreset: 'Bush 1', defaultHeight: 1.5, swatch: '#5c8a4a' }, } export const TREE_PRESET_LIST: TreePresetSpec[] = Object.values(TREE_PRESETS) diff --git a/packages/plugin-trees/src/thumbnails.ts b/packages/plugin-trees/src/thumbnails.ts new file mode 100644 index 000000000..833108209 --- /dev/null +++ b/packages/plugin-trees/src/thumbnails.ts @@ -0,0 +1,57 @@ +/** + * Preset card thumbnails, inlined as SVG data URIs so the plugin needs no asset + * hosting — the same trick ez-tree uses for its bark/leaf textures. These are + * intentionally simple *placeholders*: swap any value for a real image URL (or a + * bundler-imported asset) to ship production art. The panel just renders the + * string as an ``, so anything an `` accepts works. + */ + +function dataUri(svg: string): string { + return `data:image/svg+xml,${encodeURIComponent(svg)}` +} + +/** Stylised tree card: soft sky, ground band, trunk + layered canopy in the + * preset colour. */ +export function treeThumbnail(canopy: string): string { + return dataUri( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + ``, + ) +} + +/** Stylised flower card: stem + 6 petals around a centre, in the preset colours. */ +export function flowerThumbnail(petal: string, center: string, stem: string): string { + const petals = [ + [75, 40], + [67.5, 27], + [52.5, 27], + [45, 40], + [52.5, 53], + [67.5, 53], + ] + .map(([cx, cy]) => ``) + .join('') + return dataUri( + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + `` + + petals + + `` + + ``, + ) +} From 5de5f9ed05fce2d8683ecf57d660febde2abd8d5 Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 1 Jul 2026 08:54:34 -0400 Subject: [PATCH 07/36] feat(plugin-trees): all ez-tree presets + type + leaf/branch colours, flower petal colour, grass kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tree presets now cover all of ez-tree's built-ins via species × size (Small/Medium/Large + Bush 1/2/3 + Trellis), plus a Deciduous/Evergreen type - edit-only leaf & branch colour tints (leaves.tint / bark.tint), all folded into the instancing variant key - flowers gain a per-flower petal colour (baked from preset at placement) - new trees:grass instanced kind — procedural blade tufts (meadow/fescue/reed) with a per-tuft blade colour, reusing the generic instanced + placement core - panel: Trees/Flowers/Grass segmented switch, size + type controls, native host controls throughout; credit links Daniel Greenheck's X Co-Authored-By: Claude Opus 4.8 --- packages/plugin-trees/README.md | 26 +++-- packages/plugin-trees/src/definition.ts | 8 +- .../plugin-trees/src/flower-definition.ts | 1 + packages/plugin-trees/src/flower-geometry.ts | 32 ++----- .../plugin-trees/src/flower-parametrics.ts | 1 + packages/plugin-trees/src/flower-schema.ts | 3 + packages/plugin-trees/src/flower-system.tsx | 2 +- packages/plugin-trees/src/flower-tool.tsx | 16 +++- packages/plugin-trees/src/geometry.ts | 64 +++++++++++-- packages/plugin-trees/src/grass-definition.ts | 76 +++++++++++++++ packages/plugin-trees/src/grass-geometry.ts | 61 ++++++++++++ .../plugin-trees/src/grass-parametrics.ts | 22 +++++ packages/plugin-trees/src/grass-presets.ts | 50 ++++++++++ packages/plugin-trees/src/grass-preview.tsx | 51 ++++++++++ .../plugin-trees/src/grass-proxy-renderer.tsx | 13 +++ packages/plugin-trees/src/grass-schema.ts | 23 +++++ packages/plugin-trees/src/grass-system.tsx | 19 ++++ packages/plugin-trees/src/grass-tool.tsx | 57 +++++++++++ packages/plugin-trees/src/index.ts | 12 ++- packages/plugin-trees/src/parametrics.ts | 46 +++++++-- packages/plugin-trees/src/presets-panel.tsx | 96 ++++++++++++++++--- packages/plugin-trees/src/presets.ts | 76 +++++++++++---- packages/plugin-trees/src/schema.ts | 27 ++++-- packages/plugin-trees/src/store.ts | 39 ++++++-- packages/plugin-trees/src/thumbnails.ts | 28 ++++++ packages/plugin-trees/src/tool.tsx | 14 ++- 26 files changed, 755 insertions(+), 108 deletions(-) create mode 100644 packages/plugin-trees/src/grass-definition.ts create mode 100644 packages/plugin-trees/src/grass-geometry.ts create mode 100644 packages/plugin-trees/src/grass-parametrics.ts create mode 100644 packages/plugin-trees/src/grass-presets.ts create mode 100644 packages/plugin-trees/src/grass-preview.tsx create mode 100644 packages/plugin-trees/src/grass-proxy-renderer.tsx create mode 100644 packages/plugin-trees/src/grass-schema.ts create mode 100644 packages/plugin-trees/src/grass-system.tsx create mode 100644 packages/plugin-trees/src/grass-tool.tsx diff --git a/packages/plugin-trees/README.md b/packages/plugin-trees/README.md index 5a70f7cdc..100fea15f 100644 --- a/packages/plugin-trees/README.md +++ b/packages/plugin-trees/README.md @@ -35,15 +35,22 @@ The contribution paths a plugin has: stays on the box, and selection / outline / zone machinery works unchanged with no instanceId bookkeeping. -### Two kinds +### Three kinds -- **`trees:tree`** — ez-tree geometry (`geometry.ts`); presets Oak / Pine / - Aspen / Ash / Bush; curated inspector params (foliage density, trunk - thickness, leafless) folded into the variant key. +- **`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. A sibling kind that reuses the - exact same instanced core + placement helper (`placement.tsx`) — the template - for adding more plant kinds. + 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 @@ -62,8 +69,9 @@ import { treesPlugin } from '@pascal-app/plugin-trees' setPluginDiscovery(async () => [treesPlugin]) ``` -`treesPlugin` exports one node kind (`trees:tree`) and one panel (`Trees`), -loaded through the same `loadPlugin` path as the built-ins. +`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 diff --git a/packages/plugin-trees/src/definition.ts b/packages/plugin-trees/src/definition.ts index c86949077..8f1abc951 100644 --- a/packages/plugin-trees/src/definition.ts +++ b/packages/plugin-trees/src/definition.ts @@ -25,11 +25,15 @@ export const treeDefinition: NodeDefinition = { 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: { @@ -71,7 +75,7 @@ export const treeDefinition: NodeDefinition = { presentation: { label: 'Tree', - description: 'A procedural low-poly tree. Oak, pine, birch, or palm.', + description: 'A procedural ez-tree. Oak, pine, aspen, ash, bush, or trellis.', icon: { kind: 'iconify', name: 'lucide:trees' }, paletteSection: 'furnish', hidden: true, @@ -79,6 +83,6 @@ export const treeDefinition: NodeDefinition = { mcp: { description: - 'A procedural tree (example plugin node). Four presets — oak, pine, birch, palm — with adjustable height and a seed for canopy variation.', + '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/flower-definition.ts b/packages/plugin-trees/src/flower-definition.ts index e59f9f260..eaaefa8a1 100644 --- a/packages/plugin-trees/src/flower-definition.ts +++ b/packages/plugin-trees/src/flower-definition.ts @@ -25,6 +25,7 @@ export const flowerDefinition: NodeDefinition = { preset: 'daisy', height: 0.5, seed: 1, + petalColor: '#fcfcf2', }), capabilities: { diff --git a/packages/plugin-trees/src/flower-geometry.ts b/packages/plugin-trees/src/flower-geometry.ts index f2f832b0b..9a754cd4a 100644 --- a/packages/plugin-trees/src/flower-geometry.ts +++ b/packages/plugin-trees/src/flower-geometry.ts @@ -10,23 +10,23 @@ import { import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' import { FLOWER_PRESETS } from './flower-presets' import type { FlowerNode, FlowerPreset } from './flower-schema' -import { naturalHeight } from './geometry' +import { mulberry32, naturalHeight } from './geometry' import type { SubMesh, VariantData } from './instanced' -export function flowerVariantKey(preset: FlowerPreset, seed: number): string { - return `${preset}:${seed}` +export function flowerVariantKey(preset: FlowerPreset, seed: number, petalColor: string): string { + return `${preset}:${seed}:${petalColor}` } const variantCache = new Map() -/** Cached procedural flower geometry for a (preset, seed). 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). */ +/** 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 key = flowerVariantKey(node.preset, node.seed) + const key = flowerVariantKey(node.preset, node.seed, node.petalColor) const cached = variantCache.get(key) if (cached) return cached - const group = buildFlower(node.preset, node.seed) + const group = buildFlower(node.preset, node.seed, node.petalColor) const subMeshes: SubMesh[] = group.children .filter((c): c is Mesh => (c as Mesh).isMesh) .map((mesh) => ({ geometry: mesh.geometry, material: mesh.material })) @@ -35,12 +35,12 @@ export function getFlowerVariant(node: FlowerNode): VariantData { return data } -function buildFlower(preset: FlowerPreset, seed: number): Group { +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 = new MeshStandardMaterial({ color: spec.stemColor, roughness: 0.85 }) - const petalMat = new MeshStandardMaterial({ color: spec.petalColor, roughness: 0.7 }) + const petalMat = new MeshStandardMaterial({ color: petalColor, roughness: 0.7 }) const centerMat = new MeshStandardMaterial({ color: spec.centerColor, roughness: 0.6 }) const stemH = spec.defaultHeight @@ -106,15 +106,3 @@ function buildFlower(preset: FlowerPreset, seed: number): Group { group.add(new Mesh(mergeGeometries(petals, false) ?? petals[0], petalMat)) return group } - -/** Deterministic 32-bit RNG (mulberry32) — same seed ⇒ same flower. */ -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/flower-parametrics.ts b/packages/plugin-trees/src/flower-parametrics.ts index 4a0221b69..b81dda986 100644 --- a/packages/plugin-trees/src/flower-parametrics.ts +++ b/packages/plugin-trees/src/flower-parametrics.ts @@ -10,6 +10,7 @@ export const flowerParametrics: ParametricDescriptor = { 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 }, ], }, diff --git a/packages/plugin-trees/src/flower-schema.ts b/packages/plugin-trees/src/flower-schema.ts index 9873f7a00..0edc0b0e4 100644 --- a/packages/plugin-trees/src/flower-schema.ts +++ b/packages/plugin-trees/src/flower-schema.ts @@ -15,6 +15,9 @@ export const FlowerNode = BaseNode.extend({ 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-system.tsx b/packages/plugin-trees/src/flower-system.tsx index 357440a06..a9882a11b 100644 --- a/packages/plugin-trees/src/flower-system.tsx +++ b/packages/plugin-trees/src/flower-system.tsx @@ -4,7 +4,7 @@ import { flowerVariantKey, getFlowerVariant } from './flower-geometry' import type { FlowerNode } from './flower-schema' import { InstancedKindSystem } from './instanced' -const variantKeyOf = (node: FlowerNode) => flowerVariantKey(node.preset, node.seed) +const variantKeyOf = (node: FlowerNode) => flowerVariantKey(node.preset, node.seed, node.petalColor) const getVariant = (node: FlowerNode) => getFlowerVariant(node) /** Collective instanced renderer for every placed flower (`def.system`). */ diff --git a/packages/plugin-trees/src/flower-tool.tsx b/packages/plugin-trees/src/flower-tool.tsx index eb0155c93..1bfca30bb 100644 --- a/packages/plugin-trees/src/flower-tool.tsx +++ b/packages/plugin-trees/src/flower-tool.tsx @@ -4,21 +4,30 @@ 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_SEED_POOL } from './flower-presets' +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. */ + * 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, seed: 1, position: [0, 0, 0], rotation: [0, 0, 0] }), + () => + FlowerNode.parse({ + preset, + height, + petalColor: FLOWER_PRESETS[preset].petalColor, + seed: 1, + position: [0, 0, 0], + rotation: [0, 0, 0], + }), [preset, height], ) @@ -28,6 +37,7 @@ export default function FlowerTool() { 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.random() * Math.PI * 2, 0], diff --git a/packages/plugin-trees/src/geometry.ts b/packages/plugin-trees/src/geometry.ts index bd52a3c81..f3f341f2f 100644 --- a/packages/plugin-trees/src/geometry.ts +++ b/packages/plugin-trees/src/geometry.ts @@ -1,6 +1,6 @@ import { Tree } from '@dgreenheck/ez-tree' import { Box3, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three' -import { TREE_PRESETS } from './presets' +import { ezPresetOf } from './presets' import type { TreeNode } from './schema' /** The geometry-affecting fields of a tree. Two trees with the same spec share @@ -9,37 +9,66 @@ import type { TreeNode } from './schema' * work, not geometry. */ export type TreeSpec = Pick< TreeNode, - 'preset' | 'seed' | 'foliageDensity' | 'trunkThickness' | 'leafless' + | 'preset' + | 'size' + | 'treeType' + | 'seed' + | 'foliageDensity' + | 'trunkThickness' + | 'leafless' + | 'leafColor' + | 'branchColor' > export function treeSpecOf(node: TreeNode): TreeSpec { return { preset: node.preset, + size: node.size, + treeType: node.treeType, seed: node.seed, foliageDensity: node.foliageDensity, trunkThickness: node.trunkThickness, leafless: node.leafless, + 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.seed}:${spec.foliageDensity}:${spec.trunkThickness}:${spec.leafless}` + 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 unparseable. */ +function hexToInt(hex: string): 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). The curated inspector params - * map onto ez-tree options after the preset loads: trunk thickness scales every - * branch radius, foliage density scales the leaf count, and `leafless` zeroes - * it. Pure given its inputs — same spec ⇒ same tree — which is what lets the - * renderer cache one generation per variant and instance it everywhere. + * map onto ez-tree options after the preset loads: size picks the preset family + * member, `treeType` swaps the growth model, trunk thickness scales every branch + * radius, foliage density scales the leaf count (`leafless` zeroes it), and the + * colours drive the bark/leaf tints. Pure given its inputs — same spec ⇒ same + * tree — which is what lets the renderer cache one generation per variant. */ export function generateTree(spec: TreeSpec): Tree { - const preset = TREE_PRESETS[spec.preset] ?? TREE_PRESETS.oak const tree = new Tree() - tree.loadPreset(preset.ezPreset) + tree.loadPreset(ezPresetOf(spec.preset, spec.size)) tree.options.seed = spec.seed + ;(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)) { @@ -47,8 +76,10 @@ export function generateTree(spec: TreeSpec): Tree { if (value !== undefined) radius[level] = value * spec.trunkThickness } - const leaves = tree.options.leaves as { count: number } + const leaves = tree.options.leaves as { count: number; tint: number } leaves.count = spec.leafless ? 0 : Math.round(leaves.count * spec.foliageDensity) + leaves.tint = hexToInt(spec.leafColor) + ;(tree.options.bark as { tint: number }).tint = hexToInt(spec.branchColor) tree.generate() return tree @@ -106,3 +137,16 @@ 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/grass-definition.ts b/packages/plugin-trees/src/grass-definition.ts new file mode 100644 index 000000000..47a91a274 --- /dev/null +++ b/packages/plugin-trees/src/grass-definition.ts @@ -0,0 +1,76 @@ +import type { NodeDefinition } from '@pascal-app/core' +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', + 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: [0, Math.PI / 2, Math.PI, (3 * Math.PI) / 2] }, + 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, + + renderer: { kind: 'parametric', module: () => import('./grass-proxy-renderer') }, + system: { module: () => import('./grass-system'), priority: 3 }, + + 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..17fec6d1c --- /dev/null +++ b/packages/plugin-trees/src/grass-geometry.ts @@ -0,0 +1,61 @@ +import { + type BufferGeometry, + ConeGeometry, + DoubleSide, + Group, + Mesh, + MeshStandardMaterial, +} from 'three' +import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js' +import { mulberry32, naturalHeight } from './geometry' +import { GRASS_PRESETS } from './grass-presets' +import type { GrassNode, GrassPreset } from './grass-schema' +import type { SubMesh, VariantData } from './instanced' + +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 = new MeshStandardMaterial({ 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..f01fce24f --- /dev/null +++ b/packages/plugin-trees/src/grass-presets.ts @@ -0,0 +1,50 @@ +import type { GrassPreset } from './grass-schema' +import { grassThumbnail } from './thumbnails' + +/** 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: grassThumbnail('#5a8f3c'), + }, + fescue: { + id: 'fescue', + label: 'Fescue', + bladeColor: '#7fae55', + blades: 8, + defaultHeight: 0.7, + swatch: '#7fae55', + thumbnail: grassThumbnail('#7fae55'), + }, + reed: { + id: 'reed', + label: 'Reed', + bladeColor: '#4a7d63', + blades: 6, + defaultHeight: 1.1, + swatch: '#4a7d63', + thumbnail: grassThumbnail('#4a7d63'), + }, +} + +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-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..486f4f848 --- /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.random() * Math.PI * 2, 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 index 260ed90e8..cb0b1c73a 100644 --- a/packages/plugin-trees/src/index.ts +++ b/packages/plugin-trees/src/index.ts @@ -1,13 +1,14 @@ import type { AnyNodeDefinition, Plugin } from '@pascal-app/core' 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: two node kinds - * (`trees:tree`, `trees:flower`) 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. + * 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', @@ -15,6 +16,7 @@ export const treesPlugin: Plugin = { nodes: [ treeDefinition as unknown as AnyNodeDefinition, flowerDefinition as unknown as AnyNodeDefinition, + grassDefinition as unknown as AnyNodeDefinition, ], panels: [ { @@ -30,4 +32,6 @@ export { treeDefinition } from './definition' export { flowerDefinition } from './flower-definition' export { FlowerNode, FlowerPreset } from './flower-schema' export { generateTree } from './geometry' +export { grassDefinition } from './grass-definition' +export { GrassNode, GrassPreset } from './grass-schema' export { TreeNode, TreePreset } from './schema' diff --git a/packages/plugin-trees/src/parametrics.ts b/packages/plugin-trees/src/parametrics.ts index 520d959ec..75ebc27c8 100644 --- a/packages/plugin-trees/src/parametrics.ts +++ b/packages/plugin-trees/src/parametrics.ts @@ -1,21 +1,43 @@ import { type AnyNodeId, type ParametricDescriptor, useScene } from '@pascal-app/core' -import { TREE_SEED_POOL } from './presets' +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 the preset select, height slider, - * seed field, 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. + * 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'] }, + { + 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 }, ], }, @@ -32,6 +54,7 @@ export const treeParametrics: ParametricDescriptor = { 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 }, ], }, { @@ -53,5 +76,16 @@ export const treeParametrics: ParametricDescriptor = { } 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/presets-panel.tsx b/packages/plugin-trees/src/presets-panel.tsx index 43a429799..32a1d7c96 100644 --- a/packages/plugin-trees/src/presets-panel.tsx +++ b/packages/plugin-trees/src/presets-panel.tsx @@ -5,16 +5,25 @@ import { SegmentedControl, SliderControl, ToggleControl, useEditor } from '@pasc import { useState } from 'react' 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 { useTreesStore } from './store' -type Mode = 'trees' | 'flowers' +type Mode = 'trees' | 'flowers' | 'grass' + +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 segmented control switches the - * brush; picking a preset arms placement for that kind (`setTool('trees:tree' | - * 'trees:flower')` + build mode). The count chip reads the scene reactively, + * 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. @@ -22,15 +31,11 @@ type Mode = 'trees' | 'flowers' export default function TreesPanel() { const [mode, setMode] = useState('trees') const activeTool = useEditor((s) => s.tool) - const treeCount = useScene( - (s) => Object.values(s.nodes).filter((n) => (n.type as string) === 'trees:tree').length, - ) - const flowerCount = useScene( - (s) => Object.values(s.nodes).filter((n) => (n.type as string) === 'trees:flower').length, + const count = useScene( + (s) => Object.values(s.nodes).filter((n) => (n.type as string) === KIND[mode]).length, ) - const arming = mode === 'trees' ? activeTool === 'trees:tree' : activeTool === 'trees:flower' - const count = mode === 'trees' ? treeCount : flowerCount + const arming = activeTool === KIND[mode] return (
@@ -46,17 +51,20 @@ export default function TreesPanel() { options={[ { label: 'Trees', value: 'trees' }, { label: 'Flowers', value: 'flowers' }, + { label: 'Grass', value: 'grass' }, ]} value={mode} />

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

- {mode === 'trees' ? : } + {mode === 'trees' && } + {mode === 'flowers' && } + {mode === 'grass' && }
Trees generated with{' '} @@ -68,7 +76,16 @@ export default function TreesPanel() { > ez-tree {' '} - by Daniel Greenheck (MIT). + by{' '} + + Daniel Greenheck + {' '} + (MIT).
) @@ -76,6 +93,8 @@ export default function TreesPanel() { function TreesSection({ arming }: { arming: boolean }) { const selected = useTreesStore((s) => s.preset) + const size = useTreesStore((s) => s.size) + const treeType = useTreesStore((s) => s.treeType) const height = useTreesStore((s) => s.height) const foliageDensity = useTreesStore((s) => s.foliageDensity) const trunkThickness = useTreesStore((s) => s.trunkThickness) @@ -90,6 +109,27 @@ function TreesSection({ arming }: { arming: boolean }) { return ( <> +
+ {selected !== 'trellis' && ( + + )} + +
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, diff --git a/packages/plugin-trees/src/presets.ts b/packages/plugin-trees/src/presets.ts index a57a2b8ad..617662c64 100644 --- a/packages/plugin-trees/src/presets.ts +++ b/packages/plugin-trees/src/presets.ts @@ -1,72 +1,106 @@ -import type { TreePreset } from './schema' +import type { TreePreset, TreeSize } from './schema' import { treeThumbnail } from './thumbnails' /** - * Per-preset config: which ez-tree built-in preset to generate, a swatch colour, - * a card `thumbnail` (a replaceable placeholder image — see `thumbnails.ts`), and - * a default placement height (metres). Pure data — no three.js, no React — - * shared by the panel grid and the instanced renderer so they stay in lockstep. - * `ezPreset` is the exact ez-tree preset name passed to `tree.loadPreset(...)`. + * 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 - ezPreset: string - defaultHeight: number + /** 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', - ezPreset: 'Oak Medium', - defaultHeight: 7, + ez: ezSizes('Oak'), + height: { small: 5, medium: 7, large: 11 }, + sized: true, swatch: '#4f7942', thumbnail: treeThumbnail('#4f7942'), }, pine: { id: 'pine', label: 'Pine', - ezPreset: 'Pine Medium', - defaultHeight: 9, + ez: ezSizes('Pine'), + height: { small: 6, medium: 9, large: 14 }, + sized: true, swatch: '#2f5d3a', thumbnail: treeThumbnail('#2f5d3a'), }, aspen: { id: 'aspen', label: 'Aspen', - ezPreset: 'Aspen Medium', - defaultHeight: 8, + ez: ezSizes('Aspen'), + height: { small: 5, medium: 8, large: 12 }, + sized: true, swatch: '#8fae5d', thumbnail: treeThumbnail('#8fae5d'), }, ash: { id: 'ash', label: 'Ash', - ezPreset: 'Ash Medium', - defaultHeight: 8, + ez: ezSizes('Ash'), + height: { small: 5, medium: 8, large: 12 }, + sized: true, swatch: '#6f9457', thumbnail: treeThumbnail('#6f9457'), }, bush: { id: 'bush', label: 'Bush', - ezPreset: 'Bush 1', - defaultHeight: 1.5, + 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: treeThumbnail('#5c8a4a'), }, + trellis: { + id: 'trellis', + label: 'Trellis', + ez: { small: 'Trellis', medium: 'Trellis', large: 'Trellis' }, + height: { small: 3, medium: 3, large: 3 }, + sized: false, + swatch: '#8b6b45', + thumbnail: treeThumbnail('#8b6b45'), + }, } 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 (preset × seed) — 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. + * 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] diff --git a/packages/plugin-trees/src/schema.ts b/packages/plugin-trees/src/schema.ts index a977bcf73..5bf472b4c 100644 --- a/packages/plugin-trees/src/schema.ts +++ b/packages/plugin-trees/src/schema.ts @@ -1,20 +1,29 @@ import { BaseNode, nodeType, objectId } from '@pascal-app/core' import { z } from 'zod' -/** Tree silhouettes the plugin can place, each backed by an ez-tree preset. +/** 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']) +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` — the same string the registry, - * the tool id, and the scene JSON all key on. `height`/`seed`/`preset` are the - * only geometry-relevant fields, so the definition's `geometryKey` is built - * from them. + * `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'), @@ -22,6 +31,8 @@ export const TreeNode = BaseNode.extend({ 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'), + treeType: TreeType.default('deciduous'), height: z.number().positive().default(7), seed: z.number().int().default(1), // Curated geometry params (folded into the instancing variant key): @@ -31,6 +42,10 @@ export const TreeNode = BaseNode.extend({ 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 (hex). `#ffffff` = neutral — shows the ez-tree texture as-is. */ + leafColor: z.string().default('#ffffff'), + /** Bark/branch tint (hex). `#ffffff` = neutral. */ + branchColor: z.string().default('#ffffff'), }) export type TreeNode = z.infer diff --git a/packages/plugin-trees/src/store.ts b/packages/plugin-trees/src/store.ts index 1a5ba5430..16f98957c 100644 --- a/packages/plugin-trees/src/store.ts +++ b/packages/plugin-trees/src/store.ts @@ -1,17 +1,22 @@ import { create } from 'zustand' import { FLOWER_PRESETS } from './flower-presets' import type { FlowerPreset } from './flower-schema' -import { TREE_PRESETS } from './presets' -import type { TreePreset } from './schema' +import { GRASS_PRESETS } from './grass-presets' +import type { GrassPreset } from './grass-schema' +import { defaultHeightOf, TREE_PRESETS } from './presets' +import type { TreePreset, TreeSize, TreeType } 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 looks like. The - * presets panel writes it; the placement tool reads it. No host lifecycle slot. + * 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). */ type TreesStore = { preset: TreePreset + size: TreeSize + treeType: TreeType /** 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). */ @@ -21,6 +26,8 @@ type TreesStore = { /** Plant bare (leafless) trees. */ leafless: boolean setPreset: (preset: TreePreset) => void + setSize: (size: TreeSize) => void + setTreeType: (treeType: TreeType) => void setHeight: (height: number) => void setFoliageDensity: (value: number) => void setTrunkThickness: (value: number) => void @@ -30,17 +37,26 @@ type TreesStore = { 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) => ({ +export const useTreesStore = create((set, get) => ({ preset: 'oak', - height: TREE_PRESETS.oak.defaultHeight, + size: 'medium', + treeType: 'deciduous', + height: TREE_PRESETS.oak.height.medium, foliageDensity: 1, trunkThickness: 1, leafless: false, - // Switching preset re-seeds the height to that preset's natural default; the - // foliage/trunk brush settings carry over. - setPreset: (preset) => set({ preset, height: TREE_PRESETS[preset].defaultHeight }), + // Switching preset/size re-seeds the height to that combo's natural default; + // the foliage/trunk/type brush settings carry over. + setPreset: (preset) => set({ preset, height: defaultHeightOf(preset, get().size) }), + setSize: (size) => set({ size, height: defaultHeightOf(get().preset, size) }), + setTreeType: (treeType) => set({ treeType }), setHeight: (height) => set({ height }), setFoliageDensity: (foliageDensity) => set({ foliageDensity }), setTrunkThickness: (trunkThickness) => set({ trunkThickness }), @@ -50,4 +66,9 @@ export const useTreesStore = create((set) => ({ 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/thumbnails.ts b/packages/plugin-trees/src/thumbnails.ts index 833108209..88b4865e1 100644 --- a/packages/plugin-trees/src/thumbnails.ts +++ b/packages/plugin-trees/src/thumbnails.ts @@ -55,3 +55,31 @@ export function flowerThumbnail(petal: string, center: string, stem: string): st ``, ) } + +/** Stylised grass card: a tuft of leaning blades in the preset colour. */ +export function grassThumbnail(blade: string): string { + const spec: Array<[number, number]> = [ + [42, -18], + [50, -8], + [58, 2], + [66, -6], + [74, 12], + [82, -14], + ] + const blades = spec + .map( + ([x, lean]) => + ``, + ) + .join('') + return dataUri( + `` + + `` + + `` + + `` + + `` + + `` + + blades + + ``, + ) +} diff --git a/packages/plugin-trees/src/tool.tsx b/packages/plugin-trees/src/tool.tsx index cf292716d..b7ae7e6d3 100644 --- a/packages/plugin-trees/src/tool.tsx +++ b/packages/plugin-trees/src/tool.tsx @@ -24,6 +24,8 @@ export default function TreeTool() { () => TreeNode.parse({ preset: brush.preset, + size: brush.size, + treeType: brush.treeType, height: brush.height, foliageDensity: brush.foliageDensity, trunkThickness: brush.trunkThickness, @@ -32,7 +34,15 @@ export default function TreeTool() { position: [0, 0, 0], rotation: [0, 0, 0], }), - [brush.preset, brush.height, brush.foliageDensity, brush.trunkThickness, brush.leafless], + [ + brush.preset, + brush.size, + brush.treeType, + brush.height, + brush.foliageDensity, + brush.trunkThickness, + brush.leafless, + ], ) const { cursorRef, cursorVisible } = usePlacement(activeLevelId, (position) => { @@ -40,6 +50,8 @@ export default function TreeTool() { const s = useTreesStore.getState() const tree = TreeNode.parse({ preset: s.preset, + size: s.size, + treeType: s.treeType, height: s.height, foliageDensity: s.foliageDensity, trunkThickness: s.trunkThickness, From 758d5450fe4a231be4ad82402e9608002923a1de Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 1 Jul 2026 08:59:32 -0400 Subject: [PATCH 08/36] fix(plugin-trees): tolerate nodes persisted before new fields existed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit treeSpecOf/flowerPetalColor now default every geometry field, and hexToInt guards undefined — trees/flowers placed before size/type/colour fields were added load with neutral defaults instead of crashing the instanced renderer. Co-Authored-By: Claude Opus 4.8 --- packages/plugin-trees/src/flower-geometry.ts | 11 +++++++-- packages/plugin-trees/src/flower-system.tsx | 5 ++-- packages/plugin-trees/src/geometry.ts | 26 +++++++++++--------- 3 files changed, 26 insertions(+), 16 deletions(-) diff --git a/packages/plugin-trees/src/flower-geometry.ts b/packages/plugin-trees/src/flower-geometry.ts index 9a754cd4a..ec06fd107 100644 --- a/packages/plugin-trees/src/flower-geometry.ts +++ b/packages/plugin-trees/src/flower-geometry.ts @@ -17,16 +17,23 @@ export function flowerVariantKey(preset: FlowerPreset, seed: number, petalColor: 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 key = flowerVariantKey(node.preset, node.seed, node.petalColor) + 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, node.petalColor) + 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 })) diff --git a/packages/plugin-trees/src/flower-system.tsx b/packages/plugin-trees/src/flower-system.tsx index a9882a11b..0572acc24 100644 --- a/packages/plugin-trees/src/flower-system.tsx +++ b/packages/plugin-trees/src/flower-system.tsx @@ -1,10 +1,11 @@ 'use client' -import { flowerVariantKey, getFlowerVariant } from './flower-geometry' +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, node.petalColor) +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`). */ diff --git a/packages/plugin-trees/src/geometry.ts b/packages/plugin-trees/src/geometry.ts index f3f341f2f..be68d37c3 100644 --- a/packages/plugin-trees/src/geometry.ts +++ b/packages/plugin-trees/src/geometry.ts @@ -21,16 +21,18 @@ export type TreeSpec = Pick< > export function treeSpecOf(node: TreeNode): TreeSpec { + // Default every field — nodes persisted before a field existed load without + // it, so the renderer must tolerate partial trees (no runtime schema re-parse). return { - preset: node.preset, - size: node.size, - treeType: node.treeType, - seed: node.seed, - foliageDensity: node.foliageDensity, - trunkThickness: node.trunkThickness, - leafless: node.leafless, - leafColor: node.leafColor, - branchColor: node.branchColor, + preset: node.preset ?? 'oak', + size: node.size ?? 'medium', + treeType: node.treeType ?? 'deciduous', + seed: node.seed ?? 1, + foliageDensity: node.foliageDensity ?? 1, + trunkThickness: node.trunkThickness ?? 1, + leafless: node.leafless ?? false, + leafColor: node.leafColor ?? '#ffffff', + branchColor: node.branchColor ?? '#ffffff', } } @@ -49,9 +51,9 @@ export function treeVariantKey(spec: TreeSpec): string { ].join(':') } -/** `#rrggbb` → 0xrrggbb, defaulting to white on anything unparseable. */ -function hexToInt(hex: string): number { - const n = Number.parseInt(hex.replace('#', ''), 16) +/** `#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 } From 1afaed4c3a248e91349e29beb91041f086b7047d Mon Sep 17 00:00:00 2001 From: Wassim SAMAD Date: Wed, 1 Jul 2026 09:11:37 -0400 Subject: [PATCH 09/36] feat(plugin-trees): shared always-on wind + pin credits to panel bottom MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wind.ts injects a sway into each material's begin_vertex driven by one shared uTime uniform (advanced per frame in the instanced system); USE_INSTANCING guards keep the same material valid for the non-instanced placement ghost. Applied to tree, flower, and grass materials — a whole scene sways like ez-tree. - credits footer is now sticky to the bottom of the panel. Co-Authored-By: Claude Opus 4.8 --- packages/plugin-trees/src/flower-geometry.ts | 2 + packages/plugin-trees/src/geometry.ts | 2 + packages/plugin-trees/src/grass-geometry.ts | 2 + packages/plugin-trees/src/instanced.tsx | 8 +++ packages/plugin-trees/src/presets-panel.tsx | 2 +- packages/plugin-trees/src/wind.ts | 64 ++++++++++++++++++++ 6 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-trees/src/wind.ts diff --git a/packages/plugin-trees/src/flower-geometry.ts b/packages/plugin-trees/src/flower-geometry.ts index ec06fd107..ba67fce45 100644 --- a/packages/plugin-trees/src/flower-geometry.ts +++ b/packages/plugin-trees/src/flower-geometry.ts @@ -12,6 +12,7 @@ import { FLOWER_PRESETS } from './flower-presets' import type { FlowerNode, FlowerPreset } from './flower-schema' import { mulberry32, naturalHeight } from './geometry' import type { SubMesh, VariantData } from './instanced' +import { applyWind } from './wind' export function flowerVariantKey(preset: FlowerPreset, seed: number, petalColor: string): string { return `${preset}:${seed}:${petalColor}` @@ -49,6 +50,7 @@ function buildFlower(preset: FlowerPreset, seed: number, petalColor: string): Gr const stemMat = new MeshStandardMaterial({ color: spec.stemColor, roughness: 0.85 }) const petalMat = new MeshStandardMaterial({ color: petalColor, roughness: 0.7 }) const centerMat = new MeshStandardMaterial({ color: spec.centerColor, roughness: 0.6 }) + applyWind([stemMat, petalMat, centerMat]) const stemH = spec.defaultHeight const stem = new CylinderGeometry(0.008, 0.015, stemH, 5) diff --git a/packages/plugin-trees/src/geometry.ts b/packages/plugin-trees/src/geometry.ts index be68d37c3..6d5619e00 100644 --- a/packages/plugin-trees/src/geometry.ts +++ b/packages/plugin-trees/src/geometry.ts @@ -2,6 +2,7 @@ import { Tree } from '@dgreenheck/ez-tree' import { Box3, type BufferGeometry, type Material, type Mesh, type Object3D } from 'three' import { ezPresetOf } from './presets' import type { TreeNode } from './schema' +import { applyWind } from './wind' /** 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 @@ -127,6 +128,7 @@ export function extractSubMeshes(tree: Object3D): TreeSubMesh[] { const geometry = mesh.geometry.clone() ;(child as Mesh).updateMatrix() geometry.applyMatrix4((child as Mesh).matrix) + applyWind(mesh.material) out.push({ geometry, material: mesh.material }) } }) diff --git a/packages/plugin-trees/src/grass-geometry.ts b/packages/plugin-trees/src/grass-geometry.ts index 17fec6d1c..dfd1ebdc5 100644 --- a/packages/plugin-trees/src/grass-geometry.ts +++ b/packages/plugin-trees/src/grass-geometry.ts @@ -11,6 +11,7 @@ import { mulberry32, naturalHeight } from './geometry' import { GRASS_PRESETS } from './grass-presets' import type { GrassNode, GrassPreset } from './grass-schema' import type { SubMesh, VariantData } from './instanced' +import { applyWind } from './wind' export function grassVariantKey(preset: GrassPreset, seed: number, bladeColor: string): string { return `${preset}:${seed}:${bladeColor}` @@ -41,6 +42,7 @@ function buildGrass(preset: GrassPreset, seed: number, bladeColor: string): Grou const rng = mulberry32(seed >>> 0) const group = new Group() const mat = new MeshStandardMaterial({ color: bladeColor, roughness: 0.9, side: DoubleSide }) + applyWind(mat) const h = spec.defaultHeight const blades: BufferGeometry[] = [] diff --git a/packages/plugin-trees/src/instanced.tsx b/packages/plugin-trees/src/instanced.tsx index eda67deb6..1c891aa75 100644 --- a/packages/plugin-trees/src/instanced.tsx +++ b/packages/plugin-trees/src/instanced.tsx @@ -2,6 +2,7 @@ import { type AnyNodeId, sceneRegistry, useRegistry, useScene } from '@pascal-app/core' import { useNodeEvents, useViewer } from '@pascal-app/viewer' +import { useFrame } from '@react-three/fiber' import { useLayoutEffect, useMemo, useRef } from 'react' import { type BufferGeometry, @@ -11,6 +12,7 @@ import { MeshBasicMaterial, Object3D, } from 'three' +import { WIND } from './wind' /** * Generic instanced-rendering core shared by every plant kind (trees, flowers, @@ -51,6 +53,12 @@ export function InstancedKindSystem({ }) { const nodes = useScene((s) => s.nodes) + // Advance the shared wind clock. Mounted once per active kind — all writes set + // the same shared uniform to elapsed time, so it stays idempotent. + useFrame((state) => { + WIND.uTime.value = state.clock.elapsedTime + }) + const buckets = useMemo(() => { const map = new Map() for (const raw of Object.values(nodes)) { diff --git a/packages/plugin-trees/src/presets-panel.tsx b/packages/plugin-trees/src/presets-panel.tsx index 32a1d7c96..080035273 100644 --- a/packages/plugin-trees/src/presets-panel.tsx +++ b/packages/plugin-trees/src/presets-panel.tsx @@ -66,7 +66,7 @@ export default function TreesPanel() { {mode === 'flowers' && } {mode === 'grass' && } -