Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
74d9b8d
feat(plugins): trees tracer-bullet plugin + minimal host panel surface
wass08 Jun 30, 2026
78b0a0c
feat(plugins): merge plugin panels into the v2 sidebar layout too
wass08 Jun 30, 2026
19059d9
feat(plugin-trees): ez-tree geometry + instanced rendering + snapping
wass08 Jun 30, 2026
2781ac5
feat(plugin-trees): true-silhouette hover/select + richer presets panel
wass08 Jun 30, 2026
ff2bcde
feat(plugin-trees): curated tree params + procedural flowers (sibling…
wass08 Jun 30, 2026
590ce16
feat(plugin-trees): native inspector controls in panel + preset thumb…
wass08 Jul 1, 2026
5de5f9e
feat(plugin-trees): all ez-tree presets + type + leaf/branch colours,…
wass08 Jul 1, 2026
758d545
fix(plugin-trees): tolerate nodes persisted before new fields existed
wass08 Jul 1, 2026
1afaed4
feat(plugin-trees): shared always-on wind + pin credits to panel bottom
wass08 Jul 1, 2026
436f208
feat(plugin-trees): rename rail panel Trees -> Nature (covers trees/f…
wass08 Jul 1, 2026
d2f38f3
feat(plugin-trees): rename panel header Plant -> Nature to match the …
wass08 Jul 1, 2026
44746b8
feat: bake-exportable instanced plants + renderer-agnostic wind
wass08 Jul 1, 2026
c37fd41
feat(plugin-trees): TSL vertex-bend wind (WebGPU), replacing CPU tilt
wass08 Jul 1, 2026
eadf77a
fix(plugin-trees): tree materials render black under wind (Phong not …
wass08 Jul 1, 2026
d4e78fc
fix(plugin-trees): trees still black — build node material from map/c…
wass08 Jul 1, 2026
38c82eb
fix(plugin-trees): tree wind — per-leaf flutter (ez-tree style), stat…
wass08 Jul 1, 2026
893ca83
feat(plugins): Plugin.onSceneLoad hook + viewer application; trees wi…
wass08 Jul 1, 2026
6c9ee00
fix(plugin-trees): scale-invariant leaf wind (÷ modelScale) so baked …
wass08 Jul 1, 2026
7d8335d
revert: onSceneLoad wind approach → back to wind-in-materials
wass08 Jul 1, 2026
c804ab2
feat(bake): per-kind bake policy (static/strip/replace)
wass08 Jul 1, 2026
883ff7c
feat(bake): viewer 'replace' swap — hide baked meshes, re-render live
wass08 Jul 1, 2026
3c62ba0
perf(viewer): memoize GlbReferenceNode(s) — skip forest re-render on …
wass08 Jul 1, 2026
4f08892
perf(plugin-trees): NO_RAYCAST on KindStatic meshes — cheap hover ove…
wass08 Jul 1, 2026
a8fca05
refactor(bake): viewer 'replace' renders instanced per level (was per…
wass08 Jul 1, 2026
62cf2ec
perf(viewer): BVH the baked scene — accelerate hover/pick raycasts
wass08 Jul 1, 2026
a07135e
fix(plugin-trees): inherit ez-tree preset by default (seed/type/tints)
wass08 Jul 2, 2026
30f108c
feat(plugin-trees): bundle real preset artwork (webp, in-package)
wass08 Jul 2, 2026
8509fd2
feat(plugin-trees): square preset cards on light-gray flattened thumb…
wass08 Jul 2, 2026
5fc3298
fix(plugin-trees): correct move-drag + outline behavior for instanced…
wass08 Jul 2, 2026
6477cdf
feat(plugin-trees): 2D plan symbols + trunk-sized footprint
wass08 Jul 2, 2026
8ef5594
fix(plugin-trees): trunk-sized drag box, 45°-aligned rotation, rotate…
wass08 Jul 2, 2026
9174539
fix(plugin-trees): live rotate preview + ground-level rotate ring
wass08 Jul 2, 2026
ee59d29
feat(registry): panelForKind — 'find in catalog' opens a plugin kind'…
wass08 Jul 2, 2026
d347acb
feat(registry): plugin panels declare their workspaces (default edit-…
wass08 Jul 2, 2026
84e917e
docs: link plugin-authoring guide from README + CONTRIBUTING, fix sta…
wass08 Jul 2, 2026
b4e5268
fix(plugin-trees): keep ez-tree off the eager SSR path
wass08 Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ bun check:fix # Auto-fix issues

A key rule: **`packages/viewer` must never import from `apps/editor`**. The viewer is a standalone component; editor-specific behavior is injected via props/children.

### Building a plugin

New node kinds and sidebar panels can ship as a plugin instead of editing the built-ins. Read [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md) for the contract, and copy [`packages/plugin-trees`](packages/plugin-trees) as a worked example.

## Submitting a PR

1. **Fork the repo** and create a branch from `main`
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,15 @@ Clears dirty flag

---

## Building a Plugin

The editor is extensible: a plugin ships node kinds (schema, 3D/2D rendering, placement tools, inspector parametrics) and left-rail panels through the same `Plugin` manifest the built-ins use — there is no separate internal API.

- **Contract reference** — [`wiki/architecture/plugin-authoring.md`](wiki/architecture/plugin-authoring.md): the `Plugin` shape, panel contributions, discovery (`setPluginDiscovery`), lifecycle, and what's in/out of v1.
- **Worked example** — [`packages/plugin-trees`](packages/plugin-trees): a first-party plugin (procedural trees, flowers, grass + a presets panel) structurally identical to a third-party pack. Copy it as a starting point.

---

## Technology Stack

- **React 19** + **Next.js 16**
Expand Down
7 changes: 7 additions & 0 deletions apps/editor/lib/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,5 +79,10 @@ export async function loadExternalPlugins(): Promise<void> {
}
}

// 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()
2 changes: 2 additions & 0 deletions apps/editor/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ const nextConfig: NextConfig = {
'@pascal-app/core',
'@pascal-app/editor',
'@pascal-app/mcp',
'@pascal-app/plugin-trees',
'@dgreenheck/ez-tree',
],
turbopack: {
resolveAlias: {
Expand Down
1 change: 1 addition & 0 deletions apps/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions packages/core/src/registry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type {
TranslateHandle,
} from './handles'
export {
bakePolicyOf,
discoverPlugins,
getHostRefFields,
getSelectableKinds,
Expand All @@ -26,10 +27,12 @@ export {
isPresettableKind,
isRegistryMovable,
isRegistrySelectable,
kindsWithBakePolicy,
kindsWithFloorplanScope,
loadPlugin,
nodeRegistry,
type PluginDiscovery,
panelRegistry,
registerNode,
resolveFacingIndicator,
setPluginDiscovery,
Expand All @@ -55,6 +58,8 @@ export type {
AlignmentFootprintConfig,
AnyNodeDefinition,
AssetRef,
BakePolicy,
BakeReplaceRenderer,
Capabilities,
CapabilityCtx,
CuttableConfig,
Expand Down Expand Up @@ -95,11 +100,13 @@ export type {
PaintPatchArgs,
PaintPreviewArgs,
PaintResolveArgs,
PanelWorkspace,
ParamAction,
ParametricDescriptor,
ParamField,
ParamGroup,
Plugin,
PluginPanel,
Presentation,
Relations,
RendererSource,
Expand Down
112 changes: 109 additions & 3 deletions packages/core/src/registry/registry.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { ZodObject } from 'zod'
import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types'
import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin, PluginPanel } from './types'

const HOST_API_VERSION = 1 as const

Expand Down Expand Up @@ -86,6 +86,78 @@ export function registerNode(def: AnyNodeDefinition): void {
nodeRegistry._register(def)
}

/**
* Registry of left-rail panels contributed by plugins. Same add-only,
* duplicate-id-throws semantics as the node registry, with one difference: it
* is *observable*. Plugin discovery runs asynchronously after the first React
* render (see `loadExternalPlugins`), so the sidebar subscribes via
* {@link PanelRegistryImpl.subscribe} / {@link PanelRegistryImpl.getSnapshot}
* (the `useSyncExternalStore` contract) and re-renders when a panel lands.
* `getSnapshot` returns a stable array reference between registrations so the
* subscribing component doesn't re-render in a loop.
*/
class PanelRegistryImpl {
private readonly panels = new Map<string, PluginPanel>()
private readonly listeners = new Set<() => void>()
private cached: PluginPanel[] = []
// node kind → the (namespaced) panel id of the plugin that shipped it.
// Populated by `loadPlugin`; lets a host's "find in catalog" open the
// panel that places a kind without hardcoding per-plugin knowledge.
private readonly kindPanels = new Map<string, string>()

subscribe = (onChange: () => void): (() => void) => {
this.listeners.add(onChange)
return () => {
this.listeners.delete(onChange)
}
}

getSnapshot = (): PluginPanel[] => this.cached

_register(panel: PluginPanel): void {
if (typeof panel.id !== 'string' || panel.id.length === 0) {
throw new Error('[registry] PluginPanel.id must be a non-empty string')
}
if (this.panels.has(panel.id)) {
if (isDevMode()) {
console.warn(`[registry] re-registering plugin panel "${panel.id}" (HMR)`)
} else {
throw new Error(`[registry] duplicate plugin panel id: "${panel.id}" already registered`)
}
}
this.panels.set(panel.id, panel)
this.emit()
}

_associateKind(kind: string, panelId: string): void {
this.kindPanels.set(kind, panelId)
}

/** The (namespaced) panel id of the plugin that registered `kind`, if any. */
panelForKind = (kind: string): string | undefined => this.kindPanels.get(kind)

// Test-only — clears the registry. Not exported from the package barrel.
_reset(): void {
this.panels.clear()
this.kindPanels.clear()
this.emit()
}

private emit(): void {
this.cached = Array.from(this.panels.values())
for (const fn of this.listeners) fn()
}
}

export const panelRegistry: {
subscribe: (onChange: () => void) => () => void
getSnapshot: () => PluginPanel[]
panelForKind: (kind: string) => string | undefined
_register: (panel: PluginPanel) => void
_associateKind: (kind: string, panelId: string) => void
_reset: () => void
} = new PanelRegistryImpl()

/**
* Returns the set of registered kinds whose definition declares the
* `selectable` capability. Callers that maintain hardcoded "selectable kinds"
Expand Down Expand Up @@ -130,6 +202,26 @@ export function kindsWithFloorplanScope(scope: 'level' | 'building'): string[] {
return result
}

/**
* A kind's {@link BakePolicy} from the registry, defaulting to `'static'` for
* kinds that don't declare one (or aren't registered). The bake and the baked
* `/viewer` consult this instead of hardcoding kind names — see
* plans/editor-plugin-trees-example.md → Part D.
*/
export function bakePolicyOf(kind: string): BakePolicy {
return nodeRegistry.get(kind)?.bake ?? 'static'
}

/** Registered kinds whose {@link BakePolicy} matches `policy`. `'static'` is the
* default, so `kindsWithBakePolicy('static')` includes kinds that didn't set it. */
export function kindsWithBakePolicy(policy: BakePolicy): string[] {
const result: string[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if ((def.bake ?? 'static') === policy) result.push(kind)
}
return result
}

/**
* Returns true when the kind is movable from a 2D floor-plan handle —
* either via `capabilities.movable`, an explicit
Expand Down Expand Up @@ -225,6 +317,20 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
for (const def of plugin.nodes ?? []) {
registerNode(def)
}
for (const panel of plugin.panels ?? []) {
// Namespace the panel id by its owning plugin so two plugins can each
// ship a panel id of `'main'`. The namespaced id is what the sidebar
// uses as `activeSidebarPanel`.
panelRegistry._register({ ...panel, id: `${plugin.id}:${panel.id}` })
}
// Associate every node kind with the plugin's first panel — the panel a
// host's "find in catalog" should open to place more of that kind.
const firstPanel = plugin.panels?.[0]
if (firstPanel) {
for (const def of plugin.nodes ?? []) {
panelRegistry._associateKind(def.kind, `${plugin.id}:${firstPanel.id}`)
}
}
}

/**
Expand All @@ -235,8 +341,8 @@ export async function loadPlugin(plugin: Plugin): Promise<void> {
* {@link setPluginDiscovery} before the bootstrap module runs.
*
* Kept async so a future loader can fetch over the network without
* changing the contract. See `wiki/editor-plugin-authoring.md` for the
* plugin author surface this enables.
* changing the contract. See `wiki/architecture/plugin-authoring.md` for
* the plugin author surface this enables.
*/
export type PluginDiscovery = () => Promise<Plugin[]>

Expand Down
Loading
Loading