feat(plugins): plugin contract + first-party Nature pack (trees/flowers/grass) (#457)

* feat(plugins): trees tracer-bullet plugin + minimal host panel surface

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) <noreply@anthropic.com>

* feat(plugins): merge plugin panels into the v2 sidebar layout too

The community shell renders <Editor layoutVersion="v2" sidebarTabs={...}>,
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) <noreply@anthropic.com>

* feat(plugin-trees): ez-tree geometry + instanced rendering + snapping

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) <noreply@anthropic.com>

* feat(plugin-trees): true-silhouette hover/select + richer presets panel

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) <noreply@anthropic.com>

* feat(plugin-trees): curated tree params + procedural flowers (sibling kind)

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) <noreply@anthropic.com>

* feat(plugin-trees): native inspector controls in panel + preset thumbnails + ez-tree credit

- swap the panel's custom slider/checkbox/toggle for the host's exported
  SliderControl/ToggleControl/SegmentedControl so the brush matches the
  right-hand inspector pixel-for-pixel
- replaceable preset thumbnails (inline SVG data URIs, no asset hosting) render
  as <img> cards instead of gradient swatches
- credit footer linking @dgreenheck/ez-tree (Daniel Greenheck, MIT)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): all ez-tree presets + type + leaf/branch colours, flower petal colour, grass kind

- 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 <noreply@anthropic.com>

* fix(plugin-trees): tolerate nodes persisted before new fields existed

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 <noreply@anthropic.com>

* feat(plugin-trees): shared always-on wind + pin credits to panel bottom

- 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 <noreply@anthropic.com>

* feat(plugin-trees): rename rail panel Trees -> Nature (covers trees/flowers/grass)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): rename panel header Plant -> Nature to match the rail

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat: bake-exportable instanced plants + renderer-agnostic wind

Bake (GLB export): add a transient useViewer.isExporting flag. BakeExporter
flips it, waits for the commit, then exports. KindProxy watches it and, during
export, emits each plant's REAL visible geometry under scene-renderer (which the
exporter clones) instead of the invisible collider — so instanced kinds
(def.system) that live outside that subtree are captured. The collider box is
dropped during export so it doesn't bake as a phantom solid.

Wind: the previous onBeforeCompile wind was a no-op under the editor's WebGPU
renderer (WebGL-only hook). Replace it with a renderer-agnostic per-instance
base-pivot tilt animated in useFrame (which FrameLimiter drives continuously).
Reads as wind under any renderer; removes the dead wind.ts + applyWind calls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): TSL vertex-bend wind (WebGPU), replacing CPU tilt

wind-node.ts adds a TSL positionNode that bends each plant proportional to
height above its base, phased per-instance (instanceIndex) and per-vertex, so
tips sway and roots stay planted — animated on the GPU via the renderer's time
node. ez-tree is untouched: its materials are copied into MeshStandardNodeMaterial
(toWindMaterial); flowers/grass build node materials directly (windStandardMaterial).
Instance matrices go back to static.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): tree materials render black under wind (Phong not Standard)

ez-tree's bark/leaves are MeshPhongMaterial; copying them into a
MeshStandardNodeMaterial swapped the shading model (specular/shininess ->
roughness/metalness) and rendered black. Convert into the matching node
material type (Phong->Phong, Lambert->Lambert, else Standard) so map/color/
alphaTest/side are preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): trees still black — build node material from map/color explicitly

Material.copy() doesn't transfer a classic material's map/color onto a node
material, so textured trees rendered black (flowers/grass were fine — they carry
no map). Re-create the ez-tree materials as MeshStandardNodeMaterial with map,
alphaMap, color, side, alphaTest passed explicitly in the constructor — the same
proven path the flower/grass materials use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): tree wind — per-leaf flutter (ez-tree style), static trunk

The whole-tree height-based bend was a rigid rotation about the base ('rotating
in place'). Replicate ez-tree's actual approach: scale sway by the leaf card's
uv.y and apply it ONLY to the 'leaves' material (multi-frequency wave, phased per
instance + per leaf) so leaves flutter from their attachment while bark/branches
stay static. Flowers/grass keep the gentle whole-plant stem bend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugins): Plugin.onSceneLoad hook + viewer application; trees wind via it

Adds a broad, plugin-declared post-load scene hook so effects survive GLB export:
- core: Plugin.onSceneLoad (lazy, three-free) + observable sceneHookRegistry + loadPlugin routing.
- viewer: applyPluginSceneHooks util; PluginSceneHooksSystem re-runs it on the live
  scene as nodes change; GlbScene runs it on each loaded baked GLB.
- plugin-trees: declare onSceneLoad; wind is no longer welded into the materials —
  the hook re-attaches LEAF_FLUTTER/STEM_BEND by material name (leaves / flower-* /
  grass-*), so it now animates in the baked /viewer too, not just live.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): scale-invariant leaf wind (÷ modelScale) so baked GLB matches editor

The bake's quantiser renormalises each plant mesh to [-1,1] and moves real size
into a large node scale (~3 on trees). LEAF_FLUTTER added a constant local offset,
so in the baked GLB it was multiplied by that scale → ~3x over-swing. Divide the
sway by modelScale.x to keep a constant world-space amplitude; editor (scale 1)
is unchanged, baked now matches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* revert: onSceneLoad wind approach → back to wind-in-materials

Reverts the Plugin.onSceneLoad hook + viewer application + modelScale compensation
(commits 893ca839, 6c9ee003). Superseded by the bake-policy 'replace' model
(plans/editor-plugin-trees-example.md Part D): trees render live in the viewer via
their own path, so wind is a plain positionNode on the material again — no hook,
no baked-geometry decoration, no coordinate-space fights.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(bake): per-kind bake policy (static/strip/replace)

Adds NodeDefinition.bake ('static' default | 'strip' | 'replace') + registry
helpers bakePolicyOf/kindsWithBakePolicy in core. Generalises the previously
hardcoded scan/guide handling:
- glb-export strips kinds with bake==='strip' from the artifact (was a
  scan/guide type check); 'replace' kinds stay baked (portable static snapshot).
- glb-reference-nodes selects rebuild candidates by policy instead of type.
- scan/guide declare bake:'strip'; trees/flower/grass declare bake:'replace'.

Viewer-side 'replace' swap (strip baked meshes + live-rebuild) is the next slice.
Part of plans/editor-plugin-trees-example.md Part D.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(bake): viewer 'replace' swap — hide baked meshes, re-render live

Adds NodeDefinition.bakeReplaceRenderer (falls back to renderer). The baked
/viewer now, for kinds with bake:'replace' and a loaded plugin:
- hides the static baked meshes (bakePolicyOf(kind)==='replace') in GlbScene's
  identity pass — capability-gated, so with no plugin the meshes stay;
- re-renders each node live via buildGlbReplaceNodes + the shared portal-into-
  baked-level path (GlbReferenceNode now prefers bakeReplaceRenderer), so trees
  ride level stacking automatically and wind runs in real coordinate space.

plugin-trees ships a hookless KindStatic (real geometry + wind materials, no
collider/selection) and per-kind static-renderer binds; tree/flower/grass point
bakeReplaceRenderer at them. Completes plans/editor-plugin-trees-example.md Part D.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(viewer): memoize GlbReferenceNode(s) — skip forest re-render on camera move

GlbScene re-renders per frame during camera movement (hover raycast / walkthrough
HUD). The rebuilt nodes reconciled every frame — fine for 1-2 scans/guides, but a
bake:'replace' forest puts dozens of nodes here (profiler: KindStatic x56 6ms,
GlbReferenceNode x57 4ms per frame). memo at the node boundary skips the whole
subtree (incl. the plugin renderer) when (node, anchor) are unchanged; both props
are stable refs, so it short-circuits cleanly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(plugin-trees): NO_RAYCAST on KindStatic meshes — cheap hover over a forest

The baked scene's <primitive> has pointer handlers, so R3F recursively raycasts
every descendant on each pointer move (incl. orbit drags). The live replace trees
had fully-raycastable dense ez-tree geometry, so hover cost ~400ms/frame over a
forest (profiler: 'JavaScript' 413ms during camera move). They carry no pascalId
(a hit resolves to the level, not the tree), so they're scenery, not pick targets
— mirror KindProxy, which already NO_RAYCASTs its real geometry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(bake): viewer 'replace' renders instanced per level (was per-node)

Replaces the per-node KindStatic viewer path with a collective instanced one,
mirroring the editor's system instead of diverging from it:

- extract InstancedNodes from InstancedKindSystem (shared by both) with a
  localSpace flag: editor folds parent world matrix (root instances); viewer uses
  level-local matrices + NO_RAYCAST, portaled into the baked level.
- bakeReplaceRenderer is now a collective renderer (BakeReplaceRenderer<N>,
  receives {nodes}); GlbReplaceInstances groups replace nodes by (level, kind) and
  portals each kind's instanced renderer into that baked level. GlbReferenceNode
  reverts to per-node renderer (strip kinds only).

Fixes: forest hover cost (100+ tree meshes → a few instanced draws, R3F walks far
fewer objects) and per-tree wind phase (instanceIndex varies again; per-node
KindStatic gave every tree phase 0 → unison sway). Trees still ride level stacking
(portaled) and stay static in a plain glTF viewer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* perf(viewer): BVH the baked scene — accelerate hover/pick raycasts

The baked /viewer runs with useBvh={false}, so GlbScene's hover/pick raycasts
against the baked building were brute-force triangle intersection (profiler:
_computeIntersections + intersectTriangle ~30%). The parametric viewer wraps its
scene in <SceneBvh>; the baked one never did. SceneBvh's effect is one-shot on
mount and can't catch the async-loaded GLB, so compute a per-mesh BVH inside
GlbScene keyed on gltf.scene instead. 'replace' instances are NO_RAYCAST, so the
raycast===Mesh.prototype.raycast guard skips them (mirrors SceneBvh).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): inherit ez-tree preset by default (seed/type/tints)

Our node defaults (seed:1, treeType:deciduous, colors:#ffffff) were applied as
*overrides* on top of loadPreset, discarding each preset's tuned seed, growth
model, and tints — so our trees looked nothing like eztree.dev (pine grew
deciduous, foliage washed white, every silhouette off).

Mirror the flower petalColor pattern: seed/treeType/leafColor/branchColor are now
optional; generateTree only overrides an ez-tree option when the node set it,
otherwise loadPreset's value stands. Placement stores none of them (fresh tree =
pure preset; all same-preset trees share one instancing variant); the deciduous/
evergreen brush toggle is gone (growth model comes from the preset). The inspector
keeps them as per-tree overrides, and Randomize still varies the seed.

Verified against ez-tree: fresh Oak Medium → seed 35729 + preset tints; Pine
Medium → evergreen + seed 13977; overrides apply when set.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): bundle real preset artwork (webp, in-package)

Replaces the placeholder SVG thumbnails + lucide panel icon with the real nature
art. 13 webp (~126KB total, 256px cards / 128px icon) live in src/assets and are
imported via art.ts — both consumers are Next, so transpilePackages runs them
through the image pipeline (hashed, cached /_next/static/media URLs). No CDN, no
per-app public/ mirroring; the assets travel with the package.

- art.ts: central webp imports → TREE_ART / FLOWER_ART / GRASS_ART / NATURE_ICON
- presets/flower-presets/grass-presets: thumbnail ← bundled art (was *Thumbnail())
- index.ts: Nature panel icon ← NATURE_ICON (was lucide:leaf)
- assets.d.ts: ambient *.webp decl (no next type dep)
- remove thumbnails.ts (placeholder generators, now dead)

Verified on the running dev server: webp emitted + served (HTTP 200, image/webp).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(plugin-trees): square preset cards on light-gray flattened thumbnails

Regenerate the 12 thumbnails with the transparency flattened onto gray-100
(#f3f4f6) and render the card square (aspect-square, was h-16 crop). The
nature-icon keeps its transparency for the icon rail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(plugin-trees): correct move-drag + outline behavior for instanced plants

Two selection defects, one root cause each:

1. Outline moved mirror-wise during a move drag. The host move tool drives the
   registered Object3D imperatively with absolute level-local positions (and
   mirrors them via useLiveTransforms) — the contract ParametricNodeRenderer
   satisfies by putting position + registration on the same group. KindProxy
   registered a child nested inside the transform-carrying group, so drag
   positions landed in the node's rotated frame (placement gives every plant a
   random Y rotation → deltas rotated up to 180°). Restructure: the registered
   group now carries position/rotation (live-transform aware); the box collider
   is a positioned sibling, staying out of the outline mask.

2. Outline froze while the mesh swayed. The outline mask pass renders with a
   shared override material, so it can never follow the material positionNode
   wind. Instead, while hovered/selected the collective system skips the node
   and the proxy mounts the real geometry with static twins of the wind
   materials (toStaticMaterial — explicit property transfer; node-material
   clone() drops map/color). Outlined mesh == visible mesh, both still.

Bonus from the same restructure: a move drag now animates the actual plant in
realtime (the proxy is what the tool drives), not just the drag box.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(plugin-trees): 2D plan symbols + trunk-sized footprint

- def.floorplan for the three kinds — the registry floor-plan layer renders any
  kind that provides one, so this is all plugin nodes need to appear in 2D.
  Tree: dashed canopy ring (dashed = overhead element) in the preset swatch +
  solid trunk dot; ring is pointer-events:stroke so the large disc doesn't
  steal clicks from what's under the canopy. Flower/grass: small colour dots.
  Selected → palette stroke + move-handle; hovered → hover stroke.
- Tree floorPlaced.footprint is trunk-sized (treeTrunkRadius) instead of
  canopy-sized, so the move/placement drag box hugs where the tree actually
  plants instead of spanning the crown. The invisible hover collider keeps its
  larger radius — only the displayed box shrinks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): trunk-sized drag box, 45°-aligned rotation, rotate gizmo

- The move drag box ignored floorPlaced.footprint: with collides:false the tool
  auto-measures the rendered mesh — which, since the selection swap-out, is the
  whole canopy. Declare capabilities.dragBounds (trunk-sized, node-local) so the
  box hugs the trunk and rotates with the node instead of wrapping the crown as
  a world AABB.
- Placement wrote a fully random Y rotation, so the box/gizmo never sat on a 45°
  step. Snap the random rotation to 45° increments (variety preserved, alignment
  restored) and widen rotatable.snapAngles to 8×45° on all three kinds.
- Add the standard rotate gizmo (def.handles, shelf-style arc handle): ring
  around the trunk near the ground — not the canopy, which would put the handle
  meters out on a large oak.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): live rotate preview + ground-level rotate ring

- KindProxy now folds useLiveNodeOverrides into its transform (mirrors
  ParametricNodeRenderer) — the rotate/resize gizmos publish mid-drag patches
  there, so the plant turns in realtime instead of snapping on commit. The
  snap-on-commit (with the arc delta wrapping to ±180°) is also what made long
  drags land 'the wrong way'; with live feedback the direction reads correctly
  and matches the item gizmo pipeline exactly.
- Rotate ring drops from mid-trunk to 0.25m — a floor affordance like the item
  gizmo, not a waist-height hoop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(registry): panelForKind — 'find in catalog' opens a plugin kind's panel

loadPlugin now associates every node kind with its plugin's first (namespaced)
panel id; panelRegistry.panelForKind(kind) exposes it. A host's find-node
handler can open the right panel for any plugin kind with zero per-plugin
knowledge.

plugin-trees uses it end-to-end: a module-level selection:find-node listener
(find-sync.ts, imported by the manifest so it's live from plugin load) points
the panel store at the found node's section + preset — panel section state
moved from panel-local useState into the store to make it addressable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(registry): plugin panels declare their workspaces (default edit-only)

Plugin panels rode into the studio rail: the host swaps its own sidebar tabs by
workspaceMode, but usePluginPanels appended registry panels unconditionally.

PluginPanel gains workspaces?: readonly ('edit'|'studio')[] — manifest metadata,
default ['edit'] (an authoring panel has no business in the clean render
workspace; a plugin shipping studio tooling opts in explicitly). usePluginPanels
filters by the current workspaceMode, covering both the v1 AppSidebar and v2 tab
bar paths. Nature declares nothing and disappears from studio via the default;
the v2 layout's existing active-tab fallback handles a panel vanishing mid-use.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: link plugin-authoring guide from README + CONTRIBUTING, fix stale wiki path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(plugin-trees): keep ez-tree off the eager SSR path

ez-tree loads its inlined textures at module scope (needs document), so any
eager import chain reaching geometry.ts crashed Next prerender
(ReferenceError: document is not defined on /_not-found). Move the pure
helpers (mulberry32, naturalHeight) to variant-utils.ts so the
flower/grass builders and floorplan no longer pull ez-tree, and drop the
generateTree re-export from the package barrel (no external consumers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Wassim SAMAD
2026-07-02 13:45:20 -04:00
committed by GitHub
co-authored by Claude Fable 5
parent bf25af6add
commit 5c071ecad5
80 changed files with 3549 additions and 36 deletions
+4
View File
@@ -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`
+9
View File
@@ -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**
+7
View File
@@ -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<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
View File
@@ -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: {
+1
View File
@@ -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",
+37
View File
@@ -33,6 +33,7 @@
"@pascal-app/editor": "*",
"@pascal-app/mcp": "*",
"@pascal-app/nodes": "*",
"@pascal-app/plugin-trees": "*",
"@pascal-app/viewer": "*",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-three/drei": "^10.7.7",
@@ -257,6 +258,38 @@
"zustand": "^5",
},
},
"packages/plugin-trees": {
"name": "@pascal-app/plugin-trees",
"version": "0.1.0",
"dependencies": {
"@dgreenheck/ez-tree": "^1.1.0",
},
"devDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@pascal/typescript-config": "*",
"@react-three/fiber": "^9",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
"react": "^19",
"three": "^0.184",
"typescript": "6.0.3",
"zod": "^4",
"zustand": "^5",
},
"peerDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.184",
"zod": "^4",
"zustand": "^5",
},
},
"packages/typescript-config": {
"name": "@repo/typescript-config",
"version": "0.0.0",
@@ -371,6 +404,8 @@
"@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="],
"@dgreenheck/ez-tree": ["@dgreenheck/ez-tree@1.1.0", "", { "peerDependencies": { "three": ">=0.167" } }, "sha512-6pvS6hD6B6h00dm0SnkgYeT4ABU5Y1Z9M44p1tXiV5C0eKrQy2sKECXshoaUv0qAOqYVL68w/PwadUxDFDiHUg=="],
"@dimforge/rapier3d-compat": ["@dimforge/rapier3d-compat@0.12.0", "", {}, "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow=="],
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
@@ -683,6 +718,8 @@
"@pascal-app/nodes": ["@pascal-app/nodes@workspace:packages/nodes"],
"@pascal-app/plugin-trees": ["@pascal-app/plugin-trees@workspace:packages/plugin-trees"],
"@pascal-app/viewer": ["@pascal-app/viewer@workspace:packages/viewer"],
"@pascal/typescript-config": ["@pascal/typescript-config@workspace:tooling/typescript"],
+7
View File
@@ -16,6 +16,7 @@ export type {
TranslateHandle,
} from './handles'
export {
bakePolicyOf,
discoverPlugins,
getHostRefFields,
getSelectableKinds,
@@ -26,10 +27,12 @@ export {
isPresettableKind,
isRegistryMovable,
isRegistrySelectable,
kindsWithBakePolicy,
kindsWithFloorplanScope,
loadPlugin,
nodeRegistry,
type PluginDiscovery,
panelRegistry,
registerNode,
resolveFacingIndicator,
setPluginDiscovery,
@@ -55,6 +58,8 @@ export type {
AlignmentFootprintConfig,
AnyNodeDefinition,
AssetRef,
BakePolicy,
BakeReplaceRenderer,
Capabilities,
CapabilityCtx,
CuttableConfig,
@@ -95,11 +100,13 @@ export type {
PaintPatchArgs,
PaintPreviewArgs,
PaintResolveArgs,
PanelWorkspace,
ParamAction,
ParametricDescriptor,
ParamField,
ParamGroup,
Plugin,
PluginPanel,
Presentation,
Relations,
RendererSource,
+109 -3
View File
@@ -1,5 +1,5 @@
import type { ZodObject } from 'zod'
import type { AnyNodeDefinition, NodeRegistry, Plugin } from './types'
import type { AnyNodeDefinition, BakePolicy, NodeRegistry, Plugin, PluginPanel } from './types'
const HOST_API_VERSION = 1 as const
@@ -86,6 +86,78 @@ export function registerNode(def: AnyNodeDefinition): void {
nodeRegistry._register(def)
}
/**
* Registry of left-rail panels contributed by plugins. Same add-only,
* duplicate-id-throws semantics as the node registry, with one difference: it
* is *observable*. Plugin discovery runs asynchronously after the first React
* render (see `loadExternalPlugins`), so the sidebar subscribes via
* {@link PanelRegistryImpl.subscribe} / {@link PanelRegistryImpl.getSnapshot}
* (the `useSyncExternalStore` contract) and re-renders when a panel lands.
* `getSnapshot` returns a stable array reference between registrations so the
* subscribing component doesn't re-render in a loop.
*/
class PanelRegistryImpl {
private readonly panels = new Map<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"
@@ -130,6 +202,26 @@ export function kindsWithFloorplanScope(scope: 'level' | 'building'): string[] {
return result
}
/**
* A kind's {@link BakePolicy} from the registry, defaulting to `'static'` for
* kinds that don't declare one (or aren't registered). The bake and the baked
* `/viewer` consult this instead of hardcoding kind names — see
* plans/editor-plugin-trees-example.md → Part D.
*/
export function bakePolicyOf(kind: string): BakePolicy {
return nodeRegistry.get(kind)?.bake ?? 'static'
}
/** Registered kinds whose {@link BakePolicy} matches `policy`. `'static'` is the
* default, so `kindsWithBakePolicy('static')` includes kinds that didn't set it. */
export function kindsWithBakePolicy(policy: BakePolicy): string[] {
const result: string[] = []
for (const [kind, def] of nodeRegistry.entries()) {
if ((def.bake ?? 'static') === policy) result.push(kind)
}
return result
}
/**
* Returns true when the kind is movable from a 2D floor-plan handle —
* either via `capabilities.movable`, an explicit
@@ -225,6 +317,20 @@ export async function loadPlugin(plugin: Plugin): Promise<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}`)
}
}
}
/**
@@ -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[]>
+62
View File
@@ -695,10 +695,36 @@ export type FloorplanMoveTarget<N> = (args: {
// ─── Plugin manifest ─────────────────────────────────────────────────
/**
* A left-rail panel contributed by a plugin. The host adds `icon` to the
* sidebar icon rail; clicking it mounts `component` (lazy-loaded, behind a
* per-panel error boundary) in the sidebar content area. `id` is namespaced
* by the owning plugin id at load time, so two plugins may each declare a
* panel id of `'main'` without colliding. `icon` reuses {@link IconRef} so a
* plugin contributes a mark the same way a node's presentation does, with no
* React rendering in core.
*/
/** Host workspace a panel belongs to — `edit` (the build workspace) or
* `studio` (the render workspace). Manifest metadata, not core logic: the
* host's sidebar filters by its current workspace mode. */
export type PanelWorkspace = 'edit' | 'studio'
export type PluginPanel = {
id: string
label: string
icon: IconRef
component: LazyComponent
/** Workspaces that surface this panel. Default `['edit']` — a placement /
* authoring panel has no business in the clean render workspace; a plugin
* that ships studio tooling opts in explicitly. */
workspaces?: readonly PanelWorkspace[]
}
export type Plugin = {
id: string
apiVersion: 1
nodes?: AnyNodeDefinition[]
panels?: PluginPanel[]
}
// ─── NodeDefinition ──────────────────────────────────────────────────
@@ -726,6 +752,19 @@ export type DistributionRole = 'run' | 'fitting' | 'terminal' | 'equipment'
*/
export type SnapProfile = 'item' | 'structural'
/**
* How a kind is treated by the GLB bake and the baked `/viewer`. See
* plans/editor-plugin-trees-example.md → Part D.
* - `'static'` (default) — baked as geometry; the viewer shows the baked mesh.
* - `'strip'` — excluded from the bake; the viewer rebuilds it live from
* `scene_graph` via the registry renderer (heavy reference assets: scans, guides).
* - `'replace'` — baked as *static* geometry (a plain glTF viewer still shows it),
* but our viewer removes the baked meshes for this kind and re-renders the node
* live from `scene_graph` — for dynamic content whose runtime look differs from a
* frozen snapshot (shader wind, interactivity), rendered through its own path.
*/
export type BakePolicy = 'static' | 'strip' | 'replace'
export type NodeDefinition<S extends ZodObject<any>> = {
kind: string
schemaVersion: number
@@ -785,6 +824,9 @@ export type NodeDefinition<S extends ZodObject<any>> = {
*/
dirtyTracking?: boolean
/** GLB bake treatment for this kind (default `'static'`). See {@link BakePolicy}. */
bake?: BakePolicy
/**
* Renderer for this kind. Optional under the three-checkbox composition
* model (see `wiki/architecture/node-definitions.md`): when omitted, the
@@ -797,6 +839,17 @@ export type NodeDefinition<S extends ZodObject<any>> = {
* already null-guard on `def.renderer` so omitting it is safe.
*/
renderer?: RendererSource<z.infer<S>>
/**
* Collective renderer the baked `/viewer` uses to re-render this kind live when
* `bake === 'replace'`. It receives every node of this kind under one baked
* level and is portaled into that level's `Object3D`, so an instanced kind can
* draw them as instanced meshes in level-local space (riding level stacking for
* free) instead of the frozen baked meshes (which the viewer hides). Needed when
* the normal per-node `renderer` can't stand alone in a baked scene (e.g. an
* instanced kind whose `renderer` is an invisible selection proxy and whose real
* geometry comes from a `system`). See plans/editor-plugin-trees-example.md → Part D.
*/
bakeReplaceRenderer?: BakeReplaceRenderer<z.infer<S>>
/**
* Pure geometry builder. When set, the framework's generic
* `<GeometrySystem>` calls this on every dirty mark — `nodes` keyed by
@@ -1136,6 +1189,15 @@ export type RendererSource<N> =
| { kind: 'glb'; getAsset: (n: N) => AssetRef }
| { kind: 'instanced-glb'; getAsset: (n: N) => AssetRef }
/**
* A collective renderer for the baked `/viewer` (see `NodeDefinition.bakeReplaceRenderer`):
* a lazy module whose default export takes all of one level's `replace` nodes and
* is portaled into that baked level. Three-free indirection, same as `system`.
*/
export type BakeReplaceRenderer<N> = {
module: () => Promise<{ default: ComponentType<{ nodes: N[] }> }>
}
export type AssetRef = {
id: string
src: string
@@ -1,10 +1,20 @@
'use client'
import { useScene } from '@pascal-app/core'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect, useRef } from 'react'
import { exportSceneToGlb } from '../../lib/glb-export'
/** Resolve after the next couple of animation frames, giving React/R3F time to
* commit and mount export-only geometry (e.g. instanced kinds' real meshes)
* before the exporter clones the scene graph. */
function nextFrames(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()))
})
}
export function BakeExporter({
active,
onComplete,
@@ -21,12 +31,19 @@ export function BakeExporter({
doneRef.current = true
const run = async () => {
try {
// Signal export so instanced kinds (trees/flowers/grass) swap their
// invisible proxy for real, exportable geometry, then wait for the
// commit before cloning the scene graph.
useViewer.getState().setExporting(true)
await nextFrames()
const sceneGroup = scene.getObjectByName('scene-renderer')
if (!sceneGroup) throw new Error('scene-renderer group not found')
const buffer = await exportSceneToGlb(sceneGroup, useScene.getState().nodes)
onComplete(buffer)
} catch (err) {
onError(err instanceof Error ? err.message : String(err))
} finally {
useViewer.getState().setExporting(false)
}
}
void run()
@@ -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'
@@ -1115,6 +1116,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
@@ -1286,7 +1293,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<string, SidebarTab & { component: React.ComponentType }>(
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
@@ -1303,14 +1320,24 @@ export default function Editor({
return <Component />
}
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 (
<>
@@ -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 =
@@ -0,0 +1,97 @@
'use client'
import { Icon } from '@iconify/react'
import { type IconRef, panelRegistry, type PluginPanel } from '@pascal-app/core'
import { type ComponentType, lazy, type ReactNode, Suspense, useSyncExternalStore } from 'react'
import useEditor from '../../../store/use-editor'
import { ErrorBoundary } from '../primitives/error-boundary'
import type { ExtraPanel } from './icon-rail'
/** Resolve a plugin's {@link IconRef} into a rail-sized React node. Mirrors the
* inspector's `renderIcon`, sized for the 24px icon-rail button. */
function renderIconRef(ref: IconRef): ReactNode {
if (ref.kind === 'url') {
return <img alt="" className="h-5 w-5 object-contain" src={ref.src} />
}
if (ref.kind === 'iconify') {
return <Icon height={20} icon={ref.name} width={20} />
}
if (ref.kind === 'svg') {
return (
<svg height={20} viewBox={ref.viewBox} width={20}>
<path d={ref.path} fill="currentColor" />
</svg>
)
}
const LazyIcon = lazy(ref.module)
return (
<Suspense fallback={null}>
<LazyIcon />
</Suspense>
)
}
function PluginPanelCrashed({ label }: { label: string }) {
return (
<div className="flex flex-col gap-2 p-4 text-sm">
<p className="font-medium text-sidebar-foreground">"{label}" plugin crashed</p>
<p className="text-sidebar-foreground/50 text-xs">
This panel hit an error and was unloaded for this session. The rest of the editor is
unaffected reload to try again.
</p>
</div>
)
}
// `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<PluginPanel['component'], ComponentType>()
function resolvePanelComponent(panel: PluginPanel): ComponentType {
const cached = wrappedPanelCache.get(panel.component)
if (cached) return cached
const Lazy = lazy(panel.component)
const Wrapped: ComponentType = () => (
<ErrorBoundary fallback={<PluginPanelCrashed label={panel.label} />}>
<Suspense fallback={<div className="p-4 text-sidebar-foreground/50 text-sm">Loading</div>}>
<Lazy />
</Suspense>
</ErrorBoundary>
)
Wrapped.displayName = `PluginPanel(${panel.id})`
wrappedPanelCache.set(panel.component, Wrapped)
return Wrapped
}
/**
* Merge plugin-contributed panels (from the observable {@link panelRegistry})
* with the host's `extraPanels`, returning the combined list the icon rail and
* panel-content area render. Host panels keep their leading order and win on id
* collisions. Subscribes to the registry so a panel that registers after the
* first render (plugin discovery is async) makes the rail re-render.
*
* Panels are filtered by the current workspace: a panel surfaces only in the
* workspaces it declares (`PluginPanel.workspaces`, default `['edit']`), so an
* authoring panel like Nature doesn't ride into the studio rail.
*/
export function usePluginPanels(hostPanels?: ExtraPanel[]): ExtraPanel[] {
const registered = useSyncExternalStore(
panelRegistry.subscribe,
panelRegistry.getSnapshot,
panelRegistry.getSnapshot,
)
const workspaceMode = useEditor((s) => s.workspaceMode)
const hostIds = new Set(hostPanels?.map((p) => p.id))
const fromRegistry = registered
.filter((p) => !hostIds.has(p.id) && (p.workspaces ?? ['edit']).includes(workspaceMode))
.map(
(p): ExtraPanel => ({
id: p.id,
label: p.label,
icon: renderIconRef(p.icon),
component: resolvePanelComponent(p),
}),
)
return [...(hostPanels ?? []), ...fromRegistry]
}
+9 -6
View File
@@ -1,5 +1,6 @@
import {
type AnyNode,
bakePolicyOf,
type DoorNode,
emitter,
getLevelDisplayName,
@@ -104,14 +105,16 @@ export function prepareSceneForExport(
const scene = source.clone(true)
const cloneByOriginal = pairClones(source, scene)
// Scans (LiDAR meshes) and guides (floorplan images) are heavy reference
// assets stored elsewhere and aren't part of the compiled building. Drop them
// from the artifact entirely — `/viewer` re-adds them from the scene graph,
// gated by the project's public-visibility flags, so they never bloat the
// shared GLB nor slip past those flags into a static public file.
// Kinds with `def.bake === 'strip'` (scans/LiDAR, guides/floorplan) are heavy
// reference assets stored elsewhere and aren't part of the compiled building.
// Drop them from the artifact entirely — `/viewer` re-adds them from the scene
// graph, gated by the project's public-visibility flags, so they never bloat
// the shared GLB nor slip past those flags into a static public file.
// (`'replace'` kinds are *kept*: static for portability, the viewer swaps them
// for a live render.)
for (const [id, original] of sceneRegistry.nodes) {
const node = nodes[id]
if (node?.type === 'scan' || node?.type === 'guide') {
if (node && bakePolicyOf(node.type) === 'strip') {
cloneByOriginal.get(original)?.removeFromParent()
}
}
+8 -2
View File
@@ -172,8 +172,14 @@ export type NavigationSyncPose = {
export type NavigationSyncPoseInput = Omit<NavigationSyncPose, 'revision'>
// 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.
+3
View File
@@ -10,6 +10,9 @@ import { GuideNode } from './schema'
*/
export const guideDefinition: NodeDefinition<typeof GuideNode> = {
kind: 'guide',
// Floorplan image reference: stripped from the bake, re-added live from
// scene_graph in the viewer (see plans → Part D; glb-reference-nodes.tsx).
bake: 'strip',
schemaVersion: 1,
schema: GuideNode,
category: 'site',
+3
View File
@@ -9,6 +9,9 @@ import { ScanNode } from './schema'
*/
export const scanDefinition: NodeDefinition<typeof ScanNode> = {
kind: 'scan',
// Heavy LiDAR asset: stripped from the bake, re-added live from scene_graph
// in the viewer (see plans → Part D; glb-reference-nodes.tsx).
bake: 'strip',
schemaVersion: 1,
schema: ScanNode,
category: 'site',
+92
View File
@@ -0,0 +1,92 @@
# @pascal-app/plugin-trees
The first-party **example plugin** for the Pascal editor. It contributes a
procedural _trees_ node and a left-rail _presets_ panel, and exists to prove —
and document — the minimal host surface every future plugin reuses.
It is structurally identical to a third-party plugin: it peer-depends on
`@pascal-app/{core,viewer,editor}` (plus `react`/`three`/`@react-three/fiber`/
`zustand`) and bundles `@dgreenheck/ez-tree` for the geometry. It imports
nothing private. Copy this folder as the starting point for a new plugin.
## What it demonstrates
The contribution paths a plugin has:
1. **Left-rail panel**`panels` in the manifest. `presets-panel.tsx` is a
plain React component the host mounts behind an error boundary.
2. **Right inspector for free**`def.parametrics` (`parametrics.ts`). The host
renders the preset/height/seed controls + the Randomize action with zero
tree-specific code.
3. **Placement**`def.tool`/`def.preview` (`tool.tsx`, `preview.tsx`). The
tool respects the active snapping mode (`isGridSnapActive()` + `gridSnapStep`)
exactly like the built-in item/shelf tools.
4. **Instanced rendering** (the generic core in `instanced.tsx`, shared by both
kinds) — instead of the per-node `def.geometry` path, plants render via two
pieces:
- `def.system` — a collective renderer mounted once that groups every node of
the kind by its geometry variant and draws each variant as one
`InstancedMesh` per sub-mesh. A forest of N is a handful of draw calls.
Variant geometry is generated once and cached.
- `def.renderer` — a featherweight per-node proxy: a stable invisible box
collider (the raycast target) in an outer group, plus the real geometry
(invisible, mounted only while hovered/selected) in an inner *registered*
group. So the host's outline pass traces the **true silhouette**, picking
stays on the box, and selection / outline / zone machinery works unchanged
with no instanceId bookkeeping.
### Three kinds
- **`trees:tree`** — ez-tree geometry (`geometry.ts`); species presets Oak /
Pine / Aspen / Ash / Bush / Trellis × a Small/Medium/Large **size** (all of
ez-tree's built-in presets), a Deciduous/Evergreen **type**, curated params
(foliage density, trunk thickness, leafless), and leaf/branch **colour tints**
all folded into the variant key. Colours are edit-only (inspector), not on the
placement brush.
- **`trees:flower`** — simple procedural geometry (`flower-geometry.ts`, merged
per material); presets daisy / tulip / lavender, with a per-flower petal colour.
- **`trees:grass`** — procedural blade tufts (`grass-geometry.ts`); presets
meadow / fescue / reed, with a per-tuft blade colour.
Flowers and grass are sibling kinds that reuse the exact same instanced core +
placement helper (`instanced.tsx` / `placement.tsx`) and the shared procedural
RNG (`mulberry32` in `geometry.ts`) — the template for adding more plant kinds.
It also shows the communication triangle: `presets-panel` → plugin store
(`store.ts`) → `def.tool``SceneApi` → scene → reactive `useScene` read-back
(the "N planted" counter in the panel).
`@dgreenheck/ez-tree` ships its bark/leaf textures inlined as base64, so there
are no assets to host. Placement seeds are drawn from a small bounded pool
(`TREE_SEED_POOL`) so trees share variants — that sharing is what makes the
instancing pay off; a unique inspector seed just renders as its own variant.
## Manifest
```ts
import { treesPlugin } from '@pascal-app/plugin-trees'
// host:
setPluginDiscovery(async () => [treesPlugin])
```
`treesPlugin` exports three node kinds (`trees:tree`, `trees:flower`,
`trees:grass`) and one panel (`Trees`), loaded through the same `loadPlugin`
path as the built-ins.
## Notes / known gaps
- `createNode` and the `floorPlaced.footprint` callback are typed against the
host's hand-maintained `AnyNode` union, so the node is cast (`as AnyNode` /
`as TreeNode`). The registry derives `AnyNode` post-migration.
- The placement tool re-derives level-local conversion from the public
`sceneRegistry` because the built-in `floor-placement` helpers aren't part of
the public `@pascal-app/*` surface yet — a candidate for a future
`@pascal-app/plugin-api` re-export package.
- The instance matrices fold in the parent level's world transform; a building
move while plants are static won't refresh until a node of that kind next
changes.
- Heavy *per-node* tweaking of geometry params (or unique seeds) erodes
instancing batching — but it degrades gracefully: such a node just becomes its
own single-instance variant, never worse than the non-instanced path.
See `wiki/architecture/plugin-authoring.md` for the full contract.
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@pascal-app/plugin-trees",
"version": "0.1.0",
"description": "First-party example Pascal editor plugin — a procedural trees node with a presets rail panel. Structurally identical to a third-party plugin (peer-deps on @pascal-app/*).",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"files": [
"src",
"README.md"
],
"scripts": {
"check-types": "tsgo --noEmit"
},
"dependencies": {
"@dgreenheck/ez-tree": "^1.1.0"
},
"peerDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@react-three/fiber": "^9",
"react": "^18 || ^19",
"three": "^0.184",
"zod": "^4",
"zustand": "^5"
},
"devDependencies": {
"@pascal-app/core": "*",
"@pascal-app/editor": "*",
"@pascal-app/viewer": "*",
"@pascal/typescript-config": "*",
"@react-three/fiber": "^9",
"@types/node": "^22.19.12",
"@types/react": "^19.2.2",
"@types/three": "^0.184.0",
"react": "^19",
"three": "^0.184",
"typescript": "6.0.3",
"zod": "^4",
"zustand": "^5"
},
"keywords": [
"pascal",
"3d",
"editor",
"plugin"
],
"license": "MIT"
}
+48
View File
@@ -0,0 +1,48 @@
import ash from './assets/ash.webp'
import aspen from './assets/aspen.webp'
import bush from './assets/bush.webp'
import daisy from './assets/daisy.webp'
import fescue from './assets/fescue.webp'
import lavender from './assets/lavender.webp'
import meadow from './assets/meadow.webp'
import natureIcon from './assets/nature-icon.webp'
import oak from './assets/oak.webp'
import pine from './assets/pine.webp'
import reed from './assets/reed.webp'
import trellis from './assets/trellis.webp'
import tulip from './assets/tulip.webp'
import type { FlowerPreset } from './flower-schema'
import type { GrassPreset } from './grass-schema'
import type { TreePreset } from './schema'
/**
* Bundled preset artwork. The webp live in `./assets` and travel with the
* package — no CDN, no per-app `public/` mirroring. Both consumers are Next, so
* `transpilePackages` runs these imports through the image pipeline and `.src`
* is the hashed, cached URL. The panel renders each as an `<img src>`.
*/
const url = (asset: { src: string }): string => asset.src
export const TREE_ART: Record<TreePreset, string> = {
oak: url(oak),
pine: url(pine),
aspen: url(aspen),
ash: url(ash),
bush: url(bush),
trellis: url(trellis),
}
export const FLOWER_ART: Record<FlowerPreset, string> = {
daisy: url(daisy),
tulip: url(tulip),
lavender: url(lavender),
}
export const GRASS_ART: Record<GrassPreset, string> = {
meadow: url(meadow),
fescue: url(fescue),
reed: url(reed),
}
/** The Nature panel / section icon. */
export const NATURE_ICON = url(natureIcon)
+8
View File
@@ -0,0 +1,8 @@
// Bundled image assets. Both consumers (community + apps/editor) are Next, whose
// static image import returns a StaticImageData-shaped object; `.src` is the
// hashed, cached URL the bundler emits. Declared locally so the package needs no
// `next` type dependency. See `art.ts`.
declare module '*.webp' {
const asset: { src: string; height: number; width: number; blurDataURL?: string }
export default asset
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

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